Aggiunge la sezione Notifiche in admin e converte la dashboard a tab
Nuovo endpoint admin-only /api/public/notifiche-attive per contare e elencare i giocatori con almeno un dispositivo iscritto alle notifiche push. La dashboard admin passa da sezioni impilate a un menu a pillole scorrevole (BarraSottosezioni), come già fatto in Classifica e Squadra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { intestazioniAutenticate } from "./auth";
|
||||
|
||||
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
||||
|
||||
async function fetchNotificheAttive(): Promise<Set<string>> {
|
||||
const res = await fetch("/api/public/notifiche-attive", {
|
||||
headers: await intestazioniAutenticate(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Impossibile leggere le notifiche attive");
|
||||
const { giocatoreIds } = (await res.json()) as { giocatoreIds: string[] };
|
||||
return new Set(giocatoreIds);
|
||||
}
|
||||
|
||||
/** Insieme degli id giocatore con almeno un dispositivo iscritto alle notifiche push. */
|
||||
export function useNotificheAttive() {
|
||||
return useQuery({
|
||||
queryKey: NOTIFICHE_ATTIVE_KEY,
|
||||
queryFn: fetchNotificheAttive,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
||||
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
||||
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
||||
@@ -99,6 +100,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
||||
path: '/api/public/csi',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicNotificheAttiveRoute =
|
||||
ApiPublicNotificheAttiveRouteImport.update({
|
||||
id: '/api/public/notifiche-attive',
|
||||
path: '/api/public/notifiche-attive',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicPromemoriaPalloniRoute =
|
||||
ApiPublicPromemoriaPalloniRouteImport.update({
|
||||
id: '/api/public/promemoria-palloni',
|
||||
@@ -142,6 +149,7 @@ export interface FileRoutesByFullPath {
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
@@ -163,6 +171,7 @@ export interface FileRoutesByTo {
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
@@ -185,6 +194,7 @@ export interface FileRoutesById {
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
@@ -208,6 +218,7 @@ export interface FileRouteTypes {
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-subscribe'
|
||||
@@ -229,6 +240,7 @@ export interface FileRouteTypes {
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-subscribe'
|
||||
@@ -250,6 +262,7 @@ export interface FileRouteTypes {
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-subscribe'
|
||||
@@ -272,6 +285,7 @@ export interface RootRouteChildren {
|
||||
PartitaIdRoute: typeof PartitaIdRoute
|
||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
||||
@@ -379,6 +393,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/notifiche-attive': {
|
||||
id: '/api/public/notifiche-attive'
|
||||
path: '/api/public/notifiche-attive'
|
||||
fullPath: '/api/public/notifiche-attive'
|
||||
preLoaderRoute: typeof ApiPublicNotificheAttiveRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/promemoria-palloni': {
|
||||
id: '/api/public/promemoria-palloni'
|
||||
path: '/api/public/promemoria-palloni'
|
||||
@@ -432,6 +453,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
PartitaIdRoute: PartitaIdRoute,
|
||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
||||
|
||||
+54
-24
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
@@ -16,14 +17,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Campo,
|
||||
classiInput,
|
||||
PageHeader,
|
||||
Section,
|
||||
Select,
|
||||
StatTile,
|
||||
} from "@/components/crapp/ui-bits";
|
||||
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import {
|
||||
nomeCompleto,
|
||||
@@ -54,6 +49,7 @@ import {
|
||||
import { oggiISO } from "@/lib/palloni-core";
|
||||
import { scaricaCsv } from "@/lib/scout-export";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useNotificheAttive } from "@/lib/notifiche-admin";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
|
||||
export const Route = createFileRoute("/admin")({
|
||||
@@ -623,6 +619,7 @@ function Dashboard() {
|
||||
const admin = useIsAdmin();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const { profili, isPending } = useProfili();
|
||||
const { data: notificheAttive } = useNotificheAttive();
|
||||
const oggi = oggiISO();
|
||||
|
||||
if (!admin) {
|
||||
@@ -649,11 +646,8 @@ function Dashboard() {
|
||||
).length;
|
||||
const tesserati = attivi.filter((g) => g.numeroTessera).length;
|
||||
|
||||
return (
|
||||
const contenutoSquadra = (
|
||||
<>
|
||||
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
|
||||
|
||||
<Section titolo="Squadra">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatTile valore={attivi.length} label="Giocatori" />
|
||||
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
|
||||
@@ -666,18 +660,16 @@ function Dashboard() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))
|
||||
}
|
||||
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||
</button>
|
||||
<AggiungiGiocatore righe={squadra} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
<Section titolo="Profili" indice={1}>
|
||||
{isPending ? (
|
||||
const contenutoProfili = isPending ? (
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Caricamento…
|
||||
</p>
|
||||
@@ -687,18 +679,56 @@ function Dashboard() {
|
||||
<SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
|
||||
{disattivi.length > 0 ? (
|
||||
<Section titolo="Giocatori disattivati" indice={2}>
|
||||
const contenutoDisattivati = (
|
||||
<div className="space-y-2">
|
||||
{disattivi.map((g) => (
|
||||
<GiocatoreDisattivato key={g.id} g={g} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
);
|
||||
|
||||
const contenutoNotifiche = notificheAttive ? (
|
||||
<>
|
||||
<StatTile
|
||||
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
||||
label="Notifiche attive"
|
||||
/>
|
||||
<div className="mt-3 space-y-2">
|
||||
{attivi
|
||||
.filter((g) => notificheAttive.has(g.id))
|
||||
.map((g) => (
|
||||
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
|
||||
<Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Caricamento…
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
|
||||
|
||||
<BarraSottosezioni
|
||||
defaultId="squadra"
|
||||
variante="sottolineatura"
|
||||
riempiLarghezza
|
||||
voci={[
|
||||
{ id: "squadra", label: "Squadra", contenuto: contenutoSquadra },
|
||||
{ id: "profili", label: "Profili", contenuto: contenutoProfili },
|
||||
...(disattivi.length > 0
|
||||
? [{ id: "disattivati", label: "Disattivati", contenuto: contenutoDisattivati }]
|
||||
: []),
|
||||
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
|
||||
export const Route = createFileRoute("/api/public/notifiche-attive")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const negato = await richiediAdmin(request);
|
||||
if (negato) return negato;
|
||||
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("giocatore_id");
|
||||
if (error) {
|
||||
console.error("notifiche-attive", error);
|
||||
return new Response("Errore lettura", { status: 500 });
|
||||
}
|
||||
|
||||
const giocatoreIds = [...new Set((data ?? []).map((r) => r.giocatore_id))];
|
||||
return Response.json({ giocatoreIds });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user