583 lines
26 KiB
TypeScript
583 lines
26 KiB
TypeScript
"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"
|
|
import { PrintMenu } from "@/components/print-menu"
|
|
|
|
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,
|
|
organizationId,
|
|
tournamentId,
|
|
roundId,
|
|
roundLabel,
|
|
onClose,
|
|
}: {
|
|
base: string
|
|
organizationId: string
|
|
tournamentId: string
|
|
roundId: string
|
|
roundLabel: string
|
|
onClose: () => void
|
|
}) {
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
const [savedMessage, setSavedMessage] = useState<string | null>(null)
|
|
|
|
const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(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<ApiRoundGroup[]>([])
|
|
const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([])
|
|
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)
|
|
// <input type="datetime-local"> 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 (
|
|
<label className="flex items-center gap-2">
|
|
<span className="sr-only">Gruppe</span>
|
|
<select
|
|
value={currentSequence}
|
|
onChange={(e) => {
|
|
const v = e.target.value
|
|
moveParticipant(participantId, v === "ungrouped" ? "ungrouped" : Number(v))
|
|
}}
|
|
className={cn(CONTROL, "h-9 px-2 text-xs")}
|
|
>
|
|
<option value="ungrouped">Ugruppert</option>
|
|
{allSequences.map((seq) => (
|
|
<option key={seq} value={seq}>
|
|
Gruppe {seq}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex flex-col overflow-y-auto bg-background">
|
|
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background px-5 py-4 sm:px-8">
|
|
<div className="flex flex-col">
|
|
<h1 className="text-lg font-black text-foreground">Utslagsgrupper</h1>
|
|
<p className="text-sm font-semibold text-muted-foreground">{roundLabel}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<PrintMenu organizationId={organizationId} tournamentId={tournamentId} roundId={roundId} />
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Lukk"
|
|
className="inline-flex size-11 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<X aria-hidden="true" className="size-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6 sm:px-8">
|
|
{error && (
|
|
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm font-medium text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<p className="text-sm font-medium text-muted-foreground">Laster…</p>
|
|
) : (
|
|
<>
|
|
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
|
|
<h2 className="text-base font-bold text-foreground">Utslagsform</h2>
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="Utslagsform"
|
|
className="inline-flex w-fit rounded-xl border border-border bg-background p-1"
|
|
>
|
|
{(
|
|
[
|
|
{ value: "consecutive" as const, label: "Løpende start" },
|
|
{ value: "shotgun" as const, label: "Shotgun" },
|
|
]
|
|
).map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={startModeInput === opt.value}
|
|
onClick={() => setStartModeInput(opt.value)}
|
|
className={cn(
|
|
"h-10 rounded-lg px-4 text-sm font-bold transition-colors",
|
|
startModeInput === opt.value
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{startModeInput === "consecutive" ? (
|
|
<>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
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.
|
|
</p>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
|
<label className="flex flex-1 flex-col gap-1.5">
|
|
<span className="text-sm font-bold text-foreground">Første utslag</span>
|
|
<input
|
|
type="datetime-local"
|
|
value={scheduledAtInput}
|
|
onChange={(e) => setScheduledAtInput(e.target.value)}
|
|
className={CONTROL}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-1 flex-col gap-1.5">
|
|
<span className="text-sm font-bold text-foreground">Intervall (minutter)</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={intervalInput}
|
|
onChange={(e) => setIntervalInput(e.target.value)}
|
|
placeholder="f.eks. 10"
|
|
className={CONTROL}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveSchedule()}
|
|
className="inline-flex h-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
|
|
>
|
|
Lagre tidspunkt
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Alle grupper går ut SAMTIDIG, hver fra sitt eget starthull -- sett starthull per
|
|
gruppe nedenfor. Intervallet gjelder ikke i denne modusen.
|
|
</p>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
|
<label className="flex flex-1 flex-col gap-1.5">
|
|
<span className="text-sm font-bold text-foreground">Felles utslagstidspunkt</span>
|
|
<input
|
|
type="datetime-local"
|
|
value={scheduledAtInput}
|
|
onChange={(e) => setScheduledAtInput(e.target.value)}
|
|
className={CONTROL}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveSchedule()}
|
|
className="inline-flex h-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
|
|
>
|
|
Lagre tidspunkt
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<h2 className="text-base font-bold text-foreground">Grupper</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.
|
|
</p>
|
|
</div>
|
|
{totalRoundParticipants === 0 && (
|
|
<p className="w-full rounded-xl border border-dashed border-border bg-background px-4 py-3 text-sm font-medium text-muted-foreground">
|
|
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.
|
|
</p>
|
|
)}
|
|
<div className="flex items-end gap-2">
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-bold text-foreground">Gruppestørrelse</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={12}
|
|
value={groupSize}
|
|
onChange={(e) => setGroupSize(e.target.value)}
|
|
className={cn(CONTROL, "w-20")}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => void suggest()}
|
|
className="inline-flex h-10 items-center justify-center rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
|
|
>
|
|
Foreslå grupper
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
{groups.length === 0 && (
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
Ingen grupper ennå -- bruk "Foreslå grupper" eller "Ny gruppe".
|
|
</p>
|
|
)}
|
|
{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) => (
|
|
<div key={g.sequence} className="rounded-xl border border-border bg-background p-3 sm:p-4">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="flex size-8 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
|
|
{g.sequence}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
|
|
<Clock aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
{formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"}
|
|
</span>
|
|
{startModeInput === "shotgun" && (
|
|
<label className="flex items-center gap-1.5">
|
|
<span className="text-xs font-bold text-muted-foreground">Starthull</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={18}
|
|
value={g.start_hole ?? ""}
|
|
onChange={(e) => setGroupStartHole(g.sequence, e.target.value)}
|
|
placeholder="Hull"
|
|
className={cn(CONTROL, "h-9 w-20 px-2 text-xs")}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeGroup(g.sequence)}
|
|
aria-label={`Fjern gruppe ${g.sequence}`}
|
|
className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
</button>
|
|
</div>
|
|
{g.participants.length === 0 ? (
|
|
<p className="text-xs font-medium text-muted-foreground">Ingen spillere i denne gruppen ennå.</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-2">
|
|
{g.participants.map((p) => (
|
|
<li key={p.round_participant_id} className="flex items-center justify-between gap-2">
|
|
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
|
|
<GroupSelect participantId={p.round_participant_id} currentSequence={g.sequence} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addEmptyGroup}
|
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dashed border-border text-sm font-bold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
|
>
|
|
<Plus aria-hidden="true" className="size-4" />
|
|
Ny gruppe
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-3">
|
|
<h3 className="mb-2 text-sm font-bold text-foreground">
|
|
Ugrupperte ({ungrouped.length})
|
|
</h3>
|
|
{ungrouped.length === 0 ? (
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
{totalRoundParticipants === 0
|
|
? "Ingen deltakere lagt til i runden ennå."
|
|
: "Alle deltakere er i en gruppe."}
|
|
</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-2">
|
|
{ungrouped.map((p) => (
|
|
<li key={p.round_participant_id} className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
|
|
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
|
|
{allSequences.length > 0 ? (
|
|
<GroupSelect participantId={p.round_participant_id} currentSequence="ungrouped" />
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">Opprett en gruppe først</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<div className="flex items-center justify-end gap-3 pb-6">
|
|
{savedMessage && <span className="text-sm font-bold text-tee-strong">{savedMessage}</span>}
|
|
<button
|
|
type="button"
|
|
onClick={() => void save()}
|
|
disabled={saving}
|
|
className="inline-flex h-12 items-center justify-center rounded-xl bg-primary px-6 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{saving ? "Lagrer..." : "Lagre utslagsgrupper"}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|