"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 = { 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, }: { organizationId: string onClose: () => void }) { const [step, setStep] = useState<"loading" | "edit" | "saving" | "done">("loading") const [rows, setRows] = useState([]) // 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>({}) const [error, setError] = useState(null) const [saveWarnings, setSaveWarnings] = useState([]) 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]) 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 = {} 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 (
) } return ( {}} onColumnMappingChange={() => {}} onConfirmMapping={() => {}} onCellChange={updateCell} onDeleteRow={removeRow} onAddBlankRow={addBlankRow} onSave={() => void handleSave()} onClose={onClose} /> ) }