From bddcf5f4f9082c05dc211d1b803b18eaf2c8a4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sun, 23 Nov 2025 22:02:09 +0100 Subject: [PATCH] refactor(frontend): extract EventDetailsPage into components Split large EventDetailsPage (545 lines) into smaller, focused components: - QRCodeSection: QR code display and copy link functionality - ParticipantsSection: participants list with avatars - MatchingConfigSection: deadline and matching controls - ScheduleConfigSection: division slot configuration EventDetailsPage now 148 lines (-73%) --- .../events/MatchingConfigSection.jsx | 133 ++++++ .../components/events/ParticipantsSection.jsx | 63 +++ .../src/components/events/QRCodeSection.jsx | 77 ++++ .../events/ScheduleConfigSection.jsx | 186 ++++++++ frontend/src/pages/EventDetailsPage.jsx | 423 +----------------- 5 files changed, 472 insertions(+), 410 deletions(-) create mode 100644 frontend/src/components/events/MatchingConfigSection.jsx create mode 100644 frontend/src/components/events/ParticipantsSection.jsx create mode 100644 frontend/src/components/events/QRCodeSection.jsx create mode 100644 frontend/src/components/events/ScheduleConfigSection.jsx diff --git a/frontend/src/components/events/MatchingConfigSection.jsx b/frontend/src/components/events/MatchingConfigSection.jsx new file mode 100644 index 0000000..91b2e6e --- /dev/null +++ b/frontend/src/components/events/MatchingConfigSection.jsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { Video, Clock, Save, RefreshCw } from 'lucide-react'; +import { matchingAPI } from '../../services/api'; + +/** + * Auto-matching configuration section + * Allows setting registration deadline and running matching algorithm + */ +const MatchingConfigSection = ({ slug, event, onRefresh }) => { + const [deadlineInput, setDeadlineInput] = useState(() => { + if (event?.registrationDeadline) { + const deadline = new Date(event.registrationDeadline); + return deadline.toISOString().slice(0, 16); + } + return ''; + }); + const [savingDeadline, setSavingDeadline] = useState(false); + const [runningMatching, setRunningMatching] = useState(false); + + const handleSaveDeadline = async () => { + try { + setSavingDeadline(true); + const deadline = deadlineInput ? new Date(deadlineInput).toISOString() : null; + await matchingAPI.setRegistrationDeadline(slug, deadline); + onRefresh?.(); + } catch (err) { + console.error('Failed to save deadline:', err); + alert('Nie udalo sie zapisac deadline'); + } finally { + setSavingDeadline(false); + } + }; + + const handleRunMatching = async () => { + try { + setRunningMatching(true); + const result = await matchingAPI.runMatching(slug); + alert(`Matching zakonczony! Dopasowano: ${result.matched}, Nie znaleziono: ${result.notFound}`); + onRefresh?.(); + } catch (err) { + console.error('Failed to run matching:', err); + alert('Nie udalo sie uruchomic matchingu'); + } finally { + setRunningMatching(false); + } + }; + + return ( +
+

+

+ +
+ {/* Registration Deadline */} +
+ +

+ Matching uruchomi sie 30 min po tym terminie +

+
+ setDeadlineInput(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" + /> + +
+ {event?.registrationDeadline && ( +

+ Aktualny deadline: {new Date(event.registrationDeadline).toLocaleString('pl-PL')} +

+ )} +
+ + {/* Matching Status & Run */} +
+ + {event?.matchingRunAt ? ( +
+

+ Ostatnie uruchomienie: {new Date(event.matchingRunAt).toLocaleString('pl-PL')} +

+
+ ) : ( +
+

+ Matching nie byl jeszcze uruchomiony +

+
+ )} + +
+
+
+ ); +}; + +export default MatchingConfigSection; diff --git a/frontend/src/components/events/ParticipantsSection.jsx b/frontend/src/components/events/ParticipantsSection.jsx new file mode 100644 index 0000000..64953ce --- /dev/null +++ b/frontend/src/components/events/ParticipantsSection.jsx @@ -0,0 +1,63 @@ +import { Users } from 'lucide-react'; + +/** + * Participants section for event details page + * Displays list of event participants with avatars + */ +const ParticipantsSection = ({ participants, totalCount }) => { + return ( +
+

+ + Participants ({totalCount}) +

+ + {participants.length === 0 ? ( +
+ +

No participants yet

+

Share the QR code to get started!

+
+ ) : ( +
+ {participants.map((participant) => ( +
+ {/* Avatar */} +
+ {participant.avatar ? ( + {participant.username} + ) : ( + {participant.username.charAt(0).toUpperCase()} + )} +
+ + {/* User Info */} +
+

+ {participant.firstName && participant.lastName + ? `${participant.firstName} ${participant.lastName}` + : participant.username} +

+

@{participant.username}

+
+ + {/* Joined Date */} +
+ {new Date(participant.joinedAt).toLocaleDateString()} +
+
+ ))} +
+ )} +
+ ); +}; + +export default ParticipantsSection; diff --git a/frontend/src/components/events/QRCodeSection.jsx b/frontend/src/components/events/QRCodeSection.jsx new file mode 100644 index 0000000..44cecc6 --- /dev/null +++ b/frontend/src/components/events/QRCodeSection.jsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import { Copy, Check, QrCode } from 'lucide-react'; + +/** + * QR Code section for event check-in + * Displays QR code, copy link button, and validity period + */ +const QRCodeSection = ({ checkin, formatDate }) => { + const [copied, setCopied] = useState(false); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(checkin.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( +
+

+ + Event Check-in QR Code +

+ + {/* QR Code Display */} +
+ +
+ + {/* Check-in URL */} +
+ +
+ + +
+
+ + {/* Valid Dates */} +
+

Valid Period

+

+ {formatDate(checkin.validFrom)} - {formatDate(checkin.validUntil)} +

+ {process.env.NODE_ENV === 'development' && ( +

+ Development mode: Date validation disabled +

+ )} +
+
+ ); +}; + +export default QRCodeSection; diff --git a/frontend/src/components/events/ScheduleConfigSection.jsx b/frontend/src/components/events/ScheduleConfigSection.jsx new file mode 100644 index 0000000..c661793 --- /dev/null +++ b/frontend/src/components/events/ScheduleConfigSection.jsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from 'react'; +import { Layers, Plus, Trash2, Save, RefreshCw } from 'lucide-react'; +import { matchingAPI, divisionsAPI } from '../../services/api'; + +/** + * Schedule configuration section for division collision groups + * Allows organizing divisions into time slots for matching algorithm + */ +const ScheduleConfigSection = ({ slug, event, onRefresh }) => { + const [divisions, setDivisions] = useState([]); + const [scheduleSlots, setScheduleSlots] = useState([]); + const [savingSchedule, setSavingSchedule] = useState(false); + + // Load divisions on mount + useEffect(() => { + loadDivisions(); + }, []); + + // Initialize schedule slots when event changes + useEffect(() => { + if (event?.scheduleConfig?.slots) { + setScheduleSlots(event.scheduleConfig.slots); + } else { + setScheduleSlots([]); + } + }, [event]); + + const loadDivisions = async () => { + try { + const data = await divisionsAPI.getAll(); + setDivisions(data); + } catch (err) { + console.error('Failed to load divisions:', err); + } + }; + + const handleAddSlot = () => { + const maxOrder = scheduleSlots.length > 0 + ? Math.max(...scheduleSlots.map(s => s.order)) + : 0; + setScheduleSlots([...scheduleSlots, { order: maxOrder + 1, divisionIds: [] }]); + }; + + const handleRemoveSlot = (order) => { + setScheduleSlots(scheduleSlots.filter(s => s.order !== order)); + }; + + const handleToggleDivision = (slotOrder, divisionId) => { + setScheduleSlots(scheduleSlots.map(slot => { + if (slot.order !== slotOrder) { + // Remove division from other slots if it's being added to this one + return { + ...slot, + divisionIds: slot.divisionIds.filter(id => id !== divisionId) + }; + } + // Toggle division in this slot + const hasDiv = slot.divisionIds.includes(divisionId); + return { + ...slot, + divisionIds: hasDiv + ? slot.divisionIds.filter(id => id !== divisionId) + : [...slot.divisionIds, divisionId] + }; + })); + }; + + const handleSaveSchedule = async () => { + try { + setSavingSchedule(true); + const scheduleConfig = scheduleSlots.length > 0 + ? { slots: scheduleSlots.filter(s => s.divisionIds.length > 0) } + : null; + await matchingAPI.setScheduleConfig(slug, scheduleConfig); + onRefresh?.(); + } catch (err) { + console.error('Failed to save schedule:', err); + alert('Nie udalo sie zapisac harmonogramu'); + } finally { + setSavingSchedule(false); + } + }; + + // Get all assigned division IDs + const getAssignedDivisionIds = () => { + return new Set(scheduleSlots.flatMap(s => s.divisionIds)); + }; + + return ( +
+

+ + Konfiguracja harmonogramu +

+

+ Dywizje w tym samym slocie czasowym koliduja ze soba (nie mozna nagrywac podczas gdy sie tancczy). + Dywizje bez przypisanego slotu sa traktowane jako osobne. +

+ + {/* Slots */} +
+ {scheduleSlots + .sort((a, b) => a.order - b.order) + .map((slot) => ( +
+
+

+ Slot {slot.order} +

+ +
+
+ {divisions.map((division) => { + const isInSlot = slot.divisionIds.includes(division.id); + const assignedDivIds = getAssignedDivisionIds(); + const isInOtherSlot = assignedDivIds.has(division.id) && !isInSlot; + + return ( + + ); + })} +
+ {slot.divisionIds.length === 0 && ( +

+ Kliknij dywizje aby dodac do slotu +

+ )} +
+ ))} +
+ + {/* Add slot button */} +
+ + +
+ + {/* Current config info */} + {event?.scheduleConfig?.slots?.length > 0 && ( +
+

+ Zapisany harmonogram: {event.scheduleConfig.slots.length} slot(ow) +

+
+ )} +
+ ); +}; + +export default ScheduleConfigSection; diff --git a/frontend/src/pages/EventDetailsPage.jsx b/frontend/src/pages/EventDetailsPage.jsx index 06a61f4..8e6e165 100644 --- a/frontend/src/pages/EventDetailsPage.jsx +++ b/frontend/src/pages/EventDetailsPage.jsx @@ -1,58 +1,28 @@ import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { QRCodeSVG } from 'qrcode.react'; -import { Copy, Check, Users, Calendar, MapPin, QrCode, Video, Clock, Save, RefreshCw, Layers, Plus, Trash2 } from 'lucide-react'; +import { Calendar, MapPin } from 'lucide-react'; import Layout from '../components/layout/Layout'; -import { eventsAPI, matchingAPI, divisionsAPI } from '../services/api'; +import { eventsAPI } from '../services/api'; +import QRCodeSection from '../components/events/QRCodeSection'; +import ParticipantsSection from '../components/events/ParticipantsSection'; +import MatchingConfigSection from '../components/events/MatchingConfigSection'; +import ScheduleConfigSection from '../components/events/ScheduleConfigSection'; export default function EventDetailsPage() { const { slug } = useParams(); const [eventDetails, setEventDetails] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [copied, setCopied] = useState(false); - - // Registration deadline state - const [deadlineInput, setDeadlineInput] = useState(''); - const [savingDeadline, setSavingDeadline] = useState(false); - const [runningMatching, setRunningMatching] = useState(false); - - // Schedule config state - const [divisions, setDivisions] = useState([]); - const [scheduleSlots, setScheduleSlots] = useState([]); - const [savingSchedule, setSavingSchedule] = useState(false); useEffect(() => { fetchEventDetails(); - loadDivisions(); }, [slug]); - // Initialize schedule slots when eventDetails loads - useEffect(() => { - if (eventDetails?.event?.scheduleConfig?.slots) { - setScheduleSlots(eventDetails.event.scheduleConfig.slots); - } - }, [eventDetails]); - - const loadDivisions = async () => { - try { - const data = await divisionsAPI.getAll(); - setDivisions(data); - } catch (err) { - console.error('Failed to load divisions:', err); - } - }; - const fetchEventDetails = async () => { try { setLoading(true); const response = await eventsAPI.getDetails(slug); setEventDetails(response.data); - // Initialize deadline input - if (response.data?.event?.registrationDeadline) { - const deadline = new Date(response.data.event.registrationDeadline); - setDeadlineInput(deadline.toISOString().slice(0, 16)); // Format for datetime-local - } } catch (err) { console.error('Error loading event details:', err); setError(err.message || 'Failed to load event details'); @@ -61,103 +31,6 @@ export default function EventDetailsPage() { } }; - const handleSaveDeadline = async () => { - try { - setSavingDeadline(true); - const deadline = deadlineInput ? new Date(deadlineInput).toISOString() : null; - await matchingAPI.setRegistrationDeadline(slug, deadline); - await fetchEventDetails(); // Refresh data - } catch (err) { - console.error('Failed to save deadline:', err); - alert('Nie udalo sie zapisac deadline'); - } finally { - setSavingDeadline(false); - } - }; - - const handleRunMatching = async () => { - try { - setRunningMatching(true); - const result = await matchingAPI.runMatching(slug); - alert(`Matching zakonczony! Dopasowano: ${result.matched}, Nie znaleziono: ${result.notFound}`); - await fetchEventDetails(); // Refresh data - } catch (err) { - console.error('Failed to run matching:', err); - alert('Nie udalo sie uruchomic matchingu'); - } finally { - setRunningMatching(false); - } - }; - - // Schedule config handlers - const handleAddSlot = () => { - const maxOrder = scheduleSlots.length > 0 - ? Math.max(...scheduleSlots.map(s => s.order)) - : 0; - setScheduleSlots([...scheduleSlots, { order: maxOrder + 1, divisionIds: [] }]); - }; - - const handleRemoveSlot = (order) => { - setScheduleSlots(scheduleSlots.filter(s => s.order !== order)); - }; - - const handleToggleDivision = (slotOrder, divisionId) => { - setScheduleSlots(scheduleSlots.map(slot => { - if (slot.order !== slotOrder) { - // Remove division from other slots if it's being added to this one - return { - ...slot, - divisionIds: slot.divisionIds.filter(id => id !== divisionId) - }; - } - // Toggle division in this slot - const hasDiv = slot.divisionIds.includes(divisionId); - return { - ...slot, - divisionIds: hasDiv - ? slot.divisionIds.filter(id => id !== divisionId) - : [...slot.divisionIds, divisionId] - }; - })); - }; - - const handleSaveSchedule = async () => { - try { - setSavingSchedule(true); - const scheduleConfig = scheduleSlots.length > 0 - ? { slots: scheduleSlots.filter(s => s.divisionIds.length > 0) } - : null; - await matchingAPI.setScheduleConfig(slug, scheduleConfig); - await fetchEventDetails(); - } catch (err) { - console.error('Failed to save schedule:', err); - alert('Nie udalo sie zapisac harmonogramu'); - } finally { - setSavingSchedule(false); - } - }; - - // Get division name by ID - const getDivisionName = (divisionId) => { - const div = divisions.find(d => d.id === divisionId); - return div ? div.abbreviation : `#${divisionId}`; - }; - - // Get all assigned division IDs - const getAssignedDivisionIds = () => { - return new Set(scheduleSlots.flatMap(s => s.divisionIds)); - }; - - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText(eventDetails.checkin.url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - const formatDate = (dateString) => { return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', @@ -221,290 +94,20 @@ export default function EventDetailsPage() { + {/* QR Code & Participants Grid */}
- {/* QR Code Section */} -
-

- - Event Check-in QR Code -

- - {/* QR Code Display */} -
- -
- - {/* Check-in URL */} -
- -
- - -
-
- - {/* Valid Dates */} -
-

Valid Period

-

- {formatDate(checkin.validFrom)} - {formatDate(checkin.validUntil)} -

- {process.env.NODE_ENV === 'development' && ( -

- ⚠️ Development mode: Date validation disabled -

- )} -
-
- - {/* Participants Section */} -
-

- - Participants ({stats.totalParticipants}) -

- - {participants.length === 0 ? ( -
- -

No participants yet

-

Share the QR code to get started!

-
- ) : ( -
- {participants.map((participant) => ( -
- {/* Avatar */} -
- {participant.avatar ? ( - {participant.username} - ) : ( - {participant.username.charAt(0).toUpperCase()} - )} -
- - {/* User Info */} -
-

- {participant.firstName && participant.lastName - ? `${participant.firstName} ${participant.lastName}` - : participant.username} -

-

@{participant.username}

-
- - {/* Joined Date */} -
- {new Date(participant.joinedAt).toLocaleDateString()} -
-
- ))} -
- )} -
+ +
{/* Auto-Matching Configuration */} -
-

-

- -
- {/* Registration Deadline */} -
- -

- Matching uruchomi sie 30 min po tym terminie -

-
- setDeadlineInput(e.target.value)} - className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" - /> - -
- {event.registrationDeadline && ( -

- Aktualny deadline: {new Date(event.registrationDeadline).toLocaleString('pl-PL')} -

- )} -
- - {/* Matching Status & Run */} -
- - {event.matchingRunAt ? ( -
-

- Ostatnie uruchomienie: {new Date(event.matchingRunAt).toLocaleString('pl-PL')} -

-
- ) : ( -
-

- Matching nie byl jeszcze uruchomiony -

-
- )} - -
-
+
+
{/* Schedule Configuration */} -
-

- - Konfiguracja harmonogramu -

-

- Dywizje w tym samym slocie czasowym koliduja ze soba (nie mozna nagrywac podczas gdy sie tancczy). - Dywizje bez przypisanego slotu sa traktowane jako osobne. -

- - {/* Slots */} -
- {scheduleSlots - .sort((a, b) => a.order - b.order) - .map((slot) => ( -
-
-

- Slot {slot.order} -

- -
-
- {divisions.map((division) => { - const isInSlot = slot.divisionIds.includes(division.id); - const assignedDivIds = getAssignedDivisionIds(); - const isInOtherSlot = assignedDivIds.has(division.id) && !isInSlot; - - return ( - - ); - })} -
- {slot.divisionIds.length === 0 && ( -

- Kliknij dywizje aby dodac do slotu -

- )} -
- ))} -
- - {/* Add slot button */} -
- - -
- - {/* Current config info */} - {event.scheduleConfig?.slots?.length > 0 && ( -
-

- Zapisany harmonogram: {event.scheduleConfig.slots.length} slot(ow) -

-
- )} +
+
{/* Action Buttons */}