"use client" // Dashbord-redesign (ADR-035, 2026-07-25): organisasjon er ikke lenger det // første/viktigste en bruker møter -- en bruker er først og fremst en // GOLFSPILLER. Presentasjon fra V0 (zip 18), datalag skrevet om fra mock // til ekte fetch. Organisasjon-opprettelse er nå USYNLIG/automatisk ("Ny // turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget // "opprett organisasjon"-steg for det vanlige tilfellet) -- se // ARCHITECTURE_DECISIONS.md ADR-035 Beslutning A. import type React from "react" import { useCallback, useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { ArrowRight, Building2, ChevronRight, Flag, KeyRound, LogOut, MapPin, Plus, TrendingDown, TrendingUp, Trophy, UserCircle, UserPlus, 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 { Wordmark } from "@/components/wordmark" import { RoundCard, type Round } from "@/components/round-card" import { TournamentCard, type Tournament } from "@/components/tournament-card" import { type TournamentStatus } from "@/components/tournament-status-badge" import { cn } from "@/lib/utils" // --- API-typer --------------------------------------------------------------- type MyOrg = { organization_id: string; name: string; role: string } // ADR-031: 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 my_session_id: string | null my_match_id: string | null } type Me = { id: string email: string display_name: string preferred_locale: string profile_complete: boolean handicap_index: number | null organizations: MyOrg[] my_tournaments: MyTournament[] } type ApiTournament = { id: string name: string status: TournamentStatus start_date: string | null end_date: string | null } type ApiRoundParticipant = { is_owner: boolean; counts_for_handicap: boolean; score_differential: number | null } type ApiRound = { id: string name: string | null course_name_snapshot: string tee_name_snapshot: string played_at: string holes_planned: number completed_at: string | null participants: ApiRoundParticipant[] owner_holes_played: number owner_total_score: number | null owner_score_to_par: number | null } type ApiHandicapPoint = { handicap_index: number; recorded_at: string } // ADR-036 fase 1 -- kun det dashbordets kompakte kort trenger (ikke hele // GET /friends-responsen, som også bærer utgående forespørsler/kategorier). type ApiFriendsSummary = { friends: { first_name: string | null; last_name: string | null }[] incoming_requests: unknown[] } 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 } } function toRound(r: ApiRound): Round { const owner = r.participants.find((p) => p.is_owner) return { id: r.id, name: r.name, courseName: r.course_name_snapshot, status: r.completed_at ? "completed" : "active", teeName: r.tee_name_snapshot, holes: r.holes_planned === 9 ? 9 : 18, date: r.played_at, playerCount: r.participants.length, holesPlayed: r.owner_holes_played, totalScore: r.owner_total_score ?? undefined, toPar: r.owner_score_to_par ?? undefined, differential: owner?.counts_for_handicap ? owner.score_differential : null, } } // --- Kombinert turnering-oppføring (deltaker OG/ELLER arrangør) ------------ type CombinedTournament = { tournament: Tournament; organizer: boolean; orgId: string; sortKey: string | null } function combineTournaments(myTournaments: MyTournament[], organizerLists: { orgId: string; tournaments: ApiTournament[] }[]): CombinedTournament[] { const byId = new Map() for (const t of myTournaments) { byId.set(t.tournament_id, { tournament: { id: t.tournament_id, name: t.tournament_name, status: t.status, startDate: t.next_session_at ?? undefined }, organizer: false, orgId: t.organization_id, sortKey: t.next_session_at, }) } for (const { orgId, tournaments } of organizerLists) { for (const t of tournaments) { const existing = byId.get(t.id) byId.set(t.id, { tournament: existing?.tournament ?? toTournament(t), organizer: true, orgId: existing?.orgId ?? orgId, sortKey: existing?.sortKey ?? t.start_date, }) } } // Kun "kommende" -- fullførte/arkiverte turneringer skal ikke fylle opp // en "kommende"-liste, uansett dato. const upcoming = [...byId.values()].filter((c) => c.tournament.status === "draft" || c.tournament.status === "active") upcoming.sort((a, b) => { if (a.sortKey && b.sortKey) return a.sortKey < b.sortKey ? -1 : 1 if (a.sortKey) return -1 if (b.sortKey) return 1 return a.tournament.name.localeCompare(b.tournament.name) }) return upcoming } // --- Formattering ------------------------------------------------------------ function formatSigned(value: number, decimals = 1): string { if (value === 0) return decimals === 0 ? "0" : `0,${"0".repeat(decimals)}` const abs = Math.abs(value).toFixed(decimals).replace(".", ",") return value > 0 ? `+${abs}` : `−${abs}` } const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }) function formatDate(value: string) { const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) return value return dateFormatter.format(parsed) } // --- Root component ---------------------------------------------------------- export function Dashboard() { const router = useRouter() const [me, setMe] = useState(null) const [loadingMe, setLoadingMe] = useState(true) const [rounds, setRounds] = useState(null) const [combinedTournaments, setCombinedTournaments] = useState([]) const [hcpHistory, setHcpHistory] = useState([]) const [friendsSummary, setFriendsSummary] = useState(null) const [error, setError] = useState(null) const loadMe = useCallback(async () => { try { const res = await fetch("/auth/me", { credentials: "include" }) if (!res.ok) { router.replace("/") return } const data: Me = await res.json() if (!data.profile_complete) { router.replace("/account") return } setMe(data) } catch { router.replace("/") } finally { setLoadingMe(false) } }, [router]) useEffect(() => { void loadMe() }, [loadMe]) useEffect(() => { fetch("/rounds", { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: ApiRound[]) => setRounds(data)) .catch(() => setRounds([])) fetch("/auth/profile/handicap-history", { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: ApiHandicapPoint[]) => setHcpHistory(data)) .catch(() => {}) fetch("/friends", { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: ApiFriendsSummary | null) => setFriendsSummary(data)) .catch(() => {}) }, []) useEffect(() => { if (!me) return let cancelled = false async function loadOrganizerTournaments() { try { const lists = await Promise.all( me!.organizations.map(async (o) => { const res = await fetch(`/orgs/${o.organization_id}/tournaments`, { credentials: "include" }) const tournaments: ApiTournament[] = res.ok ? await res.json() : [] return { orgId: o.organization_id, tournaments } }), ) if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, lists)) } catch { if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, [])) } } void loadOrganizerTournaments() return () => { cancelled = true } }, [me]) // "Ny turnering": organisasjon opprettes/gjenbrukes USYNLIG (ADR-035) -- // ingen egen "opprett organisasjon"-skjerm for det vanlige tilfellet. // Har brukeren INGEN organisasjon, genereres et navn og POST /orgs kalles // FØRST ved selve innsendingen (ikke når skjemaet bare åpnes -- unngår en // foreldreløs tom org hvis brukeren avbryter). async function handleCreateTournament(name: string, chosenOrgId: string | null) { if (!me) return setError(null) try { let orgId = chosenOrgId ?? (me.organizations.length === 1 ? me.organizations[0].organization_id : null) if (!orgId) { const orgName = me.display_name ? `${me.display_name}s turneringer` : "Mine turneringer" const orgRes = await fetch("/orgs", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name: orgName }), }) if (!orgRes.ok) throw new Error("orgs") const org: { id: string; name: string; role: string } = await orgRes.json() orgId = org.id setMe((prev) => (prev ? { ...prev, organizations: [...prev.organizations, { organization_id: org.id, name: org.name, role: org.role }] } : prev)) } const res = await fetch(`/orgs/${orgId}/tournaments`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name }), }) if (!res.ok) throw new Error("create tournament") const created: ApiTournament = await res.json() router.push(`/tournaments/${created.id}?org=${orgId}&name=${encodeURIComponent(created.name)}`) } 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 (
) } if (!me) return null // router.replace("/") er allerede utløst const rows = rounds ?? [] const activeRounds = rows.filter((r) => !r.completed_at) const completedRounds = rows.filter((r) => r.completed_at) const roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3) const avgToParSample = completedRounds .filter((r) => r.owner_score_to_par !== null) .slice(0, 5) .map((r) => r.owner_score_to_par as number) const avgToPar = avgToParSample.length > 0 ? formatSigned(avgToParSample.reduce((s, v) => s + v, 0) / avgToParSample.length, 1) : "—" const hcpValues = hcpHistory.map((h) => h.handicap_index) const hcpTrend = hcpValues.length >= 2 ? hcpValues[hcpValues.length - 1] - hcpValues[hcpValues.length - 2] : null const hcpNow = me.handicap_index !== null ? me.handicap_index.toFixed(1).replace(".", ",") : "—" const coursesMap = new Map() for (const r of rows) { const existing = coursesMap.get(r.course_name_snapshot) if (existing) { existing.visits += 1 if (r.played_at > existing.lastPlayed) existing.lastPlayed = r.played_at } else { coursesMap.set(r.course_name_snapshot, { name: r.course_name_snapshot, visits: 1, lastPlayed: r.played_at }) } } const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1)) return (

