Sposta roster, eventi, presenze, voti, scout e palloni su PocketBase
Riscrive i moduli dati che parlavano con Supabase per usare l'SDK
PocketBase, mantenendo invariate le firme degli hook React Query così i
componenti a valle non cambiano: giocatori-squadra, eventi, presenze,
cacche, pagelle, mvp-voti, badge-social, scout (store/live/stato),
palloni.
Aggiunge due helper condivisi in src/integrations/pocketbase/:
- upsert.ts: PocketBase non ha upsert nativo, questi replicano il
pattern onConflict di Supabase (per id significativo o per filtro su
combinazione di campi), verificati contro un'istanza reale così da non
duplicare record sulla stessa scrittura ripetuta;
- formato.ts: normalizza le date PocketBase ("AAAA-MM-GG HH:MM:SS.sssZ",
spazio non "T") a ISO stretto, altrimenti Date.parse e i confronti
testuali con altre date si comportano in modo incoerente.
Corregge anche due problemi trovati testando contro un'istanza reale:
- alcune API rule confrontavano un campo relazione con @request.auth.id
senza richiedere l'autenticazione, lasciando passare richieste anonime
quando entrambi i lati erano vuoti;
- gli hook non riconoscevano il superuser PocketBase (solo il ruolo
admin applicativo), bloccando le operazioni fatte con pbAdmin.
Aggiunta la colonna "casa" mancante su eventi_app e i campi di sistema
created/updated (non generati automaticamente da PocketBase per le
collection create via migration, a differenza di quanto assunto
inizialmente).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* PocketBase restituisce i campi data/ora come "AAAA-MM-GG HH:MM:SS.sssZ" (spazio, non
|
||||
* "T"): `Date.parse` su questo formato non è garantito coerente su tutti i motori JS.
|
||||
* Normalizza a ISO 8601 stretto prima di qualsiasi confronto/calcolo di date.
|
||||
*/
|
||||
export function pbISO(v: string): string {
|
||||
return v.includes("T") ? v : v.replace(" ", "T");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import PocketBase, { ClientResponseError, type RecordModel } from "pocketbase";
|
||||
import { pb } from "./client";
|
||||
|
||||
/**
|
||||
* PocketBase non ha un upsert nativo. Per le collection con id significativo (es. `e1`,
|
||||
* `g1`, dove l'id stesso è la chiave di unicità che prima era gestita da
|
||||
* `onConflict: "id"`): aggiorna se il record esiste, altrimenti lo crea con quell'id.
|
||||
*
|
||||
* `client` di default è il client browser (`pb`): passare esplicitamente `pbAdmin()` nei
|
||||
* moduli server-side che oggi usano `supabaseAdmin`.
|
||||
*/
|
||||
export async function upsertById<T = RecordModel>(
|
||||
collection: string,
|
||||
id: string,
|
||||
dati: Record<string, unknown>,
|
||||
client: PocketBase = pb,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await client.collection(collection).update<T>(id, dati);
|
||||
} catch (errore) {
|
||||
if (errore instanceof ClientResponseError && errore.status === 404) {
|
||||
return await client.collection(collection).create<T>({ id, ...dati });
|
||||
}
|
||||
throw errore;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per le collection dove l'unicità è su una combinazione di campi (es.
|
||||
* `evento_id,giocatore_id`, prima gestita da un indice UNIQUE + `onConflict`) e l'id del
|
||||
* record è generato da PocketBase, non ha significato applicativo: cerca il record che
|
||||
* combacia col filtro e lo aggiorna, altrimenti ne crea uno nuovo.
|
||||
*/
|
||||
export async function upsertByFilter<T = RecordModel>(
|
||||
collection: string,
|
||||
filtro: string,
|
||||
parametri: Record<string, unknown>,
|
||||
dati: Record<string, unknown>,
|
||||
client: PocketBase = pb,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const esistente = await client
|
||||
.collection(collection)
|
||||
.getFirstListItem<T>(client.filter(filtro, parametri));
|
||||
return await client.collection(collection).update<T>((esistente as RecordModel).id, dati);
|
||||
} catch (errore) {
|
||||
if (errore instanceof ClientResponseError && errore.status === 404) {
|
||||
return await client.collection(collection).create<T>(dati);
|
||||
}
|
||||
throw errore;
|
||||
}
|
||||
}
|
||||
+31
-10
@@ -1,7 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HandHeart, Handshake, Laugh, Scale, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||
|
||||
export type CategoriaSocial = {
|
||||
id: string;
|
||||
@@ -58,6 +59,14 @@ export type VotoSocial = {
|
||||
votato_nome: string;
|
||||
};
|
||||
|
||||
type RigaSocialPocketBase = {
|
||||
evento: string;
|
||||
categoria: string;
|
||||
votante: string;
|
||||
votato: string;
|
||||
votato_nome: string;
|
||||
};
|
||||
|
||||
const CHIAVE = ["badge-social-voti"] as const;
|
||||
|
||||
/** Tutti i voti social (poche righe): nessun polling, cache lunga. */
|
||||
@@ -66,11 +75,14 @@ export function useVotiSocial() {
|
||||
queryKey: CHIAVE,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<VotoSocial[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("badge_social_voti")
|
||||
.select("match_id, categoria, votante_id, votato_id, votato_nome");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoSocial[];
|
||||
const righe = await pb.collection("badge_social_voti").getFullList<RigaSocialPocketBase>();
|
||||
return righe.map((r) => ({
|
||||
match_id: r.evento,
|
||||
categoria: r.categoria,
|
||||
votante_id: r.votante,
|
||||
votato_id: r.votato,
|
||||
votato_nome: r.votato_nome,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -79,10 +91,19 @@ export function useVotaSocial() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (voto: VotoSocial) => {
|
||||
const { error } = await supabase
|
||||
.from("badge_social_voti")
|
||||
.upsert(voto, { onConflict: "match_id,categoria,votante_id" });
|
||||
if (error) throw error;
|
||||
// No autovoto e convocazione li valida l'hook voti_convocati.pb.js.
|
||||
await upsertByFilter(
|
||||
"badge_social_voti",
|
||||
"evento = {:evento} && categoria = {:categoria} && votante = {:votante}",
|
||||
{ evento: voto.match_id, categoria: voto.categoria, votante: voto.votante_id },
|
||||
{
|
||||
evento: voto.match_id,
|
||||
categoria: voto.categoria,
|
||||
votante: voto.votante_id,
|
||||
votato: voto.votato_id,
|
||||
votato_nome: voto.votato_nome,
|
||||
},
|
||||
);
|
||||
return voto;
|
||||
},
|
||||
// Aggiornamento locale della cache: zero riletture.
|
||||
|
||||
+20
-10
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||
import { oggiISO } from "./palloni-core";
|
||||
|
||||
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
|
||||
@@ -48,6 +49,12 @@ export type RigaCacche = {
|
||||
quantita: number;
|
||||
};
|
||||
|
||||
type RigaCacchePocketBase = {
|
||||
evento: string;
|
||||
giocatore: string;
|
||||
quantita: number;
|
||||
};
|
||||
|
||||
export const CACCHE_KEY = ["cacche"] as const;
|
||||
|
||||
export function useCacche() {
|
||||
@@ -55,11 +62,12 @@ export function useCacche() {
|
||||
queryKey: CACCHE_KEY,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<RigaCacche[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("cacche_partita")
|
||||
.select("evento_id, giocatore_id, quantita");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as RigaCacche[];
|
||||
const righe = await pb.collection("cacche_partita").getFullList<RigaCacchePocketBase>();
|
||||
return righe.map((r) => ({
|
||||
evento_id: r.evento,
|
||||
giocatore_id: r.giocatore,
|
||||
quantita: r.quantita,
|
||||
}));
|
||||
},
|
||||
});
|
||||
return { ...query, righe: query.data ?? [] };
|
||||
@@ -69,10 +77,12 @@ export function useSalvaCacche() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (riga: RigaCacche) => {
|
||||
const { error } = await supabase
|
||||
.from("cacche_partita")
|
||||
.upsert(riga, { onConflict: "evento_id,giocatore_id" });
|
||||
if (error) throw error;
|
||||
await upsertByFilter(
|
||||
"cacche_partita",
|
||||
"evento = {:evento} && giocatore = {:giocatore}",
|
||||
{ evento: riga.evento_id, giocatore: riga.giocatore_id },
|
||||
{ evento: riga.evento_id, giocatore: riga.giocatore_id, quantita: riga.quantita },
|
||||
);
|
||||
return riga;
|
||||
},
|
||||
onSuccess: (riga) => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { daRiga, type Evento, type RigaEvento } from "./eventi";
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||
|
||||
/** Lettura eventi lato server (route API): stessa conversione del client. */
|
||||
export async function leggiEventi(): Promise<Evento[]> {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data } = await supabaseAdmin.from("eventi_app").select(COLONNE).order("data");
|
||||
return ((data ?? []) as RigaEvento[]).map(daRiga);
|
||||
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||
const admin = await pbAdmin();
|
||||
const righe = await admin.collection("eventi_app").getFullList<RigaEvento>({ sort: "data" });
|
||||
return righe.map(daRiga);
|
||||
}
|
||||
|
||||
+25
-27
@@ -1,5 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { pbISO } from "@/integrations/pocketbase/formato";
|
||||
import { upsertById } from "@/integrations/pocketbase/upsert";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
|
||||
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
|
||||
@@ -25,6 +27,7 @@ export type Evento = {
|
||||
creatoIl?: string | undefined;
|
||||
};
|
||||
|
||||
/** Riga così come la restituisce PocketBase. */
|
||||
export type RigaEvento = {
|
||||
id: string;
|
||||
tipo: string;
|
||||
@@ -32,35 +35,37 @@ export type RigaEvento = {
|
||||
luogo: string;
|
||||
data: string;
|
||||
ora: string;
|
||||
note: string | null;
|
||||
convocati: string[] | null;
|
||||
note: string;
|
||||
convocati: string[];
|
||||
campionato: boolean;
|
||||
casa: boolean | null;
|
||||
casa: boolean;
|
||||
pagelle_chiuse: boolean;
|
||||
creato_il?: string;
|
||||
created?: string;
|
||||
};
|
||||
|
||||
/** I campi "date" di PocketBase tornano come datetime completo: qui serve solo "YYYY-MM-DD". */
|
||||
function soloData(v: string): string {
|
||||
return v.slice(0, 10);
|
||||
}
|
||||
|
||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||
export function daRiga(r: RigaEvento): Evento {
|
||||
return {
|
||||
id: r.id,
|
||||
tipo: (r.tipo as EventoTipo) ?? "evento",
|
||||
tipo: (r.tipo as EventoTipo) || "evento",
|
||||
titolo: r.titolo,
|
||||
luogo: r.luogo ?? "",
|
||||
data: r.data,
|
||||
ora: r.ora ?? "",
|
||||
note: r.note ?? "",
|
||||
luogo: r.luogo || "",
|
||||
data: soloData(r.data),
|
||||
ora: r.ora || "",
|
||||
note: r.note || "",
|
||||
convocati: r.convocati ?? [],
|
||||
campionato: !!r.campionato,
|
||||
casa: r.casa ?? true,
|
||||
casa: r.casa,
|
||||
pagelleChiuse: !!r.pagelle_chiuse,
|
||||
creatoIl: r.creato_il,
|
||||
creatoIl: r.created ? pbISO(r.created) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
||||
|
||||
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
||||
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
||||
|
||||
@@ -79,9 +84,8 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
|
||||
export const EVENTI_KEY = ["eventi"] as const;
|
||||
|
||||
async function fetchEventi(): Promise<Evento[]> {
|
||||
const { data, error } = await supabase.from("eventi_app").select(COLONNE).order("data");
|
||||
if (error) throw error;
|
||||
return ((data ?? []) as RigaEvento[]).map(daRiga);
|
||||
const righe = await pb.collection("eventi_app").getFullList<RigaEvento>({ sort: "data" });
|
||||
return righe.map(daRiga);
|
||||
}
|
||||
|
||||
/** Una lettura per sessione: il calendario cambia raramente. */
|
||||
@@ -119,9 +123,7 @@ export function useSalvaEvento() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (evento: Evento) => {
|
||||
const { error } = await supabase.from("eventi_app").upsert(
|
||||
{
|
||||
id: evento.id,
|
||||
await upsertById("eventi_app", evento.id, {
|
||||
tipo: evento.tipo,
|
||||
titolo: evento.titolo,
|
||||
luogo: evento.luogo,
|
||||
@@ -132,10 +134,7 @@ export function useSalvaEvento() {
|
||||
campionato: evento.campionato,
|
||||
casa: evento.casa,
|
||||
pagelle_chiuse: evento.pagelleChiuse,
|
||||
},
|
||||
{ onConflict: "id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
});
|
||||
return evento;
|
||||
},
|
||||
// Aggiornamento locale della cache: nessuna rilettura dal database.
|
||||
@@ -152,8 +151,7 @@ export function useEliminaEvento() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase.from("eventi_app").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
await pb.collection("eventi_app").delete(id);
|
||||
return id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
COLONNE_SQUADRA,
|
||||
daRigaSquadra,
|
||||
type GiocatoreSquadra,
|
||||
type RigaGiocatoreSquadra,
|
||||
} from "./giocatori-squadra";
|
||||
|
||||
/** Lettura squadra lato server (route API): stessa conversione del client. */
|
||||
/** Lettura squadra lato server (route API): usa il superuser per non dipendere dalla
|
||||
* sessione del chiamante, stessa conversione del client. */
|
||||
export async function leggiGiocatoriSquadra(): Promise<GiocatoreSquadra[]> {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
// `types.ts` non include ancora `giocatori_squadra` con le colonne di M8 (vedi client-nuove-tabelle.ts).
|
||||
const client = supabaseAdmin as unknown as SupabaseClient;
|
||||
const { data } = await client
|
||||
.from("giocatori_squadra")
|
||||
.select(COLONNE_SQUADRA)
|
||||
.order("cognome")
|
||||
.order("nome");
|
||||
return ((data ?? []) as RigaGiocatoreSquadra[]).map(daRigaSquadra);
|
||||
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||
const admin = await pbAdmin();
|
||||
const righe = await admin.collection("giocatori_squadra").getFullList<RigaGiocatoreSquadra>({
|
||||
sort: "cognome,nome",
|
||||
});
|
||||
return righe.map(daRigaSquadra);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { dividiNome, giocatori } from "./crapp-data";
|
||||
|
||||
/**
|
||||
@@ -20,17 +20,18 @@ export type GiocatoreSquadra = {
|
||||
dataTessera: string | null;
|
||||
};
|
||||
|
||||
/** Riga così come la restituisce PocketBase: le relazioni/campi vuoti sono "", mai null. */
|
||||
export type RigaGiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
auth_user_id: string | null;
|
||||
auth_user_id: string;
|
||||
attivo: boolean;
|
||||
email: string | null;
|
||||
numero_tessera: string | null;
|
||||
data_tessera: string | null;
|
||||
email: string;
|
||||
numero_tessera: string;
|
||||
data_tessera: string;
|
||||
};
|
||||
|
||||
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
|
||||
@@ -76,10 +77,18 @@ export function slotPerEmail(
|
||||
return righe.find((g) => !g.authUserId && g.email?.trim().toLowerCase() === cercata) ?? null;
|
||||
}
|
||||
|
||||
export const COLONNE_SQUADRA =
|
||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera";
|
||||
/** "" -> null: PocketBase non ha un concetto di colonna NULL, i campi vuoti sono stringa vuota. */
|
||||
function vuotoANull(v: string): string | null {
|
||||
return v ? v : null;
|
||||
}
|
||||
|
||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||
/** I campi "date" di PocketBase tornano come datetime completo ("2026-01-01 00:00:00.000Z"):
|
||||
* qui serve solo "YYYY-MM-DD" (input HTML type="date", confronti testuali con altre date). */
|
||||
function soloData(v: string): string | null {
|
||||
return v ? v.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** Conversione riga PocketBase -> modello applicativo (riusabile anche lato server). */
|
||||
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -87,22 +96,18 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
cognome: r.cognome,
|
||||
numero: r.numero,
|
||||
ruolo: r.ruolo,
|
||||
authUserId: r.auth_user_id,
|
||||
authUserId: vuotoANull(r.auth_user_id),
|
||||
attivo: r.attivo,
|
||||
email: r.email,
|
||||
numeroTessera: r.numero_tessera,
|
||||
dataTessera: r.data_tessera,
|
||||
email: vuotoANull(r.email),
|
||||
numeroTessera: vuotoANull(r.numero_tessera),
|
||||
dataTessera: soloData(r.data_tessera),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.select(COLONNE_SQUADRA)
|
||||
.order("cognome")
|
||||
.order("nome");
|
||||
if (error) throw error;
|
||||
const righe = (data ?? []) as RigaGiocatoreSquadra[];
|
||||
const righe = await pb.collection("giocatori_squadra").getFullList<RigaGiocatoreSquadra>({
|
||||
sort: "cognome,nome",
|
||||
});
|
||||
return righe.map(daRigaSquadra);
|
||||
}
|
||||
|
||||
@@ -118,8 +123,8 @@ export function useGiocatoriSquadra() {
|
||||
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
|
||||
|
||||
/**
|
||||
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
||||
* meglio dirlo qui che far tornare un errore Postgres all'utente.
|
||||
* Controlli che rispecchiano i vincoli della collection (`numero > 0`, campi obbligatori):
|
||||
* meglio dirlo qui che far tornare un errore di PocketBase all'utente.
|
||||
* Restituisce il messaggio da mostrare, oppure `null` se va bene.
|
||||
*/
|
||||
export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
||||
@@ -132,7 +137,7 @@ export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della tabella. */
|
||||
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della collection. */
|
||||
export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string {
|
||||
const max = righe.reduce((acc, g) => {
|
||||
const n = Number(g.id.slice(1));
|
||||
@@ -150,7 +155,7 @@ export function numeroGiaUsato(
|
||||
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
|
||||
}
|
||||
|
||||
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
|
||||
/** Modifica dei dati squadra. Solo un admin passa le API rules di M1. */
|
||||
export function useSalvaDatiSquadra() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -160,14 +165,10 @@ export function useSalvaDatiSquadra() {
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
email: input.dati.email?.trim() || null,
|
||||
email: input.dati.email?.trim() || "",
|
||||
};
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return { giocatoreId: input.giocatoreId, dati };
|
||||
await pb.collection("giocatori_squadra").update(input.giocatoreId, dati);
|
||||
return { giocatoreId: input.giocatoreId, dati: { ...dati, email: dati.email || null } };
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
@@ -182,7 +183,7 @@ export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTes
|
||||
|
||||
/**
|
||||
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
|
||||
* il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
|
||||
* l'hook di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
|
||||
* tesserato.
|
||||
*/
|
||||
export function useSalvaTesseramento() {
|
||||
@@ -190,17 +191,16 @@ export function useSalvaTesseramento() {
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
|
||||
const dati = {
|
||||
numero_tessera: input.dati.numeroTessera?.trim() || null,
|
||||
data_tessera: input.dati.dataTessera || null,
|
||||
numero_tessera: input.dati.numeroTessera?.trim() || "",
|
||||
data_tessera: input.dati.dataTessera || "",
|
||||
};
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
await pb.collection("giocatori_squadra").update(input.giocatoreId, dati);
|
||||
return {
|
||||
giocatoreId: input.giocatoreId,
|
||||
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
|
||||
dati: {
|
||||
numeroTessera: vuotoANull(dati.numero_tessera),
|
||||
dataTessera: vuotoANull(dati.data_tessera),
|
||||
},
|
||||
};
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
@@ -212,7 +212,7 @@ export function useSalvaTesseramento() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1.
|
||||
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le API rules di M1.
|
||||
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
|
||||
* prima di chiamare questa mutazione.
|
||||
*/
|
||||
@@ -226,15 +226,20 @@ export function useAggiungiGiocatore() {
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
email: input.dati.email?.trim() || null,
|
||||
email: input.dati.email?.trim() || "",
|
||||
attivo: true,
|
||||
};
|
||||
await pb.collection("giocatori_squadra").create(riga);
|
||||
return {
|
||||
...riga,
|
||||
email: riga.email || null,
|
||||
authUserId: null,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
};
|
||||
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
|
||||
if (error) throw error;
|
||||
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
|
||||
return { ...riga, authUserId: null, attivo: true, numeroTessera: null, dataTessera: null };
|
||||
},
|
||||
// Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
|
||||
// (`.order("cognome").order("nome")`).
|
||||
// (sort "cognome,nome").
|
||||
onSuccess: (nuovo) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
[...(prec ?? []), nuovo].sort(
|
||||
@@ -253,11 +258,7 @@ export function useImpostaAttivo() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; attivo: boolean }) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({ attivo: input.attivo })
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
await pb.collection("giocatori_squadra").update(input.giocatoreId, { attivo: input.attivo });
|
||||
return input;
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
@@ -276,11 +277,7 @@ export function useScollegaAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (giocatoreId: string) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({ auth_user_id: null })
|
||||
.eq("id", giocatoreId);
|
||||
if (error) throw error;
|
||||
await pb.collection("giocatori_squadra").update(giocatoreId, { auth_user_id: "" });
|
||||
return giocatoreId;
|
||||
},
|
||||
onSuccess: (giocatoreId) => {
|
||||
@@ -292,20 +289,17 @@ export function useScollegaAccount() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se
|
||||
* lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel
|
||||
* database, non qui.
|
||||
* Collega l'account al giocatore scelto. L'hook giocatori_squadra_claim.pb.js accetta
|
||||
* l'operazione solo se lo slot è libero, l'email combacia e nessun altro campo cambia
|
||||
* (DD-016 regola 2): il vincolo vive nel database, non qui.
|
||||
*/
|
||||
export function useCollegaGiocatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; utenteId: string }) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({ auth_user_id: input.utenteId })
|
||||
.eq("id", input.giocatoreId)
|
||||
.is("auth_user_id", null);
|
||||
if (error) throw error;
|
||||
await pb
|
||||
.collection("giocatori_squadra")
|
||||
.update(input.giocatoreId, { auth_user_id: input.utenteId });
|
||||
return input;
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
|
||||
+28
-10
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||
|
||||
export type VotoMvp = {
|
||||
match_id: string;
|
||||
@@ -8,6 +9,13 @@ export type VotoMvp = {
|
||||
votato_nome: string;
|
||||
};
|
||||
|
||||
type RigaMvpPocketBase = {
|
||||
evento: string;
|
||||
votante: string;
|
||||
votato: string;
|
||||
votato_nome: string;
|
||||
};
|
||||
|
||||
const CHIAVE = ["mvp-voti"] as const;
|
||||
|
||||
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
|
||||
@@ -27,11 +35,13 @@ export function useVotiMvp() {
|
||||
queryKey: CHIAVE,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<VotoMvp[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("mvp_voti")
|
||||
.select("match_id, votante_id, votato_id, votato_nome");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoMvp[];
|
||||
const righe = await pb.collection("mvp_voti").getFullList<RigaMvpPocketBase>();
|
||||
return righe.map((r) => ({
|
||||
match_id: r.evento,
|
||||
votante_id: r.votante,
|
||||
votato_id: r.votato,
|
||||
votato_nome: r.votato_nome,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -40,10 +50,18 @@ export function useVotaMvp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (voto: VotoMvp) => {
|
||||
const { error } = await supabase
|
||||
.from("mvp_voti")
|
||||
.upsert(voto, { onConflict: "match_id,votante_id" });
|
||||
if (error) throw error;
|
||||
// No autovoto e convocazione li valida l'hook voti_convocati.pb.js.
|
||||
await upsertByFilter(
|
||||
"mvp_voti",
|
||||
"evento = {:evento} && votante = {:votante}",
|
||||
{ evento: voto.match_id, votante: voto.votante_id },
|
||||
{
|
||||
evento: voto.match_id,
|
||||
votante: voto.votante_id,
|
||||
votato: voto.votato_id,
|
||||
votato_nome: voto.votato_nome,
|
||||
},
|
||||
);
|
||||
return voto;
|
||||
},
|
||||
// Aggiorna la cache localmente: nessuna rilettura dal database.
|
||||
|
||||
+28
-10
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||
import { VOTI_MINIMI_PAGELLA } from "./badges";
|
||||
|
||||
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
|
||||
@@ -10,6 +11,13 @@ export type VotoPagella = {
|
||||
voto: number;
|
||||
};
|
||||
|
||||
type RigaPagellaPocketBase = {
|
||||
evento: string;
|
||||
votante: string;
|
||||
votato: string;
|
||||
voto: number;
|
||||
};
|
||||
|
||||
export const PAGELLE_KEY = ["pagelle"] as const;
|
||||
|
||||
/** Poche righe per stagione: una lettura per sessione basta. */
|
||||
@@ -18,11 +26,13 @@ export function usePagelle() {
|
||||
queryKey: PAGELLE_KEY,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<VotoPagella[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("pagelle_voti")
|
||||
.select("match_id, votante_id, votato_id, voto");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoPagella[];
|
||||
const righe = await pb.collection("pagelle_voti").getFullList<RigaPagellaPocketBase>();
|
||||
return righe.map((r) => ({
|
||||
match_id: r.evento,
|
||||
votante_id: r.votante,
|
||||
votato_id: r.votato,
|
||||
voto: r.voto,
|
||||
}));
|
||||
},
|
||||
});
|
||||
return { ...query, voti: query.data ?? [] };
|
||||
@@ -32,10 +42,18 @@ export function useVotaPagella() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (voto: VotoPagella) => {
|
||||
const { error } = await supabase
|
||||
.from("pagelle_voti")
|
||||
.upsert(voto, { onConflict: "match_id,votante_id,votato_id" });
|
||||
if (error) throw error;
|
||||
// Convocazione e "pagelle non chiuse" li valida l'hook voti_convocati.pb.js.
|
||||
await upsertByFilter(
|
||||
"pagelle_voti",
|
||||
"evento = {:evento} && votante = {:votante} && votato = {:votato}",
|
||||
{ evento: voto.match_id, votante: voto.votante_id, votato: voto.votato_id },
|
||||
{
|
||||
evento: voto.match_id,
|
||||
votante: voto.votante_id,
|
||||
votato: voto.votato_id,
|
||||
voto: voto.voto,
|
||||
},
|
||||
);
|
||||
return voto;
|
||||
},
|
||||
// Cache aggiornata localmente: nessuna rilettura.
|
||||
|
||||
+14
-16
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||
import { completaTurni } from "./palloni-core";
|
||||
import { useEventi } from "./eventi";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
@@ -7,18 +8,15 @@ import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
export const TURNI_KEY = ["turni-palloni"] as const;
|
||||
|
||||
type RigaTurno = {
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
aggiornato_da: string | null;
|
||||
evento: string;
|
||||
giocatore: string;
|
||||
aggiornato_da: string;
|
||||
};
|
||||
|
||||
async function fetchTurni(): Promise<Record<string, string>> {
|
||||
const { data, error } = await supabase
|
||||
.from("turni_palloni")
|
||||
.select("evento_id, giocatore_id, aggiornato_da");
|
||||
if (error) throw error;
|
||||
const righe = await pb.collection("turni_palloni").getFullList<RigaTurno>();
|
||||
const mappa: Record<string, string> = {};
|
||||
for (const riga of (data ?? []) as RigaTurno[]) mappa[riga.evento_id] = riga.giocatore_id;
|
||||
for (const riga of righe) mappa[riga.evento] = riga.giocatore;
|
||||
return mappa;
|
||||
}
|
||||
|
||||
@@ -37,16 +35,16 @@ export function useAssegnaTurno() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; da: string | null }) => {
|
||||
const { error } = await supabase.from("turni_palloni").upsert(
|
||||
await upsertByFilter(
|
||||
"turni_palloni",
|
||||
"evento = {:evento}",
|
||||
{ evento: input.eventoId },
|
||||
{
|
||||
evento_id: input.eventoId,
|
||||
giocatore_id: input.giocatoreId,
|
||||
aggiornato_da: input.da,
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
evento: input.eventoId,
|
||||
giocatore: input.giocatoreId,
|
||||
aggiornato_da: input.da ?? "",
|
||||
},
|
||||
{ onConflict: "evento_id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
return input;
|
||||
},
|
||||
// Scrittura unica + aggiornamento cache locale, senza rilettura.
|
||||
|
||||
+32
-24
@@ -1,5 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { pbISO } from "@/integrations/pocketbase/formato";
|
||||
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||
import type { Stato } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
@@ -13,6 +15,14 @@ export type MappaPresenze = Record<string, Record<string, Stato>>;
|
||||
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
|
||||
export type MappaTempiRisposta = Record<string, Record<string, string>>;
|
||||
|
||||
type RigaPresenza = {
|
||||
id: string;
|
||||
evento: string;
|
||||
giocatore: string;
|
||||
stato: string;
|
||||
risposto_il: string;
|
||||
};
|
||||
|
||||
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
|
||||
function eventiContanoPresenze(
|
||||
eventi: Evento[],
|
||||
@@ -158,15 +168,12 @@ function serieSu(
|
||||
type LetturaPresenze = { presenze: MappaPresenze; tempi: MappaTempiRisposta };
|
||||
|
||||
async function fetchPresenze(): Promise<LetturaPresenze> {
|
||||
const { data, error } = await supabase
|
||||
.from("risposte_presenze")
|
||||
.select("evento_id, giocatore_id, stato, risposto_il");
|
||||
if (error) throw error;
|
||||
const righe = await pb.collection("risposte_presenze").getFullList<RigaPresenza>();
|
||||
const presenze: MappaPresenze = {};
|
||||
const tempi: MappaTempiRisposta = {};
|
||||
for (const riga of data ?? []) {
|
||||
(presenze[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
|
||||
(tempi[riga.evento_id] ??= {})[riga.giocatore_id] = riga.risposto_il;
|
||||
for (const riga of righe) {
|
||||
(presenze[riga.evento] ??= {})[riga.giocatore] = riga.stato as Stato;
|
||||
(tempi[riga.evento] ??= {})[riga.giocatore] = pbISO(riga.risposto_il);
|
||||
}
|
||||
return { presenze, tempi };
|
||||
}
|
||||
@@ -188,7 +195,7 @@ export function usePresenzeEvento(eventoId: string) {
|
||||
* Due dettagli non sono cosmetici e non vanno persi (vedi `docs/modules/serie-presenze.md`):
|
||||
*
|
||||
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
|
||||
* non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima,
|
||||
* non viene inviato sulla scrittura e un hook lo congela: è la prima risposta, non l'ultima,
|
||||
* e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h";
|
||||
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
|
||||
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
|
||||
@@ -219,23 +226,24 @@ export function useSalvaPresenza() {
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; stato: Stato | null }) => {
|
||||
if (input.stato === null) {
|
||||
const { error } = await supabase
|
||||
.from("risposte_presenze")
|
||||
.delete()
|
||||
.eq("evento_id", input.eventoId)
|
||||
.eq("giocatore_id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
const esistente = await pb
|
||||
.collection("risposte_presenze")
|
||||
.getFirstListItem(
|
||||
pb.filter("evento = {:evento} && giocatore = {:giocatore}", {
|
||||
evento: input.eventoId,
|
||||
giocatore: input.giocatoreId,
|
||||
}),
|
||||
)
|
||||
.catch(() => null);
|
||||
if (esistente) await pb.collection("risposte_presenze").delete(esistente.id);
|
||||
} else {
|
||||
const { error } = await supabase.from("risposte_presenze").upsert(
|
||||
{
|
||||
evento_id: input.eventoId,
|
||||
giocatore_id: input.giocatoreId,
|
||||
stato: input.stato,
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "evento_id,giocatore_id" },
|
||||
// risposto_il NON va inviato: lo valorizza/congela l'hook risposte_presenze_immutabili.pb.js.
|
||||
await upsertByFilter(
|
||||
"risposte_presenze",
|
||||
"evento = {:evento} && giocatore = {:giocatore}",
|
||||
{ evento: input.eventoId, giocatore: input.giocatoreId },
|
||||
{ evento: input.eventoId, giocatore: input.giocatoreId, stato: input.stato },
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
return input;
|
||||
},
|
||||
|
||||
+63
-29
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { pbISO } from "@/integrations/pocketbase/formato";
|
||||
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||
import { useEventi, type Evento } from "./eventi";
|
||||
|
||||
/** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */
|
||||
@@ -29,6 +32,23 @@ export type SessioneScout = {
|
||||
aggiornato_il: string;
|
||||
};
|
||||
|
||||
type RigaSessionePocketBase = {
|
||||
id: string;
|
||||
evento: string;
|
||||
giocatore: string;
|
||||
giocatore_nome: string;
|
||||
updated: string;
|
||||
};
|
||||
|
||||
function daRigaSessione(r: RigaSessionePocketBase): SessioneScout {
|
||||
return {
|
||||
evento_id: r.evento,
|
||||
giocatore_id: r.giocatore,
|
||||
giocatore_nome: r.giocatore_nome,
|
||||
aggiornato_il: pbISO(r.updated),
|
||||
};
|
||||
}
|
||||
|
||||
export function sessioneScaduta(s: SessioneScout | null): boolean {
|
||||
if (!s) return true;
|
||||
const aggiornato = new Date(s.aggiornato_il).getTime();
|
||||
@@ -41,13 +61,17 @@ export const SESSIONE_KEY = (eventoId: string) => ["scout-sessione", eventoId] a
|
||||
|
||||
/** Sessione condivisa: chi la tiene aperta lo vede chiunque, su qualsiasi dispositivo. */
|
||||
async function leggiSessione(eventoId: string): Promise<SessioneScout | null> {
|
||||
const { data, error } = await supabase
|
||||
.from("scout_sessioni")
|
||||
.select("evento_id, giocatore_id, giocatore_nome, aggiornato_il")
|
||||
.eq("evento_id", eventoId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
try {
|
||||
const riga = await pb
|
||||
.collection("scout_sessioni")
|
||||
.getFirstListItem<RigaSessionePocketBase>(
|
||||
pb.filter("evento = {:evento}", { evento: eventoId }),
|
||||
);
|
||||
return daRigaSessione(riga);
|
||||
} catch (errore) {
|
||||
if (errore instanceof ClientResponseError && errore.status === 404) return null;
|
||||
throw errore;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessioneScout(eventoId: string | null) {
|
||||
@@ -70,16 +94,12 @@ export function useApriSessioneScout() {
|
||||
if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) {
|
||||
return false;
|
||||
}
|
||||
const { error } = await supabase.from("scout_sessioni").upsert(
|
||||
{
|
||||
evento_id: input.eventoId,
|
||||
giocatore_id: input.giocatoreId,
|
||||
giocatore_nome: input.nome,
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "evento_id" },
|
||||
await upsertByFilter(
|
||||
"scout_sessioni",
|
||||
"evento = {:evento}",
|
||||
{ evento: input.eventoId },
|
||||
{ evento: input.eventoId, giocatore: input.giocatoreId, giocatore_nome: input.nome },
|
||||
);
|
||||
if (error) throw error;
|
||||
return true;
|
||||
},
|
||||
onSuccess: (_ok, input) => {
|
||||
@@ -92,13 +112,17 @@ export function useChiudiSessioneScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
|
||||
const attuale = await leggiSessione(input.eventoId);
|
||||
if (attuale && attuale.giocatore_id === input.giocatoreId) {
|
||||
const { error } = await supabase
|
||||
.from("scout_sessioni")
|
||||
.delete()
|
||||
.eq("evento_id", input.eventoId);
|
||||
if (error) throw error;
|
||||
const attuale = await pb
|
||||
.collection("scout_sessioni")
|
||||
.getFirstListItem<RigaSessionePocketBase>(
|
||||
pb.filter("evento = {:evento}", { evento: input.eventoId }),
|
||||
)
|
||||
.catch((errore) => {
|
||||
if (errore instanceof ClientResponseError && errore.status === 404) return null;
|
||||
throw errore;
|
||||
});
|
||||
if (attuale && attuale.giocatore === input.giocatoreId) {
|
||||
await pb.collection("scout_sessioni").delete(attuale.id);
|
||||
}
|
||||
},
|
||||
onSuccess: (_d, input) => {
|
||||
@@ -116,11 +140,21 @@ export function useHeartbeatScout(
|
||||
useEffect(() => {
|
||||
if (!attivo || !eventoId || !giocatoreId) return;
|
||||
const id = window.setInterval(() => {
|
||||
void supabase
|
||||
.from("scout_sessioni")
|
||||
.update({ aggiornato_il: new Date().toISOString() })
|
||||
.eq("evento_id", eventoId)
|
||||
.eq("giocatore_id", giocatoreId);
|
||||
// Solo aggiornamento, mai creazione: se la sessione è stata chiusa o presa da un
|
||||
// altro giocatore nel frattempo, il filtro non trova nulla e il battito è un no-op
|
||||
// (stesso comportamento dell'update Supabase originale). Riscrivere `giocatore` con
|
||||
// lo stesso valore basta a PocketBase per aggiornare `updated`, che qui fa le veci
|
||||
// di `aggiornato_il`.
|
||||
void pb
|
||||
.collection("scout_sessioni")
|
||||
.getFirstListItem<RigaSessionePocketBase>(
|
||||
pb.filter("evento = {:evento} && giocatore = {:giocatore}", {
|
||||
evento: eventoId,
|
||||
giocatore: giocatoreId,
|
||||
}),
|
||||
)
|
||||
.then((riga) => pb.collection("scout_sessioni").update(riga.id, { giocatore: giocatoreId }))
|
||||
.catch(() => {});
|
||||
}, 60_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [attivo, eventoId, giocatoreId]);
|
||||
|
||||
+28
-18
@@ -1,5 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||
import type { Azione } from "./scout-store";
|
||||
|
||||
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
|
||||
@@ -19,6 +21,8 @@ export const statoIniziale = (avversario: string, casa: boolean): StatoScout =>
|
||||
|
||||
export const SCOUT_STATO_KEY = (eventoId: string) => ["scout-stato", eventoId] as const;
|
||||
|
||||
type RigaScoutLive = { evento: string; stato: unknown };
|
||||
|
||||
export function useStatoScout(eventoId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: SCOUT_STATO_KEY(eventoId ?? "-"),
|
||||
@@ -26,13 +30,14 @@ export function useStatoScout(eventoId: string | null) {
|
||||
staleTime: Infinity,
|
||||
queryFn: async (): Promise<StatoScout | null> => {
|
||||
if (!eventoId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from("scout_live")
|
||||
.select("stato")
|
||||
.eq("evento_id", eventoId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
const stato = data?.stato as StatoScout | undefined;
|
||||
const riga = await pb
|
||||
.collection("scout_live")
|
||||
.getFirstListItem<RigaScoutLive>(pb.filter("evento = {:evento}", { evento: eventoId }))
|
||||
.catch((errore) => {
|
||||
if (errore instanceof ClientResponseError && errore.status === 404) return null;
|
||||
throw errore;
|
||||
});
|
||||
const stato = riga?.stato as StatoScout | undefined;
|
||||
if (!stato || !Array.isArray(stato.azioni)) return null;
|
||||
return stato;
|
||||
},
|
||||
@@ -43,15 +48,12 @@ export function useSalvaStatoScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; stato: StatoScout }) => {
|
||||
const { error } = await supabase.from("scout_live").upsert(
|
||||
{
|
||||
evento_id: input.eventoId,
|
||||
stato: JSON.parse(JSON.stringify(input.stato)),
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "evento_id" },
|
||||
await upsertByFilter(
|
||||
"scout_live",
|
||||
"evento = {:evento}",
|
||||
{ evento: input.eventoId },
|
||||
{ evento: input.eventoId, stato: JSON.parse(JSON.stringify(input.stato)) },
|
||||
);
|
||||
if (error) throw error;
|
||||
return input;
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
@@ -64,8 +66,16 @@ export function useCancellaStatoScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (eventoId: string) => {
|
||||
const { error } = await supabase.from("scout_live").delete().eq("evento_id", eventoId);
|
||||
if (error) throw error;
|
||||
const riga = await pb
|
||||
.collection("scout_live")
|
||||
.getFirstListItem<RigaScoutLive & { id: string }>(
|
||||
pb.filter("evento = {:evento}", { evento: eventoId }),
|
||||
)
|
||||
.catch((errore) => {
|
||||
if (errore instanceof ClientResponseError && errore.status === 404) return null;
|
||||
throw errore;
|
||||
});
|
||||
if (riga) await pb.collection("scout_live").delete(riga.id);
|
||||
return eventoId;
|
||||
},
|
||||
onSuccess: (eventoId) => {
|
||||
|
||||
+10
-14
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { pb } from "@/integrations/pocketbase/client";
|
||||
import { giocatori, type Giocatore } from "./crapp-data";
|
||||
|
||||
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
|
||||
@@ -85,7 +85,7 @@ type RigaScoutPartita = {
|
||||
function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||
return {
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
data: r.data.slice(0, 10),
|
||||
avversario: r.avversario,
|
||||
casa: r.casa,
|
||||
setNostri: r.set_nostri,
|
||||
@@ -98,12 +98,10 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||
export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
|
||||
|
||||
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
|
||||
const { data, error } = await supabase
|
||||
.from("scout_partite")
|
||||
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni")
|
||||
.order("creato_il", { ascending: false });
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(daRiga);
|
||||
const righe = await pb.collection("scout_partite").getFullList<RigaScoutPartita>({
|
||||
sort: "-created",
|
||||
});
|
||||
return righe.map(daRiga);
|
||||
}
|
||||
|
||||
/** Partite scoutate condivise con tutta la squadra: chi scoutizza le vede da qualsiasi
|
||||
@@ -121,9 +119,9 @@ export function useSalvaScoutMatch() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string | null; match: ScoutMatch }) => {
|
||||
const { error } = await supabase.from("scout_partite").insert({
|
||||
await pb.collection("scout_partite").create({
|
||||
id: input.match.id,
|
||||
evento_id: input.eventoId,
|
||||
evento: input.eventoId ?? "",
|
||||
data: input.match.data,
|
||||
avversario: input.match.avversario,
|
||||
casa: input.match.casa,
|
||||
@@ -132,10 +130,9 @@ export function useSalvaScoutMatch() {
|
||||
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
|
||||
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
|
||||
});
|
||||
if (error) throw error;
|
||||
return input.match;
|
||||
},
|
||||
// La query ordina per `creato_il` decrescente: la partita appena salvata è la più recente.
|
||||
// La query ordina per creazione decrescente: la partita appena salvata è la più recente.
|
||||
onSuccess: (match) =>
|
||||
queryClient.setQueryData<ScoutMatch[]>(SCOUT_MATCHES_KEY, (prec) => [
|
||||
match,
|
||||
@@ -148,8 +145,7 @@ export function useEliminaScoutMatch() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase.from("scout_partite").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
await pb.collection("scout_partite").delete(id);
|
||||
return id;
|
||||
},
|
||||
onSuccess: (id) =>
|
||||
|
||||
+10
-14
@@ -23,10 +23,10 @@ const riga: RigaEvento = {
|
||||
campionato: true,
|
||||
casa: true,
|
||||
pagelle_chiuse: false,
|
||||
creato_il: "2026-08-20T09:00:00Z",
|
||||
created: "2026-08-20 09:00:00.000Z",
|
||||
};
|
||||
|
||||
// --- daRiga: i NULL del database diventano valori sicuri ---------------------
|
||||
// --- daRiga: normalizza data/creato_il, PocketBase non ha NULL (solo "", [], false) -----
|
||||
assert.deepEqual(daRiga(riga), {
|
||||
id: "e1",
|
||||
tipo: "partita",
|
||||
@@ -39,20 +39,20 @@ assert.deepEqual(daRiga(riga), {
|
||||
campionato: true,
|
||||
casa: true,
|
||||
pagelleChiuse: false,
|
||||
creatoIl: "2026-08-20T09:00:00Z",
|
||||
creatoIl: "2026-08-20T09:00:00.000Z",
|
||||
});
|
||||
|
||||
const vuota = daRiga({
|
||||
...riga,
|
||||
note: null,
|
||||
convocati: null,
|
||||
casa: null,
|
||||
note: "",
|
||||
convocati: [],
|
||||
casa: false,
|
||||
campionato: false,
|
||||
pagelle_chiuse: false,
|
||||
});
|
||||
assert.equal(vuota.note, "", "note NULL → stringa vuota");
|
||||
assert.deepEqual(vuota.convocati, [], "convocati NULL → tutta la rosa (array vuoto)");
|
||||
assert.equal(vuota.casa, true, "casa NULL → si gioca in casa");
|
||||
assert.equal(vuota.note, "", "note vuota resta vuota");
|
||||
assert.deepEqual(vuota.convocati, [], "convocati vuoti → tutta la rosa (array vuoto)");
|
||||
assert.equal(vuota.casa, false, "casa è quello che c'è in database, nessun default implicito");
|
||||
assert.equal(vuota.pagelleChiuse, false);
|
||||
|
||||
// --- categoriaEvento: l'amichevole è una partita fuori campionato ------------
|
||||
@@ -100,11 +100,7 @@ assert.deepEqual(
|
||||
["g2"],
|
||||
"con i convocati indicati si filtra",
|
||||
);
|
||||
assert.deepEqual(
|
||||
convocatiEvento(daRiga({ ...riga, convocati: null }), rosa),
|
||||
rosa,
|
||||
"vuoto = tutti",
|
||||
);
|
||||
assert.deepEqual(convocatiEvento(daRiga({ ...riga, convocati: [] }), rosa), rosa, "vuoto = tutti");
|
||||
assert.deepEqual(convocatiEvento(null, rosa), rosa, "senza evento restano tutti");
|
||||
assert.deepEqual(
|
||||
convocatiEvento(daRiga({ ...riga, convocati: ["ignoto"] }), rosa),
|
||||
|
||||
Reference in New Issue
Block a user