Compare commits
2 commits
a1f6d08931
...
e1ddf13cce
| Author | SHA1 | Date | |
|---|---|---|---|
| e1ddf13cce | |||
| 90bb02e6de |
10 changed files with 1157 additions and 14 deletions
|
|
@ -178,7 +178,17 @@
|
||||||
"Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(1\\).zip\")",
|
"Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(1\\).zip\")",
|
||||||
"Bash(python3 -m zipfile -e \"tee-cup-login-screen \\(1\\).zip\" /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0_dashboard_export/)",
|
"Bash(python3 -m zipfile -e \"tee-cup-login-screen \\(1\\).zip\" /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0_dashboard_export/)",
|
||||||
"Bash(curl -s -o /dev/null -w 'teecup.teeoff.no/dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)",
|
"Bash(curl -s -o /dev/null -w 'teecup.teeoff.no/dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)",
|
||||||
"Bash(grep -n \"Selve\\\\s*$\\\\|skjerm-/skrive-flyten IKKE testet\" /opt/teecup/CLAUDE.md)"
|
"Bash(grep -n \"Selve\\\\s*$\\\\|skjerm-/skrive-flyten IKKE testet\" /opt/teecup/CLAUDE.md)",
|
||||||
|
"Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(2\\).zip\")",
|
||||||
|
"Bash(python3 -m py_compile /opt/teecup/app/routers/tournaments.py)",
|
||||||
|
"Bash(cp .env .env.scratch)",
|
||||||
|
"Bash(sed -i -e 's#^TEECUP_DB_NAME=.*#TEECUP_DB_NAME=teecup_scratch#' -e 's#^TEECUP_DEV_LOG_MAGIC_LINKS=.*#TEECUP_DEV_LOG_MAGIC_LINKS=true#' .env.scratch)",
|
||||||
|
"Bash(echo \"TEECUP_DEV_LOG_MAGIC_LINKS=true\")",
|
||||||
|
"Bash(break)",
|
||||||
|
"Bash(rm -f /opt/teecup/.env.scratch /tmp/roster_cookies.txt)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'health: %{http_code}\\\\n' https://teecup.teeoff.no/health)",
|
||||||
|
"Bash(docker rm -f teecup_scratch_api >/dev/null 2>&1 *)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
116
007_registration_and_player_fields.sql
Normal file
116
007_registration_and_player_fields.sql
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 007
|
||||||
|
-- Selvregistrering + utvidet spillerprofil (ADR-017)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Kjøres etter 001-006. Alt additivt (nullable eller default) bortsett fra
|
||||||
|
-- den nye tabellen, som er helt ny.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 1. Utvidet spillerprofil (ADR-017 Beslutning D)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
ALTER TABLE player ADD COLUMN mobile text;
|
||||||
|
ALTER TABLE player ADD COLUMN email text;
|
||||||
|
ALTER TABLE player ADD COLUMN birth_date date;
|
||||||
|
ALTER TABLE player ADD COLUMN nickname text;
|
||||||
|
ALTER TABLE player ADD COLUMN country text;
|
||||||
|
ALTER TABLE player ADD COLUMN club text;
|
||||||
|
ALTER TABLE player ADD COLUMN club_member_number text;
|
||||||
|
|
||||||
|
-- Bevisst IKKE en UNIQUE-constraint på e-post: familier deler av og til
|
||||||
|
-- e-post (forelder melder på barn), og en hard unik-regel ville krasje
|
||||||
|
-- akkurat den vanlige situasjonen. E-postmatching ved påmelding/innlogging
|
||||||
|
-- (ADR-017 Beslutning B) er derfor et mykt, applikasjonslags-oppslag, ikke
|
||||||
|
-- en databasegaranti.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 2. Påmeldingsinnstillinger på turnering (ADR-017 Beslutning C)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
ALTER TABLE tournament ADD COLUMN registration_deadline timestamptz;
|
||||||
|
|
||||||
|
ALTER TABLE tournament ADD COLUMN registration_capacity integer
|
||||||
|
CHECK (registration_capacity IS NULL OR registration_capacity > 0);
|
||||||
|
|
||||||
|
ALTER TABLE tournament ADD COLUMN registration_overflow_policy text
|
||||||
|
NOT NULL DEFAULT 'closed'
|
||||||
|
CHECK (registration_overflow_policy IN ('waitlist', 'closed'));
|
||||||
|
|
||||||
|
ALTER TABLE tournament ADD COLUMN registration_requires_approval boolean
|
||||||
|
NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 3. tournament_registration (ADR-017 Beslutning C)
|
||||||
|
--
|
||||||
|
-- Bevisst ATSKILT fra team_roster: en registrering betyr "vil kanskje
|
||||||
|
-- spille", team_roster betyr "committed til et bestemt lag". Organisator
|
||||||
|
-- forfremmer en registrering til roster via de eksisterende
|
||||||
|
-- team-/roster-endepunktene -- denne tabellen påvirker ALDRI team_roster
|
||||||
|
-- automatisk.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE tournament_registration (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL,
|
||||||
|
tournament_id uuid NOT NULL,
|
||||||
|
player_id uuid NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'confirmed', 'waitlisted', 'declined', 'withdrawn')),
|
||||||
|
registered_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
-- Bevis for at samtykke faktisk ble gitt ved innsending, ikke bare
|
||||||
|
-- antatt (ADR-017). API-laget skal avvise innsending uten samtykke --
|
||||||
|
-- NOT NULL her er den siste linjen av forsvar, ikke den eneste.
|
||||||
|
consent_given_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (organization_id, tournament_id)
|
||||||
|
REFERENCES tournament(organization_id, id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (organization_id, player_id)
|
||||||
|
REFERENCES player(organization_id, id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE (organization_id, id), -- for sammensatte FK-er
|
||||||
|
UNIQUE (tournament_id, player_id) -- én registrering per spiller per turnering
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ON tournament_registration (organization_id, tournament_id);
|
||||||
|
|
||||||
|
-- RLS: samme org-isolasjon som resten (ADR-003). Skrevet direkte med
|
||||||
|
-- app_current_org() siden denne tabellen kommer etter 005-fiksen -- ingen
|
||||||
|
-- grunn til å reprodusere tomstreng-buggen bare for å rette den igjen.
|
||||||
|
ALTER TABLE tournament_registration ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tournament_registration FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY org_isolation ON tournament_registration
|
||||||
|
USING (organization_id = app_current_org())
|
||||||
|
WITH CHECK (organization_id = app_current_org());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON tournament_registration TO teecup_app;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 4. public_tournament_org() -- den ENESTE broen fra en uautentisert
|
||||||
|
-- forespørsel (ingen app.current_org satt ennå) til riktig org-kontekst
|
||||||
|
-- (ADR-017 Beslutning A).
|
||||||
|
--
|
||||||
|
-- SECURITY DEFINER: kjører med skaperens rettigheter (teeoff_admin, som
|
||||||
|
-- har BYPASSRLS) slik at oppslaget kan lese `tournament` FØR RLS-
|
||||||
|
-- konteksten er kjent -- det er selve poenget, ikke et hull. Eksponerer
|
||||||
|
-- KUN organization_id for en gitt turnering-id, ingenting annet fra
|
||||||
|
-- tournament-raden. `SET search_path = public` hindrer search_path-
|
||||||
|
-- kapring (standard herding for SECURITY DEFINER-funksjoner).
|
||||||
|
--
|
||||||
|
-- Bruksmønster i app-laget: slå opp org_id via denne FØRST, åpne deretter
|
||||||
|
-- en vanlig org_connection(org_id) og fortsett med normal RLS-håndhevelse
|
||||||
|
-- for alt det faktiske arbeidet (sjekk frist/kapasitet, sett inn
|
||||||
|
-- registrering). Samme "løs kontekst-problemet FØR RLS kan håndheve
|
||||||
|
-- noe"-mønster som selvrefererende org-bootstrap (app/routers/
|
||||||
|
-- organizations.py), nå for et lese-oppslag i stedet for en innsetting.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE FUNCTION public_tournament_org(p_tournament_id uuid)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT organization_id FROM tournament WHERE id = p_tournament_id;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public_tournament_org(uuid) TO teecup_app;
|
||||||
|
|
@ -405,6 +405,92 @@ sin port er ikke lenger tenkt nåbar direkte utenfra i prod.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-017 — Selvregistrering og utvidet spillerprofil
|
||||||
|
|
||||||
|
**Kontekst:** Reist av brukeren rett etter at lag/roster-skjermen var live.
|
||||||
|
Dagens modell antar at organisator kjenner og legger inn alle spillere selv
|
||||||
|
— i praksis vet organisator ofte ikke hvem som faktisk blir med før de
|
||||||
|
melder seg på selv.
|
||||||
|
|
||||||
|
**Beslutning A — Påmelding er offentlig, krever IKKE innlogging.** En
|
||||||
|
delbar lenke (`/register/{tournament_id}` — turneringens UUID er allerede
|
||||||
|
uforutsigelig nok, ingen ny token-mekanisme) viser et minimalt skjema. Ingen
|
||||||
|
magic-link, ingen konto kreves for å melde seg på.
|
||||||
|
|
||||||
|
**Begrunnelse:** Å kreve innlogging FØR man kan melde seg på er unødvendig
|
||||||
|
friksjon for "jeg blir med lørdag"-bruksmønsteret. Kontosammenkobling skjer
|
||||||
|
gratis senere (Beslutning B), ikke som et eget steg i selve påmeldingen.
|
||||||
|
|
||||||
|
**Beslutning B — E-post er sammenkoblingsnøkkelen** mellom en organisator-
|
||||||
|
forhåndsopprettet `player`-rad og en spiller som senere melder seg selv på
|
||||||
|
eller logger inn. Finnes det en `player`-rad i org-en med samme e-post ved
|
||||||
|
påmelding, fylles manglende felt inn på DEN raden i stedet for å opprette en
|
||||||
|
duplikat. `player.user_id` kobles først når noen med matchende e-post
|
||||||
|
faktisk logger inn via magic-link — `verify_magic_link` utvides til også å
|
||||||
|
slå opp org-scopede `player`-rader på e-post, ikke bare `app_user`.
|
||||||
|
|
||||||
|
**Konsekvens:** `player.email` er bevisst IKKE en `UNIQUE`-constraint —
|
||||||
|
familier deler av og til e-post (forelder melder på barn), en hard unik-
|
||||||
|
regel ville krasje akkurat den vanlige situasjonen. Matching er et mykt,
|
||||||
|
applikasjonslags-oppslag.
|
||||||
|
|
||||||
|
**Beslutning C — Påmelding er et eget, lettvekts steg, atskilt fra
|
||||||
|
`team_roster`**, med konfigurerbar godkjenning, kapasitet og samtykke. Ny
|
||||||
|
tabell `tournament_registration` (status: `pending`/`confirmed`/
|
||||||
|
`waitlisted`/`declined`/`withdrawn`). Tre nye felt på `tournament`:
|
||||||
|
`registration_capacity` (nullable — organisators valg om det i det hele
|
||||||
|
tatt skal være en grense), `registration_overflow_policy`
|
||||||
|
(`waitlist`/`closed`, kun relevant når kapasitet er satt),
|
||||||
|
`registration_requires_approval` (boolean). Rekkefølge ved en ny
|
||||||
|
påmelding: (1) er fristen passert → avvis; (2) er kapasitet nådd →
|
||||||
|
`waitlisted` eller avvis, avhengig av policy; (3) ellers `pending` eller
|
||||||
|
`confirmed`, avhengig av godkjenningsbryteren.
|
||||||
|
|
||||||
|
**Begrunnelse:** `team_roster` betyr i dag "committed til et bestemt lag".
|
||||||
|
Å blande "vil kanskje spille" med "spiller garantert" i samme tabell ville
|
||||||
|
gjort det umulig å skille en påmeldt-men-ikke-plukket spiller fra en som
|
||||||
|
aldri var interessert. Kapasitet og godkjenning er to reelle, uavhengige
|
||||||
|
organisator-beslutninger — å låse ett svar for alle turneringer ville vært
|
||||||
|
feil for minst noen av dem (brukeren bekreftet eksplisitt: begge skal være
|
||||||
|
konfigurerbare valg, ikke faste regler).
|
||||||
|
|
||||||
|
**Beslutning D — Utvidet spillerprofil + samtykke.** Nye felt på `player`:
|
||||||
|
`mobile`, `email`, `birth_date` (IKKE alder — alder blir feil neste år,
|
||||||
|
fødselsdato er ikke det), `nickname`, `country`, `club`,
|
||||||
|
`club_member_number`. Samtykke er obligatorisk ved påmelding — API-et
|
||||||
|
avviser innsending (400) uten `consent: true`, og
|
||||||
|
`tournament_registration.consent_given_at` er beviset på at det faktisk ble
|
||||||
|
gitt, ikke bare antatt. Samtykket lever på REGISTRERINGEN, ikke på
|
||||||
|
`player`, fordi det er selve påmeldingshandlingen for DENNE turneringen
|
||||||
|
samtykket knytter seg til.
|
||||||
|
|
||||||
|
**Ikke et nytt felt:** "utslagssted for anledningen" er sannsynligvis
|
||||||
|
allerede dekket av `match_participant.tee_id` (per match, ikke per
|
||||||
|
spillerprofil, siden det kan variere fra runde til runde).
|
||||||
|
|
||||||
|
**Beslutning E — `public_tournament_org()`: den eneste broen fra en
|
||||||
|
uautentisert forespørsel til riktig RLS-kontekst.** Et offentlig
|
||||||
|
påmeldingskall kjenner en turnering-id, men ikke organisasjonen den hører
|
||||||
|
til — og uten `app.current_org` satt slipper RLS ingen rader gjennom
|
||||||
|
(heller ikke selve oppslaget for å FINNE riktig org). Løst med en snever
|
||||||
|
`SECURITY DEFINER`-SQL-funksjon (`p_tournament_id -> organization_id`, kjørt
|
||||||
|
med skaperens BYPASSRLS-rettigheter, `SET search_path = public` mot
|
||||||
|
kapring) — samme "løs kontekst-problemet FØR RLS kan håndheve noe"-mønster
|
||||||
|
som den selvrefererende org-bootstrapen (`app/routers/organizations.py`),
|
||||||
|
nå for et lese-oppslag i stedet for en innsetting.
|
||||||
|
|
||||||
|
**Konsekvens:** App-laget slår opp org-id via denne funksjonen FØRST, åpner
|
||||||
|
deretter en vanlig `org_connection(org_id)` og fortsetter med normal
|
||||||
|
RLS-håndhevelse for alt det faktiske arbeidet. Funksjonen eksponerer KUN en
|
||||||
|
uuid->uuid-kobling, ingenting annet fra `tournament`-raden — et bevisst
|
||||||
|
smalt unntak, ikke en generell RLS-omgåelse. Ethvert fremtidig offentlig
|
||||||
|
(uautentisert) endepunkt som trenger å slå opp org-kontekst fra en kjent
|
||||||
|
ressurs-id bør gjenbruke akkurat dette mønsteret, ikke finne opp et nytt.
|
||||||
|
|
||||||
|
**Migrasjon:** `007_registration_and_player_fields.sql`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Åpne spørsmål (ikke besluttet ennå)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
83
CLAUDE.md
83
CLAUDE.md
|
|
@ -3,7 +3,7 @@
|
||||||
Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
|
Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
|
||||||
|
|
||||||
## Autoritative kilder (les før du gjør noe)
|
## Autoritative kilder (les før du gjør noe)
|
||||||
- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…016). Fasit.
|
- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…017). Fasit.
|
||||||
- `FEATURE_BACKLOG.md` — hva som gjenstår, hva som er utsatt, hva som mangler.
|
- `FEATURE_BACKLOG.md` — hva som gjenstår, hva som er utsatt, hva som mangler.
|
||||||
- Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge
|
- Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge
|
||||||
filene oppdatert når noe avgjøres.
|
filene oppdatert når noe avgjøres.
|
||||||
|
|
@ -329,12 +329,79 @@ Ferdig og verifisert:
|
||||||
dato), ett-org-visningen viste riktig uten unødvendig bytter-UI. Første
|
dato), ett-org-visningen viste riktig uten unødvendig bytter-UI. Første
|
||||||
gang en HEL skrive-flyt (ikke bare lesing) er bevist ende-til-ende fra
|
gang en HEL skrive-flyt (ikke bare lesing) er bevist ende-til-ende fra
|
||||||
frontend mot ekte produksjonsdata.
|
frontend mot ekte produksjonsdata.
|
||||||
|
- **Lag/roster-skjerm LIVE (2026-07-18):** tredje V0-skjerm
|
||||||
|
(`/tournaments/[id]`), samme re-eksport-mønster som dashboard-runden —
|
||||||
|
diffet mot live-treet, tok kun inn `tournament-detail.tsx` og et
|
||||||
|
`Link`-basert `tournament-card.tsx` (navigasjon fra dashbordet).
|
||||||
|
**URL-design bevisst avvikende fra V0s forslag:** V0s genererte side leste
|
||||||
|
aldri `params.id` og hadde ingen organization_id i det hele tatt — holdt
|
||||||
|
derfor V0s flate `/tournaments/[id]`-struktur (i stedet for en nøstet
|
||||||
|
`/orgs/[orgId]/tournaments/[id]`, som ville krevd manuell ombygging ved
|
||||||
|
HVER fremtidig V0-reeksport) og la `org`+`name` til som søkeparametre i
|
||||||
|
`tournament-card.tsx` sin lenke — API-et krever organization_id på alle
|
||||||
|
team-/roster-kall (RLS).
|
||||||
|
**Reelt hull funnet FØR integrering, ikke etter:** V0-skjermen bygger inn
|
||||||
|
"fjern spiller"/"gjør til kaptein"-handlinger, men backend hadde KUN
|
||||||
|
GET/POST på `team_roster` — ingen DELETE eller PATCH. Spurte bruker
|
||||||
|
eksplisitt (samme mønster som andre scope-avklaringer denne økten) — svar:
|
||||||
|
bygg de to endepunktene nå. Lagt til i `app/routers/tournaments.py`:
|
||||||
|
`PATCH .../roster/{roster_id}` (bevisst enkel — setter/fjerner
|
||||||
|
`is_captain` på NØYAKTIG denne raden, håndhever IKKE "kun én kaptein per
|
||||||
|
lag", siden kaptein fortsatt bare er et merke, ikke en egen
|
||||||
|
autorisasjonsrolle) og `DELETE .../roster/{roster_id}` (204, idempotent
|
||||||
|
`NOT_FOUND` ved dobbel sletting — ikke krasj).
|
||||||
|
**`tournament-detail.tsx` sitt datalag skrevet om fra V0s mock:** henter
|
||||||
|
lag + roster (roster-radene bærer allerede `display_name`/
|
||||||
|
`handicap_index_snapshot` fra APIet, så V0s separate `poolById`-oppslag
|
||||||
|
ble fjernet som overflødig) og organisasjonens spillerpool
|
||||||
|
(`GET /orgs/{id}/players`, brukt til type-ahead ved "legg til spiller").
|
||||||
|
Alle fem mutasjonene (opprett lag, legg til eksisterende spiller, opprett
|
||||||
|
ny spiller inline + rostre, endre kaptein, fjern fra roster) kablet mot
|
||||||
|
ekte endepunkter — presentasjonskomponentene (kort, type-ahead,
|
||||||
|
fargevelger, bekreft-fjerning) beholdt uendret fra V0.
|
||||||
|
**Verifisert:** de to nye endepunktene testet mot en fersk
|
||||||
|
`teecup_scratch` (PATCH setter kaptein + riktig `NOT_FOUND` på ugyldig id,
|
||||||
|
DELETE gir 204 + idempotent `NOT_FOUND` ved gjentak, `test_isolation.sql`
|
||||||
|
fortsatt 12/12), ekte typesjekket frontend-build (5 ruter), redeploy av
|
||||||
|
BEGGE containere (backend-endepunktene er nye), `teecup.teeoff.no/dashboard`
|
||||||
|
→ 200, `teeoff.no` upåvirket. Selve skrive-flyten på `/tournaments/[id]`
|
||||||
|
(opprett lag/roster) ikke testet med ekte data i denne runden — venter på
|
||||||
|
brukeren, samme mønster som dashboard-rundens skrive-test.
|
||||||
|
- **ADR-017 + migrasjon 007 (2026-07-18):** brukeren reiste selvregistrering
|
||||||
|
rett etter at roster-skrive-flyten var bekreftet. Full ADR skrevet
|
||||||
|
(5 beslutninger: offentlig påmelding uten innlogging, e-post som
|
||||||
|
sammenkoblingsnøkkel mot forhåndsopprettede spillere, egen
|
||||||
|
`tournament_registration`-tabell atskilt fra `team_roster` med
|
||||||
|
konfigurerbar godkjenning/kapasitet/venteliste, utvidet spillerprofil +
|
||||||
|
obligatorisk samtykke, og `public_tournament_org()`). Migrasjon
|
||||||
|
`007_registration_and_player_fields.sql` skrevet og scratch-verifisert
|
||||||
|
(001→007 kjører rent, `test_isolation.sql` fortsatt 12/12).
|
||||||
|
**Reelt arkitekturproblem løst underveis, ikke bare skjema:** et
|
||||||
|
offentlig (uautentisert) påmeldingskall kjenner en turnering-id, men
|
||||||
|
ingen org-kontekst — og uten `app.current_org` slipper RLS ingen rader
|
||||||
|
gjennom, heller ikke oppslaget for å FINNE riktig org. Løst med en snever
|
||||||
|
`SECURITY DEFINER`-funksjon (`public_tournament_org`) som KUN eksponerer
|
||||||
|
uuid→uuid-koblingen. **Verifisert presist, ikke bare "kjørte uten feil":**
|
||||||
|
kalt funksjonen som `teecup_app`-rollen med ingen `app.current_org` satt
|
||||||
|
— ga korrekt org-id for en kjent turnering, `NULL` (ikke feil) for en
|
||||||
|
ukjent — OG et RÅTT `SELECT` på `tournament` på SAMME tilkobling/rolle ga
|
||||||
|
fortsatt 0 rader, som beviser RLS ikke er brutt generelt, bare dette ene
|
||||||
|
smale unntaket eksisterer.
|
||||||
|
**Kun migrasjon + ADR i denne runden** — API-endepunkter (offentlig
|
||||||
|
registrerings-router, utvidede `players.py`-felt) og frontend
|
||||||
|
(påmeldingsskjema) er IKKE bygget ennå, og migrasjonen er IKKE kjørt mot
|
||||||
|
ekte `teecup_db`.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. Flere V0-skjermer (lag/roster, økt/program, blind draw, scorekort,
|
1. ADR-017: kjøre migrasjon 007 mot ekte `teecup_db` (venter på brukerens
|
||||||
leaderboard) — samme mønster: design i V0 (fortsett i samme prosjekt),
|
eksplisitte bekreftelse), bygge API-et (offentlig registrerings-router,
|
||||||
FORVENT en full re-eksport neste gang også — diff mot live-treet i et
|
utvidet `players.py`), så et påmeldingsskjema i frontend.
|
||||||
scratch-område før noe pakkes ut over eksisterende filer (se
|
2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
|
||||||
dashboard-runden over for hvorfor dette er nødvendig hver gang).
|
samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full
|
||||||
2. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
re-eksport hver gang — diff mot live-treet i et scratch-område før noe
|
||||||
3. Kommunikasjon (chat/feed) — ikke startet.
|
pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn
|
||||||
|
handlinger backend ikke støtter ennå FØR integrering (se
|
||||||
|
dashboard-/roster-rundene over for hvorfor begge er nødvendige hver
|
||||||
|
gang).
|
||||||
|
3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||||
|
4. Kommunikasjon (chat/feed) — ikke startet.
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@
|
||||||
| i18n-forberedelse (nb/en) | ✅ | ADR-015. `preferred_locale` + ekte engelsk e-postmal. Se egen seksjon under. |
|
| i18n-forberedelse (nb/en) | ✅ | ADR-015. `preferred_locale` + ekte engelsk e-postmal. Se egen seksjon under. |
|
||||||
| Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. |
|
| Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. |
|
||||||
| Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyt bekreftet med ekte data (org "Tjøme Gents" + turnering opprettet av bruker). |
|
| Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyt bekreftet med ekte data (org "Tjøme Gents" + turnering opprettet av bruker). |
|
||||||
|
| Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. |
|
||||||
|
| Selvregistrering + utvidet spillerprofil | 🔨 | ADR-017. Migrasjon 007 skrevet + scratch-verifisert. API/frontend gjenstår. |
|
||||||
|
| Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -300,6 +303,10 @@
|
||||||
- **Mangler:** «tilskuer» som begrep. Henger sammen med hvem som ser den
|
- **Mangler:** «tilskuer» som begrep. Henger sammen med hvem som ser den
|
||||||
offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke
|
offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke
|
||||||
rettigheter (sette oppstilling), ikke bare være et flagg.
|
rettigheter (sette oppstilling), ikke bare være et flagg.
|
||||||
|
- **Nytt 2026-07-18:** `PATCH .../roster/{id}` (sette/fjerne kaptein) håndhever
|
||||||
|
bevisst IKKE «kun én kaptein per lag» — flere spillere kan i dag merkes
|
||||||
|
kaptein samtidig på samme lag. Bør revurderes samtidig med resten av dette
|
||||||
|
punktet, ikke løses isolert i roster-endepunktet.
|
||||||
|
|
||||||
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
|
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
|
||||||
- **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over.
|
- **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over.
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,65 @@ async def add_roster_entry(
|
||||||
return RosterEntry(**dict(row))
|
return RosterEntry(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class RosterEntryUpdate(BaseModel):
|
||||||
|
is_captain: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
|
||||||
|
response_model=RosterEntry,
|
||||||
|
)
|
||||||
|
async def update_roster_entry(
|
||||||
|
team_id: str,
|
||||||
|
roster_id: str,
|
||||||
|
body: RosterEntryUpdate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> RosterEntry:
|
||||||
|
# Bevisst enkelt: setter/fjerner kapteinmerket på NØYAKTIG denne raden,
|
||||||
|
# håndhever ikke "kun én kaptein per lag" -- kaptein er i dag bare et
|
||||||
|
# merke, ikke en egen autorisasjonsrolle (se FEATURE_BACKLOG).
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
WITH updated AS (
|
||||||
|
UPDATE team_roster
|
||||||
|
SET is_captain = $1
|
||||||
|
WHERE id = $2 AND team_id = $3
|
||||||
|
RETURNING id, player_id, handicap_index_snapshot, is_captain
|
||||||
|
)
|
||||||
|
SELECT updated.id::text, updated.player_id::text,
|
||||||
|
updated.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
|
updated.is_captain, p.display_name
|
||||||
|
FROM updated JOIN player p ON p.id = updated.player_id
|
||||||
|
""",
|
||||||
|
body.is_captain,
|
||||||
|
roster_id,
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
|
||||||
|
return RosterEntry(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
async def delete_roster_entry(
|
||||||
|
team_id: str,
|
||||||
|
roster_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> None:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
deleted = await conn.fetchval(
|
||||||
|
"DELETE FROM team_roster WHERE id = $1 AND team_id = $2 RETURNING id",
|
||||||
|
roster_id,
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
if deleted is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Økter (ADR-007: turnering = ordnet sekvens av økter)
|
# Økter (ADR-007: turnering = ordnet sekvens av økter)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
28
frontend/app/tournaments/[id]/page.tsx
Normal file
28
frontend/app/tournaments/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { TournamentDetail } from "@/components/tournament-detail"
|
||||||
|
|
||||||
|
export default async function TournamentPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
searchParams: Promise<{ org?: string; name?: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const { org, name } = await searchParams
|
||||||
|
const tournamentName = name?.trim() || "Turnering"
|
||||||
|
|
||||||
|
// org-parameteren følger alltid med fra dashbordets TournamentCard-lenke
|
||||||
|
// (se components/tournament-card.tsx) -- API-et krever organization_id på
|
||||||
|
// alle team-/roster-endepunkter (RLS, se ADR-001/003).
|
||||||
|
if (!org) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TournamentDetail organizationId={org} tournamentId={id} tournamentName={tournamentName} />
|
||||||
|
}
|
||||||
|
|
@ -328,7 +328,9 @@ function OrganizationView({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : tournaments.length > 0 ? (
|
) : tournaments.length > 0 ? (
|
||||||
tournaments.map((t) => <TournamentCard key={t.id} tournament={t} />)
|
tournaments.map((t) => (
|
||||||
|
<TournamentCard key={t.id} tournament={t} orgId={org.organization_id} />
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<EmptyTournamentState orgName={org.name} />
|
<EmptyTournamentState orgName={org.name} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Link from "next/link"
|
||||||
import { Calendar, ChevronRight } from "lucide-react"
|
import { Calendar, ChevronRight } from "lucide-react"
|
||||||
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
|
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
|
||||||
|
|
||||||
|
|
@ -29,12 +30,18 @@ function formatDateRange(start?: string, end?: string) {
|
||||||
return from ?? to ?? null
|
return from ?? to ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TournamentCard({ tournament }: { tournament: Tournament }) {
|
export function TournamentCard({
|
||||||
|
tournament,
|
||||||
|
orgId,
|
||||||
|
}: {
|
||||||
|
tournament: Tournament
|
||||||
|
orgId: string
|
||||||
|
}) {
|
||||||
const dateRange = formatDateRange(tournament.startDate, tournament.endDate)
|
const dateRange = formatDateRange(tournament.startDate, tournament.endDate)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
href={`/tournaments/${tournament.id}?org=${orgId}&name=${encodeURIComponent(tournament.name)}`}
|
||||||
className="group flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
|
className="group flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
|
|
@ -57,6 +64,6 @@ export function TournamentCard({ tournament }: { tournament: Tournament }) {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||||
/>
|
/>
|
||||||
</button>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
761
frontend/components/tournament-detail.tsx
Normal file
761
frontend/components/tournament-detail.tsx
Normal file
|
|
@ -0,0 +1,761 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
MoreVertical,
|
||||||
|
Plus,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/players.py) -
|
||||||
|
|
||||||
|
type ApiPlayer = {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
handicap_index: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiRosterEntry = {
|
||||||
|
id: string
|
||||||
|
player_id: string
|
||||||
|
display_name: string
|
||||||
|
handicap_index_snapshot: number | null
|
||||||
|
is_captain: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
roster: ApiRosterEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra
|
||||||
|
// app-ens merkevare-grønn/oransje.
|
||||||
|
const TEAM_COLORS: { value: string; label: string }[] = [
|
||||||
|
{ value: "#2563eb", label: "Blå" },
|
||||||
|
{ value: "#dc2626", label: "Rød" },
|
||||||
|
{ value: "#7c3aed", label: "Lilla" },
|
||||||
|
{ value: "#0891b2", label: "Turkis" },
|
||||||
|
{ value: "#db2777", label: "Rosa" },
|
||||||
|
{ value: "#475569", label: "Skifer" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// --- Component -------------------------------------------------------------
|
||||||
|
|
||||||
|
export function TournamentDetail({
|
||||||
|
organizationId,
|
||||||
|
tournamentId,
|
||||||
|
tournamentName,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
tournamentId: string
|
||||||
|
tournamentName: string
|
||||||
|
}) {
|
||||||
|
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
|
||||||
|
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [teamsRes, poolRes] = await Promise.all([
|
||||||
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
|
||||||
|
credentials: "include",
|
||||||
|
}),
|
||||||
|
fetch(`/orgs/${organizationId}/players`, { credentials: "include" }),
|
||||||
|
])
|
||||||
|
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
|
||||||
|
const teamsData: { id: string; name: string; color: string | null }[] =
|
||||||
|
await teamsRes.json()
|
||||||
|
const poolData: ApiPlayer[] = await poolRes.json()
|
||||||
|
|
||||||
|
const withRosters = await Promise.all(
|
||||||
|
teamsData.map(async (t) => {
|
||||||
|
const rosterRes = await fetch(`/orgs/${organizationId}/teams/${t.id}/roster`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
const roster: ApiRosterEntry[] = rosterRes.ok ? await rosterRes.json() : []
|
||||||
|
return { id: t.id, name: t.name, color: t.color ?? TEAM_COLORS[0].value, roster }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (cancelled) return
|
||||||
|
setTeams([withRosters[0] ?? null, withRosters[1] ?? null])
|
||||||
|
setPool(poolData)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å laste lag og spillere. Prøv å laste siden på nytt.")
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, tournamentId])
|
||||||
|
|
||||||
|
async function createTeam(slot: 0 | 1, name: string, color: string) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ name, color }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create team: ${res.status}`)
|
||||||
|
const created: { id: string; name: string; color: string | null } = await res.json()
|
||||||
|
setTeams((prev) => {
|
||||||
|
const next: [Team | null, Team | null] = [prev[0], prev[1]]
|
||||||
|
next[slot] = { id: created.id, name: created.name, color: created.color ?? color, roster: [] }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette laget. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addExistingPlayer(teamId: string, playerId: string) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ player_id: playerId, is_captain: false }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`add roster: ${res.status}`)
|
||||||
|
const entry: ApiRosterEntry = await res.json()
|
||||||
|
setTeams((prev) =>
|
||||||
|
prev.map((t) => (t && t.id === teamId ? { ...t, roster: [...t.roster, entry] } : t)) as [
|
||||||
|
Team | null,
|
||||||
|
Team | null,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å legge til spilleren. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNewPlayer(teamId: string, name: string, handicap?: number) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const playerRes = await fetch(`/orgs/${organizationId}/players`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null }),
|
||||||
|
})
|
||||||
|
if (!playerRes.ok) throw new Error(`create player: ${playerRes.status}`)
|
||||||
|
const player: ApiPlayer = await playerRes.json()
|
||||||
|
setPool((prev) => [...prev, player])
|
||||||
|
await addExistingPlayer(teamId, player.id)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette spilleren. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCaptain(teamId: string, rosterId: string, current: boolean) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ is_captain: !current }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`toggle captain: ${res.status}`)
|
||||||
|
const updated: ApiRosterEntry = await res.json()
|
||||||
|
setTeams((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t && t.id === teamId
|
||||||
|
? { ...t, roster: t.roster.map((r) => (r.id === updated.id ? updated : r)) }
|
||||||
|
: t,
|
||||||
|
) as [Team | null, Team | null],
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å endre kaptein. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePlayer(teamId: string, rosterId: string) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok && res.status !== 204) throw new Error(`remove roster: ${res.status}`)
|
||||||
|
setTeams((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t && t.id === teamId ? { ...t, roster: t.roster.filter((r) => r.id !== rosterId) } : t,
|
||||||
|
) as [Team | null, Team | null],
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å fjerne spilleren. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hvilket lag er en gitt spiller (fra pool) allerede rostret på, om noen?
|
||||||
|
function findPlayerTeam(playerId: string): Team | null {
|
||||||
|
for (const t of teams) {
|
||||||
|
if (t && t.roster.some((r) => r.player_id === playerId)) return t
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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-4xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
aria-label="Tilbake til dashbord"
|
||||||
|
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">
|
||||||
|
Turnering
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
||||||
|
{tournamentName}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
<div className="mb-5 flex flex-col gap-1">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Lag og spillere</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Ryder Cup-format: nøyaktig to lag. Sett opp begge lagene og fyll troppene før
|
||||||
|
turneringen kan starte.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{([0, 1] as const).map((slot) => {
|
||||||
|
const team = teams[slot]
|
||||||
|
if (team) {
|
||||||
|
const otherTeam = teams[slot === 0 ? 1 : 0]
|
||||||
|
return (
|
||||||
|
<TeamPanel
|
||||||
|
key={team.id}
|
||||||
|
team={team}
|
||||||
|
otherTeam={otherTeam}
|
||||||
|
pool={pool}
|
||||||
|
findPlayerTeam={findPlayerTeam}
|
||||||
|
onToggleCaptain={toggleCaptain}
|
||||||
|
onRemovePlayer={removePlayer}
|
||||||
|
onAddExisting={addExistingPlayer}
|
||||||
|
onAddNew={addNewPlayer}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <CreateTeamCard key={`slot-${slot}`} slot={slot} onCreate={createTeam} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create team (State A) -------------------------------------------------
|
||||||
|
|
||||||
|
function CreateTeamCard({
|
||||||
|
slot,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
slot: 0 | 1
|
||||||
|
onCreate: (slot: 0 | 1, name: string, color: string) => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [color, setColor] = useState(TEAM_COLORS[slot === 0 ? 0 : 1].value)
|
||||||
|
const valid = name.trim().length >= 2
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!valid) return
|
||||||
|
onCreate(slot, name.trim(), color)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col gap-5 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Lag {slot + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-bold text-foreground">Opprett lag</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`team-name-${slot}`} className="text-sm font-semibold">
|
||||||
|
Lagnavn
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`team-name-${slot}`}
|
||||||
|
placeholder="F.eks. Lag Birdie"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Lagfarge</Label>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
{TEAM_COLORS.map((c) => {
|
||||||
|
const selected = c.value === color
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c.value)}
|
||||||
|
aria-label={c.label}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={c.label}
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 items-center justify-center rounded-full ring-2 ring-offset-2 ring-offset-card transition-transform hover:scale-105",
|
||||||
|
selected ? "ring-foreground" : "ring-transparent",
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
>
|
||||||
|
{selected && <Check aria-hidden="true" className="size-4 text-white" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!valid}
|
||||||
|
className="h-12 rounded-2xl text-base font-bold shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-5" />
|
||||||
|
Opprett lag
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Team panel with roster (State B) --------------------------------------
|
||||||
|
|
||||||
|
function TeamPanel({
|
||||||
|
team,
|
||||||
|
otherTeam,
|
||||||
|
pool,
|
||||||
|
findPlayerTeam,
|
||||||
|
onToggleCaptain,
|
||||||
|
onRemovePlayer,
|
||||||
|
onAddExisting,
|
||||||
|
onAddNew,
|
||||||
|
}: {
|
||||||
|
team: Team
|
||||||
|
otherTeam: Team | null
|
||||||
|
pool: ApiPlayer[]
|
||||||
|
findPlayerTeam: (playerId: string) => Team | null
|
||||||
|
onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void
|
||||||
|
onRemovePlayer: (teamId: string, rosterId: string) => void
|
||||||
|
onAddExisting: (teamId: string, playerId: string) => void
|
||||||
|
onAddNew: (teamId: string, name: string, handicap?: number) => void
|
||||||
|
}) {
|
||||||
|
const [confirmingId, setConfirmingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col overflow-hidden rounded-3xl border border-border bg-card shadow-sm shadow-black/5"
|
||||||
|
style={{ borderLeftWidth: 6, borderLeftColor: team.color }}
|
||||||
|
aria-label={`Lag ${team.name}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: team.color }}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<h3 className="truncate text-lg font-extrabold tracking-tight text-foreground">
|
||||||
|
{team.name}
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{team.roster.length} {team.roster.length === 1 ? "spiller" : "spillere"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{team.roster.length === 0 && (
|
||||||
|
<li className="px-5 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Ingen spillere ennå. Legg til nedenfor.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{team.roster.map((entry) => {
|
||||||
|
const confirming = confirmingId === entry.id
|
||||||
|
|
||||||
|
if (confirming) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between gap-3 border-b border-border bg-destructive/5 px-5 py-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground text-pretty">
|
||||||
|
Fjern <span className="font-semibold">{entry.display_name}</span> fra laget?
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-9 rounded-xl"
|
||||||
|
onClick={() => {
|
||||||
|
onRemovePlayer(team.id, entry.id)
|
||||||
|
setConfirmingId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fjern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 rounded-xl"
|
||||||
|
onClick={() => setConfirmingId(null)}
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center gap-3 border-b border-border px-5 py-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<span className="truncate font-semibold text-foreground">
|
||||||
|
{entry.display_name}
|
||||||
|
</span>
|
||||||
|
{entry.is_captain && (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-foreground">
|
||||||
|
<Star aria-hidden="true" className="size-3 fill-primary text-primary" />
|
||||||
|
Kaptein
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 tabular-nums text-sm font-medium text-muted-foreground">
|
||||||
|
{formatHandicap(entry.handicap_index_snapshot)}
|
||||||
|
</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
aria-label={`Handlinger for ${entry.display_name}`}
|
||||||
|
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<MoreVertical aria-hidden="true" className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52 rounded-2xl p-1.5">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onToggleCaptain(team.id, entry.id, entry.is_captain)}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
<Star aria-hidden="true" className="size-4" />
|
||||||
|
{entry.is_captain ? "Fjern som kaptein" : "Gjør til kaptein"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setConfirmingId(entry.id)}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
|
Fjern fra laget
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
<AddPlayerControl
|
||||||
|
team={team}
|
||||||
|
otherTeam={otherTeam}
|
||||||
|
pool={pool}
|
||||||
|
findPlayerTeam={findPlayerTeam}
|
||||||
|
onAddExisting={onAddExisting}
|
||||||
|
onAddNew={onAddNew}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add player type-ahead -------------------------------------------------
|
||||||
|
|
||||||
|
function AddPlayerControl({
|
||||||
|
team,
|
||||||
|
pool,
|
||||||
|
findPlayerTeam,
|
||||||
|
onAddExisting,
|
||||||
|
onAddNew,
|
||||||
|
}: {
|
||||||
|
team: Team
|
||||||
|
otherTeam: Team | null
|
||||||
|
pool: ApiPlayer[]
|
||||||
|
findPlayerTeam: (playerId: string) => Team | null
|
||||||
|
onAddExisting: (teamId: string, playerId: string) => void
|
||||||
|
onAddNew: (teamId: string, name: string, handicap?: number) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newHandicap, setNewHandicap] = useState("")
|
||||||
|
|
||||||
|
const trimmed = query.trim()
|
||||||
|
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
if (!trimmed) return []
|
||||||
|
const q = trimmed.toLowerCase()
|
||||||
|
return pool
|
||||||
|
.filter((p) => p.display_name.toLowerCase().includes(q))
|
||||||
|
.map((p) => {
|
||||||
|
const onTeam = findPlayerTeam(p.id)
|
||||||
|
return {
|
||||||
|
player: p,
|
||||||
|
onThisTeam: onTeam?.id === team.id,
|
||||||
|
onOtherTeam: onTeam && onTeam.id !== team.id ? onTeam : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((m) => !m.onThisTeam)
|
||||||
|
}, [pool, trimmed, findPlayerTeam, team.id])
|
||||||
|
|
||||||
|
const exactMatch = useMemo(
|
||||||
|
() => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()),
|
||||||
|
[pool, trimmed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const showCreate = trimmed.length >= 2 && !exactMatch
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setQuery("")
|
||||||
|
setCreating(false)
|
||||||
|
setNewHandicap("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddExisting(playerId: string) {
|
||||||
|
onAddExisting(team.id, playerId)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (trimmed.length < 2) return
|
||||||
|
const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", "."))
|
||||||
|
const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined
|
||||||
|
onAddNew(team.id, trimmed, hcp)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="h-11 w-full rounded-2xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
<UserPlus aria-hidden="true" className="size-4" />
|
||||||
|
Legg til spiller
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Søk eller skriv navn…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setCreating(false)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false)
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-11 flex-1 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
reset()
|
||||||
|
}}
|
||||||
|
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
|
||||||
|
aria-label="Lukk"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trimmed.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border bg-background">
|
||||||
|
<ul className="flex max-h-64 flex-col overflow-auto">
|
||||||
|
{matches.map(({ player, onOtherTeam }) => {
|
||||||
|
if (onOtherTeam) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={player.id}
|
||||||
|
className="flex cursor-not-allowed items-center justify-between gap-3 border-b border-border px-4 py-2.5 opacity-60 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium text-muted-foreground line-through">
|
||||||
|
{player.display_name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
allerede på {onOtherTeam.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||||
|
{formatHandicap(player.handicap_index)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={player.id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddExisting(player.id)}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{player.display_name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-sm text-muted-foreground">
|
||||||
|
{formatHandicap(player.handicap_index)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{matches.length === 0 && !showCreate && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
Ingen treff.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && !creating && (
|
||||||
|
<li className="border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-semibold text-primary transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Opprett ny spiller: «{trimmed}»
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && creating && (
|
||||||
|
<li className="flex flex-col gap-3 border-t border-border bg-accent/30 px-4 py-3">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
Ny spiller: {trimmed}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="new-hcp" className="text-xs font-semibold text-muted-foreground">
|
||||||
|
Handicap-indeks (valgfritt)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="new-hcp"
|
||||||
|
autoFocus
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="F.eks. 12.5"
|
||||||
|
value={newHandicap}
|
||||||
|
onChange={(e) => setNewHandicap(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
handleCreate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-11 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="h-11 shrink-0 rounded-xl font-bold"
|
||||||
|
>
|
||||||
|
Legg til
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatHandicap(handicap: number | null | undefined) {
|
||||||
|
if (handicap === null || handicap === undefined) return "—"
|
||||||
|
return handicap.toFixed(1)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue