- Créer hook useStaffOrgSelection avec persistence localStorage - Ajouter badge StaffOrgBadge dans Sidebar - Synchroniser filtres org dans toutes les pages (contrats, cotisations, facturation, etc.) - Fix calcul cachets: utiliser totalQuantities au lieu de dates.length - Fix structure field bug: ne plus écraser avec production_name - Ajouter création note lors modification contrat - Implémenter montants personnalisés pour virements salaires - Migrations SQL: custom_amount + fix_structure_field - Réorganiser boutons ContractEditor en carte flottante droite
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { api } from "@/lib/fetcher";
|
||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
|
||
|
||
type StructureInfos = {
|
||
raison_sociale?: string;
|
||
siret?: string;
|
||
forme_juridique?: string;
|
||
declaration?: string;
|
||
convention_collective?: string;
|
||
code_ape?: string;
|
||
rna?: string;
|
||
adresse_siege?: string;
|
||
presidente?: string;
|
||
tresoriere?: string;
|
||
|
||
contact_principal?: string;
|
||
email?: string;
|
||
telephone?: string;
|
||
signataire_contrats?: string;
|
||
signataire_delegation?: string; // "Oui"/"Non"
|
||
|
||
licence_spectacles?: string;
|
||
urssaf?: string;
|
||
audiens?: string;
|
||
conges_spectacles?: string;
|
||
pole_emploi_spectacle?: string;
|
||
recouvrement_pe_spectacle?: string;
|
||
afdas?: string;
|
||
fnas?: string;
|
||
fcap?: string;
|
||
};
|
||
|
||
type Spectacle = {
|
||
nom: string;
|
||
numero_objet?: string; // "N° objet"
|
||
declaration?: string | null; // ISO date/string from Airtable
|
||
};
|
||
|
||
type ClientInfo = {
|
||
id: string;
|
||
name: string;
|
||
api_name?: string;
|
||
} | null;
|
||
|
||
function Line({ label, value }: { label: string; value?: string | number | null }) {
|
||
return (
|
||
<div className="grid grid-cols-3 gap-2 border-b last:border-b-0 py-2">
|
||
<div className="text-slate-500">{label}</div>
|
||
<div className="col-span-2">{value ?? "—"}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function fmtDateFR(d?: string | null) {
|
||
if (!d) return "—";
|
||
const t = new Date(d);
|
||
if (isNaN(t.getTime())) return d; // fallback to raw if unparsable
|
||
return t.toLocaleDateString("fr-FR");
|
||
}
|
||
|
||
export default function InformationsPage() {
|
||
usePageTitle("Informations de la structure");
|
||
|
||
// Helper pour valider les UUIDs
|
||
const isValidUUID = (str: string | null): boolean => {
|
||
if (!str) return false;
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
|
||
};
|
||
|
||
// Zustand store pour la sélection d'organisation (staff)
|
||
const {
|
||
selectedOrgId: globalSelectedOrgId,
|
||
setSelectedOrg: setGlobalSelectedOrg
|
||
} = useStaffOrgSelection();
|
||
|
||
const [page, setPage] = useState(1);
|
||
const limit = 10;
|
||
|
||
// État local initialisé avec la valeur globale si elle est un UUID valide
|
||
const [selectedOrgId, setSelectedOrgId] = useState<string>(
|
||
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ""
|
||
);
|
||
|
||
// Récupération dynamique des infos client via /api/me
|
||
const { data: clientInfo } = useQuery({
|
||
queryKey: ["client-info"],
|
||
queryFn: async () => {
|
||
try {
|
||
const res = await fetch("/api/me", {
|
||
cache: "no-store",
|
||
headers: { Accept: "application/json" },
|
||
credentials: "include"
|
||
});
|
||
if (!res.ok) return null;
|
||
const me = await res.json();
|
||
|
||
return {
|
||
id: me.active_org_id || null,
|
||
name: me.active_org_name || "Organisation",
|
||
api_name: me.active_org_api_name
|
||
} as ClientInfo;
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
staleTime: 30_000, // Cache 30s
|
||
});
|
||
|
||
// Détection staff
|
||
const { data: meData } = useQuery({
|
||
queryKey: ["me-info"],
|
||
queryFn: async () => {
|
||
try {
|
||
const res = await fetch('/api/me', { cache: 'no-store', credentials: 'include' });
|
||
if (!res.ok) return null;
|
||
return await res.json();
|
||
} catch { return null; }
|
||
},
|
||
});
|
||
|
||
// Chargement des organisations (staff uniquement)
|
||
const { data: organizations } = useQuery({
|
||
queryKey: ["organizations"],
|
||
queryFn: async () => {
|
||
try {
|
||
const res = await fetch('/api/organizations', { cache: 'no-store', credentials: 'include' });
|
||
if (!res.ok) return [];
|
||
const data = await res.json();
|
||
return (data.items || []).map((org: any) => ({
|
||
id: org.id,
|
||
name: org.name,
|
||
api_name: org.structure_api || org.key
|
||
}));
|
||
} catch { return []; }
|
||
},
|
||
enabled: !!meData?.is_staff,
|
||
});
|
||
|
||
// Synchronisation bidirectionnelle : global → local
|
||
useEffect(() => {
|
||
if (meData?.is_staff && isValidUUID(globalSelectedOrgId)) {
|
||
setSelectedOrgId(globalSelectedOrgId);
|
||
}
|
||
}, [globalSelectedOrgId, meData?.is_staff]);
|
||
|
||
// Trouver l'organisation complète sélectionnée (pour passer api_name aux queries)
|
||
const selectedOrgData = selectedOrgId
|
||
? organizations?.find((org: any) => org.id === selectedOrgId)
|
||
: null;
|
||
|
||
// effectiveClientInfo : override avec selectedOrgData si staff sélectionne une org
|
||
const effectiveClientInfo = selectedOrgData
|
||
? { id: selectedOrgData.id, name: selectedOrgData.name, api_name: selectedOrgData.api_name } as ClientInfo
|
||
: clientInfo;
|
||
|
||
// 1) Infos structure
|
||
const { data: infosResp, isLoading: loadingInfos, isError: errInfos } = useQuery({
|
||
queryKey: ["structure-infos", effectiveClientInfo?.id], // Inclure l'ID client dans la queryKey
|
||
queryFn: () => api<{ infos: StructureInfos }>("/informations", {}, effectiveClientInfo), // Passer effectiveClientInfo au helper api()
|
||
enabled: !!effectiveClientInfo, // Ne pas exécuter si pas d'infos client
|
||
});
|
||
const structure = infosResp?.infos;
|
||
|
||
// 2) Productions (Supabase via API interne)
|
||
const { data: prods, isLoading: loadingProds, isError: errProds } = useQuery({
|
||
queryKey: ["spectacles", page, limit, effectiveClientInfo?.id], // Inclure l'ID client dans la queryKey
|
||
queryFn: () => api<{ items: Spectacle[]; total: number; hasMore: boolean }>(`/informations/productions?page=${page}&limit=${limit}`, {}, effectiveClientInfo), // Passer effectiveClientInfo au helper api()
|
||
enabled: !!effectiveClientInfo, // Ne pas exécuter si pas d'infos client
|
||
});
|
||
|
||
const total = prods?.total ?? 0;
|
||
const hasMore = prods?.hasMore ?? false;
|
||
|
||
return (
|
||
<main className="p-4 md:p-6 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-lg font-semibold">Vos informations</h1>
|
||
</div>
|
||
|
||
{/* Sélecteur d'organisation (visible uniquement par le staff) */}
|
||
{meData?.is_staff && (
|
||
<section className="rounded-2xl border bg-white p-4">
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-sm font-medium text-slate-700">Organisation :</label>
|
||
<select
|
||
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
|
||
value={selectedOrgId}
|
||
onChange={(e) => {
|
||
const newOrgId = e.target.value;
|
||
setSelectedOrgId(newOrgId);
|
||
setPage(1);
|
||
|
||
// Synchronisation bidirectionnelle : local → global
|
||
if (newOrgId) {
|
||
const selectedOrg = organizations?.find((org: any) => org.id === newOrgId);
|
||
if (selectedOrg) {
|
||
setGlobalSelectedOrg(newOrgId, selectedOrg.name);
|
||
}
|
||
} else {
|
||
setGlobalSelectedOrg(null, null);
|
||
}
|
||
}}
|
||
disabled={!organizations || organizations.length === 0}
|
||
>
|
||
<option value="">
|
||
{!organizations || organizations.length === 0
|
||
? "Chargement..."
|
||
: "Toutes les organisations"}
|
||
</option>
|
||
{organizations && organizations.map((org: any) => (
|
||
<option key={org.id} value={org.id}>{org.name}</option>
|
||
))}
|
||
</select>
|
||
{organizations && organizations.length > 0 && (
|
||
<span className="text-xs text-slate-500">({organizations.length})</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Bandeau compte spécifique (optionnel) */}
|
||
{/* <div className="rounded-2xl border bg-amber-50 text-amber-900 border-amber-200 p-4 text-sm">
|
||
Compte spécifique — vous pouvez mettre ici un message d'info si besoin.
|
||
</div> */}
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
|
||
<div className="space-y-4">
|
||
{/* Colonne gauche : Votre structure */}
|
||
<section className="rounded-2xl border bg-white">
|
||
<div className="px-4 py-3 border-b">
|
||
<h2 className="font-medium">Votre structure</h2>
|
||
</div>
|
||
|
||
<div className="p-4 text-sm">
|
||
{loadingInfos && <div className="text-slate-500">Chargement…</div>}
|
||
{errInfos && <div className="text-red-600">Erreur de chargement.</div>}
|
||
{!!structure && (
|
||
<div className="space-y-2">
|
||
<Line label="Raison sociale" value={structure.raison_sociale} />
|
||
<Line label="SIRET" value={structure.siret} />
|
||
<Line label="Forme juridique" value={structure.forme_juridique} />
|
||
<Line label="Déclaration" value={structure.declaration} />
|
||
<Line label="Convention collective" value={structure.convention_collective} />
|
||
<Line label="Code APE" value={structure.code_ape} />
|
||
<Line label="N° RNA" value={structure.rna} />
|
||
<Line label="Adresse siège" value={structure.adresse_siege} />
|
||
<Line label="Président" value={structure.presidente} />
|
||
<Line label="Trésorier" value={structure.tresoriere} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Vos productions */}
|
||
<section className="rounded-2xl border bg-white">
|
||
<div className="px-4 py-3 border-b">
|
||
<h2 className="font-medium">Vos productions</h2>
|
||
</div>
|
||
|
||
<div className="p-2">
|
||
{loadingProds && <div className="p-3 text-slate-500">Chargement…</div>}
|
||
{errProds && <div className="p-3 text-red-600">Erreur de chargement.</div>}
|
||
{!!prods?.items?.length ? (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left border-b">
|
||
<th className="px-3 py-2">Production</th>
|
||
<th className="px-3 py-2">Déclaration</th>
|
||
<th className="px-3 py-2">N° d’objet</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{prods.items.map((s, i) => (
|
||
<tr key={`${s.nom}-${s.numero_objet ?? i}`} className="border-b last:border-b-0">
|
||
<td className="px-3 py-2">{s.nom}</td>
|
||
<td className="px-3 py-2">{fmtDateFR(s.declaration)}</td>
|
||
<td className="px-3 py-2">{s.numero_objet ?? "—"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="p-3 text-slate-500">Aucune production.</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-2 p-3 border-t">
|
||
<button
|
||
className="px-3 py-1.5 rounded-lg border bg-white hover:bg-slate-50"
|
||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||
disabled={page === 1}
|
||
>
|
||
Précédent
|
||
</button>
|
||
<div className="text-sm text-slate-500">Page {page}</div>
|
||
<button
|
||
className="px-3 py-1.5 rounded-lg border bg-white hover:bg-slate-50 disabled:opacity-50"
|
||
onClick={() => setPage((p) => (hasMore ? p + 1 : p))}
|
||
disabled={!hasMore}
|
||
>
|
||
Suivant
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{/* Colonne droite : Contact + Caisses */}
|
||
<section className="rounded-2xl border bg-white">
|
||
<div className="px-4 py-3 border-b">
|
||
<h2 className="font-medium">Informations de contact</h2>
|
||
</div>
|
||
<div className="p-4 text-sm">
|
||
{!!structure ? (
|
||
<div className="space-y-2">
|
||
<Line label="Contact principal" value={structure.contact_principal} />
|
||
<Line label="Adresse email" value={structure.email} />
|
||
<Line label="Tél contact" value={structure.telephone} />
|
||
<Line label="Signataire des contrats" value={structure.signataire_contrats} />
|
||
<Line label="Signataire agissant par délégation ?" value={structure.signataire_delegation} />
|
||
</div>
|
||
) : (
|
||
<div className="text-slate-500">—</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-2xl border bg-white">
|
||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||
<h2 className="font-medium">Caisses & organismes</h2>
|
||
</div>
|
||
<div className="p-4 text-sm">
|
||
{!!structure ? (
|
||
<div className="space-y-2">
|
||
<Line label="Licence spectacles" value={structure.licence_spectacles} />
|
||
<Line label="URSSAF" value={structure.urssaf} />
|
||
<Line label="AUDIENS" value={structure.audiens} />
|
||
<Line label="Congés Spectacles" value={structure.conges_spectacles} />
|
||
<Line label="Pôle Emploi Spectacle" value={structure.pole_emploi_spectacle} />
|
||
<Line label="Recouvrement PE Spectacle" value={structure.recouvrement_pe_spectacle} />
|
||
<Line label="AFDAS" value={structure.afdas} />
|
||
<Line label="FNAS" value={structure.fnas} />
|
||
<Line label="FCAP" value={structure.fcap} />
|
||
</div>
|
||
) : (
|
||
<div className="text-slate-500">—</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|