Frontend for per-deltaker-fullføring (ADR-065): Avslutt/Ferdig/Angre/Send scorekort
Player.completed lagt til, delt ParticipantCompletionActions-komponent i både Score-fanens PlayerHoleCards og "Spillere og runde"-fanens PlayerList. allHolesEnteredForEveryone og ScoringWizard-samlebåndet tar nå hensyn til individuelt fullførte spillere. Nettleserverifisert lys+mørk mot ekte teecup.golf (test-runde slettet etterpå). tsc/vitest rent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
baa1c42d24
commit
d12af07ba7
3 changed files with 223 additions and 12 deletions
|
|
@ -6396,12 +6396,36 @@ on-demand-endepunktet fungerer også lenge etter rundefullføring.
|
||||||
Deployet til ekte `teecup_db`/`teecup_api` sammen med migrasjon 067
|
Deployet til ekte `teecup_db`/`teecup_api` sammen med migrasjon 067
|
||||||
(GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13.
|
(GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13.
|
||||||
|
|
||||||
**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`-felt,
|
**Tillegg samme dag — frontend-UI bygget og rullet ut.** `Player.
|
||||||
"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper i
|
completed` lagt til (avledet fra `ApiParticipant.completed`), delt
|
||||||
`PlayerHoleCards` og "Spillere og runde"-fanen, `allHolesEnteredFor
|
`ParticipantCompletionActions`-komponent gjenbrukt i BÅDE
|
||||||
Everyone`-justering, ScoringWizard-spillerkjede-hopp for fullførte
|
`PlayerHoleCards` (Score-fanen) og `PlayerList` ("Spillere og
|
||||||
deltakere) — backend er bygget og verifisert, men ikke koblet inn i UI
|
runde"-fanen) -- "Avslutt for [navn]" (skjult når runden selv er
|
||||||
ennå.
|
fullført, siden backend uansett avviser da), "Ferdig ✓ (N hull)" +
|
||||||
|
"Angre" (kun når ikke rundefullført), "Send scorekort" (alltid synlig,
|
||||||
|
uansett fullføringsstatus -- dekker "send til enkeltspiller i etterkant,
|
||||||
|
lenge etter fullføring"-tillegget). `allHolesEnteredForEveryone` og
|
||||||
|
hurtig-hopp-stripens "registrert"-sjekk teller nå en `completed`-spiller
|
||||||
|
som ferdig uavhengig av faktisk hull-dekning. `advanceWizardPlayer`
|
||||||
|
hopper over fullførte spillere i samlebånds-kjeden. Et ikke-spilt hull
|
||||||
|
for en fullført spiller viser nå "Ferdig" i stedet for "Registrer" +
|
||||||
|
den stiplede pluss-boksen.
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, 45/45 vitest. Ekte nettleser-
|
||||||
|
verifisering (ekte innlogging inkl. reell TOTP-2FA-kode generert fra
|
||||||
|
kontoens lagrede secret, ekte runde opprettet på Tjøme -- cachet bane,
|
||||||
|
0 nye GolfAPI-kall -- med to deltakere): "Avslutt for X"/"Send
|
||||||
|
scorekort" synlig på begge kort i begge faner, avslutning av gjesten
|
||||||
|
trigget e-post umiddelbart (ingen feil i `teecup_api`-loggen, SMTP
|
||||||
|
konfigurert), UI oppdaterte seg til "Ferdig ✓"-merke + "Angre"-lenke +
|
||||||
|
"Ferdig"-tekst i score-kolonnen, "Send scorekort" for eieren (lenket
|
||||||
|
konto, andre kodesti enn gjesten) ga "Sendt ✓"-tilbakemelding, "Angre"
|
||||||
|
tilbakestilte korrekt og var persistent etter fane-bytte. Bekreftet
|
||||||
|
visuelt lys+mørk. Ingen konsollfeil gjennom hele forløpet. Testrunden
|
||||||
|
slettet etter verifisering (`DELETE /rounds/{id}`, bekreftet fjernet fra
|
||||||
|
`teecup_db`). **Rullet ut 2026-08-13** -- ren frontend-endring (ingen ny
|
||||||
|
migrasjon), `docker compose build teecup_frontend && up -d
|
||||||
|
teecup_frontend`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -10909,10 +10909,33 @@ Neste steg:
|
||||||
**Rullet ut 2026-08-13**, samme økt, sammen med migrasjon 067
|
**Rullet ut 2026-08-13**, samme økt, sammen med migrasjon 067
|
||||||
(punkt 79 under). `docker compose build teecup_api && up -d`.
|
(punkt 79 under). `docker compose build teecup_api && up -d`.
|
||||||
|
|
||||||
**Bevisst utenfor denne runden:** frontend-UI (`Player.completed`,
|
**Tillegg samme dag -- frontend-UI bygget og rullet ut.**
|
||||||
"Avslutt for [navn]"/"Ferdig ✓"/"Angre"/"Send scorekort"-knapper,
|
`Player.completed` (fra `ApiParticipant.completed`), delt
|
||||||
nagging-justering, ScoringWizard-spillerkjede-hopp) -- backend
|
`ParticipantCompletionActions`-komponent i BÅDE `PlayerHoleCards`
|
||||||
bygget og verifisert, ikke koblet inn i UI ennå.
|
(Score-fanen) og `PlayerList` ("Spillere og runde"-fanen):
|
||||||
|
"Avslutt for [navn]" (skjult når runden er fullført),
|
||||||
|
"Ferdig ✓ (N hull)" + "Angre" (kun før rundefullføring),
|
||||||
|
"Send scorekort" (alltid synlig, uansett fullføringsstatus).
|
||||||
|
`allHolesEnteredForEveryone` + hurtig-hopp-stripen teller en
|
||||||
|
`completed`-spiller som ferdig uansett faktisk hull-dekning.
|
||||||
|
`advanceWizardPlayer` hopper over fullførte spillere. Et ikke-spilt
|
||||||
|
hull for en fullført spiller viser "Ferdig" i stedet for
|
||||||
|
"Registrer".
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, 45/45 vitest. Ekte
|
||||||
|
nettleserverifisering (ekte innlogging inkl. reell TOTP-2FA-kode
|
||||||
|
generert fra kontoens lagrede secret, ekte runde på Tjøme -- cachet
|
||||||
|
bane, 0 nye GolfAPI-kall -- to deltakere): avslutning av gjesten
|
||||||
|
trigget e-post umiddelbart (ingen feil i loggen), UI oppdaterte til
|
||||||
|
"Ferdig ✓"/"Angre"/"Ferdig"-tekst korrekt, "Send scorekort" for
|
||||||
|
eieren (lenket konto, annen kodesti enn gjesten) ga "Sendt ✓",
|
||||||
|
"Angre" tilbakestilte og var persistent etter fane-bytte. Lys+mørk
|
||||||
|
bekreftet visuelt, ingen konsollfeil. Testrunden slettet etter
|
||||||
|
verifisering, bekreftet fjernet fra `teecup_db`.
|
||||||
|
|
||||||
|
**Rullet ut 2026-08-13**, samme økt. Ren frontend-endring (ingen ny
|
||||||
|
migrasjon) -- `docker compose build teecup_frontend && up -d
|
||||||
|
teecup_frontend`.
|
||||||
|
|
||||||
79. **To nye baner (Larvik/Seasidebanen, Nesbyen/Nesfjellet) via
|
79. **To nye baner (Larvik/Seasidebanen, Nesbyen/Nesfjellet) via
|
||||||
GolfAPI + en reell datakvalitetsbug funnet under importen —
|
GolfAPI + en reell datakvalitetsbug funnet under importen —
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ type Player = {
|
||||||
playingHandicap: number | null
|
playingHandicap: number | null
|
||||||
// Money Ball (2026-07-30) -- se ApiParticipant.lineup_order.
|
// Money Ball (2026-07-30) -- se ApiParticipant.lineup_order.
|
||||||
lineupOrder: number | null
|
lineupOrder: number | null
|
||||||
|
// ADR-065 -- se ApiParticipant.completed.
|
||||||
|
completed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type HoleStat = {
|
type HoleStat = {
|
||||||
|
|
@ -195,6 +197,10 @@ type ApiParticipant = {
|
||||||
// Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3),
|
// Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3),
|
||||||
// null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball".
|
// null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball".
|
||||||
lineup_order: number | null
|
lineup_order: number | null
|
||||||
|
// Per-deltaker-fullføring (ADR-065, migrasjon 066) -- UAVHENGIG av
|
||||||
|
// round.completed_at, kun en nagging-brems + e-post-trigger for DENNE
|
||||||
|
// spilleren, ikke en cascade til hele rundens fullføring.
|
||||||
|
completed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiSide = {
|
type ApiSide = {
|
||||||
|
|
@ -880,6 +886,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
roundSideId: p.round_side_id,
|
roundSideId: p.round_side_id,
|
||||||
playingHandicap: p.playing_handicap,
|
playingHandicap: p.playing_handicap,
|
||||||
lineupOrder: p.lineup_order,
|
lineupOrder: p.lineup_order,
|
||||||
|
completed: p.completed,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
|
|
||||||
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
|
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
|
||||||
|
|
@ -1029,7 +1036,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
// ALDRI via denne per-deltaker-veiviseren (se scrambleSoloTeamSideId), så
|
// ALDRI via denne per-deltaker-veiviseren (se scrambleSoloTeamSideId), så
|
||||||
// de skal aldri kunne bli et "neste"-mål her heller.
|
// de skal aldri kunne bli et "neste"-mål her heller.
|
||||||
function advanceWizardPlayer() {
|
function advanceWizardPlayer() {
|
||||||
const chain = scrambleSoloTeamSideId ? players.filter((p) => p.roundSideId !== scrambleSoloTeamSideId) : players
|
// Fullførte spillere (ADR-065) hopper over i samlebåndet -- de skal
|
||||||
|
// ikke bli "neste mål" for registrering, akkurat som scramble_solos
|
||||||
|
// lag-spillere allerede hoppes over.
|
||||||
|
const chain = players.filter((p) => (!scrambleSoloTeamSideId || p.roundSideId !== scrambleSoloTeamSideId) && !p.completed)
|
||||||
const idx = chain.findIndex((p) => p.id === wizardPlayerId)
|
const idx = chain.findIndex((p) => p.id === wizardPlayerId)
|
||||||
const isLast = idx === -1 || idx === chain.length - 1
|
const isLast = idx === -1 || idx === chain.length - 1
|
||||||
if (!isLast) {
|
if (!isLast) {
|
||||||
|
|
@ -1062,6 +1072,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
})
|
})
|
||||||
: players.length > 0 &&
|
: players.length > 0 &&
|
||||||
players.every((p) => {
|
players.every((p) => {
|
||||||
|
// ADR-065 -- en individuelt avsluttet spiller teller som "ferdig"
|
||||||
|
// for CTA-formål selv om ikke alle hull i holeOrder faktisk er ført.
|
||||||
|
if (p.completed) return true
|
||||||
const list = holesByParticipant[p.id]
|
const list = holesByParticipant[p.id]
|
||||||
return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true)
|
return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true)
|
||||||
}))
|
}))
|
||||||
|
|
@ -1223,6 +1236,26 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADR-065 -- fungerer UANSETT fullføringsstatus, også lenge etter at
|
||||||
|
// runden (eller kun denne deltakeren) er fullført. Muterer ingen
|
||||||
|
// databasetilstand, kun les+send -- derfor ingen lokal round-oppdatering
|
||||||
|
// her, ulikt patchParticipant.
|
||||||
|
async function sendScorecard(participantId: string): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||||
|
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/send-scorecard`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.json().catch(() => null)
|
||||||
|
const message =
|
||||||
|
errBody?.detail?.code === "NO_EMAIL_ADDRESS"
|
||||||
|
? "Denne spilleren har ingen registrert e-postadresse."
|
||||||
|
: (errBody?.detail?.message ?? "Klarte ikke å sende scorekortet. Prøv igjen.")
|
||||||
|
return { ok: false, message }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
async function finishRound() {
|
async function finishRound() {
|
||||||
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
|
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
|
||||||
setCompleting(true)
|
setCompleting(true)
|
||||||
|
|
@ -1473,6 +1506,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
roundId={roundId}
|
roundId={roundId}
|
||||||
players={players}
|
players={players}
|
||||||
sides={round.sides}
|
sides={round.sides}
|
||||||
|
holesByParticipant={holesByParticipant}
|
||||||
activePlayerId={activePlayer.id}
|
activePlayerId={activePlayer.id}
|
||||||
onSelect={setActivePlayerId}
|
onSelect={setActivePlayerId}
|
||||||
onRemove={removeGuest}
|
onRemove={removeGuest}
|
||||||
|
|
@ -1483,6 +1517,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
editingPlayerId={editingPlayerId}
|
editingPlayerId={editingPlayerId}
|
||||||
onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))}
|
onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))}
|
||||||
onPatchParticipant={patchParticipant}
|
onPatchParticipant={patchParticipant}
|
||||||
|
onSendScorecard={sendScorecard}
|
||||||
onAddGuest={addGuest}
|
onAddGuest={addGuest}
|
||||||
onAddSearched={addSearchedParticipant}
|
onAddSearched={addSearchedParticipant}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1638,6 +1673,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
onPrevHole={goPrev}
|
onPrevHole={goPrev}
|
||||||
onNextHole={goNext}
|
onNextHole={goNext}
|
||||||
onOpenEntry={openPlayerOrTeamSideEntry}
|
onOpenEntry={openPlayerOrTeamSideEntry}
|
||||||
|
onPatchParticipant={patchParticipant}
|
||||||
|
onSendScorecard={sendScorecard}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
|
@ -3929,6 +3966,7 @@ function PlayerList({
|
||||||
roundId,
|
roundId,
|
||||||
players,
|
players,
|
||||||
sides,
|
sides,
|
||||||
|
holesByParticipant,
|
||||||
activePlayerId,
|
activePlayerId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
|
@ -3939,6 +3977,7 @@ function PlayerList({
|
||||||
editingPlayerId,
|
editingPlayerId,
|
||||||
onToggleEdit,
|
onToggleEdit,
|
||||||
onPatchParticipant,
|
onPatchParticipant,
|
||||||
|
onSendScorecard,
|
||||||
onAddGuest,
|
onAddGuest,
|
||||||
onAddSearched,
|
onAddSearched,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -3947,6 +3986,9 @@ function PlayerList({
|
||||||
// ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve
|
// ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve
|
||||||
// tildelingen skjer i SidesPanel, ikke her.
|
// tildelingen skjer i SidesPanel, ikke her.
|
||||||
sides: ApiSide[]
|
sides: ApiSide[]
|
||||||
|
// ADR-065 -- kun brukt til "Ferdig · N hull"-tellingen i
|
||||||
|
// ParticipantCompletionActions.
|
||||||
|
holesByParticipant: Record<string, ApiHole[]>
|
||||||
activePlayerId: string
|
activePlayerId: string
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
|
|
@ -3960,6 +4002,7 @@ function PlayerList({
|
||||||
editingPlayerId: string | null
|
editingPlayerId: string | null
|
||||||
onToggleEdit: (id: string) => void
|
onToggleEdit: (id: string) => void
|
||||||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
|
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||||||
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -4068,6 +4111,14 @@ function PlayerList({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ParticipantCompletionActions
|
||||||
|
player={player}
|
||||||
|
holesPlayed={holesByParticipant[player.id]?.filter((h) => h.played).length ?? 0}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onPatch={onPatchParticipant}
|
||||||
|
onSendScorecard={onSendScorecard}
|
||||||
|
/>
|
||||||
|
|
||||||
{editing && canEdit && (
|
{editing && canEdit && (
|
||||||
<EditParticipantPanel
|
<EditParticipantPanel
|
||||||
roundId={roundId}
|
roundId={roundId}
|
||||||
|
|
@ -5469,6 +5520,99 @@ function sideRank(roundSideId: string | null, sides: ApiSide[]): number {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADR-065 -- delt mellom PlayerHoleCards (Score-fanen) og PlayerList
|
||||||
|
// ("Spillere og runde"-fanen), samme handlinger begge steder. "Avslutt
|
||||||
|
// for [navn]"/"Angre" er FLIGHT-STYRT (patchParticipant, ikke eier-only
|
||||||
|
// -- se update_participant i rounds.py), skjules når runden selv er
|
||||||
|
// fullført (readOnly) siden backend uansett avviser med ALREADY_COMPLETED
|
||||||
|
// da. "Send scorekort" fungerer derimot UANSETT fullføringsstatus.
|
||||||
|
function ParticipantCompletionActions({
|
||||||
|
player,
|
||||||
|
holesPlayed,
|
||||||
|
readOnly,
|
||||||
|
onPatch,
|
||||||
|
onSendScorecard,
|
||||||
|
}: {
|
||||||
|
player: Player
|
||||||
|
holesPlayed: number
|
||||||
|
readOnly: boolean
|
||||||
|
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
|
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [busyError, setBusyError] = useState<string | null>(null)
|
||||||
|
const [sendState, setSendState] = useState<"idle" | "sending" | "sent" | "error">("idle")
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function toggleCompleted(next: boolean) {
|
||||||
|
setBusy(true)
|
||||||
|
setBusyError(null)
|
||||||
|
const result = await onPatch(player.id, { completed: next })
|
||||||
|
setBusy(false)
|
||||||
|
if (!result.ok) setBusyError(result.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
setSendState("sending")
|
||||||
|
setSendError(null)
|
||||||
|
const result = await onSendScorecard(player.id)
|
||||||
|
setSendState(result.ok ? "sent" : "error")
|
||||||
|
if (!result.ok) setSendError(result.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-2.5">
|
||||||
|
{player.completed ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary">
|
||||||
|
<Check aria-hidden="true" className="size-4" />
|
||||||
|
Ferdig{holesPlayed > 0 ? ` · ${holesPlayed} hull` : ""}
|
||||||
|
</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => toggleCompleted(false)}
|
||||||
|
className="flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Angre
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => toggleCompleted(true)}
|
||||||
|
className="flex min-h-11 items-center justify-center rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Avslutt for {player.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={sendState === "sending"}
|
||||||
|
onClick={handleSend}
|
||||||
|
className="ml-auto flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{sendState === "sending" ? "Sender…" : sendState === "sent" ? "Sendt ✓" : "Send scorekort"}
|
||||||
|
</button>
|
||||||
|
{busyError && (
|
||||||
|
<p role="alert" className="w-full text-sm font-medium text-destructive">
|
||||||
|
{busyError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sendState === "error" && sendError && (
|
||||||
|
<p role="alert" className="w-full text-sm font-medium text-destructive">
|
||||||
|
{sendError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes: number; par: number } | null {
|
function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes: number; par: number } | null {
|
||||||
if (!list || numbers.length === 0) return null
|
if (!list || numbers.length === 0) return null
|
||||||
const played = numbers
|
const played = numbers
|
||||||
|
|
@ -5509,6 +5653,8 @@ function PlayerHoleCards({
|
||||||
onPrevHole,
|
onPrevHole,
|
||||||
onNextHole,
|
onNextHole,
|
||||||
onOpenEntry,
|
onOpenEntry,
|
||||||
|
onPatchParticipant,
|
||||||
|
onSendScorecard,
|
||||||
}: {
|
}: {
|
||||||
roundId: string
|
roundId: string
|
||||||
players: Player[]
|
players: Player[]
|
||||||
|
|
@ -5525,6 +5671,8 @@ function PlayerHoleCards({
|
||||||
onPrevHole: () => void
|
onPrevHole: () => void
|
||||||
onNextHole: () => void
|
onNextHole: () => void
|
||||||
onOpenEntry: (playerId: string, holeNumber: number) => void
|
onOpenEntry: (playerId: string, holeNumber: number) => void
|
||||||
|
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
|
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
}) {
|
}) {
|
||||||
const holeMeta: Record<number, { par: number; index: number }> = {}
|
const holeMeta: Record<number, { par: number; index: number }> = {}
|
||||||
for (const list of Object.values(holesByParticipant)) {
|
for (const list of Object.values(holesByParticipant)) {
|
||||||
|
|
@ -5551,6 +5699,9 @@ function PlayerHoleCards({
|
||||||
const allRegisteredByHole: Record<number, boolean> = {}
|
const allRegisteredByHole: Record<number, boolean> = {}
|
||||||
for (const n of holeOrder) {
|
for (const n of holeOrder) {
|
||||||
allRegisteredByHole[n] = players.every((p) => {
|
allRegisteredByHole[n] = players.every((p) => {
|
||||||
|
// ADR-065 -- en avsluttet spiller blokkerer aldri "registrert"-status
|
||||||
|
// for et hull de aldri kommer til å spille.
|
||||||
|
if (p.completed) return true
|
||||||
const h = holesByParticipant[p.id]?.find((x) => x.hole_number === n)
|
const h = holesByParticipant[p.id]?.find((x) => x.hole_number === n)
|
||||||
return Boolean(h?.played) && (h?.score !== null || h?.picked_up)
|
return Boolean(h?.played) && (h?.score !== null || h?.picked_up)
|
||||||
})
|
})
|
||||||
|
|
@ -5680,7 +5831,9 @@ function PlayerHoleCards({
|
||||||
aria-label={
|
aria-label={
|
||||||
hasScore
|
hasScore
|
||||||
? `${player.name}, hull ${currentHole}, ${apiHole?.picked_up ? "plukket opp" : `${apiHole?.score} slag`}${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
|
? `${player.name}, hull ${currentHole}, ${apiHole?.picked_up ? "plukket opp" : `${apiHole?.score} slag`}${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
|
||||||
: `Registrer ${player.name} sin score på hull ${currentHole}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
|
: player.completed
|
||||||
|
? `${player.name} har avsluttet runden, hull ${currentHole} ikke spilt`
|
||||||
|
: `Registrer ${player.name} sin score på hull ${currentHole}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
|
||||||
}
|
}
|
||||||
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/30 disabled:opacity-60"
|
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/30 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
|
|
@ -5749,6 +5902,10 @@ function PlayerHoleCards({
|
||||||
{points !== null ? ` · ${points} p` : ""}
|
{points !== null ? ` · ${points} p` : ""}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
) : player.completed ? (
|
||||||
|
// ADR-065 -- spilleren er individuelt avsluttet, dette
|
||||||
|
// hullet blir aldri spilt. Ingen "Registrer"-oppfordring.
|
||||||
|
<span className="text-[11px] font-semibold text-muted-foreground">Ferdig</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex size-12 items-center justify-center rounded-[10px] border-2 border-dashed border-primary/50 text-primary">
|
<span className="flex size-12 items-center justify-center rounded-[10px] border-2 border-dashed border-primary/50 text-primary">
|
||||||
|
|
@ -5775,6 +5932,13 @@ function PlayerHoleCards({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ParticipantCompletionActions
|
||||||
|
player={player}
|
||||||
|
holesPlayed={list?.filter((h) => h.played).length ?? 0}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onPatch={onPatchParticipant}
|
||||||
|
onSendScorecard={onSendScorecard}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue