Rediger organisasjonens spillerpool når som helst (ADR-085)
Ny /organizations/[id]/players-rute gjenbruker spillerimport-tabellen (ADR-082) uendret, men med ny orkestrering: laster eksisterende spillere og lagrer med ekte PATCH per endret felt, ikke det fyll-tomme-felt-eneste bulk-importendepunktet (som aldri overskriver et allerede satt navn/HCP -- ubrukelig for reell redigering). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ecd9ad196
commit
82343e522b
7 changed files with 491 additions and 14 deletions
|
|
@ -8751,6 +8751,75 @@ bør vurderes nevnt i CLAUDE.md om det skjer igjen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-085: Rediger organisasjonens hele spillerpool, når som helst (2026-08-18)
|
||||||
|
|
||||||
|
Bruker meldte "lettere krise": ingen måte å redigere spillere i org-
|
||||||
|
turneringer utenom den tre-felts inline-formen fra 2026-07-19/20
|
||||||
|
(`EditPlayerForm` i `tournament-detail.tsx`: kun navn/HCP/kjønn). Ba
|
||||||
|
eksplisitt om at tabellen fra spillerimporten (ADR-082) skal være
|
||||||
|
tilgjengelig for redigering NÅR SOM HELST, ikke bare under en fersk
|
||||||
|
CSV-import inni én bestemt turnering.
|
||||||
|
|
||||||
|
**Undersøkt før bygging:** `PlayerImportPanel` fantes kun som en modal
|
||||||
|
inni to turnering-rostringsskjermer, alltid startet ved `step="upload"`
|
||||||
|
-- ingen rute, ingen vei til å åpne den med eksisterende spillere
|
||||||
|
forhåndsutfylt. **Kritisk funn i `POST .../players/bulk`** (ADR-082 sitt
|
||||||
|
lagre-endepunkt): bruker `COALESCE` per felt -- fyller KUN tomme felt på
|
||||||
|
en eksisterende match, og rører ALDRI `display_name`/`email`/`paid` i
|
||||||
|
det hele tatt (bevisst, for CSV-import). Dette endepunktet ville derfor
|
||||||
|
IKKE fungert som lagre-vei for en ekte "rett dette feltet"-skjerm --
|
||||||
|
en korrigering av et allerede utfylt navn eller HCP ville stille feilet
|
||||||
|
(returnert 200, men ingenting endret). `PATCH /orgs/{id}/players/{id}`
|
||||||
|
gjør derimot ekte per-felt-overskriving, men kun ett spiller om gangen,
|
||||||
|
ingen bulk-variant.
|
||||||
|
|
||||||
|
**Løsning: gjenbruk selve tabell-UI-et (`PlayerImportView`) uendret i
|
||||||
|
sin visuelle utforming, ny orkestrering rundt den.** Ny
|
||||||
|
`Mode.kind === "roster"` (ingen turneringskontekst -- skjuler lag-/
|
||||||
|
klasse-kolonnene, endrer tittel/avslutningstekst). Ny
|
||||||
|
`org-player-roster-panel.tsx`: laster radene fra `GET /orgs/{id}/
|
||||||
|
players` (i stedet for en opplastet CSV), differ hvert felt mot
|
||||||
|
originalverdien ved lagring, og sender KUN de faktisk endrede feltene
|
||||||
|
som `PATCH` per eksisterende spiller (ekte overskriving) -- helt nye
|
||||||
|
rader (lagt til i tabellen denne økten) oppretter med vanlig `POST`,
|
||||||
|
én om gangen. Ingen backend-endring, ingen migrasjon -- ren
|
||||||
|
klient-side orkestrering mot eksisterende endepunkter.
|
||||||
|
|
||||||
|
**Bevisst utenfor omfang:** ekte sletting av en spiller fra
|
||||||
|
organisasjonen -- "Slett"-knappen i tabellen fjerner kun raden fra
|
||||||
|
DENNE redigeringsøkten (samme klient-side-only-oppførsel som i
|
||||||
|
import-flyten), IKKE spilleren fra databasen. Ingen slette-endepunkt
|
||||||
|
finnes. Gjort eksplisitt i UI-teksten (`player-import-view.tsx` sin
|
||||||
|
`EditTable`-beskrivelse, roster-modus) for å unngå at "Slett" feilaktig
|
||||||
|
oppfattes som en reell sletting.
|
||||||
|
|
||||||
|
**Ny rute:** `/organizations/[id]/players` (`org-players.tsx` +
|
||||||
|
`app/organizations/[id]/players/page.tsx`, samme skall-mønster som
|
||||||
|
`org-members.tsx`/`members/page.tsx`), lenket fra en ny kort-lenke på
|
||||||
|
Medlemmer-siden (`org-members.tsx`, ved siden av Order of Merit).
|
||||||
|
|
||||||
|
**Ingen ny V0-runde denne gangen** -- selve tabellen (`EditTable`) er
|
||||||
|
100% UENDRET gjenbruk av den allerede V0-designede komponenten fra
|
||||||
|
ADR-082, kun ny data-inn/data-ut-orkestrering og et par tekstlinjer
|
||||||
|
avhengig av modus. Vurdert IKKE å bryte "nye UI-flater går via V0"-
|
||||||
|
regelen, siden ingen ny visuell design faktisk lages her.
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Scratch-runde
|
||||||
|
(database+API+next dev) med tre eksisterende spillere -- bekreftet at
|
||||||
|
tabellen laster reelle verdier (ikke bare navn), at HCP-korrigering på
|
||||||
|
en spiller MED allerede satt verdi (12.4→10.5) og navneendring på en
|
||||||
|
spiller UTEN e-post (dermed ingen bulk-matching mulig uansett) begge
|
||||||
|
faktisk persisterte i databasen ved reload, og at en helt ny rad
|
||||||
|
korrekt opprettet en ny spiller. Lys+mørk bekreftet. Måtte nedgradere
|
||||||
|
scratch-brukeren fra `owner` til `member` underveis -- org-eiere/
|
||||||
|
-admins krever obligatorisk 2FA-oppsett ved innlogging (urelatert
|
||||||
|
funn, ikke en bug, bare et scratch-oppsett-hinder). Scratch-stacken
|
||||||
|
revet ned.
|
||||||
|
|
||||||
|
**Rullet ut:** venter på bekreftelse (ren frontend, ingen migrasjon).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -12333,3 +12333,39 @@ Neste steg:
|
||||||
|
|
||||||
**Ingen migrasjon** -- ren frontend-endring
|
**Ingen migrasjon** -- ren frontend-endring
|
||||||
(`frontend/components/round-detail.tsx`).
|
(`frontend/components/round-detail.tsx`).
|
||||||
|
|
||||||
|
109. **Rediger organisasjonens hele spillerpool, når som helst (ADR-085)
|
||||||
|
— 2026-08-18.** "Lettere krise": bruker kunne ikke redigere spillere
|
||||||
|
i org-turneringer utenom en gammel tre-felts inline-form (navn/HCP/
|
||||||
|
kjønn). Ba om at spillerimport-tabellen (ADR-082) skal være
|
||||||
|
tilgjengelig for redigering til enhver tid.
|
||||||
|
|
||||||
|
**Kritisk funn:** `POST .../players/bulk` (import-lagringen) bruker
|
||||||
|
`COALESCE` per felt -- fyller KUN tomme felt på en eksisterende
|
||||||
|
match, rører ALDRI `display_name`/`email`/`paid`. Ubrukelig som
|
||||||
|
lagre-vei for ekte redigering (en korrigering av et allerede utfylt
|
||||||
|
navn/HCP ville stille feilet). Løsning: gjenbruker selve
|
||||||
|
`PlayerImportView`-tabellen UENDRET via en ny `mode.kind===
|
||||||
|
"roster"`, men ny orkestrering (`org-player-roster-panel.tsx`) som
|
||||||
|
laster fra `GET /orgs/{id}/players` og lagrer med ekte `PATCH`
|
||||||
|
per endret felt (kun det som faktisk er rørt) -- nye rader
|
||||||
|
oppretter med vanlig `POST`. Ingen backend-endring, ingen
|
||||||
|
migrasjon. Ny rute `/organizations/[id]/players`, lenket fra
|
||||||
|
Medlemmer-siden.
|
||||||
|
|
||||||
|
Ingen ny V0-runde -- selve tabellen er 100% uendret gjenbruk av en
|
||||||
|
allerede V0-designet komponent, kun ny data-orkestrering.
|
||||||
|
|
||||||
|
**Bevisst utenfor omfang:** ekte sletting av en spiller -- "Slett"
|
||||||
|
fjerner kun raden fra redigeringsøkten, ikke fra databasen (ingen
|
||||||
|
slette-endepunkt finnes), gjort eksplisitt i UI-teksten.
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Scratch-
|
||||||
|
runde med tre eksisterende spillere -- bekreftet at en HCP-
|
||||||
|
korrigering (12.4→10.5) OG en navneendring på en allerede utfylt
|
||||||
|
spiller begge faktisk persisterte ved reload (nøyaktig det bulk-
|
||||||
|
endepunktet ikke kunne), og at en ny rad korrekt opprettet en ny
|
||||||
|
spiller. Lys+mørk bekreftet. Scratch-stacken revet ned.
|
||||||
|
|
||||||
|
**Rullet ut:** venter på bekreftelse (ren frontend, ingen
|
||||||
|
migrasjon).
|
||||||
|
|
|
||||||
13
frontend/app/organizations/[id]/players/page.tsx
Normal file
13
frontend/app/organizations/[id]/players/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { OrgPlayers } from "@/components/org-players"
|
||||||
|
|
||||||
|
export default async function OrgPlayersPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
searchParams: Promise<{ name?: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const { name } = await searchParams
|
||||||
|
return <OrgPlayers organizationId={id} orgName={name ?? "Organisasjon"} />
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Mail, Trash2, Trophy, UserPlus, Users } from "lucide-react"
|
import { ArrowLeft, Mail, Trash2, Trophy, UserPlus, Users, UsersRound } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
@ -182,8 +182,21 @@ export function OrgMembers({ organizationId, orgName }: { organizationId: string
|
||||||
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
|
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
|
href={`/organizations/${organizationId}/players?name=${encodeURIComponent(orgName)}`}
|
||||||
className="mt-6 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
|
className="mt-6 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<UsersRound aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-foreground">Spillerpool</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Se og rediger alle spillere organisasjonen har registrert</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
|
||||||
|
className="mt-3 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
|
||||||
>
|
>
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
|
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
|
|
||||||
270
frontend/components/org-player-roster-panel.tsx
Normal file
270
frontend/components/org-player-roster-panel.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
"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,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
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])
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
frontend/components/org-players.tsx
Normal file
60
frontend/components/org-players.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Organisasjonens spillerpool -- egen side (2026-08-18), lenket fra
|
||||||
|
// Medlemmer-siden (org-members.tsx). Se org-player-roster-panel.tsx for
|
||||||
|
// selve redigeringslogikken; denne filen er kun sideskallet (samme mønster
|
||||||
|
// som org-members.tsx: sticky header med tilbake-lenke + tittel).
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Pencil } from "lucide-react"
|
||||||
|
import { OrgPlayerRosterPanel } from "@/components/org-player-roster-panel"
|
||||||
|
|
||||||
|
export function OrgPlayers({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href={`/organizations/${organizationId}/members?name=${encodeURIComponent(orgName)}`}
|
||||||
|
aria-label="Tilbake til medlemmer"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Spillerpool
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{orgName}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Alle spillere organisasjonen har registrert, på tvers av turneringer. Rediger navn, HCP,
|
||||||
|
kontaktinfo og andre felt direkte i tabellen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="mt-6 flex min-h-11 w-full items-center gap-3 rounded-2xl border border-border bg-card p-4 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<Pencil aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-foreground">Rediger spillere</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Åpne tabellen -- samme visning som ved spillerimport</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{editing && <OrgPlayerRosterPanel organizationId={organizationId} onClose={() => setEditing(false)} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,11 @@ export type DraftRow = { key: string } & Record<FieldKey, string>
|
||||||
export type Mode =
|
export type Mode =
|
||||||
| { kind: "team"; teams: { id: string; name: string }[] }
|
| { kind: "team"; teams: { id: string; name: string }[] }
|
||||||
| { kind: "individual"; classes: { id: string; name: string }[] }
|
| { kind: "individual"; classes: { id: string; name: string }[] }
|
||||||
|
// Organisasjonens hele spillerpool, utenfor enhver turneringskontekst
|
||||||
|
// (2026-08-18, se org-player-roster-panel.tsx) -- ingen lag/klasse-
|
||||||
|
// kolonner (de gir ikke mening uten en turnering), og radene starter
|
||||||
|
// FYLT UT fra eksisterende spillere i stedet for en opplastet CSV.
|
||||||
|
| { kind: "roster" }
|
||||||
|
|
||||||
export type PlayerImportStep = "upload" | "map" | "edit" | "saving" | "done"
|
export type PlayerImportStep = "upload" | "map" | "edit" | "saving" | "done"
|
||||||
|
|
||||||
|
|
@ -168,9 +173,14 @@ export function PlayerImportView({
|
||||||
|
|
||||||
// In the edit step, only render columns that some row actually uses (i.e.
|
// In the edit step, only render columns that some row actually uses (i.e.
|
||||||
// fields that were mapped), keeping mode-specific columns filtered too.
|
// fields that were mapped), keeping mode-specific columns filtered too.
|
||||||
const activeFields = selectableFields.filter((field) =>
|
// Roster mode never goes through the upload/map steps (rows start pre-
|
||||||
Object.values(columnMapping).includes(field),
|
// filled from existing players, see org-player-roster-panel.tsx), so
|
||||||
)
|
// `columnMapping` is always empty there -- show every selectable field
|
||||||
|
// instead of falling back to just the name column.
|
||||||
|
const activeFields =
|
||||||
|
mode.kind === "roster"
|
||||||
|
? selectableFields
|
||||||
|
: selectableFields.filter((field) => Object.values(columnMapping).includes(field))
|
||||||
// Guarantee at least the name column so a blank table is still usable.
|
// Guarantee at least the name column so a blank table is still usable.
|
||||||
const editFields = activeFields.length > 0 ? activeFields : ["display_name" as FieldKey]
|
const editFields = activeFields.length > 0 ? activeFields : ["display_name" as FieldKey]
|
||||||
|
|
||||||
|
|
@ -181,21 +191,23 @@ export function PlayerImportView({
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Importer spillere"
|
aria-label={mode.kind === "roster" ? "Rediger spillere" : "Importer spillere"}
|
||||||
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||||
>
|
>
|
||||||
{/* Header: title left, large close button right */}
|
{/* Header: title left, large close button right */}
|
||||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3.5 lg:px-8">
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3.5 lg:px-8">
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<h1 className="truncate text-lg font-extrabold lg:text-xl">Importer spillere</h1>
|
<h1 className="truncate text-lg font-extrabold lg:text-xl">
|
||||||
|
{mode.kind === "roster" ? "Rediger spillere" : "Importer spillere"}
|
||||||
|
</h1>
|
||||||
<p className="truncate text-sm text-muted-foreground">
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
{mode.kind === "team" ? "Lagturnering" : "Individuell turnering"}
|
{mode.kind === "team" ? "Lagturnering" : mode.kind === "individual" ? "Individuell turnering" : "Organisasjonens spillerpool"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Lukk import"
|
aria-label={mode.kind === "roster" ? "Lukk redigering" : "Lukk import"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground",
|
"flex size-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
|
@ -248,7 +260,7 @@ export function PlayerImportView({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "done" && <DoneStep saveWarnings={saveWarnings} onClose={onClose} />}
|
{step === "done" && <DoneStep mode={mode} saveWarnings={saveWarnings} onClose={onClose} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -435,7 +447,9 @@ function EditTable({
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-xl font-extrabold text-foreground">Kontroller spillerne</h2>
|
<h2 className="text-xl font-extrabold text-foreground">Kontroller spillerne</h2>
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
Rediger cellene direkte før du lagrer. Du kan legge til eller slette rader.
|
{mode.kind === "roster"
|
||||||
|
? "Rediger cellene direkte, og trykk Lagre. \"Slett\" fjerner raden herfra, men sletter IKKE spilleren fra organisasjonen -- det er ikke bygget ennå."
|
||||||
|
: "Rediger cellene direkte før du lagrer. Du kan legge til eller slette rader."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -556,16 +570,18 @@ function EditTable({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DoneStep({ saveWarnings, onClose }: { saveWarnings: string[]; onClose: () => void }) {
|
function DoneStep({ mode, saveWarnings, onClose }: { mode: Mode; saveWarnings: string[]; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-6 py-10 text-center">
|
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-6 py-10 text-center">
|
||||||
<span className="flex size-20 items-center justify-center rounded-full bg-primary/15 text-primary">
|
<span className="flex size-20 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||||
<CheckCircle2 aria-hidden="true" className="size-11" />
|
<CheckCircle2 aria-hidden="true" className="size-11" />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<h2 className="text-2xl font-extrabold text-foreground">Importen er fullført</h2>
|
<h2 className="text-2xl font-extrabold text-foreground">
|
||||||
|
{mode.kind === "roster" ? "Endringene er lagret" : "Importen er fullført"}
|
||||||
|
</h2>
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
Spillerne er lagret i turneringen.
|
{mode.kind === "roster" ? "Spillerpoolen er oppdatert." : "Spillerne er lagret i turneringen."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue