"use client" // Utslagsgrupper (2026-08-18) -- hvem spiller med hvem, klokka når, for // én runde i en individuell turnering. Bruker: "Jeg må jo kunne // bulksjekke hvem som skal spille fra hvor, med hvem, klokka når?" // Design bekreftet: TRADISJONELT utslag (samme starthull for alle, // rundens eget start_hole -- uendret her), staggerte klokkeslett per // gruppe med fast intervall, MANUELL gruppesammensetning med et forslag // å justere, FAST gruppestørrelse. // // Håndkodet direkte (ikke sendt via V0 først) -- bruker ba eksplisitt om // funksjonen NÅ ("Jeg vil ha den nå, takk"), samme avveining som // DuplicateStep i player-import-panel.tsx: en avgrenset, funksjonell // arbeidsflate satt inn i en eksisterende flyt, bygget med de samme // globale design-tokenene som resten av appen (border/bg-card/bg-primær // osv.), ikke en forsøkt "ferdig polert" ny flaggskip-skjerm. Kan // poleres videre via V0 senere om ønskelig. import { useEffect, useState } from "react" import { Clock, Plus, Trash2, X } from "lucide-react" import { cn } from "@/lib/utils" type ApiGroupParticipant = { round_participant_id: string player_name: string } type ApiRoundGroup = { id: string | null sequence: number tee_time: string | null tee_time_override: string | null // Kun meningsfullt når runden er 'shotgun' (migrasjon 088, ADR-096) -- // `null` for en 'consecutive'-runde, som fortsatt bruker rundens EGET // delte start_hole (uendret). start_hole: number | null participants: ApiGroupParticipant[] } type ApiRoundGroups = { groups: ApiRoundGroup[] ungrouped: ApiGroupParticipant[] } type ApiRoundInfo = { scheduled_at: string | null tee_interval_minutes: number | null start_hole: number start_mode: string } function formatTeeTime(iso: string | null) { if (!iso) return null const date = new Date(iso) if (Number.isNaN(date.getTime())) return null return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) } // datetime-local-inputet gir "YYYY-MM-DDTHH:mm" (ingen tidssone) -- server // (Pydantic datetime-felt) godtar dette direkte, tolkes som lokal tid. function toDatetimeLocalValue(iso: string | null) { if (!iso) return "" const date = new Date(iso) if (Number.isNaN(date.getTime())) return "" const pad = (n: number) => String(n).padStart(2, "0") return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` } const CONTROL = "h-10 rounded-lg border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" export function RoundGroupsPanel({ base, roundId, roundLabel, onClose, }: { base: string roundId: string roundLabel: string onClose: () => void }) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [saving, setSaving] = useState(false) const [savedMessage, setSavedMessage] = useState(null) const [roundInfo, setRoundInfo] = useState(null) const [scheduledAtInput, setScheduledAtInput] = useState("") const [intervalInput, setIntervalInput] = useState("") // Shotgun-start (migrasjon 088, ADR-096) -- bruker: "Det må komme frem // om det er Shotgun eller løpende start... Sorteringen av startlisten // gjøres etter utslagsform." 'consecutive' = dagens oppførsel uendret. const [startModeInput, setStartModeInput] = useState<"consecutive" | "shotgun">("consecutive") const [groups, setGroups] = useState([]) const [ungrouped, setUngrouped] = useState([]) const [groupSize, setGroupSize] = useState("4") async function loadAll() { setLoading(true) setError(null) try { const [roundRes, groupsRes] = await Promise.all([ fetch(`${base}/rounds`, { credentials: "include" }), fetch(`${base}/rounds/${roundId}/groups`, { credentials: "include" }), ]) if (!roundRes.ok || !groupsRes.ok) throw new Error() const rounds: (ApiRoundInfo & { id: string })[] = await roundRes.json() const thisRound = rounds.find((r) => r.id === roundId) ?? null const groupsData: ApiRoundGroups = await groupsRes.json() setRoundInfo(thisRound) setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null)) setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "") setStartModeInput(thisRound?.start_mode === "shotgun" ? "shotgun" : "consecutive") setGroups(groupsData.groups) setUngrouped(groupsData.ungrouped) } catch { setError("Klarte ikke å laste utslagsgruppene. Prøv igjen.") } finally { setLoading(false) } } useEffect(() => { void loadAll() // eslint-disable-next-line react-hooks/exhaustive-deps }, [roundId]) async function saveSchedule() { setError(null) // gir en TIDSSONELØS streng som betyr // "organisatorens lokale klokkeslett" (f.eks. "10:00" i Norge). new // Date(...) på en slik streng tolkes av nettleseren som LOKAL tid -- // .toISOString() konverterer deretter til riktig UTC-tidspunkt FØR // sending. Uten dette steget ville "10:00" blitt lagret som 10:00 UTC // (feil med hele tidssoneavviket, f.eks. 2 timer for norsk sommertid) // -- funnet under egen scratch-verifisering av akkurat denne skjermen. const scheduledAtIso = scheduledAtInput ? new Date(scheduledAtInput).toISOString() : null const res = await fetch(`${base}/rounds/${roundId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ scheduled_at: scheduledAtIso, tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null, start_mode: startModeInput, }), }) if (!res.ok) { setError("Klarte ikke å lagre utslagstidspunktet.") return } await loadAll() } async function suggest() { const size = Number(groupSize) if (!size || size < 1) return const hasExistingWork = groups.some((g) => g.participants.length > 0) if (hasExistingWork && !confirm("Dette erstatter det du eventuelt allerede har justert her (ikke lagret ennå). Fortsette?")) { return } setError(null) const res = await fetch(`${base}/rounds/${roundId}/groups/suggest`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ group_size: size }), }) if (!res.ok) { setError("Klarte ikke å beregne et forslag.") return } const data: ApiRoundGroups = await res.json() setGroups(data.groups) setUngrouped(data.ungrouped) setSavedMessage(null) } function addEmptyGroup() { const nextSequence = (groups.reduce((m, g) => Math.max(m, g.sequence), 0) || 0) + 1 setGroups((prev) => [ ...prev, { id: null, sequence: nextSequence, tee_time: null, tee_time_override: null, start_hole: null, participants: [] }, ]) } function setGroupStartHole(sequence: number, value: string) { const startHole = value.trim() ? Number(value) : null setGroups((prev) => prev.map((g) => (g.sequence === sequence ? { ...g, start_hole: startHole } : g))) } function removeGroup(sequence: number) { const group = groups.find((g) => g.sequence === sequence) if (!group) return setUngrouped((prev) => [...prev, ...group.participants]) setGroups((prev) => prev.filter((g) => g.sequence !== sequence)) } // VIKTIG: `moved` må leses SYNKRONT fra det gjeldende (allerede rendrede) // state-et, IKKE inne i en setState-oppdateringsfunksjon -- React kjører // ikke oppdateringsfunksjoner umiddelbart, så en `let moved` satt inni én // slik funksjon er fortsatt `null` når den leses rett etterpå i samme // kall. Funnet under egen scratch-verifisering: en flyttet spiller // forsvant sporløst (fjernet fra den gamle gruppen, aldri lagt til den // nye) fordi nettopp dette skjedde. function moveParticipant(participantId: string, toSequence: number | "ungrouped") { const moved = groups.flatMap((g) => g.participants).find((p) => p.round_participant_id === participantId) ?? ungrouped.find((p) => p.round_participant_id === participantId) if (!moved) return setGroups((prev) => prev.map((g) => { if (g.sequence === toSequence) { const withoutMoved = g.participants.filter((p) => p.round_participant_id !== participantId) return { ...g, participants: [...withoutMoved, moved] } } return { ...g, participants: g.participants.filter((p) => p.round_participant_id !== participantId) } }), ) setUngrouped((prev) => { const withoutMoved = prev.filter((p) => p.round_participant_id !== participantId) return toSequence === "ungrouped" ? [...withoutMoved, moved] : withoutMoved }) } async function save() { setSaving(true) setError(null) setSavedMessage(null) try { const res = await fetch(`${base}/rounds/${roundId}/groups`, { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ groups: groups.map((g) => ({ sequence: g.sequence, tee_time_override: null, start_hole: g.start_hole, round_participant_ids: g.participants.map((p) => p.round_participant_id), })), }), }) if (!res.ok) throw new Error() const data: ApiRoundGroups = await res.json() setGroups(data.groups) setUngrouped(data.ungrouped) setSavedMessage("Lagret.") } catch { setError("Klarte ikke å lagre utslagsgruppene. Prøv igjen.") } finally { setSaving(false) } } const allSequences = groups.map((g) => g.sequence).sort((a, b) => a - b) // Skiller "ingen deltakere i runden i det hele tatt" fra "alle er // gruppert" -- begge ga tidligere identisk ungrouped.length===0, som // gjorde "Alle deltakere er i en gruppe" misvisende når det egentlig // betydde at ingen var lagt til runden ennå (egen tildeling under // "Runder og baner", adskilt fra å være med i selve turneringen). const totalRoundParticipants = ungrouped.length + groups.reduce((sum, g) => sum + g.participants.length, 0) function GroupSelect({ participantId, currentSequence }: { participantId: string; currentSequence: number | "ungrouped" }) { return ( ) } return (

