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%)
This commit is contained in:
133
frontend/src/components/events/MatchingConfigSection.jsx
Normal file
133
frontend/src/components/events/MatchingConfigSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Video className="text-primary-600" />
|
||||||
|
Auto-Matching (Nagrywanie)
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{/* Registration Deadline */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Clock size={16} className="inline mr-1" />
|
||||||
|
Deadline rejestracji
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
|
Matching uruchomi sie 30 min po tym terminie
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={deadlineInput}
|
||||||
|
onChange={(e) => setDeadlineInput(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveDeadline}
|
||||||
|
disabled={savingDeadline}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{savingDeadline ? (
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={16} />
|
||||||
|
)}
|
||||||
|
Zapisz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{event?.registrationDeadline && (
|
||||||
|
<p className="text-sm text-green-600 mt-2">
|
||||||
|
Aktualny deadline: {new Date(event.registrationDeadline).toLocaleString('pl-PL')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matching Status & Run */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Status matchingu
|
||||||
|
</label>
|
||||||
|
{event?.matchingRunAt ? (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-green-800 text-sm">
|
||||||
|
Ostatnie uruchomienie: {new Date(event.matchingRunAt).toLocaleString('pl-PL')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-amber-800 text-sm">
|
||||||
|
Matching nie byl jeszcze uruchomiony
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleRunMatching}
|
||||||
|
disabled={runningMatching}
|
||||||
|
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{runningMatching ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
Trwa matching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Video size={16} />
|
||||||
|
Uruchom Matching
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchingConfigSection;
|
||||||
63
frontend/src/components/events/ParticipantsSection.jsx
Normal file
63
frontend/src/components/events/ParticipantsSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Users className="text-primary-600" />
|
||||||
|
Participants ({totalCount})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{participants.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Users size={48} className="mx-auto mb-2 text-gray-300" />
|
||||||
|
<p>No participants yet</p>
|
||||||
|
<p className="text-sm">Share the QR code to get started!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||||
|
{participants.map((participant) => (
|
||||||
|
<div
|
||||||
|
key={participant.userId}
|
||||||
|
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary-600 flex items-center justify-center text-white font-semibold">
|
||||||
|
{participant.avatar ? (
|
||||||
|
<img
|
||||||
|
src={participant.avatar}
|
||||||
|
alt={participant.username}
|
||||||
|
className="w-10 h-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{participant.username.charAt(0).toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{participant.firstName && participant.lastName
|
||||||
|
? `${participant.firstName} ${participant.lastName}`
|
||||||
|
: participant.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">@{participant.username}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Joined Date */}
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{new Date(participant.joinedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParticipantsSection;
|
||||||
77
frontend/src/components/events/QRCodeSection.jsx
Normal file
77
frontend/src/components/events/QRCodeSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<QrCode className="text-primary-600" />
|
||||||
|
Event Check-in QR Code
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* QR Code Display */}
|
||||||
|
<div className="bg-white p-6 rounded-lg border-2 border-gray-200 mb-4 flex justify-center">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={checkin.url}
|
||||||
|
size={256}
|
||||||
|
level="H"
|
||||||
|
includeMargin={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Check-in URL */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Check-in Link
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={checkin.url}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Valid Dates */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm">
|
||||||
|
<p className="font-medium text-blue-900 mb-1">Valid Period</p>
|
||||||
|
<p className="text-blue-700">
|
||||||
|
{formatDate(checkin.validFrom)} - {formatDate(checkin.validUntil)}
|
||||||
|
</p>
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<p className="text-blue-600 mt-2 text-xs">
|
||||||
|
Development mode: Date validation disabled
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QRCodeSection;
|
||||||
186
frontend/src/components/events/ScheduleConfigSection.jsx
Normal file
186
frontend/src/components/events/ScheduleConfigSection.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Layers className="text-primary-600" />
|
||||||
|
Konfiguracja harmonogramu
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Dywizje w tym samym slocie czasowym koliduja ze soba (nie mozna nagrywac podczas gdy sie tancczy).
|
||||||
|
Dywizje bez przypisanego slotu sa traktowane jako osobne.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Slots */}
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
|
{scheduleSlots
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((slot) => (
|
||||||
|
<div key={slot.order} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-medium text-gray-900">
|
||||||
|
Slot {slot.order}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveSlot(slot.order)}
|
||||||
|
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
||||||
|
title="Usun slot"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{divisions.map((division) => {
|
||||||
|
const isInSlot = slot.divisionIds.includes(division.id);
|
||||||
|
const assignedDivIds = getAssignedDivisionIds();
|
||||||
|
const isInOtherSlot = assignedDivIds.has(division.id) && !isInSlot;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={division.id}
|
||||||
|
onClick={() => handleToggleDivision(slot.order, division.id)}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
||||||
|
isInSlot
|
||||||
|
? 'bg-primary-600 text-white border-primary-600'
|
||||||
|
: isInOtherSlot
|
||||||
|
? 'bg-gray-100 text-gray-400 border-gray-200 cursor-pointer'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:border-primary-400'
|
||||||
|
}`}
|
||||||
|
title={isInOtherSlot ? `Przypisane do innego slotu` : division.name}
|
||||||
|
>
|
||||||
|
{division.abbreviation}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{slot.divisionIds.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400 mt-2 italic">
|
||||||
|
Kliknij dywizje aby dodac do slotu
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add slot button */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleAddSlot}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-gray-600 hover:border-primary-400 hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Dodaj slot
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSchedule}
|
||||||
|
disabled={savingSchedule}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingSchedule ? (
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={16} />
|
||||||
|
)}
|
||||||
|
Zapisz harmonogram
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current config info */}
|
||||||
|
{event?.scheduleConfig?.slots?.length > 0 && (
|
||||||
|
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
Zapisany harmonogram: {event.scheduleConfig.slots.length} slot(ow)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduleConfigSection;
|
||||||
@@ -1,58 +1,28 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { Calendar, MapPin } from 'lucide-react';
|
||||||
import { Copy, Check, Users, Calendar, MapPin, QrCode, Video, Clock, Save, RefreshCw, Layers, Plus, Trash2 } from 'lucide-react';
|
|
||||||
import Layout from '../components/layout/Layout';
|
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() {
|
export default function EventDetailsPage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
const [eventDetails, setEventDetails] = useState(null);
|
const [eventDetails, setEventDetails] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
fetchEventDetails();
|
fetchEventDetails();
|
||||||
loadDivisions();
|
|
||||||
}, [slug]);
|
}, [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 () => {
|
const fetchEventDetails = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await eventsAPI.getDetails(slug);
|
const response = await eventsAPI.getDetails(slug);
|
||||||
setEventDetails(response.data);
|
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) {
|
} catch (err) {
|
||||||
console.error('Error loading event details:', err);
|
console.error('Error loading event details:', err);
|
||||||
setError(err.message || 'Failed to load event details');
|
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) => {
|
const formatDate = (dateString) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -221,290 +94,20 @@ export default function EventDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code & Participants Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
{/* QR Code Section */}
|
<QRCodeSection checkin={checkin} formatDate={formatDate} />
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<ParticipantsSection participants={participants} totalCount={stats.totalParticipants} />
|
||||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<QrCode className="text-primary-600" />
|
|
||||||
Event Check-in QR Code
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* QR Code Display */}
|
|
||||||
<div className="bg-white p-6 rounded-lg border-2 border-gray-200 mb-4 flex justify-center">
|
|
||||||
<QRCodeSVG
|
|
||||||
value={checkin.url}
|
|
||||||
size={256}
|
|
||||||
level="H"
|
|
||||||
includeMargin={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Check-in URL */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Check-in Link
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={checkin.url}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Valid Dates */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm">
|
|
||||||
<p className="font-medium text-blue-900 mb-1">Valid Period</p>
|
|
||||||
<p className="text-blue-700">
|
|
||||||
{formatDate(checkin.validFrom)} - {formatDate(checkin.validUntil)}
|
|
||||||
</p>
|
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<p className="text-blue-600 mt-2 text-xs">
|
|
||||||
⚠️ Development mode: Date validation disabled
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Participants Section */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<Users className="text-primary-600" />
|
|
||||||
Participants ({stats.totalParticipants})
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{participants.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<Users size={48} className="mx-auto mb-2 text-gray-300" />
|
|
||||||
<p>No participants yet</p>
|
|
||||||
<p className="text-sm">Share the QR code to get started!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
|
||||||
{participants.map((participant) => (
|
|
||||||
<div
|
|
||||||
key={participant.userId}
|
|
||||||
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
{/* Avatar */}
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-600 flex items-center justify-center text-white font-semibold">
|
|
||||||
{participant.avatar ? (
|
|
||||||
<img
|
|
||||||
src={participant.avatar}
|
|
||||||
alt={participant.username}
|
|
||||||
className="w-10 h-10 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>{participant.username.charAt(0).toUpperCase()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Info */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-gray-900">
|
|
||||||
{participant.firstName && participant.lastName
|
|
||||||
? `${participant.firstName} ${participant.lastName}`
|
|
||||||
: participant.username}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">@{participant.username}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Joined Date */}
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{new Date(participant.joinedAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto-Matching Configuration */}
|
{/* Auto-Matching Configuration */}
|
||||||
<div className="mt-6 bg-white rounded-lg shadow-md p-6">
|
<div className="mt-6">
|
||||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
<MatchingConfigSection slug={slug} event={event} onRefresh={fetchEventDetails} />
|
||||||
<Video className="text-primary-600" />
|
|
||||||
Auto-Matching (Nagrywanie)
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
{/* Registration Deadline */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
<Clock size={16} className="inline mr-1" />
|
|
||||||
Deadline rejestracji
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-500 mb-2">
|
|
||||||
Matching uruchomi sie 30 min po tym terminie
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={deadlineInput}
|
|
||||||
onChange={(e) => setDeadlineInput(e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveDeadline}
|
|
||||||
disabled={savingDeadline}
|
|
||||||
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{savingDeadline ? (
|
|
||||||
<RefreshCw size={16} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save size={16} />
|
|
||||||
)}
|
|
||||||
Zapisz
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{event.registrationDeadline && (
|
|
||||||
<p className="text-sm text-green-600 mt-2">
|
|
||||||
Aktualny deadline: {new Date(event.registrationDeadline).toLocaleString('pl-PL')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Matching Status & Run */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Status matchingu
|
|
||||||
</label>
|
|
||||||
{event.matchingRunAt ? (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-3">
|
|
||||||
<p className="text-green-800 text-sm">
|
|
||||||
Ostatnie uruchomienie: {new Date(event.matchingRunAt).toLocaleString('pl-PL')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-3">
|
|
||||||
<p className="text-amber-800 text-sm">
|
|
||||||
Matching nie byl jeszcze uruchomiony
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleRunMatching}
|
|
||||||
disabled={runningMatching}
|
|
||||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{runningMatching ? (
|
|
||||||
<>
|
|
||||||
<RefreshCw size={16} className="animate-spin" />
|
|
||||||
Trwa matching...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Video size={16} />
|
|
||||||
Uruchom Matching
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schedule Configuration */}
|
{/* Schedule Configuration */}
|
||||||
<div className="mt-6 bg-white rounded-lg shadow-md p-6">
|
<div className="mt-6">
|
||||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
<ScheduleConfigSection slug={slug} event={event} onRefresh={fetchEventDetails} />
|
||||||
<Layers className="text-primary-600" />
|
|
||||||
Konfiguracja harmonogramu
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Dywizje w tym samym slocie czasowym koliduja ze soba (nie mozna nagrywac podczas gdy sie tancczy).
|
|
||||||
Dywizje bez przypisanego slotu sa traktowane jako osobne.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Slots */}
|
|
||||||
<div className="space-y-4 mb-4">
|
|
||||||
{scheduleSlots
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map((slot) => (
|
|
||||||
<div key={slot.order} className="border border-gray-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="font-medium text-gray-900">
|
|
||||||
Slot {slot.order}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveSlot(slot.order)}
|
|
||||||
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
|
||||||
title="Usun slot"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{divisions.map((division) => {
|
|
||||||
const isInSlot = slot.divisionIds.includes(division.id);
|
|
||||||
const assignedDivIds = getAssignedDivisionIds();
|
|
||||||
const isInOtherSlot = assignedDivIds.has(division.id) && !isInSlot;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={division.id}
|
|
||||||
onClick={() => handleToggleDivision(slot.order, division.id)}
|
|
||||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
|
||||||
isInSlot
|
|
||||||
? 'bg-primary-600 text-white border-primary-600'
|
|
||||||
: isInOtherSlot
|
|
||||||
? 'bg-gray-100 text-gray-400 border-gray-200 cursor-pointer'
|
|
||||||
: 'bg-white text-gray-700 border-gray-300 hover:border-primary-400'
|
|
||||||
}`}
|
|
||||||
title={isInOtherSlot ? `Przypisane do innego slotu` : division.name}
|
|
||||||
>
|
|
||||||
{division.abbreviation}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{slot.divisionIds.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-400 mt-2 italic">
|
|
||||||
Kliknij dywizje aby dodac do slotu
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add slot button */}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleAddSlot}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-gray-600 hover:border-primary-400 hover:text-primary-600"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
Dodaj slot
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveSchedule}
|
|
||||||
disabled={savingSchedule}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{savingSchedule ? (
|
|
||||||
<RefreshCw size={16} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save size={16} />
|
|
||||||
)}
|
|
||||||
Zapisz harmonogram
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current config info */}
|
|
||||||
{event.scheduleConfig?.slots?.length > 0 && (
|
|
||||||
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
Zapisany harmonogram: {event.scheduleConfig.slots.length} slot(ow)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
|
|||||||
Reference in New Issue
Block a user