Dashboard amministratore e profilo per il tesseramento

Implementa la voce "Dashboard amministratore" della roadmap v1.1, come descritta
in docs/modules/profilo-giocatore.md, insieme alla parte di profilo che la
alimenta.

- /admin: stato dei profili della squadra, download di documento, certificato e
  foto tessera, export CSV con i 12 campi del tesseramento CSI. Un certificato
  scaduto non conta come valido.
- Profilo giocatore: dati personali, documento (fronte e retro), certificato e
  foto tessera, con widget di completamento in Home che sparisce al 100%.
- I permessi di amministrazione arrivano da user_roles (DD-011) e non più dalla
  lista di nomi in crapp-data.ts, che resta come ponte finché
  VITE_AUTH_OBBLIGATORIA non viene acceso in produzione.
- Login Google via Supabase Auth: al primo accesso l'account si collega a uno
  slot libero di giocatori_squadra, e il vincolo lo fa rispettare il trigger di
  M1 (DD-016 regola 2).

Migration additive: M2 crea profili_giocatore, M3 il bucket privato
profili-giocatore. Nessuna tabella v1.0 viene toccata, quindi si possono
applicare senza cambiare il comportamento attuale dell'app.

supabase/config.toml e seed.sql configurano lo stack locale: serve perché il
progetto Supabase è uno solo, condiviso tra sviluppo e produzione.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 17:57:59 +02:00
co-authored by Claude Opus 5
parent ea29f053e3
commit da51517ffc
23 changed files with 1635 additions and 63 deletions
+132 -25
View File
@@ -1,7 +1,18 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { LogIn } from "lucide-react";
import { toast } from "sonner";
import { TeamLogo } from "@/components/crapp/ui-bits";
import { giocatori } from "@/lib/crapp-data";
import { accediConGoogle, authObbligatoria, useSessione } from "@/lib/auth";
import {
nomeCompleto,
slotDi,
slotLiberi,
useCollegaGiocatore,
useGiocatoriSquadra,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
export const Route = createFileRoute("/benvenuto")({
@@ -10,27 +21,89 @@ export const Route = createFileRoute("/benvenuto")({
{ title: "Benvenuto — CrAPP DEVELOP" },
{
name: "description",
content: "Seleziona il tuo profilo giocatore per iniziare.",
content: "Accedi e collega il tuo profilo giocatore per iniziare.",
},
{ property: "og:title", content: "Benvenuto — CrAPP DEVELOP" },
{
property: "og:description",
content: "Seleziona il tuo profilo giocatore per iniziare.",
content: "Accedi e collega il tuo profilo giocatore per iniziare.",
},
],
}),
component: Benvenuto,
});
function Scheda({
titolo,
sottotitolo,
onClick,
iniziali,
}: {
titolo: string;
sottotitolo: string;
onClick: () => void;
iniziali: string;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]"
>
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg">
{iniziali}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="font-semibold leading-tight">{titolo}</p>
<p className="text-xs text-muted-foreground">{sottotitolo}</p>
</div>
</button>
);
}
function Benvenuto() {
const navigate = useNavigate();
const giocatore = useGiocatoreCorrente();
const { pronta, utenteId } = useSessione();
const { righe } = useGiocatoriSquadra();
const collega = useCollegaGiocatore();
const [inCorso, setInCorso] = useState(false);
const mioSlot = slotDi(righe, utenteId);
// Con l'auth obbligatoria si entra solo da loggati; finché non lo è, la selezione
// diretta resta come ponte per chi non ha ancora collegato l'account (DD-011).
const puoEntrare = !!giocatore && (!!utenteId || !authObbligatoria());
useEffect(() => {
if (giocatore) {
navigate({ to: "/" });
if (puoEntrare) navigate({ to: "/" });
}, [puoEntrare, navigate]);
// L'account è già collegato a uno slot: nessuna scelta da fare.
useEffect(() => {
if (mioSlot) impostaGiocatore(mioSlot.id);
}, [mioSlot]);
async function accedi() {
setInCorso(true);
try {
await accediConGoogle();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Accesso non riuscito");
setInCorso(false);
}
}, [giocatore, navigate]);
}
async function reclama(g: GiocatoreSquadra) {
if (!utenteId) return;
try {
await collega.mutateAsync({ giocatoreId: g.id, utenteId });
impostaGiocatore(g.id);
} catch {
toast.error("Profilo già collegato a un altro account. Chiedi a un amministratore.");
}
}
const liberi = slotLiberi(righe);
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 py-12">
@@ -38,29 +111,63 @@ function Benvenuto() {
<h1 className="mt-6 text-center font-display text-4xl uppercase leading-none">
Benvenuto in CrAPP DEVELOP
</h1>
<p className="mt-2 text-center text-sm text-muted-foreground">
Seleziona chi sei per personalizzare l'app.
</p>
<div className="mt-8 w-full max-w-sm space-y-2">
{giocatori.map((g) => (
{!pronta ? null : !utenteId ? (
<>
<p className="mt-2 text-center text-sm text-muted-foreground">
Accedi con il tuo account Google per collegare il profilo giocatore.
</p>
<button
key={g.id}
type="button"
onClick={() => impostaGiocatore(g.id)}
className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]"
onClick={accedi}
disabled={inCorso}
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
>
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg">
{g.iniziali}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="font-semibold leading-tight">{g.nome}</p>
<p className="text-xs text-muted-foreground">
#{g.numero} · {g.ruolo}
</p>
</div>
<LogIn className="h-4 w-4" /> Accedi con Google
</button>
))}
</div>
{authObbligatoria() ? null : (
<div className="mt-10 w-full max-w-sm">
<p className="mb-3 text-center text-xs text-muted-foreground">
Oppure entra scegliendo il tuo nome, come prima.
</p>
<div className="space-y-2">
{giocatori.map((g) => (
<Scheda
key={g.id}
titolo={g.nome}
sottotitolo={`#${g.numero} · ${g.ruolo}`}
iniziali={g.iniziali}
onClick={() => impostaGiocatore(g.id)}
/>
))}
</div>
</div>
)}
</>
) : (
<>
<p className="mt-2 text-center text-sm text-muted-foreground">
Sei entrato. Scegli il tuo nome: resterà collegato a questo account.
</p>
<div className="mt-8 w-full max-w-sm space-y-2">
{liberi.map((g) => (
<Scheda
key={g.id}
titolo={nomeCompleto(g)}
sottotitolo={`#${g.numero} · ${g.ruolo}`}
iniziali={`${g.nome[0] ?? ""}${g.cognome[0] ?? ""}`.toUpperCase()}
onClick={() => void reclama(g)}
/>
))}
{liberi.length === 0 ? (
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
Nessun profilo libero: chiedi a un amministratore di collegarti.
</p>
) : null}
</div>
</>
)}
</div>
);
}