God dag, {me.display_name}

Klar for en ny runde? Her er golfen din på ett sted.

{error && (

{error}

)}
) } // --- 1. Hurtighandlinger ----------------------------------------------------- function QuickActions({ organizations, onCreateTournament, }: { organizations: MyOrg[] onCreateTournament: (name: string, orgId: string | null) => Promise }) { const [open, setOpen] = useState<"tournament" | "code" | null>(null) return (
setOpen(open === "tournament" ? null : "tournament")} /> setOpen(open === "code" ? null : "code")} />
{open === "tournament" && ( setOpen(null)} /> )} {open === "code" && setOpen(null)} />}
) } function QuickAction({ icon: Icon, label, href, onClick, }: { icon: typeof Flag label: string href?: string onClick?: () => void }) { const className = "flex min-h-[108px] flex-1 flex-col items-center justify-center gap-2.5 rounded-2xl border border-border bg-card p-4 text-center shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" const inner = ( <> {label} ) if (href) { return ( {inner} ) } return ( ) } function NewTournamentInline({ organizations, onCreate, onClose, }: { organizations: MyOrg[] onCreate: (name: string, orgId: string | null) => Promise onClose: () => void }) { const [name, setName] = useState("") const [orgId, setOrgId] = useState(organizations[0]?.organization_id ?? "") const [submitting, setSubmitting] = useState(false) const valid = name.trim().length >= 2 async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!valid || submitting) return setSubmitting(true) await onCreate(name.trim(), organizations.length > 1 ? orgId : null) setSubmitting(false) } return (
setName(e.target.value)} className="h-11 rounded-xl" />
{organizations.length > 1 && (
)}
) } function JoinByCodeInline({ onClose }: { onClose: () => void }) { const router = useRouter() const [code, setCode] = useState("") const [loading, setLoading] = useState(false) const [error, setError] = useState(null) async function handleSubmit(e: React.FormEvent) { e.preventDefault() const trimmed = code.trim() if (!trimmed || loading) return setLoading(true) setError(null) try { const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`) if (!res.ok) { const body = await res.json().catch(() => null) setError(body?.detail?.message ?? "Fant ingen turnering med denne koden.") return } const data: { tournament_id: string } = await res.json() router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`) } catch { setError("Klarte ikke å slå opp koden. Prøv igjen.") } finally { setLoading(false) } } return (
setCode(e.target.value.toUpperCase())} className="h-11 rounded-xl uppercase tracking-widest" /> {error &&

{error}

}
) } // --- Delt seksjon-"chrome" ---------------------------------------------------- function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) { return (

{title}

{action}
) } function SeeAllLink({ href, label }: { href: string; label: string }) { return ( {label}