Utslagsgrupper

{roundLabel}

{error && (
{error}
)} {loading ? (

Laster…

) : ( <>

Utslagsform

{( [ { value: "consecutive" as const, label: "Løpende start" }, { value: "shotgun" as const, label: "Shotgun" }, ] ).map((opt) => ( ))}
{startModeInput === "consecutive" ? ( <>

Alle grupper starter fra samme hull (hull {roundInfo?.start_hole ?? 1}, satt for hele runden), med staggerte klokkeslett ut fra intervallet under. Gruppe 1 slår ut først, gruppe 2 dette antall minutter senere, og så videre.

) : ( <>

Alle grupper går ut SAMTIDIG, hver fra sitt eget starthull -- sett starthull per gruppe nedenfor. Intervallet gjelder ikke i denne modusen.

)}

Grupper

Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.

{totalRoundParticipants === 0 && (

Ingen deltakere er lagt til i DENNE runden ennå -- det er et eget steg fra å være med i turneringen. Gå til "Runder og baner", utvid runden og legg til deltakere der, så dukker de opp her.

)}
{groups.length === 0 && (

Ingen grupper ennå -- bruk "Foreslå grupper" eller "Ny gruppe".

)} {groups .slice() .sort((a, b) => // Shotgun (migrasjon 088, ADR-096): vis sortert etter // STARTHULL, ikke sequence -- speiler backend sin // tilsvarende sortering (_fetch_round_groups), men må // også gjelde HER siden lokale, ulagrede endringer // (f.eks. et nettopp justert starthull) ellers ikke // ville flyttet gruppen i visningen før neste lagring/ // reload. startModeInput === "shotgun" ? (a.start_hole === null ? 1 : 0) - (b.start_hole === null ? 1 : 0) || (a.start_hole ?? 0) - (b.start_hole ?? 0) : a.sequence - b.sequence, ) .map((g) => (
{g.sequence} {startModeInput === "shotgun" && ( )}
{g.participants.length === 0 ? (

Ingen spillere i denne gruppen ennå.

) : (
    {g.participants.map((p) => (
  • {p.player_name}
  • ))}
)}
))}

Ugrupperte ({ungrouped.length})

{ungrouped.length === 0 ? (

{totalRoundParticipants === 0 ? "Ingen deltakere lagt til i runden ennå." : "Alle deltakere er i en gruppe."}

) : (
    {ungrouped.map((p) => (
  • {p.player_name} {allSequences.length > 0 ? ( ) : ( Opprett en gruppe først )}
  • ))}
)}
{savedMessage && {savedMessage}}
)}
) }