teecup/frontend/components/org-player-roster-panel.tsx
Erol Haagenrud 48f238d158 Fiks HCP-komma app-bredt, utdatert deltakertabell, zebra-striper i spillerpool
HCP: type="number" blokkerte komma-tegnet i nettleseren før JS-parsingen
noensinne så det; byttet til text+inputMode=decimal og lagt til manglende
.replace(",",".") i tre lagre-stier. Deltakertabell: "Rediger i
spillerpoolen" er en full sidenavigasjon, bfcache kunne gjenopprette
turneringssiden i utdatert tilstand -- løst med stille bakgrunns-refresh
ved visibilitychange/pageshow. Spillerpool: zebra-striper lagt tilbake
oppå rutenett-stilen fra forrige runde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 09:50:56 +02:00

294 lines
11 KiB
TypeScript

"use client"
// Rediger HELE organisasjonens spillerpool, når som helst -- ikke bare de
// spillerne som tilfeldigvis dukker opp i en turnerings deltakerliste
// (2026-08-18, "lettere krise": brukeren kunne ikke redigere spillere i
// org-turneringer, ba eksplisitt om at tabellen fra spillerimporten (ADR-082)
// skal være tilgjengelig for redigering til enhver tid, ikke bare under en
// fersk CSV-import).
//
// Gjenbruker `PlayerImportView` (V0-eksportert, se player-import-view.tsx)
// UENDRET som selve tabell-UI-et via en ny `mode.kind === "roster"` --
// ORKESTRERINGEN her er ny og annerledes enn player-import-panel.tsx sin:
// radene starter FYLT UT fra `GET /orgs/{id}/players` (ikke en opplastet
// CSV), og "Lagre" gjør ekte PATCH-per-endret-felt mot eksisterende
// spillere (IKKE `/players/bulk`, som kun fyller TOMME felt og aldri
// overskriver et allerede satt navn/verdi -- feil oppførsel for en ekte
// "rett dette feltet"-redigeringsskjerm, se app/routers/players.py sin
// egen COALESCE-forklaring). Nye rader (lagt til i tabellen, uten kjent
// spiller-id) opprettes med vanlig POST, én om gangen.
import { useEffect, useState } from "react"
import { PlayerImportView, type DraftRow, type FieldKey } from "@/components/player-import-view"
const EMPTY_ROW: Omit<DraftRow, "key"> = {
display_name: "",
email: "",
handicap_index: "",
gender: "",
birth_date: "",
mobile: "",
club: "",
nickname: "",
country: "",
club_member_number: "",
first_name: "",
last_name: "",
comment: "",
team_name: "",
class_name: "",
}
// Speiler player-import-panel.tsx sin gjeng-konvertering, men begge veier
// -- radene her starter fra EKTE backend-data (m/f/x), ikke rå CSV-tekst.
function fromBackendGender(value: string | null): string {
if (value === "f") return "female"
if (value === "m") return "male"
if (value === "x") return "other"
return ""
}
function toBackendGender(value: string): string | null {
if (value === "female") return "f"
if (value === "male") return "m"
if (value === "other") return "x"
return null
}
type ApiPlayer = {
id: string
display_name: string
handicap_index: number | null
gender: string | null
mobile: string | null
email: string | null
birth_date: string | null
nickname: string | null
country: string | null
club: string | null
club_member_number: string | null
first_name: string | null
last_name: string | null
paid: boolean
comment: string | null
}
// De feltene et rad-objekt kan avvike fra originalen på -- team_name/
// class_name finnes ikke i roster-modus (ingen turneringskontekst), og
// display_name/email/gender/birth_date/handicap_index håndteres med egen
// konvertering ved diff, ikke rå strenglikhet.
const PLAYER_FIELDS: FieldKey[] = [
"display_name",
"email",
"handicap_index",
"gender",
"birth_date",
"mobile",
"club",
"nickname",
"country",
"club_member_number",
"first_name",
"last_name",
"comment",
]
function playerToRow(p: ApiPlayer): DraftRow {
return {
key: p.id,
...EMPTY_ROW,
display_name: p.display_name,
email: p.email ?? "",
handicap_index: p.handicap_index !== null ? String(p.handicap_index) : "",
gender: fromBackendGender(p.gender),
birth_date: p.birth_date ?? "",
mobile: p.mobile ?? "",
club: p.club ?? "",
nickname: p.nickname ?? "",
country: p.country ?? "",
club_member_number: p.club_member_number ?? "",
first_name: p.first_name ?? "",
last_name: p.last_name ?? "",
comment: p.comment ?? "",
}
}
export function OrgPlayerRosterPanel({
organizationId,
onClose,
highlightPlayerId,
}: {
organizationId: string
onClose: () => void
// Direkte kobling fra en turneringsside (2026-08-18, "det bør dessuten
// være kobling direkte mellom spillerpool og turneringsside") -- når
// satt, scroller panelet til nettopp denne spilleren sin rad og gir
// den et kort visuelt blink, i stedet for at organisator må lete den
// opp manuelt i en potensielt lang tabell.
highlightPlayerId?: string | null
}) {
const [step, setStep] = useState<"loading" | "edit" | "saving" | "done">("loading")
const [rows, setRows] = useState<DraftRow[]>([])
// Nøkkelen for en rad som ALLEREDE var en ekte spiller er selve
// spiller-id-en (satt i playerToRow over) -- originalById lar handleSave
// vite hvilke felt som faktisk endret seg, slik at PATCH-en kun sender
// det som er rørt (samme prinsipp som backend sin `exclude_unset`).
const [originalById, setOriginalById] = useState<Record<string, DraftRow>>({})
const [error, setError] = useState<string | null>(null)
const [saveWarnings, setSaveWarnings] = useState<string[]>([])
useEffect(() => {
let cancelled = false
fetch(`/orgs/${organizationId}/players`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`players: ${res.status}`))))
.then((players: ApiPlayer[]) => {
if (cancelled) return
const mapped = players.map(playerToRow)
setRows(mapped)
setOriginalById(Object.fromEntries(mapped.map((r) => [r.key, r])))
setStep("edit")
})
.catch(() => {
if (!cancelled) setError("Klarte ikke å hente spillerne. Prøv å laste siden på nytt.")
})
return () => {
cancelled = true
}
}, [organizationId])
// Scroll-til-spiller (2026-08-18) -- bruker `data-row-key` (satt i
// player-import-view.tsx sin EditTable, et rent orkestrerings-anker,
// se dens egen kommentar) i stedet for å endre selve V0-komponentens
// props-kontrakt. Kjøres når raden faktisk er i DOM-en (step "edit").
useEffect(() => {
if (step !== "edit" || !highlightPlayerId) return
const el = document.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(highlightPlayerId)}"]`)
if (!el) return
el.scrollIntoView({ behavior: "smooth", block: "center" })
el.classList.add("ring-2", "ring-primary", "ring-inset")
const timeout = setTimeout(() => el.classList.remove("ring-2", "ring-primary", "ring-inset"), 2500)
return () => clearTimeout(timeout)
}, [step, highlightPlayerId, rows])
function updateCell(key: string, field: FieldKey, value: string) {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, [field]: value } : r)))
}
function addBlankRow() {
setRows((prev) => [...prev, { key: `new-${Date.now()}`, ...EMPTY_ROW }])
}
function removeRow(key: string) {
// Fjerner KUN raden fra denne redigeringsøkten -- sletter ikke
// spilleren (ingen slette-endepunkt finnes ennå, se kommentar i
// player-import-view.tsx sin EditTable-beskrivelse for roster-modus).
setRows((prev) => prev.filter((r) => r.key !== key))
}
async function handleSave() {
setStep("saving")
setError(null)
setSaveWarnings([])
const warnings: string[] = []
try {
for (const row of rows) {
const isExisting = row.key in originalById
if (isExisting) {
const original = originalById[row.key]
const patch: Record<string, unknown> = {}
for (const field of PLAYER_FIELDS) {
if (row[field] === original[field]) continue
if (field === "gender") {
patch.gender = toBackendGender(row.gender)
} else if (field === "handicap_index") {
// Komma-desimal skal fungere likt som punktum (2026-08-19).
patch.handicap_index = row.handicap_index.trim()
? Number(row.handicap_index.replace(",", "."))
: null
} else if (field === "birth_date") {
patch.birth_date = row.birth_date.trim() || null
} else {
patch[field] = row[field].trim() || null
}
}
// display_name er required (min_length=1) på backend -- aldri
// send en tom streng, selv om raden ble tømt ved et uhell.
if ("display_name" in patch && !String(patch.display_name ?? "").trim()) {
delete patch.display_name
}
if (Object.keys(patch).length === 0) continue
const res = await fetch(`/orgs/${organizationId}/players/${row.key}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patch),
})
if (!res.ok) {
warnings.push(`${row.display_name || "(uten navn)"}: klarte ikke å lagre endringen.`)
}
} else if (row.display_name.trim()) {
// Ny rad (lagt til i tabellen denne økten) -- ekte oppretting,
// én om gangen (samme mønster som player-import-panel.tsx).
const res = await fetch(`/orgs/${organizationId}/players`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
display_name: row.display_name.trim(),
email: row.email.trim() || null,
handicap_index: row.handicap_index.trim() ? Number(row.handicap_index.replace(",", ".")) : null,
gender: toBackendGender(row.gender),
birth_date: row.birth_date.trim() || null,
mobile: row.mobile.trim() || null,
club: row.club.trim() || null,
nickname: row.nickname.trim() || null,
country: row.country.trim() || null,
club_member_number: row.club_member_number.trim() || null,
first_name: row.first_name.trim() || null,
last_name: row.last_name.trim() || null,
comment: row.comment.trim() || null,
}),
})
if (!res.ok) {
warnings.push(`${row.display_name}: klarte ikke å opprette spilleren.`)
}
}
}
setSaveWarnings(warnings)
setStep("done")
} catch {
setError("Lagringen feilet. Prøv igjen.")
setStep("edit")
}
}
if (step === "loading") {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
return (
<PlayerImportView
mode={{ kind: "roster" }}
step={step}
csvHeaders={[]}
columnMapping={{}}
rows={rows}
errorMessage={error}
saveWarnings={saveWarnings}
onFileSelected={() => {}}
onColumnMappingChange={() => {}}
onConfirmMapping={() => {}}
onCellChange={updateCell}
onDeleteRow={removeRow}
onAddBlankRow={addBlankRow}
onSave={() => void handleSave()}
onClose={onClose}
/>
)
}