feat(matching): add schedule config for division collision groups
Allow event organizers to configure which divisions run in parallel (same time slot) for accurate collision detection in the auto-matching algorithm. Divisions in the same slot will collide with each other. - Add scheduleConfig JSON field to Event model - Add PUT /events/:slug/schedule-config API endpoint - Update matching algorithm to use slot-based collision detection - Add UI in EventDetailsPage for managing division slots - Add unit tests for schedule-based collision detection
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "events" ADD COLUMN "schedule_config" JSONB;
|
||||
@@ -80,6 +80,7 @@ model Event {
|
||||
// Auto-matching configuration
|
||||
registrationDeadline DateTime? @map("registration_deadline") // When registration closes
|
||||
matchingRunAt DateTime? @map("matching_run_at") // When auto-matching was last run
|
||||
scheduleConfig Json? @map("schedule_config") // Division order and collision groups
|
||||
|
||||
// Relations
|
||||
chatRooms ChatRoom[]
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
const {
|
||||
getTimeSlot,
|
||||
getBufferSlots,
|
||||
getCoverableHeats,
|
||||
hasCollision,
|
||||
getLocationScore,
|
||||
buildDivisionSlotMap,
|
||||
MAX_RECORDINGS_PER_PERSON,
|
||||
HEAT_BUFFER,
|
||||
} = require('../services/matching');
|
||||
@@ -197,4 +199,191 @@ describe('Matching Service - Unit Tests', () => {
|
||||
expect(HEAT_BUFFER).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schedule Config - buildDivisionSlotMap', () => {
|
||||
it('should return empty map for null config', () => {
|
||||
const result = buildDivisionSlotMap(null);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty map for empty slots', () => {
|
||||
const result = buildDivisionSlotMap({ slots: [] });
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should map divisions to their slots', () => {
|
||||
const config = {
|
||||
slots: [
|
||||
{ order: 1, divisionIds: [1, 2] },
|
||||
{ order: 2, divisionIds: [3, 4] },
|
||||
]
|
||||
};
|
||||
const result = buildDivisionSlotMap(config);
|
||||
|
||||
expect(result.get(1)).toBe(1);
|
||||
expect(result.get(2)).toBe(1);
|
||||
expect(result.get(3)).toBe(2);
|
||||
expect(result.get(4)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schedule Config - getTimeSlot with divisionSlotMap', () => {
|
||||
it('should use division-based slot without map', () => {
|
||||
const heat = { divisionId: 1, competitionTypeId: 2, heatNumber: 3 };
|
||||
expect(getTimeSlot(heat)).toBe('1-2-3');
|
||||
expect(getTimeSlot(heat, null)).toBe('1-2-3');
|
||||
expect(getTimeSlot(heat, new Map())).toBe('1-2-3');
|
||||
});
|
||||
|
||||
it('should use slot-based collision with map', () => {
|
||||
const divisionSlotMap = new Map([
|
||||
[1, 1], // Division 1 -> Slot 1
|
||||
[2, 1], // Division 2 -> Slot 1 (same slot!)
|
||||
[3, 2], // Division 3 -> Slot 2
|
||||
]);
|
||||
|
||||
const heat1 = { divisionId: 1, competitionTypeId: 1, heatNumber: 1 };
|
||||
const heat2 = { divisionId: 2, competitionTypeId: 1, heatNumber: 1 };
|
||||
const heat3 = { divisionId: 3, competitionTypeId: 1, heatNumber: 1 };
|
||||
|
||||
// Divisions 1 and 2 should have the same slot
|
||||
expect(getTimeSlot(heat1, divisionSlotMap)).toBe('slot1-1-1');
|
||||
expect(getTimeSlot(heat2, divisionSlotMap)).toBe('slot1-1-1');
|
||||
// Division 3 should be in slot 2
|
||||
expect(getTimeSlot(heat3, divisionSlotMap)).toBe('slot2-1-1');
|
||||
});
|
||||
|
||||
it('should fallback for divisions not in map', () => {
|
||||
const divisionSlotMap = new Map([
|
||||
[1, 1], // Only division 1 is mapped
|
||||
]);
|
||||
|
||||
const heat = { divisionId: 99, competitionTypeId: 1, heatNumber: 1 };
|
||||
expect(getTimeSlot(heat, divisionSlotMap)).toBe('99-1-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schedule Config - getCoverableHeats with collision groups', () => {
|
||||
it('should detect collision between divisions in same slot', () => {
|
||||
// Schedule: Novice (1) and Intermediate (2) run in parallel (same slot)
|
||||
const divisionSlotMap = new Map([
|
||||
[1, 1], // Novice -> Slot 1
|
||||
[2, 1], // Intermediate -> Slot 1
|
||||
]);
|
||||
|
||||
const dancerHeats = [
|
||||
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // Novice J&J H1
|
||||
];
|
||||
const recorderHeats = [
|
||||
{ divisionId: 2, competitionTypeId: 1, heatNumber: 1 }, // Intermediate J&J H1 (same slot!)
|
||||
];
|
||||
|
||||
const result = getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap);
|
||||
// Should be empty - recorder is busy in Intermediate H1 which is in same slot as Novice H1
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should NOT detect collision between divisions in different slots', () => {
|
||||
// Schedule: Novice (1) in slot 1, Advanced (3) in slot 2
|
||||
const divisionSlotMap = new Map([
|
||||
[1, 1], // Novice -> Slot 1
|
||||
[3, 2], // Advanced -> Slot 2
|
||||
]);
|
||||
|
||||
const dancerHeats = [
|
||||
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, // Novice J&J H1
|
||||
];
|
||||
const recorderHeats = [
|
||||
{ divisionId: 3, competitionTypeId: 1, heatNumber: 1 }, // Advanced J&J H1 (different slot)
|
||||
];
|
||||
|
||||
const result = getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap);
|
||||
// Recorder can cover this heat - different time slots
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('should apply buffer within same slot group', () => {
|
||||
// Schedule: Novice (1) and Intermediate (2) in same slot
|
||||
const divisionSlotMap = new Map([
|
||||
[1, 1], // Novice -> Slot 1
|
||||
[2, 1], // Intermediate -> Slot 1
|
||||
]);
|
||||
|
||||
const dancerHeats = [
|
||||
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 2 }, // Novice J&J H2
|
||||
];
|
||||
const recorderHeats = [
|
||||
{ divisionId: 2, competitionTypeId: 1, heatNumber: 1 }, // Intermediate J&J H1
|
||||
];
|
||||
|
||||
const result = getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap);
|
||||
// Heat 2 is within buffer of Heat 1 (same slot group)
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle complex schedule with multiple slots', () => {
|
||||
// Typical dance event schedule:
|
||||
// Slot 1: Newcomer (1)
|
||||
// Slot 2: Novice (2), Intermediate (3) - run in parallel
|
||||
// Slot 3: Advanced (4), All-Star (5), Champions (6) - run in parallel
|
||||
const divisionSlotMap = new Map([
|
||||
[1, 1], // Newcomer
|
||||
[2, 2], // Novice
|
||||
[3, 2], // Intermediate
|
||||
[4, 3], // Advanced
|
||||
[5, 3], // All-Star
|
||||
[6, 3], // Champions
|
||||
]);
|
||||
|
||||
const dancerHeats = [
|
||||
{ id: 1, divisionId: 2, competitionTypeId: 1, heatNumber: 1 }, // Novice H1
|
||||
{ id: 2, divisionId: 4, competitionTypeId: 1, heatNumber: 1 }, // Advanced H1
|
||||
];
|
||||
|
||||
// Recorder is dancing Intermediate H1 (same slot as Novice H1)
|
||||
const recorderHeats = [
|
||||
{ divisionId: 3, competitionTypeId: 1, heatNumber: 1 }, // Intermediate H1
|
||||
];
|
||||
|
||||
const result = getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap);
|
||||
// Only Advanced H1 should be coverable (different slot)
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schedule Config - hasCollision with collision groups', () => {
|
||||
it('should detect collision between same-slot divisions', () => {
|
||||
const divisionSlotMap = new Map([
|
||||
[1, 1],
|
||||
[2, 1], // Same slot as 1
|
||||
]);
|
||||
|
||||
const dancerHeats = [
|
||||
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
|
||||
];
|
||||
const recorderHeats = [
|
||||
{ divisionId: 2, competitionTypeId: 1, heatNumber: 1 },
|
||||
];
|
||||
|
||||
expect(hasCollision(dancerHeats, recorderHeats, divisionSlotMap)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect collision between different-slot divisions', () => {
|
||||
const divisionSlotMap = new Map([
|
||||
[1, 1],
|
||||
[2, 2], // Different slot
|
||||
]);
|
||||
|
||||
const dancerHeats = [
|
||||
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
|
||||
];
|
||||
const recorderHeats = [
|
||||
{ divisionId: 2, competitionTypeId: 1, heatNumber: 1 },
|
||||
];
|
||||
|
||||
expect(hasCollision(dancerHeats, recorderHeats, divisionSlotMap)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,6 +381,7 @@ router.get('/:slug/details', authenticate, async (req, res, next) => {
|
||||
description: event.description,
|
||||
registrationDeadline: event.registrationDeadline,
|
||||
matchingRunAt: event.matchingRunAt,
|
||||
scheduleConfig: event.scheduleConfig,
|
||||
},
|
||||
checkin: {
|
||||
token: checkinToken.token,
|
||||
@@ -914,6 +915,76 @@ router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => {
|
||||
// AUTO-MATCHING ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
// PUT /api/events/:slug/schedule-config - Set schedule configuration (division order and collision groups)
|
||||
router.put('/:slug/schedule-config', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { scheduleConfig } = req.body;
|
||||
|
||||
// Validate schedule config structure
|
||||
if (scheduleConfig !== null && scheduleConfig !== undefined) {
|
||||
if (!scheduleConfig.slots || !Array.isArray(scheduleConfig.slots)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'scheduleConfig must have a "slots" array',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate each slot
|
||||
for (let i = 0; i < scheduleConfig.slots.length; i++) {
|
||||
const slot = scheduleConfig.slots[i];
|
||||
if (typeof slot.order !== 'number' || !Array.isArray(slot.divisionIds)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Slot ${i} must have "order" (number) and "divisionIds" (array)`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate divisionIds are numbers
|
||||
if (!slot.divisionIds.every(id => typeof id === 'number')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Slot ${i} divisionIds must be an array of numbers`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find event
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Event not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Update schedule config
|
||||
const updated = await prisma.event.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
scheduleConfig: scheduleConfig || null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
scheduleConfig: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/events/:slug/registration-deadline - Set registration deadline
|
||||
router.put('/:slug/registration-deadline', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Matches dancers with recorders based on:
|
||||
* - Heat collision avoidance (can't record while dancing)
|
||||
* - Schedule config (divisions in same slot collide)
|
||||
* - Buffer time (1 heat after dancing)
|
||||
* - Location preference (same city > same country > anyone)
|
||||
* - Max recordings per person (3)
|
||||
@@ -15,19 +16,65 @@ const MAX_RECORDINGS_PER_PERSON = 3;
|
||||
const HEAT_BUFFER = 1; // Number of heats after dancing before can record
|
||||
|
||||
/**
|
||||
* Represents a time slot as a unique string
|
||||
* Format: "divisionId-competitionTypeId-heatNumber"
|
||||
* Build division-to-slot mapping from schedule config
|
||||
* Returns Map<divisionId, slotOrder>
|
||||
*
|
||||
* If no schedule config, each division is its own slot
|
||||
*/
|
||||
function getTimeSlot(heat) {
|
||||
function buildDivisionSlotMap(scheduleConfig) {
|
||||
const divisionSlotMap = new Map();
|
||||
|
||||
if (!scheduleConfig || !scheduleConfig.slots) {
|
||||
return divisionSlotMap; // Empty map = fallback to division-based slots
|
||||
}
|
||||
|
||||
for (const slot of scheduleConfig.slots) {
|
||||
for (const divisionId of slot.divisionIds) {
|
||||
divisionSlotMap.set(divisionId, slot.order);
|
||||
}
|
||||
}
|
||||
|
||||
return divisionSlotMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a time slot as a unique string
|
||||
* Format: "slotOrder-competitionTypeId-heatNumber"
|
||||
*
|
||||
* If scheduleConfig is provided, uses slot-based collision detection
|
||||
* (divisions in the same slot collide with each other)
|
||||
* Otherwise falls back to division-based slots
|
||||
*/
|
||||
function getTimeSlot(heat, divisionSlotMap = null) {
|
||||
// If we have a slot map, use slot-based collision
|
||||
if (divisionSlotMap && divisionSlotMap.size > 0) {
|
||||
const slotOrder = divisionSlotMap.get(heat.divisionId);
|
||||
if (slotOrder !== undefined) {
|
||||
return `slot${slotOrder}-${heat.competitionTypeId}-${heat.heatNumber}`;
|
||||
}
|
||||
}
|
||||
// Fallback: each division+competitionType is its own slot
|
||||
return `${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get adjacent slots (for buffer calculation)
|
||||
* Returns the next slot within the same division+competitionType
|
||||
* Returns the next slot within the same slot group
|
||||
*/
|
||||
function getBufferSlots(heat) {
|
||||
function getBufferSlots(heat, divisionSlotMap = null) {
|
||||
const bufferSlots = [];
|
||||
|
||||
if (divisionSlotMap && divisionSlotMap.size > 0) {
|
||||
const slotOrder = divisionSlotMap.get(heat.divisionId);
|
||||
if (slotOrder !== undefined) {
|
||||
for (let i = 1; i <= HEAT_BUFFER; i++) {
|
||||
bufferSlots.push(`slot${slotOrder}-${heat.competitionTypeId}-${heat.heatNumber + i}`);
|
||||
}
|
||||
return bufferSlots;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: division-based buffer
|
||||
for (let i = 1; i <= HEAT_BUFFER; i++) {
|
||||
bufferSlots.push(`${heat.divisionId}-${heat.competitionTypeId}-${heat.heatNumber + i}`);
|
||||
}
|
||||
@@ -39,27 +86,27 @@ function getBufferSlots(heat) {
|
||||
* User A is dancing, User B wants to record
|
||||
* Returns true if B cannot record A (collision exists)
|
||||
*/
|
||||
function hasCollision(dancerHeats, recorderHeats) {
|
||||
function hasCollision(dancerHeats, recorderHeats, divisionSlotMap = null) {
|
||||
// Get all slots where dancer is dancing + buffer slots
|
||||
const blockedSlots = new Set();
|
||||
|
||||
for (const heat of dancerHeats) {
|
||||
blockedSlots.add(getTimeSlot(heat));
|
||||
blockedSlots.add(getTimeSlot(heat, divisionSlotMap));
|
||||
}
|
||||
|
||||
// Get all slots where recorder is dancing + their buffer slots
|
||||
const recorderBusySlots = new Set();
|
||||
for (const heat of recorderHeats) {
|
||||
recorderBusySlots.add(getTimeSlot(heat));
|
||||
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
|
||||
// Add buffer after their heats
|
||||
for (const bufferSlot of getBufferSlots(heat)) {
|
||||
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) {
|
||||
recorderBusySlots.add(bufferSlot);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of dancer's heats fall within recorder's busy slots
|
||||
for (const heat of dancerHeats) {
|
||||
const slot = getTimeSlot(heat);
|
||||
const slot = getTimeSlot(heat, divisionSlotMap);
|
||||
if (recorderBusySlots.has(slot)) {
|
||||
return true; // Collision: recorder is busy during this heat
|
||||
}
|
||||
@@ -72,18 +119,18 @@ function hasCollision(dancerHeats, recorderHeats) {
|
||||
* Check which specific heats a recorder can cover for a dancer
|
||||
* Returns array of heat IDs that recorder can film
|
||||
*/
|
||||
function getCoverableHeats(dancerHeats, recorderHeats) {
|
||||
function getCoverableHeats(dancerHeats, recorderHeats, divisionSlotMap = null) {
|
||||
const recorderBusySlots = new Set();
|
||||
|
||||
for (const heat of recorderHeats) {
|
||||
recorderBusySlots.add(getTimeSlot(heat));
|
||||
for (const bufferSlot of getBufferSlots(heat)) {
|
||||
recorderBusySlots.add(getTimeSlot(heat, divisionSlotMap));
|
||||
for (const bufferSlot of getBufferSlots(heat, divisionSlotMap)) {
|
||||
recorderBusySlots.add(bufferSlot);
|
||||
}
|
||||
}
|
||||
|
||||
return dancerHeats.filter(heat => {
|
||||
const slot = getTimeSlot(heat);
|
||||
const slot = getTimeSlot(heat, divisionSlotMap);
|
||||
return !recorderBusySlots.has(slot);
|
||||
});
|
||||
}
|
||||
@@ -111,6 +158,18 @@ function getLocationScore(dancer, recorder) {
|
||||
* Greedy approach: for each heat, find the best available recorder
|
||||
*/
|
||||
async function runMatching(eventId) {
|
||||
// 0. Load event with schedule config
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: {
|
||||
id: true,
|
||||
scheduleConfig: true,
|
||||
}
|
||||
});
|
||||
|
||||
// Build division-to-slot map from schedule config
|
||||
const divisionSlotMap = buildDivisionSlotMap(event?.scheduleConfig);
|
||||
|
||||
// 1. Get all participants with their heats and user info
|
||||
const participants = await prisma.eventParticipant.findMany({
|
||||
where: { eventId },
|
||||
@@ -190,7 +249,7 @@ async function runMatching(eventId) {
|
||||
|
||||
// Check if this recorder can cover this specific heat
|
||||
const recorderHeats = heatsByUser.get(recorder.userId) || [];
|
||||
const coverableHeats = getCoverableHeats([heat], recorderHeats);
|
||||
const coverableHeats = getCoverableHeats([heat], recorderHeats, divisionSlotMap);
|
||||
|
||||
if (coverableHeats.length > 0) {
|
||||
candidates.push({
|
||||
@@ -337,7 +396,9 @@ module.exports = {
|
||||
hasCollision,
|
||||
getCoverableHeats,
|
||||
getTimeSlot,
|
||||
getBufferSlots,
|
||||
getLocationScore,
|
||||
buildDivisionSlotMap,
|
||||
MAX_RECORDINGS_PER_PERSON,
|
||||
HEAT_BUFFER,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 } 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 { eventsAPI, matchingAPI } from '../services/api';
|
||||
import { eventsAPI, matchingAPI, divisionsAPI } from '../services/api';
|
||||
|
||||
export default function EventDetailsPage() {
|
||||
const { slug } = useParams();
|
||||
@@ -17,10 +17,32 @@ export default function EventDetailsPage() {
|
||||
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);
|
||||
@@ -67,6 +89,65 @@ export default function EventDetailsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
@@ -331,6 +412,101 @@ export default function EventDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Configuration */}
|
||||
<div className="mt-6 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>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex gap-4">
|
||||
<Link
|
||||
|
||||
@@ -430,6 +430,15 @@ export const matchingAPI = {
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// Set schedule config (admin)
|
||||
async setScheduleConfig(slug, scheduleConfig) {
|
||||
const data = await fetchAPI(`/events/${slug}/schedule-config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ scheduleConfig }),
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
Reference in New Issue
Block a user