Compare commits
3
Commits
pocketbase
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0314560885 | ||
|
|
0286961e92 | ||
|
|
6372ef9c8d |
@@ -1,10 +1,11 @@
|
|||||||
# Modulo — Calendario ed Eventi
|
# Modulo — Calendario ed Eventi
|
||||||
|
|
||||||
**Stato:** implementato
|
**Stato:** implementato
|
||||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
|
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||||
(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
|
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||||
`src/components/crapp/EventoCard.tsx` (card condivisa)
|
`src/routes/eventi.tsx` (creazione/modifica, solo admin), `src/components/crapp/EventoCard.tsx`
|
||||||
**Test:** `test/unit/eventi.test.ts`
|
(card condivisa)
|
||||||
|
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,7 +27,14 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
|
|||||||
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
||||||
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||||
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
||||||
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database).
|
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). Sopra alla lista
|
||||||
|
cronologica c'è una griglia mensile (stessa logica di `/calendario`, tramite le funzioni
|
||||||
|
condivise di `src/lib/calendario.ts`): ogni giorno è cliccabile, anche senza eventi, e apre
|
||||||
|
un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento in
|
||||||
|
questo giorno" che apre il form con la data già precompilata. Creare, modificare ed
|
||||||
|
eliminare passano solo da lì: la lista cronologica sotto il calendario è un elenco senza
|
||||||
|
azioni dirette, cliccare una riga apre lo stesso drawer del giorno corrispondente (anche se
|
||||||
|
è in un mese diverso da quello mostrato sulla griglia) invece di duplicare matita/cestino.
|
||||||
|
|
||||||
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
||||||
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||||
|
|||||||
@@ -83,12 +83,22 @@ variabile d'ambiente.
|
|||||||
|
|
||||||
| Route | Controllo | Chi la chiama |
|
| Route | Controllo | Chi la chiama |
|
||||||
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
|
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
|
||||||
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||||||
|
|
||||||
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
|
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
|
||||||
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||||
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
||||||
|
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
|
||||||
|
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
|
||||||
|
l'ha ancora attivata.
|
||||||
|
|
||||||
|
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
|
||||||
|
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
|
||||||
|
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
|
||||||
|
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
|
||||||
|
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
|
||||||
|
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */
|
||||||
|
export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
|
||||||
|
|
||||||
|
export const mesiIT = [
|
||||||
|
"Gennaio",
|
||||||
|
"Febbraio",
|
||||||
|
"Marzo",
|
||||||
|
"Aprile",
|
||||||
|
"Maggio",
|
||||||
|
"Giugno",
|
||||||
|
"Luglio",
|
||||||
|
"Agosto",
|
||||||
|
"Settembre",
|
||||||
|
"Ottobre",
|
||||||
|
"Novembre",
|
||||||
|
"Dicembre",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function pad2(n: number) {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */
|
||||||
|
export function giorniDelMese(anno: number, mese: number) {
|
||||||
|
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
|
||||||
|
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
|
||||||
|
const offsetLunedi = (primoGiorno + 6) % 7;
|
||||||
|
return { giorni, offsetLunedi };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */
|
||||||
|
export function useMeseNav(initial?: { anno: number; mese: number }) {
|
||||||
|
const oggi = new Date();
|
||||||
|
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
|
||||||
|
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
|
||||||
|
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
|
||||||
|
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
|
||||||
|
const [direzione, setDirezione] = useState(0);
|
||||||
|
|
||||||
|
const precedente = () => {
|
||||||
|
setDirezione(-1);
|
||||||
|
if (mese === 0) {
|
||||||
|
setMese(11);
|
||||||
|
setAnno((a) => a - 1);
|
||||||
|
} else {
|
||||||
|
setMese((m) => m - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const successivo = () => {
|
||||||
|
setDirezione(1);
|
||||||
|
if (mese === 11) {
|
||||||
|
setMese(0);
|
||||||
|
setAnno((a) => a + 1);
|
||||||
|
} else {
|
||||||
|
setMese((m) => m + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { anno, mese, direzione, precedente, successivo };
|
||||||
|
}
|
||||||
+1
-1
@@ -104,7 +104,7 @@ export function eventoVuoto(): Evento {
|
|||||||
id: nuovoIdEvento(),
|
id: nuovoIdEvento(),
|
||||||
tipo: "allenamento",
|
tipo: "allenamento",
|
||||||
titolo: "",
|
titolo: "",
|
||||||
luogo: "Palestra Comunale",
|
luogo: "",
|
||||||
data: new Date().toISOString().slice(0, 10),
|
data: new Date().toISOString().slice(0, 10),
|
||||||
ora: "20:30",
|
ora: "20:30",
|
||||||
note: "",
|
note: "",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { intestazioniAutenticate } from "./auth";
|
import { intestazioniAutenticate } from "./auth";
|
||||||
|
|
||||||
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
||||||
@@ -20,3 +20,18 @@ export function useNotificheAttive() {
|
|||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */
|
||||||
|
export function useInviaNotifica() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => {
|
||||||
|
const res = await fetch("/api/public/notifica-personalizzata", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
|
||||||
|
body: JSON.stringify({ messaggio, giocatoreId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Impossibile inviare la notifica");
|
||||||
|
return (await res.json()) as { inviate: number; destinatari: number };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
|||||||
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
||||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
||||||
|
import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
|
||||||
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
||||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||||
@@ -100,6 +101,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
|||||||
path: '/api/public/csi',
|
path: '/api/public/csi',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiPublicNotificaPersonalizzataRoute =
|
||||||
|
ApiPublicNotificaPersonalizzataRouteImport.update({
|
||||||
|
id: '/api/public/notifica-personalizzata',
|
||||||
|
path: '/api/public/notifica-personalizzata',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiPublicNotificheAttiveRoute =
|
const ApiPublicNotificheAttiveRoute =
|
||||||
ApiPublicNotificheAttiveRouteImport.update({
|
ApiPublicNotificheAttiveRouteImport.update({
|
||||||
id: '/api/public/notifiche-attive',
|
id: '/api/public/notifiche-attive',
|
||||||
@@ -149,6 +156,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
@@ -171,6 +179,7 @@ export interface FileRoutesByTo {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
@@ -194,6 +203,7 @@ export interface FileRoutesById {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
@@ -218,6 +228,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
@@ -240,6 +251,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
@@ -262,6 +274,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
@@ -285,6 +298,7 @@ export interface RootRouteChildren {
|
|||||||
PartitaIdRoute: typeof PartitaIdRoute
|
PartitaIdRoute: typeof PartitaIdRoute
|
||||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||||
|
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||||
@@ -393,6 +407,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/public/notifica-personalizzata': {
|
||||||
|
id: '/api/public/notifica-personalizzata'
|
||||||
|
path: '/api/public/notifica-personalizzata'
|
||||||
|
fullPath: '/api/public/notifica-personalizzata'
|
||||||
|
preLoaderRoute: typeof ApiPublicNotificaPersonalizzataRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/public/notifiche-attive': {
|
'/api/public/notifiche-attive': {
|
||||||
id: '/api/public/notifiche-attive'
|
id: '/api/public/notifiche-attive'
|
||||||
path: '/api/public/notifiche-attive'
|
path: '/api/public/notifiche-attive'
|
||||||
@@ -453,6 +474,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
PartitaIdRoute: PartitaIdRoute,
|
PartitaIdRoute: PartitaIdRoute,
|
||||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||||
|
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
|
||||||
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||||
|
|||||||
+123
-7
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Bell,
|
Bell,
|
||||||
|
BellOff,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
|
Send,
|
||||||
Unlink,
|
Unlink,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
@@ -49,8 +51,17 @@ import {
|
|||||||
import { oggiISO } from "@/lib/palloni-core";
|
import { oggiISO } from "@/lib/palloni-core";
|
||||||
import { scaricaCsv } from "@/lib/scout-export";
|
import { scaricaCsv } from "@/lib/scout-export";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
import { useNotificheAttive } from "@/lib/notifiche-admin";
|
import { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin";
|
||||||
import { Reveal } from "@/components/motion/Reveal";
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin")({
|
export const Route = createFileRoute("/admin")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
@@ -621,6 +632,32 @@ function Dashboard() {
|
|||||||
const { data: notificheAttive } = useNotificheAttive();
|
const { data: notificheAttive } = useNotificheAttive();
|
||||||
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
|
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
|
||||||
const oggi = oggiISO();
|
const oggi = oggiISO();
|
||||||
|
const inviaNotifica = useInviaNotifica();
|
||||||
|
const [destinatarioMessaggio, setDestinatarioMessaggio] = useState<{
|
||||||
|
id?: string;
|
||||||
|
nome: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [messaggio, setMessaggio] = useState("");
|
||||||
|
|
||||||
|
async function inviaMessaggio() {
|
||||||
|
if (!destinatarioMessaggio || !messaggio.trim()) return;
|
||||||
|
try {
|
||||||
|
const dati = await inviaNotifica.mutateAsync(
|
||||||
|
destinatarioMessaggio.id
|
||||||
|
? { messaggio: messaggio.trim(), giocatoreId: destinatarioMessaggio.id }
|
||||||
|
: { messaggio: messaggio.trim() },
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
dati.inviate > 0
|
||||||
|
? `Notifica inviata a ${dati.inviate} dispositivi`
|
||||||
|
: "Nessun dispositivo con notifiche attive per l'invio",
|
||||||
|
);
|
||||||
|
setDestinatarioMessaggio(null);
|
||||||
|
setMessaggio("");
|
||||||
|
} catch {
|
||||||
|
toast.error("Non sono riuscito a inviare la notifica");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
return (
|
return (
|
||||||
@@ -703,15 +740,42 @@ function Dashboard() {
|
|||||||
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
||||||
label="Notifiche attive"
|
label="Notifiche attive"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
||||||
|
</button>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{attivi
|
{attivi.map((g) => {
|
||||||
.filter((g) => notificheAttive.has(g.id))
|
const attiva = notificheAttive.has(g.id);
|
||||||
.map((g) => (
|
return (
|
||||||
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
|
<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" />
|
{attiva ? (
|
||||||
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
|
<Bell className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
|
||||||
|
!attiva && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{nomeCompleto(g)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDestinatarioMessaggio({ id: g.id, nome: nomeCompleto(g) })}
|
||||||
|
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
||||||
|
aria-label={`Invia messaggio a ${nomeCompleto(g)}`}
|
||||||
|
>
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -737,6 +801,58 @@ function Dashboard() {
|
|||||||
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
|
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={!!destinatarioMessaggio}
|
||||||
|
onOpenChange={(aperto) => {
|
||||||
|
if (!aperto) {
|
||||||
|
setDestinatarioMessaggio(null);
|
||||||
|
setMessaggio("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Invia messaggio</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="px-4">
|
||||||
|
<textarea
|
||||||
|
value={messaggio}
|
||||||
|
maxLength={300}
|
||||||
|
rows={3}
|
||||||
|
onChange={(e) => setMessaggio(e.target.value)}
|
||||||
|
placeholder="Scrivi il messaggio da inviare come notifica push…"
|
||||||
|
className={cn(classiInput, "h-auto")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DrawerFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={inviaMessaggio}
|
||||||
|
disabled={inviaNotifica.isPending || !messaggio.trim()}
|
||||||
|
className="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 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{inviaNotifica.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}{" "}
|
||||||
|
Invia
|
||||||
|
</button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||||
|
import { inviaPush } from "@/lib/webpush.server";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
messaggio: z.string().min(1).max(300),
|
||||||
|
giocatoreId: z.string().min(1).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/public/notifica-personalizzata")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ request }) => {
|
||||||
|
const negato = await richiediAdmin(request);
|
||||||
|
if (negato) return negato;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
|
||||||
|
|
||||||
|
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||||
|
|
||||||
|
let query = supabaseAdmin.from("push_subscriptions").select("endpoint, p256dh, auth");
|
||||||
|
if (parsed.data.giocatoreId) {
|
||||||
|
query = query.eq("giocatore_id", parsed.data.giocatoreId);
|
||||||
|
}
|
||||||
|
const { data: iscrizioni } = await query;
|
||||||
|
|
||||||
|
const titolo = "Messaggio dallo staff";
|
||||||
|
const testo = parsed.data.messaggio.trim();
|
||||||
|
|
||||||
|
let inviate = 0;
|
||||||
|
for (const iscrizione of iscrizioni ?? []) {
|
||||||
|
try {
|
||||||
|
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||||
|
if (stato === 404 || stato === 410) {
|
||||||
|
await supabaseAdmin
|
||||||
|
.from("push_subscriptions")
|
||||||
|
.delete()
|
||||||
|
.eq("endpoint", iscrizione.endpoint);
|
||||||
|
} else if (stato >= 200 && stato < 300) {
|
||||||
|
inviate += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("notifica-personalizzata", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ inviate, destinatari: iscrizioni?.length ?? 0 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
|||||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
|
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -37,8 +38,6 @@ export const Route = createFileRoute("/calendario")({
|
|||||||
component: Calendario,
|
component: Calendario,
|
||||||
});
|
});
|
||||||
|
|
||||||
const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
|
|
||||||
|
|
||||||
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */
|
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */
|
||||||
const coloreTipo: Record<Evento["tipo"], string> = {
|
const coloreTipo: Record<Evento["tipo"], string> = {
|
||||||
partita: "var(--accent)",
|
partita: "var(--accent)",
|
||||||
@@ -47,63 +46,6 @@ const coloreTipo: Record<Evento["tipo"], string> = {
|
|||||||
compleanno: "var(--success)",
|
compleanno: "var(--success)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mesiIT = [
|
|
||||||
"Gennaio",
|
|
||||||
"Febbraio",
|
|
||||||
"Marzo",
|
|
||||||
"Aprile",
|
|
||||||
"Maggio",
|
|
||||||
"Giugno",
|
|
||||||
"Luglio",
|
|
||||||
"Agosto",
|
|
||||||
"Settembre",
|
|
||||||
"Ottobre",
|
|
||||||
"Novembre",
|
|
||||||
"Dicembre",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function useMeseNav(initial?: { anno: number; mese: number }) {
|
|
||||||
const oggi = new Date();
|
|
||||||
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
|
|
||||||
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
|
|
||||||
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
|
|
||||||
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
|
|
||||||
const [direzione, setDirezione] = useState(0);
|
|
||||||
|
|
||||||
const precedente = () => {
|
|
||||||
setDirezione(-1);
|
|
||||||
if (mese === 0) {
|
|
||||||
setMese(11);
|
|
||||||
setAnno((a) => a - 1);
|
|
||||||
} else {
|
|
||||||
setMese((m) => m - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const successivo = () => {
|
|
||||||
setDirezione(1);
|
|
||||||
if (mese === 11) {
|
|
||||||
setMese(0);
|
|
||||||
setAnno((a) => a + 1);
|
|
||||||
} else {
|
|
||||||
setMese((m) => m + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { anno, mese, direzione, precedente, successivo };
|
|
||||||
}
|
|
||||||
|
|
||||||
function giorniDelMese(anno: number, mese: number) {
|
|
||||||
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
|
|
||||||
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
|
|
||||||
const offsetLunedi = (primoGiorno + 6) % 7;
|
|
||||||
return { giorni, offsetLunedi };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad2(n: number) {
|
|
||||||
return String(n).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
function Calendario() {
|
function Calendario() {
|
||||||
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
||||||
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
||||||
|
|||||||
+223
-34
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Campo, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
|
import { Campo, Card, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
import { formatData } from "@/lib/crapp-data";
|
import { formatData } from "@/lib/crapp-data";
|
||||||
|
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import {
|
import {
|
||||||
categoriaEvento,
|
categoriaEvento,
|
||||||
@@ -66,8 +67,19 @@ function GestioneEventi() {
|
|||||||
const elimina = useEliminaEvento();
|
const elimina = useEliminaEvento();
|
||||||
const [bozza, setBozza] = useState<Evento | null>(null);
|
const [bozza, setBozza] = useState<Evento | null>(null);
|
||||||
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
|
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
|
||||||
|
const [confermaModifica, setConfermaModifica] = useState(false);
|
||||||
const rosa = squadra.filter((g) => g.attivo);
|
const rosa = squadra.filter((g) => g.attivo);
|
||||||
|
|
||||||
|
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
||||||
|
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const d = new Date();
|
||||||
|
setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() });
|
||||||
|
}, []);
|
||||||
|
const [giornoSelezionato, setGiornoSelezionato] = useState<string | null>(null);
|
||||||
|
const { anno, mese, precedente, successivo } = useMeseNav();
|
||||||
|
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||||
|
|
||||||
if (!io || !admin) {
|
if (!io || !admin) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -85,18 +97,31 @@ function GestioneEventi() {
|
|||||||
setBozza((b) => (b ? { ...b, ...patch } : b));
|
setBozza((b) => (b ? { ...b, ...patch } : b));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function conferma() {
|
const modificaEsistente = bozza ? eventi.some((e) => e.id === bozza.id) : false;
|
||||||
|
|
||||||
|
function chiediConferma() {
|
||||||
if (!bozza) return;
|
if (!bozza) return;
|
||||||
if (!bozza.titolo.trim()) {
|
if (!bozza.titolo.trim()) {
|
||||||
toast.error("Serve un titolo per l'evento");
|
toast.error("Serve un titolo per l'evento");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (modificaEsistente) {
|
||||||
|
setConfermaModifica(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
conferma();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function conferma() {
|
||||||
|
if (!bozza) return;
|
||||||
try {
|
try {
|
||||||
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
|
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
|
||||||
toast.success("Evento salvato");
|
toast.success("Evento salvato");
|
||||||
setBozza(null);
|
setBozza(null);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Non sono riuscito a salvare l'evento");
|
toast.error("Non sono riuscito a salvare l'evento");
|
||||||
|
} finally {
|
||||||
|
setConfermaModifica(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,19 +137,105 @@ function GestioneEventi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
|
||||||
|
const eventiPerGiorno = new Map<number, Evento[]>();
|
||||||
|
for (const e of eventi) {
|
||||||
|
if (!e.data.startsWith(mesePrefix)) continue;
|
||||||
|
const g = Number(e.data.slice(8, 10));
|
||||||
|
const lista = eventiPerGiorno.get(g) ?? [];
|
||||||
|
lista.push(e);
|
||||||
|
eventiPerGiorno.set(g, lista);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apriGiorno(giorno: number) {
|
||||||
|
setGiornoSelezionato(`${mesePrefix}-${pad2(giorno)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nuovoNelGiorno() {
|
||||||
|
if (!giornoSelezionato) return;
|
||||||
|
setBozza({ ...eventoVuoto(), data: giornoSelezionato });
|
||||||
|
setGiornoSelezionato(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modificaDalGiorno(e: Evento) {
|
||||||
|
setBozza(e);
|
||||||
|
setGiornoSelezionato(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eliminaDalGiorno(e: Evento) {
|
||||||
|
setDaEliminare(e);
|
||||||
|
setGiornoSelezionato(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non usa `eventiPerGiorno` (limitato al mese in vista sul calendario): il giorno
|
||||||
|
// selezionato può arrivare anche dalla lista sotto, per un mese diverso da quello mostrato.
|
||||||
|
const eventiGiornoSelezionato = giornoSelezionato
|
||||||
|
? eventi.filter((e) => e.data === giornoSelezionato)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
|
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
|
||||||
|
|
||||||
<div className="px-5 pt-4">
|
<Section titolo="Calendario">
|
||||||
<button
|
<Card>
|
||||||
type="button"
|
<div className="mb-3 flex items-center justify-between">
|
||||||
onClick={() => setBozza(eventoVuoto())}
|
<button
|
||||||
className="premi 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"
|
type="button"
|
||||||
>
|
onClick={precedente}
|
||||||
<CalendarPlus className="h-4 w-4" /> Nuovo evento
|
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
|
||||||
</button>
|
aria-label="Mese precedente"
|
||||||
</div>
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<span className="font-display-sm text-xl uppercase">
|
||||||
|
{mesiIT[mese]} {anno}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={successivo}
|
||||||
|
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
|
||||||
|
aria-label="Mese successivo"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
||||||
|
{giorniIT.map((g, i) => (
|
||||||
|
<span key={i}>{g}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid grid-cols-7 gap-1">
|
||||||
|
{Array.from({ length: offsetLunedi }).map((_, i) => (
|
||||||
|
<span key={`v${i}`} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: giorni }).map((_, i) => {
|
||||||
|
const giorno = i + 1;
|
||||||
|
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
|
||||||
|
const haEventi = eventiGiorno.length > 0;
|
||||||
|
const isOggi =
|
||||||
|
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={giorno}
|
||||||
|
type="button"
|
||||||
|
onClick={() => apriGiorno(giorno)}
|
||||||
|
className={cn(
|
||||||
|
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
|
||||||
|
haEventi ? "bg-accent text-accent-foreground" : "bg-secondary text-foreground",
|
||||||
|
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
|
||||||
|
)}
|
||||||
|
aria-label={`${giorno} ${mesiIT[mese]}${haEventi ? `: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}` : ", nessun evento"}`}
|
||||||
|
aria-current={isOggi ? "date" : undefined}
|
||||||
|
>
|
||||||
|
{giorno}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{bozza ? (
|
{bozza ? (
|
||||||
<Section
|
<Section
|
||||||
@@ -154,7 +265,6 @@ function GestioneEventi() {
|
|||||||
value={bozza.titolo}
|
value={bozza.titolo}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
onChange={(e) => aggiorna({ titolo: e.target.value })}
|
onChange={(e) => aggiorna({ titolo: e.target.value })}
|
||||||
placeholder="Es. CRAP Volley vs Aurora Nera"
|
|
||||||
className={classiInput}
|
className={classiInput}
|
||||||
/>
|
/>
|
||||||
</Campo>
|
</Campo>
|
||||||
@@ -272,7 +382,7 @@ function GestioneEventi() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={conferma}
|
onClick={chiediConferma}
|
||||||
disabled={salva.isPending}
|
disabled={salva.isPending}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -305,9 +415,11 @@ function GestioneEventi() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{eventi.map((e) => (
|
{eventi.map((e) => (
|
||||||
<div
|
<button
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"
|
type="button"
|
||||||
|
onClick={() => setGiornoSelezionato(e.data)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-3xl bg-card p-3 text-left shadow-card active:scale-[0.99]"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||||
@@ -315,28 +427,105 @@ function GestioneEventi() {
|
|||||||
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
|
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
|
||||||
onClick={() => setBozza(e)}
|
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
|
||||||
aria-label={`Modifica ${e.titolo}`}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDaEliminare(e)}
|
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
|
|
||||||
aria-label={`Elimina ${e.titolo}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={!!giornoSelezionato}
|
||||||
|
onOpenChange={(aperto) => !aperto && setGiornoSelezionato(null)}
|
||||||
|
>
|
||||||
|
<DrawerContent className="rounded-t-[24px] border-border bg-background px-4 pb-6 pt-2">
|
||||||
|
<DrawerHeader className="relative px-0 pb-2 text-left">
|
||||||
|
<DrawerTitle className="font-display-lg text-2xl uppercase">
|
||||||
|
{giornoSelezionato ? formatData(giornoSelezionato) : "Giorno"}
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerClose className="absolute right-0 top-0 grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground transition-transform active:scale-90">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Chiudi</span>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto py-2">
|
||||||
|
{eventiGiornoSelezionato.length > 0 ? (
|
||||||
|
eventiGiornoSelezionato.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{e.ora} · {e.luogo || "luogo da definire"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => modificaDalGiorno(e)}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
||||||
|
aria-label={`Modifica ${e.titolo}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => eliminaDalGiorno(e)}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
|
||||||
|
aria-label={`Elimina ${e.titolo}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||||
|
Nessun evento in programma
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nuovoNelGiorno}
|
||||||
|
className="premi mt-1 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"
|
||||||
|
>
|
||||||
|
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
||||||
|
</button>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={confermaModifica}
|
||||||
|
onOpenChange={(aperto) => !aperto && setConfermaModifica(false)}
|
||||||
|
>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Salvare le modifiche?</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{bozza ? `Aggiorni l'evento "${bozza.titolo.trim() || "senza titolo"}".` : ""}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={conferma}
|
||||||
|
disabled={salva.isPending}
|
||||||
|
className="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 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
|
||||||
|
</button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
|
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
|
|||||||
@@ -230,6 +230,13 @@ try {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prova("l'invio di un messaggio personalizzato chiede le credenziali", async () => {
|
||||||
|
assert.equal(
|
||||||
|
(await postJson("/api/public/notifica-personalizzata", { messaggio: "prova" })).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await prova("un token malformato non passa", async () => {
|
await prova("un token malformato non passa", async () => {
|
||||||
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -107,6 +107,31 @@ if (!locale) {
|
|||||||
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
|
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
|
||||||
|
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
|
||||||
|
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", ...intestazioni },
|
||||||
|
body: JSON.stringify({ messaggio: "prova" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("senza token non si manda un messaggio personalizzato", async () => {
|
||||||
|
assert.equal((await chiamaMessaggio()).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un giocatore autenticato non manda messaggi personalizzati", async () => {
|
||||||
|
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenGiocatore}` });
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
|
||||||
|
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const corpo = (await res.json()) as { inviate: number; destinatari: number };
|
||||||
|
assert.equal(corpo.destinatari, 0);
|
||||||
|
assert.equal(corpo.inviate, 0);
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
server.stop();
|
server.stop();
|
||||||
for (const id of idUtenti) {
|
for (const id of idUtenti) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Check delle funzioni pure della griglia mensile: `bun test/unit/calendario.test.ts`. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { giorniDelMese, pad2 } from "@/lib/calendario";
|
||||||
|
|
||||||
|
// --- pad2 --------------------------------------------------------------
|
||||||
|
assert.equal(pad2(1), "01");
|
||||||
|
assert.equal(pad2(9), "09");
|
||||||
|
assert.equal(pad2(10), "10");
|
||||||
|
assert.equal(pad2(31), "31");
|
||||||
|
|
||||||
|
// --- giorniDelMese -------------------------------------------------------
|
||||||
|
// Febbraio 2026 (non bisestile): 28 giorni, inizia di domenica -> offset 6.
|
||||||
|
assert.deepEqual(giorniDelMese(2026, 1), { giorni: 28, offsetLunedi: 6 });
|
||||||
|
|
||||||
|
// Febbraio 2028 (bisestile): 29 giorni.
|
||||||
|
assert.deepEqual(giorniDelMese(2028, 1), { giorni: 29, offsetLunedi: 1 });
|
||||||
|
|
||||||
|
// Settembre 2026: 30 giorni, il 1° settembre 2026 è martedì -> offset 1.
|
||||||
|
assert.deepEqual(giorniDelMese(2026, 8), { giorni: 30, offsetLunedi: 1 });
|
||||||
|
|
||||||
|
// Gennaio 2027: il 1° gennaio è venerdì -> offset 4.
|
||||||
|
assert.deepEqual(giorniDelMese(2027, 0), { giorni: 31, offsetLunedi: 4 });
|
||||||
|
|
||||||
|
console.log("calendario: ok");
|
||||||
Reference in New Issue
Block a user