Ny status-kolonne på tournament_participant, overstyrer leaderboardets total og blokkerer score i alle runder. Ny direkte lenke fra turneringens Deltakere-liste til riktig rad i org-spillerpoolen. Migrasjon 087 lagt til, men IKKE anvendt mot ekte teecup_db ennå.
291 lines
10 KiB
TypeScript
291 lines
10 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") {
|
|
patch.handicap_index = row.handicap_index.trim() ? Number(row.handicap_index) : 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) : 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}
|
|
/>
|
|
)
|
|
}
|