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)
|
||||
|
||||
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
|
||||
(`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 { useEffect, useState } from "react"
|
||||
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 { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
|
@ -182,8 +182,21 @@ export function OrgMembers({ organizationId, orgName }: { organizationId: string
|
|||
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
|
||||
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<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 =
|
||||
| { kind: "team"; teams: { 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"
|
||||
|
||||
|
|
@ -168,9 +173,14 @@ export function PlayerImportView({
|
|||
|
||||
// In the edit step, only render columns that some row actually uses (i.e.
|
||||
// fields that were mapped), keeping mode-specific columns filtered too.
|
||||
const activeFields = selectableFields.filter((field) =>
|
||||
Object.values(columnMapping).includes(field),
|
||||
)
|
||||
// Roster mode never goes through the upload/map steps (rows start pre-
|
||||
// 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.
|
||||
const editFields = activeFields.length > 0 ? activeFields : ["display_name" as FieldKey]
|
||||
|
||||
|
|
@ -181,21 +191,23 @@ export function PlayerImportView({
|
|||
<div
|
||||
role="dialog"
|
||||
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"
|
||||
>
|
||||
{/* 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">
|
||||
<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">
|
||||
{mode.kind === "team" ? "Lagturnering" : "Individuell turnering"}
|
||||
{mode.kind === "team" ? "Lagturnering" : mode.kind === "individual" ? "Individuell turnering" : "Organisasjonens spillerpool"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Lukk import"
|
||||
aria-label={mode.kind === "roster" ? "Lukk redigering" : "Lukk import"}
|
||||
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",
|
||||
"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>
|
||||
|
||||
|
|
@ -435,7 +447,9 @@ function EditTable({
|
|||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-extrabold text-foreground">Kontroller spillerne</h2>
|
||||
<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>
|
||||
</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 (
|
||||
<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">
|
||||
<CheckCircle2 aria-hidden="true" className="size-11" />
|
||||
</span>
|
||||
<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">
|
||||
Spillerne er lagret i turneringen.
|
||||
{mode.kind === "roster" ? "Spillerpoolen er oppdatert." : "Spillerne er lagret i turneringen."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue