Implementa il ruolo allenatore nell'app
- Modello: tipo sugli slot, inRosa() al posto del filtro su attivo in tutte le letture della rosa di gioco (presenze, convocati, voti, palloni, classifiche, badge, sollecito); gli allenatori restano solo nei compleanni e in Squadra. - Permessi: user_roles letto una volta per tutti i ruoli; usePuoGestireEventi() apre /eventi e il sollecito presenze anche all'allenatore, lato server con richiediGestoreEventi(). Palloni, sondaggio e messaggi restano all'admin. - Identità: useGiocatoreInCampo() è null per l'allenatore, così non risponde alle presenze e non vota; useSonoAllenatore() decide cosa si vede. - Profilo: per l'allenatore solo Docs (nome, cognome, nascita, telefono, email) e Opzioni; card avatar e tab Opzioni estratte per riusarle. - Home, Squadra e partita: niente statistiche personali, badge, cacche e obiettivi per l'allenatore; in Rosa compare con la dicitura «Allenatore». - Admin: tipo in «Aggiungi», allenatori a parte in Profili, fuori da conteggi ed export CSI. Test unit su rosa di gioco e completamento, route test sull'allenatore. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
15a010855e
commit
2ec72fd08f
35 files changed
+1215
-577
No files matched your search
+232
-181
@@ -14,8 +14,9 @@ import {
|
||||
} from "@/lib/avatar-store";
|
||||
import { SerieGriglia } from "@/components/crapp/SerieCard";
|
||||
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
|
||||
import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { ProfiloAllenatore, ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { useVotiSocial } from "@/lib/badge-social";
|
||||
import { inizialiDa } from "@/lib/crapp-data";
|
||||
import { useIo } from "@/lib/rosa";
|
||||
import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
|
||||
import {
|
||||
@@ -24,7 +25,13 @@ import {
|
||||
pushSupportato,
|
||||
statoNotifiche,
|
||||
} from "@/lib/push-client";
|
||||
import { resetGiocatore } from "@/lib/user-store";
|
||||
import { resetGiocatore, useGiocatoreBase } from "@/lib/user-store";
|
||||
import {
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
ruoloVisibile,
|
||||
type GiocatoreSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { esci } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
@@ -60,130 +67,29 @@ export const Route = createFileRoute("/profilo")({
|
||||
});
|
||||
|
||||
function Profilo() {
|
||||
// L'allenatore ha un profilo ridotto: solo Docs e Opzioni, niente stagione né badge (DD-034).
|
||||
const base = useGiocatoreBase();
|
||||
if (base && isAllenatore(base)) return <ProfiloAllenatorePagina g={base} />;
|
||||
return <ProfiloGiocatore />;
|
||||
}
|
||||
|
||||
function ProfiloGiocatore() {
|
||||
const { tab } = Route.useSearch();
|
||||
const tabIniziale = tab ?? "stagione";
|
||||
const votiSocial = useVotiSocial();
|
||||
const g = useIo();
|
||||
const admin = useIsAdmin();
|
||||
const ultimoMese = usePresenzeUltimoMese(g?.id);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fotoEsiste = useAvatarEsiste(g?.id);
|
||||
const impostaAvatarEsiste = useImpostaAvatarEsiste();
|
||||
const [bust, setBust] = useState(0);
|
||||
const [notifiche, setNotifiche] = useState(false);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
const [supportate, setSupportate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setSupportate(pushSupportato());
|
||||
statoNotifiche()
|
||||
.then(setNotifiche)
|
||||
.catch(() => setNotifiche(false));
|
||||
}, []);
|
||||
|
||||
if (!g) return null;
|
||||
|
||||
async function cambiaNotifiche() {
|
||||
if (!g || inCorso) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
if (notifiche) {
|
||||
await disattivaNotifiche();
|
||||
setNotifiche(false);
|
||||
toast.success("Notifiche disattivate");
|
||||
} else {
|
||||
await attivaNotifiche(g.id);
|
||||
setNotifiche(true);
|
||||
toast.success("Notifiche attive");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await esci();
|
||||
resetGiocatore();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
||||
}
|
||||
}
|
||||
|
||||
const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file || !g) return;
|
||||
try {
|
||||
await caricaAvatar(g.id, file);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(g.id, true);
|
||||
toast.success("Immagine profilo aggiornata");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a caricare l'immagine");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sul profilo il logo torna in home al posto del link al profilo. */}
|
||||
<PageHeader
|
||||
titolo={g.nome}
|
||||
sottotitolo={`#${g.numero} · ${g.ruolo}`}
|
||||
azione={
|
||||
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
|
||||
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<PageHeader titolo={g.nome} sottotitolo={`#${g.numero} · ${g.ruolo}`} azione={<LinkHome />} />
|
||||
|
||||
<Reveal className="-mt-6 px-5">
|
||||
<Card>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" bust={bust} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
Cambia immagine profilo
|
||||
</button>
|
||||
{fotoEsiste.data ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("Rimuovere l'immagine profilo?")) return;
|
||||
try {
|
||||
await rimuoviAvatar(g.id);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(g.id, false);
|
||||
toast.success("Immagine rimossa");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a rimuovere l'immagine");
|
||||
}
|
||||
}}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
|
||||
aria-label="Rimuovi immagine"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
<CardAvatar id={g.id} fallback={g.iniziali} />
|
||||
|
||||
<BarraSottosezioni
|
||||
key={tabIniziale}
|
||||
@@ -236,78 +142,223 @@ function Profilo() {
|
||||
{
|
||||
id: "impostazioni",
|
||||
label: "Opzioni",
|
||||
contenuto: (
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cambiaNotifiche}
|
||||
disabled={!supportate || inCorso}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Notifiche</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{supportate
|
||||
? notifiche
|
||||
? "Attive su questo dispositivo"
|
||||
: "Palloni, solleciti presenze e avvisi in app"
|
||||
: "Non supportate su questo dispositivo"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
|
||||
notifiche
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
) : null}
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Esci
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
contenuto: <TabOpzioni giocatoreId={g.id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfiloAllenatorePagina({ g }: { g: GiocatoreSquadra }) {
|
||||
const { tab } = Route.useSearch();
|
||||
// Stagione e Badge non esistono per l'allenatore: un link diretto apre Docs.
|
||||
const tabIniziale = tab === "impostazioni" ? "impostazioni" : "documenti";
|
||||
const nome = nomeCompleto(g);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo={nome} sottotitolo={ruoloVisibile(g)} azione={<LinkHome />} />
|
||||
|
||||
<CardAvatar id={g.id} fallback={inizialiDa(nome)} />
|
||||
|
||||
<BarraSottosezioni
|
||||
key={tabIniziale}
|
||||
defaultId={tabIniziale}
|
||||
riempiLarghezza
|
||||
voci={[
|
||||
{ id: "documenti", label: "Docs", contenuto: <ProfiloAllenatore g={g} /> },
|
||||
{
|
||||
id: "impostazioni",
|
||||
label: "Opzioni",
|
||||
contenuto: <TabOpzioni giocatoreId={g.id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkHome() {
|
||||
return (
|
||||
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
|
||||
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Foto profilo: stessa per giocatore e allenatore (bucket `avatar-giocatori`). */
|
||||
function CardAvatar({ id, fallback }: { id: string; fallback: string }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fotoEsiste = useAvatarEsiste(id);
|
||||
const impostaAvatarEsiste = useImpostaAvatarEsiste();
|
||||
const [bust, setBust] = useState(0);
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
await caricaAvatar(id, file);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(id, true);
|
||||
toast.success("Immagine profilo aggiornata");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a caricare l'immagine");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Reveal className="-mt-6 px-5">
|
||||
<Card>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Avatar id={id} fallback={fallback} className="h-20 w-20 text-2xl" bust={bust} />
|
||||
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
Cambia immagine profilo
|
||||
</button>
|
||||
{fotoEsiste.data ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("Rimuovere l'immagine profilo?")) return;
|
||||
try {
|
||||
await rimuoviAvatar(id);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(id, false);
|
||||
toast.success("Immagine rimossa");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a rimuovere l'immagine");
|
||||
}
|
||||
}}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
|
||||
aria-label="Rimuovi immagine"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tab Opzioni: notifiche, dashboard admin, segnalazioni, uscita. Uguale per tutti. */
|
||||
function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
||||
const admin = useIsAdmin();
|
||||
const [notifiche, setNotifiche] = useState(false);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
const [supportate, setSupportate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setSupportate(pushSupportato());
|
||||
statoNotifiche()
|
||||
.then(setNotifiche)
|
||||
.catch(() => setNotifiche(false));
|
||||
}, []);
|
||||
|
||||
async function cambiaNotifiche() {
|
||||
if (inCorso) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
if (notifiche) {
|
||||
await disattivaNotifiche();
|
||||
setNotifiche(false);
|
||||
toast.success("Notifiche disattivate");
|
||||
} else {
|
||||
await attivaNotifiche(giocatoreId);
|
||||
setNotifiche(true);
|
||||
toast.success("Notifiche attive");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await esci();
|
||||
resetGiocatore();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cambiaNotifiche}
|
||||
disabled={!supportate || inCorso}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Notifiche</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{supportate
|
||||
? notifiche
|
||||
? "Attive su questo dispositivo"
|
||||
: "Palloni, solleciti presenze e avvisi in app"
|
||||
: "Non supportate su questo dispositivo"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
|
||||
notifiche
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
) : null}
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Esci
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user