teecup/frontend/components/dashboard.tsx

581 lines
19 KiB
TypeScript
Raw Normal View History

"use client"
import type React from "react"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
import { Building2, Calendar, ChevronRight, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } 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 { Wordmark } from "@/components/wordmark"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
type MyOrg = { organization_id: string; name: string; role: string }
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
// ADR-031: "Mine runder" -- turneringer brukeren er SPILLER i (rostret på et
// lag), på tvers av organisasjoner, uavhengig av organisasjonsmedlemskap.
type MyTournament = {
organization_id: string
organization_name: string
tournament_id: string
tournament_name: string
status: TournamentStatus
team_id: string
team_name: string
team_color: string | null
next_session_at: string | null
}
type Me = {
id: string
email: string
display_name: string
preferred_locale: string
organizations: MyOrg[]
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
my_tournaments: MyTournament[]
}
type ApiTournament = {
id: string
name: string
status: TournamentStatus
start_date: string | null
end_date: string | null
}
function toTournament(t: ApiTournament): Tournament {
return {
id: t.id,
name: t.name,
status: t.status,
startDate: t.start_date ?? undefined,
endDate: t.end_date ?? undefined,
}
}
// --- Component -------------------------------------------------------------
export function Dashboard() {
const router = useRouter()
const [me, setMe] = useState<Me | null>(null)
const [loadingMe, setLoadingMe] = useState(true)
const [activeOrgId, setActiveOrgId] = useState<string | null>(null)
const [tournaments, setTournaments] = useState<Tournament[]>([])
const [loadingTournaments, setLoadingTournaments] = useState(false)
const [error, setError] = useState<string | null>(null)
// Hent innlogget bruker + organisasjonsmedlemskap. Ingen gyldig sesjon ->
// tilbake til innloggingssiden (denne siden krever auth).
useEffect(() => {
let cancelled = false
async function loadMe() {
try {
const res = await fetch("/auth/me", { credentials: "include" })
if (!res.ok) {
router.replace("/")
return
}
const data: Me = await res.json()
if (cancelled) return
setMe(data)
setActiveOrgId(data.organizations[0]?.organization_id ?? null)
} catch {
if (!cancelled) router.replace("/")
} finally {
if (!cancelled) setLoadingMe(false)
}
}
void loadMe()
return () => {
cancelled = true
}
}, [router])
// Turneringer hentes per valgt organisasjon, ikke i /auth/me-kallet.
useEffect(() => {
if (!activeOrgId) {
setTournaments([])
return
}
let cancelled = false
async function loadTournaments() {
setLoadingTournaments(true)
setError(null)
try {
const res = await fetch(`/orgs/${activeOrgId}/tournaments`, { credentials: "include" })
if (!res.ok) throw new Error(`tournaments: ${res.status}`)
const data: ApiTournament[] = await res.json()
if (!cancelled) setTournaments(data.map(toTournament))
} catch {
if (!cancelled) setError("Klarte ikke å hente turneringer. Prøv igjen om litt.")
} finally {
if (!cancelled) setLoadingTournaments(false)
}
}
void loadTournaments()
return () => {
cancelled = true
}
}, [activeOrgId])
async function handleCreateOrg(name: string) {
setError(null)
try {
const res = await fetch("/orgs", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error(`orgs: ${res.status}`)
const org: { id: string; name: string; role: string } = await res.json()
setMe((prev) =>
prev
? {
...prev,
organizations: [
...prev.organizations,
{ organization_id: org.id, name: org.name, role: org.role },
],
}
: prev,
)
setActiveOrgId(org.id)
} catch {
setError("Klarte ikke å opprette organisasjonen. Prøv igjen.")
}
}
async function handleCreateTournament(name: string) {
if (!activeOrgId) return
setError(null)
try {
const res = await fetch(`/orgs/${activeOrgId}/tournaments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error(`create tournament: ${res.status}`)
const created: ApiTournament = await res.json()
setTournaments((prev) => [toTournament(created), ...prev])
} catch {
setError("Klarte ikke å opprette turneringen. Prøv igjen.")
}
}
async function handleLogout() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/")
}
}
if (loadingMe) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (!me) return null // router.replace("/") er allerede utløst
const activeOrg = me.organizations.find((o) => o.organization_id === activeOrgId) ?? null
const hasOrg = me.organizations.length > 0 && activeOrg !== null
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
const hasMyTournaments = me.my_tournaments.length > 0
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-3xl items-center justify-between gap-4 px-5 py-4">
<Wordmark compact />
<div className="flex items-center gap-1">
<Link
href="/account"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<UserCircle aria-hidden="true" className="size-4" />
Konto
</Link>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
</button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
<div className="flex flex-col gap-8">
{hasMyTournaments && <MyToursSection tournaments={me.my_tournaments} />}
{hasOrg && activeOrg ? (
<OrganizationView
org={activeOrg}
orgs={me.organizations}
tournaments={tournaments}
loadingTournaments={loadingTournaments}
onSelectOrg={setActiveOrgId}
onCreateTournament={handleCreateTournament}
/>
) : (
<CreateOrganizationState onCreate={handleCreateOrg} compact={hasMyTournaments} />
)}
</div>
</main>
</div>
)
}
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
// --- "Mine runder" (ADR-031) -----------------------------------------------
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
// Lenker til den offentlige turnering-siden (fungerer for enhver deltaker,
// se ADR-031-utvidelsen av check_visibility) -- IKKE til lag-chat/
// scorekort ennå, som fortsatt krever organisasjonsmedlemskap (kjent,
// notert begrensning, se FEATURE_BACKLOG.md).
function MyToursSection({ tournaments }: { tournaments: MyTournament[] }) {
return (
<section className="flex flex-col gap-3" aria-label="Mine runder">
<h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mine runder</h2>
<div className="flex flex-col gap-3">
{tournaments.map((t) => (
<Link
key={`${t.organization_id}-${t.tournament_id}`}
href={`/t/${t.tournament_id}`}
className="group flex 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"
>
<span
aria-hidden="true"
className="size-3 shrink-0 rounded-full"
style={{ backgroundColor: t.team_color ?? "#64748b" }}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h3 className="truncate text-base font-bold text-foreground">{t.tournament_name}</h3>
<TournamentStatusBadge status={t.status} />
</div>
<span className="truncate text-sm text-muted-foreground">
{t.organization_name} · {t.team_name}
</span>
{t.next_session_at && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar aria-hidden="true" className="size-4 shrink-0" />
<span className="tabular-nums">{formatNextSession(t.next_session_at)}</span>
</div>
)}
</div>
<ChevronRight
aria-hidden="true"
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
/>
</Link>
))}
</div>
</section>
)
}
function formatNextSession(iso: string) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat("no-NO", {
weekday: "short",
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date)
}
// --- State A: no organization ---------------------------------------------
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
function CreateOrganizationState({
onCreate,
compact = false,
}: {
onCreate: (name: string) => void
compact?: boolean
}) {
const [name, setName] = useState("")
const valid = name.trim().length >= 2
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid) return
onCreate(name.trim())
setName("")
}
return (
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
<div
className={cn(
"mx-auto flex max-w-md flex-col items-center text-center",
compact ? "gap-4" : "gap-6 pt-6 sm:pt-12",
)}
>
{!compact && (
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<Building2 aria-hidden="true" className="size-8 text-primary" />
</div>
)}
<div className="flex flex-col gap-2">
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
<h1 className={cn("font-extrabold tracking-tight text-foreground text-balance", compact ? "text-lg" : "text-2xl")}>
{compact ? "Opprett din egen organisasjon" : "Du har ingen organisasjon ennå"}
</h1>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
{compact
? "Skal du selv arrangere turneringer? En organisasjon er golfklubben eller bedriften som står bak."
: "En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for å komme i gang."}
</p>
</div>
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-4 rounded-3xl border border-border bg-card p-6 text-left shadow-lg shadow-black/5"
>
<div className="flex flex-col gap-2">
<Label htmlFor="org-name" className="text-sm font-semibold">
Navn organisasjon
</Label>
<Input
id="org-name"
autoFocus
placeholder="F.eks. Oslo Golfklubb"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-14 rounded-2xl text-base"
/>
</div>
<Button
type="submit"
disabled={!valid}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
Opprett organisasjon
</Button>
</form>
</div>
)
}
// --- State B: has organization(s) -----------------------------------------
function OrganizationView({
org,
orgs,
tournaments,
loadingTournaments,
onSelectOrg,
onCreateTournament,
}: {
org: MyOrg
orgs: MyOrg[]
tournaments: Tournament[]
loadingTournaments: boolean
onSelectOrg: (id: string) => void
onCreateTournament: (name: string) => void
}) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{orgs.length > 1 ? (
<OrganizationSwitcher org={org} orgs={orgs} onSelectOrg={onSelectOrg} />
) : (
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
<Building2 aria-hidden="true" className="size-5 text-primary" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Organisasjon
</span>
<span className="text-lg font-extrabold tracking-tight text-foreground">
{org.name}
</span>
</div>
</div>
)}
<div className="flex items-center gap-2">
<Link
href={`/organizations/${org.organization_id}/members?name=${encodeURIComponent(org.name)}`}
className="inline-flex h-11 items-center gap-1.5 rounded-2xl border border-border bg-card px-4 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
>
<Users aria-hidden="true" className="size-4" />
Medlemmer
</Link>
<NewTournamentControl onCreate={onCreateTournament} />
</div>
</div>
<section className="flex flex-col gap-3" aria-label="Turneringer">
{loadingTournaments ? (
<div className="flex justify-center py-12">
<div
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : tournaments.length > 0 ? (
tournaments.map((t) => (
<TournamentCard key={t.id} tournament={t} orgId={org.organization_id} />
))
) : (
<EmptyTournamentState orgName={org.name} />
)}
</section>
</div>
)
}
function OrganizationSwitcher({
org,
orgs,
onSelectOrg,
}: {
org: MyOrg
orgs: MyOrg[]
onSelectOrg: (id: string) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-3 rounded-2xl border border-border bg-card px-3 py-2.5 text-left shadow-sm shadow-black/5 transition-colors hover:bg-accent/50">
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
<Building2 aria-hidden="true" className="size-5 text-primary" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Organisasjon
</span>
<span className="text-lg font-extrabold tracking-tight text-foreground">
{org.name}
</span>
</div>
<ChevronsUpDown aria-hidden="true" className="ml-1 size-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 rounded-2xl p-1.5">
{orgs.map((o) => (
<DropdownMenuItem
key={o.organization_id}
onClick={() => onSelectOrg(o.organization_id)}
className="flex cursor-pointer items-center justify-between gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
>
{o.name}
{o.organization_id === org.organization_id && (
<Check aria-hidden="true" className="size-4 text-primary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function NewTournamentControl({ onCreate }: { onCreate: (name: string) => void }) {
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const valid = name.trim().length >= 2
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid) return
onCreate(name.trim())
setName("")
setOpen(false)
}
if (!open) {
return (
<Button
type="button"
onClick={() => setOpen(true)}
className="h-12 shrink-0 rounded-2xl text-base font-bold shadow-sm"
>
<Plus aria-hidden="true" className="size-5" />
Ny turnering
</Button>
)
}
return (
<form
onSubmit={handleSubmit}
className="flex w-full items-center gap-2 rounded-2xl border border-border bg-card p-2 shadow-sm shadow-black/5 sm:w-auto"
>
<Input
autoFocus
placeholder="Navn på turnering"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false)
}}
className="h-10 min-w-0 flex-1 rounded-xl text-base sm:w-56"
/>
<Button
type="submit"
disabled={!valid}
size="icon"
className="size-10 shrink-0 rounded-xl"
aria-label="Opprett turnering"
>
<Check aria-hidden="true" className="size-5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setOpen(false)}
className="size-10 shrink-0 rounded-xl text-muted-foreground"
aria-label="Avbryt"
>
<X aria-hidden="true" className="size-5" />
</Button>
</form>
)
}
function EmptyTournamentState({ orgName }: { orgName: string }) {
return (
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground text-balance">
Ingen turneringer ennå i {orgName}
</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Trykk «Ny turnering» for å opprette den første.
</p>
</div>
</div>
)
}