Shotgun-start: frontend (individuell + Cup-format)
Løpende/Shotgun-veksler i rundens gruppeoppsett og øktoppsett, per- gruppe/per-match starthull-felt i shotgun-modus. Fant og rettet en reell bug underveis: begge skjermene hadde en hardkodet sequence-sort som stille overstyrte backendens starthull-sortering for shotgun. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f1575c9b42
commit
9f81797324
3 changed files with 297 additions and 70 deletions
|
|
@ -30,6 +30,10 @@ type ApiRoundGroup = {
|
||||||
sequence: number
|
sequence: number
|
||||||
tee_time: string | null
|
tee_time: string | null
|
||||||
tee_time_override: 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[]
|
participants: ApiGroupParticipant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +46,7 @@ type ApiRoundInfo = {
|
||||||
scheduled_at: string | null
|
scheduled_at: string | null
|
||||||
tee_interval_minutes: number | null
|
tee_interval_minutes: number | null
|
||||||
start_hole: number
|
start_hole: number
|
||||||
|
start_mode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTeeTime(iso: string | null) {
|
function formatTeeTime(iso: string | null) {
|
||||||
|
|
@ -83,6 +88,10 @@ export function RoundGroupsPanel({
|
||||||
const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(null)
|
const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(null)
|
||||||
const [scheduledAtInput, setScheduledAtInput] = useState("")
|
const [scheduledAtInput, setScheduledAtInput] = useState("")
|
||||||
const [intervalInput, setIntervalInput] = 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 [groups, setGroups] = useState<ApiRoundGroup[]>([])
|
||||||
const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([])
|
const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([])
|
||||||
|
|
@ -103,6 +112,7 @@ export function RoundGroupsPanel({
|
||||||
setRoundInfo(thisRound)
|
setRoundInfo(thisRound)
|
||||||
setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null))
|
setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null))
|
||||||
setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "")
|
setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "")
|
||||||
|
setStartModeInput(thisRound?.start_mode === "shotgun" ? "shotgun" : "consecutive")
|
||||||
setGroups(groupsData.groups)
|
setGroups(groupsData.groups)
|
||||||
setUngrouped(groupsData.ungrouped)
|
setUngrouped(groupsData.ungrouped)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -134,6 +144,7 @@ export function RoundGroupsPanel({
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
scheduled_at: scheduledAtIso,
|
scheduled_at: scheduledAtIso,
|
||||||
tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null,
|
tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null,
|
||||||
|
start_mode: startModeInput,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -169,7 +180,15 @@ export function RoundGroupsPanel({
|
||||||
|
|
||||||
function addEmptyGroup() {
|
function addEmptyGroup() {
|
||||||
const nextSequence = (groups.reduce((m, g) => Math.max(m, g.sequence), 0) || 0) + 1
|
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, participants: [] }])
|
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) {
|
function removeGroup(sequence: number) {
|
||||||
|
|
@ -220,6 +239,7 @@ export function RoundGroupsPanel({
|
||||||
groups: groups.map((g) => ({
|
groups: groups.map((g) => ({
|
||||||
sequence: g.sequence,
|
sequence: g.sequence,
|
||||||
tee_time_override: null,
|
tee_time_override: null,
|
||||||
|
start_hole: g.start_hole,
|
||||||
round_participant_ids: g.participants.map((p) => p.round_participant_id),
|
round_participant_ids: g.participants.map((p) => p.round_participant_id),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
|
|
@ -297,41 +317,99 @@ export function RoundGroupsPanel({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
|
<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">Første utslag og intervall</h2>
|
<h2 className="text-base font-bold text-foreground">Utslagsform</h2>
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
<div
|
||||||
Alle grupper starter fra samme hull (hull {roundInfo?.start_hole ?? 1}, satt for hele runden),
|
role="radiogroup"
|
||||||
med staggerte klokkeslett ut fra intervallet under. Gruppe 1 slår ut først, gruppe 2 dette
|
aria-label="Utslagsform"
|
||||||
antall minutter senere, og så videre.
|
className="inline-flex w-fit rounded-xl border border-border bg-background p-1"
|
||||||
</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>
|
{ value: "consecutive" as const, label: "Løpende start" },
|
||||||
<input
|
{ value: "shotgun" as const, label: "Shotgun" },
|
||||||
type="datetime-local"
|
]
|
||||||
value={scheduledAtInput}
|
).map((opt) => (
|
||||||
onChange={(e) => setScheduledAtInput(e.target.value)}
|
<button
|
||||||
className={CONTROL}
|
key={opt.value}
|
||||||
/>
|
type="button"
|
||||||
</label>
|
role="radio"
|
||||||
<label className="flex flex-1 flex-col gap-1.5">
|
aria-checked={startModeInput === opt.value}
|
||||||
<span className="text-sm font-bold text-foreground">Intervall (minutter)</span>
|
onClick={() => setStartModeInput(opt.value)}
|
||||||
<input
|
className={cn(
|
||||||
type="number"
|
"h-10 rounded-lg px-4 text-sm font-bold transition-colors",
|
||||||
min={1}
|
startModeInput === opt.value
|
||||||
value={intervalInput}
|
? "bg-primary text-primary-foreground"
|
||||||
onChange={(e) => setIntervalInput(e.target.value)}
|
: "text-muted-foreground hover:text-foreground",
|
||||||
placeholder="f.eks. 10"
|
)}
|
||||||
className={CONTROL}
|
>
|
||||||
/>
|
{opt.label}
|
||||||
</label>
|
</button>
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
|
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
|
||||||
|
|
@ -379,11 +457,23 @@ export function RoundGroupsPanel({
|
||||||
)}
|
)}
|
||||||
{groups
|
{groups
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.sequence - b.sequence)
|
.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) => (
|
.map((g) => (
|
||||||
<div key={g.sequence} className="rounded-xl border border-border bg-background p-3 sm:p-4">
|
<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="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center 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">
|
<span className="flex size-8 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
|
||||||
{g.sequence}
|
{g.sequence}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -391,6 +481,20 @@ export function RoundGroupsPanel({
|
||||||
<Clock aria-hidden="true" className="size-4 text-muted-foreground" />
|
<Clock aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
{formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"}
|
{formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Clock, EyeOff, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
|
import { ArrowLeft, Clock, EyeOff, Flag, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -61,6 +61,10 @@ type ApiSession = {
|
||||||
course_id: string
|
course_id: string
|
||||||
points_per_match: number
|
points_per_match: number
|
||||||
scoring_mode: ScoringMode
|
scoring_mode: ScoringMode
|
||||||
|
// Shotgun-start (migrasjon 088, ADR-096) -- 'shotgun': matchene under
|
||||||
|
// legges til med eget starthull hver (se addMatch), ikke felles
|
||||||
|
// klokkeslett-staggering.
|
||||||
|
start_mode: "consecutive" | "shotgun"
|
||||||
locked_team_ids: string[]
|
locked_team_ids: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +89,9 @@ type ApiMatch = {
|
||||||
points_side_b: number | null
|
points_side_b: number | null
|
||||||
leading_side: "a" | "b" | null
|
leading_side: "a" | "b" | null
|
||||||
tee_time: string | null
|
tee_time: string | null
|
||||||
|
// Kun meningsfullt (og satt av organisator ved opprettelse) når økten
|
||||||
|
// er 'shotgun' (migrasjon 088, ADR-096).
|
||||||
|
start_hole: number | null
|
||||||
participants: ApiParticipant[]
|
participants: ApiParticipant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +197,20 @@ export function SessionBlindDraw({
|
||||||
}
|
}
|
||||||
}, [organizationId, tournamentId, sessionId])
|
}, [organizationId, tournamentId, sessionId])
|
||||||
|
|
||||||
const sortedMatches = useMemo(() => [...matches].sort((a, b) => a.sequence - b.sequence), [matches])
|
// Shotgun (migrasjon 088, ADR-096): vis sortert etter STARTHULL, ikke
|
||||||
|
// sequence -- speiler backend sin tilsvarende sortering (fetch_matches
|
||||||
|
// i matches.py), men må også gjelde HER siden lokale, nettopp lagt-til
|
||||||
|
// matcher ellers ikke ville vist i riktig rekkefølge før neste reload.
|
||||||
|
const sortedMatches = useMemo(
|
||||||
|
() =>
|
||||||
|
[...matches].sort((a, b) =>
|
||||||
|
session?.start_mode === "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,
|
||||||
|
),
|
||||||
|
[matches, session?.start_mode],
|
||||||
|
)
|
||||||
const nextSequence = useMemo(
|
const nextSequence = useMemo(
|
||||||
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
|
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
|
||||||
[matches],
|
[matches],
|
||||||
|
|
@ -203,7 +223,7 @@ export function SessionBlindDraw({
|
||||||
if (res.ok) setMatches(await res.json())
|
if (res.ok) setMatches(await res.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMatch() {
|
async function addMatch(startHole?: number) {
|
||||||
if (!teams) return
|
if (!teams) return
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -211,7 +231,12 @@ export function SessionBlindDraw({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ sequence: nextSequence, team_a_id: teams[0].id, team_b_id: teams[1].id }),
|
body: JSON.stringify({
|
||||||
|
sequence: nextSequence,
|
||||||
|
team_a_id: teams[0].id,
|
||||||
|
team_b_id: teams[1].id,
|
||||||
|
start_hole: startHole ?? null,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`create match: ${res.status}`)
|
if (!res.ok) throw new Error(`create match: ${res.status}`)
|
||||||
const created: ApiMatch = await res.json()
|
const created: ApiMatch = await res.json()
|
||||||
|
|
@ -413,6 +438,7 @@ export function SessionBlindDraw({
|
||||||
<RevealedView
|
<RevealedView
|
||||||
teams={teams}
|
teams={teams}
|
||||||
matches={sortedMatches}
|
matches={sortedMatches}
|
||||||
|
startMode={session.start_mode}
|
||||||
scorecardHrefFor={(matchId) =>
|
scorecardHrefFor={(matchId) =>
|
||||||
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +456,7 @@ export function SessionBlindDraw({
|
||||||
matches={sortedMatches}
|
matches={sortedMatches}
|
||||||
perSide={maxSlotsPerSide(session.format)}
|
perSide={maxSlotsPerSide(session.format)}
|
||||||
format={session.format}
|
format={session.format}
|
||||||
|
startMode={session.start_mode}
|
||||||
locked={session.locked_team_ids.includes(team.id)}
|
locked={session.locked_team_ids.includes(team.id)}
|
||||||
usedPlayerIds={usedPlayerIds}
|
usedPlayerIds={usedPlayerIds}
|
||||||
onAddParticipant={addParticipant}
|
onAddParticipant={addParticipant}
|
||||||
|
|
@ -523,6 +550,7 @@ function TeamColumn({
|
||||||
matches,
|
matches,
|
||||||
perSide,
|
perSide,
|
||||||
format,
|
format,
|
||||||
|
startMode,
|
||||||
locked,
|
locked,
|
||||||
usedPlayerIds,
|
usedPlayerIds,
|
||||||
onAddParticipant,
|
onAddParticipant,
|
||||||
|
|
@ -542,6 +570,9 @@ function TeamColumn({
|
||||||
matches: ApiMatch[]
|
matches: ApiMatch[]
|
||||||
perSide: number
|
perSide: number
|
||||||
format: Format
|
format: Format
|
||||||
|
// Shotgun-start (migrasjon 088, ADR-096) -- styrer om "Legg til match"
|
||||||
|
// spør om starthull (shotgun) eller ikke (consecutive, som før).
|
||||||
|
startMode: "consecutive" | "shotgun"
|
||||||
locked: boolean
|
locked: boolean
|
||||||
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
|
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
|
||||||
onAddParticipant: (
|
onAddParticipant: (
|
||||||
|
|
@ -552,7 +583,7 @@ function TeamColumn({
|
||||||
lineupOrder?: number | null,
|
lineupOrder?: number | null,
|
||||||
) => void
|
) => void
|
||||||
onRemoveParticipant: (matchId: string, participantId: string) => void
|
onRemoveParticipant: (matchId: string, participantId: string) => void
|
||||||
onAddMatch: () => void
|
onAddMatch: (startHole?: number) => void
|
||||||
complete: boolean
|
complete: boolean
|
||||||
confirming: boolean
|
confirming: boolean
|
||||||
onRequestLock: () => void
|
onRequestLock: () => void
|
||||||
|
|
@ -560,6 +591,7 @@ function TeamColumn({
|
||||||
onConfirmLock: () => void
|
onConfirmLock: () => void
|
||||||
}) {
|
}) {
|
||||||
const color = team.color ?? "#64748b"
|
const color = team.color ?? "#64748b"
|
||||||
|
const [newMatchStartHole, setNewMatchStartHole] = useState("")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|
@ -595,10 +627,17 @@ function TeamColumn({
|
||||||
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
|
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
|
||||||
{match.sequence}
|
{match.sequence}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
{startMode === "shotgun" ? (
|
||||||
<Clock aria-hidden="true" className="size-4" />
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
|
<Flag aria-hidden="true" className="size-4" />
|
||||||
</span>
|
{match.start_hole ? `Starthull ${match.start_hole}` : "Starthull ikke satt"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Clock aria-hidden="true" className="size-4" />
|
||||||
|
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
|
@ -658,10 +697,31 @@ function TeamColumn({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!locked && startMode === "shotgun" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex flex-1 items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-muted-foreground">Starthull</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={18}
|
||||||
|
value={newMatchStartHole}
|
||||||
|
onChange={(e) => setNewMatchStartHole(e.target.value)}
|
||||||
|
placeholder="Hull"
|
||||||
|
className="h-10 w-full rounded-xl 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!locked && (
|
{!locked && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAddMatch}
|
onClick={() => {
|
||||||
|
const hole = newMatchStartHole.trim() ? Number(newMatchStartHole) : undefined
|
||||||
|
onAddMatch(hole)
|
||||||
|
setNewMatchStartHole("")
|
||||||
|
}}
|
||||||
className="flex items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-background/50 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
className="flex items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-background/50 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Plus aria-hidden="true" className="size-4" />
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
|
@ -827,10 +887,12 @@ function AddSlotForm({
|
||||||
function RevealedView({
|
function RevealedView({
|
||||||
teams,
|
teams,
|
||||||
matches,
|
matches,
|
||||||
|
startMode,
|
||||||
scorecardHrefFor,
|
scorecardHrefFor,
|
||||||
}: {
|
}: {
|
||||||
teams: [ApiTeam, ApiTeam]
|
teams: [ApiTeam, ApiTeam]
|
||||||
matches: ApiMatch[]
|
matches: ApiMatch[]
|
||||||
|
startMode: "consecutive" | "shotgun"
|
||||||
scorecardHrefFor: (matchId: string) => string
|
scorecardHrefFor: (matchId: string) => string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -865,6 +927,11 @@ function RevealedView({
|
||||||
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
|
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
|
||||||
{match.status_text}
|
{match.status_text}
|
||||||
</span>
|
</span>
|
||||||
|
) : startMode === "shotgun" ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Flag aria-hidden="true" className="size-4" />
|
||||||
|
{match.start_hole ? `Starthull ${match.start_hole}` : "Starthull ikke satt"}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Clock aria-hidden="true" className="size-4" />
|
<Clock aria-hidden="true" className="size-4" />
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,11 @@ type ApiSession = {
|
||||||
scheduled_at: string | null
|
scheduled_at: string | null
|
||||||
tee_interval_minutes: number | null
|
tee_interval_minutes: number | null
|
||||||
start_hole: number
|
start_hole: number
|
||||||
|
// Shotgun-start (migrasjon 088, ADR-096) -- 'consecutive' (standard) er
|
||||||
|
// dagens oppførsel uendret. 'shotgun': alle matcher i økten går ut
|
||||||
|
// SAMTIDIG, fra hvert sitt starthull (match.start_hole, satt i
|
||||||
|
// trekning-skjermen) i stedet for staggert klokkeslett.
|
||||||
|
start_mode: "consecutive" | "shotgun"
|
||||||
locked_team_ids: string[]
|
locked_team_ids: string[]
|
||||||
revealed: boolean
|
revealed: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -718,14 +723,25 @@ function SessionCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={href} className="flex flex-col gap-1.5 border-t border-border pt-3">
|
<Link href={href} className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-0.5",
|
||||||
|
session.start_mode === "shotgun" ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{session.start_mode === "shotgun" ? "Shotgun" : "Løpende start"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{session.scheduled_at ? (
|
{session.scheduled_at ? (
|
||||||
<div className="flex items-start gap-2 text-sm text-foreground">
|
<div className="flex items-start gap-2 text-sm text-foreground">
|
||||||
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
|
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
<span className="text-pretty">
|
<span className="text-pretty">
|
||||||
Første utslag {formatDateTime(session.scheduled_at)}
|
{session.start_mode === "shotgun"
|
||||||
{session.tee_interval_minutes
|
? `Felles utslag ${formatDateTime(session.scheduled_at)}`
|
||||||
? `, ${session.tee_interval_minutes} min mellom hver`
|
: `Første utslag ${formatDateTime(session.scheduled_at)}${
|
||||||
: ""}
|
session.tee_interval_minutes ? `, ${session.tee_interval_minutes} min mellom hver` : ""
|
||||||
|
}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -734,7 +750,7 @@ function SessionCard({
|
||||||
<span>Tidspunkt ikke satt</span>
|
<span>Tidspunkt ikke satt</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.start_hole !== 1 && (
|
{session.start_mode === "consecutive" && session.start_hole !== 1 && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
||||||
<span>Starter på hull {session.start_hole}</span>
|
<span>Starter på hull {session.start_hole}</span>
|
||||||
|
|
@ -823,6 +839,12 @@ function EditSessionForm({
|
||||||
const [teeInterval, setTeeInterval] = useState(
|
const [teeInterval, setTeeInterval] = useState(
|
||||||
session.tee_interval_minutes ? String(session.tee_interval_minutes) : "",
|
session.tee_interval_minutes ? String(session.tee_interval_minutes) : "",
|
||||||
)
|
)
|
||||||
|
// 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." 'shotgun': starthull settes per MATCH i
|
||||||
|
// trekning-skjermen i stedet (økten sitt eget start_hole blir da
|
||||||
|
// urelevant, skjules under).
|
||||||
|
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
|
||||||
const [courseId, setCourseId] = useState(session.course_id)
|
const [courseId, setCourseId] = useState(session.course_id)
|
||||||
const [courseName, setCourseName] = useState(course?.name ?? "")
|
const [courseName, setCourseName] = useState(course?.name ?? "")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
@ -841,8 +863,10 @@ function EditSessionForm({
|
||||||
name: name.trim() || null,
|
name: name.trim() || null,
|
||||||
points_per_match: pointsValue,
|
points_per_match: pointsValue,
|
||||||
start_hole: startHoleValue,
|
start_hole: startHoleValue,
|
||||||
|
start_mode: startMode,
|
||||||
scheduled_at: hasTime ? scheduledAt : null,
|
scheduled_at: hasTime ? scheduledAt : null,
|
||||||
tee_interval_minutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
|
tee_interval_minutes:
|
||||||
|
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
})
|
})
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
|
|
@ -895,6 +919,36 @@ function EditSessionForm({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Utslagsform</Label>
|
||||||
|
<div role="radiogroup" aria-label="Utslagsform" className="inline-flex w-fit rounded-2xl 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={startMode === opt.value}
|
||||||
|
variant={startMode === opt.value ? "default" : "ghost"}
|
||||||
|
onClick={() => setStartMode(opt.value)}
|
||||||
|
className="h-10 rounded-xl px-4 text-sm font-bold"
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{startMode === "shotgun" && (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Alle matcher i økten går ut samtidig -- starthull settes per match i trekning-skjermen
|
||||||
|
i stedet for her.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold">
|
<Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold">
|
||||||
|
|
@ -911,26 +965,28 @@ function EditSessionForm({
|
||||||
className="h-12 rounded-2xl text-base"
|
className="h-12 rounded-2xl text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
{startMode === "consecutive" && (
|
||||||
<Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold">
|
<div className="flex flex-col gap-2">
|
||||||
Starthull
|
<Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold">
|
||||||
</Label>
|
Starthull
|
||||||
<Input
|
</Label>
|
||||||
id={`edit-starthole-${uid}`}
|
<Input
|
||||||
inputMode="numeric"
|
id={`edit-starthole-${uid}`}
|
||||||
type="number"
|
inputMode="numeric"
|
||||||
min="1"
|
type="number"
|
||||||
max="18"
|
min="1"
|
||||||
value={startHole}
|
max="18"
|
||||||
onChange={(e) => setStartHole(e.target.value)}
|
value={startHole}
|
||||||
className="h-12 rounded-2xl text-base"
|
onChange={(e) => setStartHole(e.target.value)}
|
||||||
/>
|
className="h-12 rounded-2xl text-base"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor={`edit-time-${uid}`} className="text-sm font-semibold">
|
<Label htmlFor={`edit-time-${uid}`} className="text-sm font-semibold">
|
||||||
Startdato og -klokkeslett{" "}
|
{startMode === "shotgun" ? "Felles utslagstidspunkt" : "Startdato og -klokkeslett"}{" "}
|
||||||
<span className="font-normal text-muted-foreground">(valgfritt)</span>
|
<span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -942,7 +998,7 @@ function EditSessionForm({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasTime && (
|
{hasTime && startMode === "consecutive" && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor={`edit-interval-${uid}`} className="text-sm font-semibold">
|
<Label htmlFor={`edit-interval-${uid}`} className="text-sm font-semibold">
|
||||||
Minutter mellom hver flight
|
Minutter mellom hver flight
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue