260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
// frontend/src/components/Forms/TournamentForm.tsx
|
|
|
|
import { Loader2, Plus } from 'lucide-react';
|
|
import React, { useEffect, useState } from 'react';
|
|
import api from '../../services/api';
|
|
|
|
interface Court {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface TournamentSettings {
|
|
id: string | number;
|
|
name: string;
|
|
code: string;
|
|
type: string;
|
|
timestamp: string;
|
|
duration: number;
|
|
courts: Court[];
|
|
teams: { name?: string }[];
|
|
}
|
|
|
|
interface TournamentFormProps {
|
|
tournamentId?: string | number | null;
|
|
onSuccess: () => void;
|
|
onDelete?: (id: string | number) => void;
|
|
}
|
|
|
|
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
|
|
const [initialData, setInitialData] = useState<TournamentSettings | null>(null);
|
|
const [globalCourts, setGlobalCourts] = useState<Court[]>([]);
|
|
const [selectedCourts, setSelectedCourts] = useState<number[]>([]);
|
|
const [newCourtName, setNewCourtName] = useState('');
|
|
|
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
const fetchDependencies = async () => {
|
|
try {
|
|
const courtsRes = await api.get<Court[]>('/courts');
|
|
setGlobalCourts(courtsRes);
|
|
|
|
if (tournamentId) {
|
|
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
|
|
setInitialData(data);
|
|
setSelectedCourts(data.courts.map(c => c.id));
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Failed to load tournament data.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
void fetchDependencies();
|
|
}, [tournamentId]);
|
|
|
|
const handleAddCourt = async () => {
|
|
if (!newCourtName.trim()) return;
|
|
try {
|
|
const added = await api.post<Court>('/courts', { name: newCourtName.trim() });
|
|
setGlobalCourts([...globalCourts, added]);
|
|
setSelectedCourts([...selectedCourts, added.id]);
|
|
setNewCourtName('');
|
|
} catch {
|
|
setError("Failed to create new court.");
|
|
}
|
|
};
|
|
|
|
const toggleCourt = (id: number) => {
|
|
setSelectedCourts(prev =>
|
|
prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]
|
|
);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
|
|
const formData = new FormData(e.currentTarget);
|
|
|
|
const rawTeams = formData.get('teams') as string;
|
|
const date = formData.get('date') as string;
|
|
const startTime = formData.get('start_time') as string;
|
|
const typeRaw = formData.get('type') as string;
|
|
const duration = formData.get('duration') as string;
|
|
const name = formData.get('name') as string;
|
|
const code = formData.get('code') as string;
|
|
|
|
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0);
|
|
|
|
if (teams.length < 2) {
|
|
setError("At least 2 teams required.");
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
if (selectedCourts.length === 0) {
|
|
setError("Please select at least one court.");
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
const timestamp = `${date}T${startTime}:00`;
|
|
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
|
|
const parsedDuration = parseInt(duration, 10);
|
|
|
|
try {
|
|
if (tournamentId) {
|
|
const basePayload = {
|
|
name, code, type: formattedType, timestamp, duration: parsedDuration
|
|
};
|
|
|
|
await Promise.all([
|
|
api.patch(`/tournaments/${tournamentId}`, basePayload),
|
|
api.patch(`/tournaments/${tournamentId}/teams`, teams),
|
|
api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts)
|
|
]);
|
|
} else {
|
|
const fullPayload = {
|
|
name, code, type: formattedType, timestamp, duration: parsedDuration,
|
|
teams, courts: selectedCourts
|
|
};
|
|
await api.post('/tournaments', fullPayload);
|
|
}
|
|
onSuccess();
|
|
} catch (err: unknown) {
|
|
console.error(err);
|
|
setError("Error saving tournament");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex justify-center items-center p-12">
|
|
<Loader2 className="animate-spin text-orange-600" size={32} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let defaultDate = new Date().toISOString().split('T')[0];
|
|
let defaultTime = "09:00";
|
|
|
|
if (initialData?.timestamp) {
|
|
const [d, t] = initialData.timestamp.split('T');
|
|
defaultDate = d;
|
|
defaultTime = t ? t.substring(0, 5) : "09:00";
|
|
}
|
|
|
|
return (
|
|
<form key={initialData?.id || 'new'} onSubmit={handleSubmit} className="space-y-4">
|
|
{error && (
|
|
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
|
|
<input name="name" defaultValue={initialData?.name} required placeholder="My Awesome Tournament" autoFocus className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
|
|
<input name="code" defaultValue={initialData?.code} required placeholder="••••" autoComplete="off" className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
|
</div>
|
|
<div>
|
|
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
|
|
<select name="type" defaultValue={initialData?.type?.toLowerCase() || "double"} className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white">
|
|
<option value="double">Double Elimination</option>
|
|
<option value="single">Single Elimination</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-4">
|
|
<div className="col-span-2">
|
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
|
|
<input type="number" name="duration" defaultValue={initialData?.duration || 30} min="0" className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
|
|
<input type="time" name="start_time" defaultValue={defaultTime} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
|
</div>
|
|
<div className="col-span-3">
|
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
|
|
<input type="date" name="date" defaultValue={defaultDate} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* --- NEW: GLOBAL COURT SELECTOR --- */}
|
|
<div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800">
|
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label>
|
|
<div className="flex flex-wrap gap-2 mb-3">
|
|
{globalCourts.map(c => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => toggleCourt(c.id)}
|
|
className={`px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition ${selectedCourts.includes(c.id)
|
|
? 'bg-orange-500 text-white shadow-md'
|
|
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
{c.name}
|
|
</button>
|
|
))}
|
|
{globalCourts.length === 0 && <span className="text-xs italic text-zinc-400">No courts registered yet.</span>}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newCourtName}
|
|
onChange={(e) => setNewCourtName(e.target.value)}
|
|
placeholder="Add new court..."
|
|
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-3 py-1 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
|
|
/>
|
|
<button type="button" onClick={handleAddCourt} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1 rounded transition flex items-center">
|
|
<Plus size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-bold text-zinc-500 uppercase">Teams</label>
|
|
<textarea
|
|
name="teams"
|
|
placeholder="One team per line..."
|
|
defaultValue={initialData?.teams?.map(t => t.name || t).join('\n')}
|
|
rows={5}
|
|
required
|
|
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 font-mono text-sm focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
|
|
{tournamentId && onDelete && (
|
|
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline h-5 self-end">
|
|
Delete Tournament
|
|
</button>
|
|
)}
|
|
{!tournamentId && <div className="hidden"></div>}
|
|
|
|
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50">
|
|
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
} |