feat: Implémenter store global Zustand + calcul total quantités + fix structure field + montants personnalisés virements

- 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
This commit is contained in:
odentas 2025-12-01 21:51:57 +01:00
parent 8ba984af1d
commit 266eb3598a
32 changed files with 964 additions and 106 deletions

View file

@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { ChevronLeft, ChevronRight, Loader2, Search, Plus, Pencil, Copy, Table, HelpCircle } from "lucide-react";
import { useDemoMode } from "@/hooks/useDemoMode";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
// --- Types
export type Contrat = {
@ -279,8 +280,28 @@ export default function PageContrats(){
},
});
// Zustand store pour la sélection d'organisation (uniquement pour le staff)
const { selectedOrgId, setSelectedOrg: setGlobalSelectedOrg } = useStaffOrgSelection();
// Helper: vérifier si une string est un UUID valide
const isValidUUID = (str: string | null): boolean => {
if (!str) return false;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
const [orgs, setOrgs] = useState<Array<{ id: string; name: string }>>([]);
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
// N'utiliser selectedOrgId que si c'est un UUID valide (pas un nom de structure)
const [selectedOrg, setSelectedOrg] = useState<string | null>(
isValidUUID(selectedOrgId) ? selectedOrgId : null
);
// Synchroniser le filtre local avec le store global quand selectedOrgId change
useEffect(() => {
if (meData?.is_staff && isValidUUID(selectedOrgId)) {
setSelectedOrg(selectedOrgId);
}
}, [selectedOrgId, meData?.is_staff]);
const { data, isLoading, isError, error, isFetching } = useContrats({
regime,
@ -333,7 +354,18 @@ export default function PageContrats(){
{meData?.is_staff && (
<select
value={selectedOrg || ""}
onChange={(e) => { setSelectedOrg(e.target.value || null); setPage(1); }}
onChange={(e) => {
const value = e.target.value || null;
setSelectedOrg(value);
setPage(1);
// Synchroniser avec le store global
if (value) {
const org = orgs.find(o => o.id === value);
setGlobalSelectedOrg(value, org?.name || null);
} else {
setGlobalSelectedOrg(null, null);
}
}}
className="px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="">Toutes les structures</option>

View file

@ -7,6 +7,7 @@ import { api } from "@/lib/fetcher";
import { Calendar, Loader2, Building2, Info } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
import { createPortal } from "react-dom";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
/* =========================
Types attendus du backend
@ -297,12 +298,26 @@ export default function CotisationsMensuellesPage() {
const now = new Date();
// 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();
// Vérification staff
const { data: staffCheck } = useStaffCheck();
const { data: organizations = [] } = useOrganizations();
// État pour l'org sélectionnée (staff uniquement)
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
// État local initialisé avec la valeur globale si elle est un UUID valide
const [selectedOrgId, setSelectedOrgId] = useState<string>(
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ""
);
const handlePeriodChange = (value: Filters["period"]) => {
setFilters((f) => {
@ -348,6 +363,13 @@ export default function CotisationsMensuellesPage() {
return Array.from({ length: 7 }, (_, i) => base - i);
}, [now]);
// Synchronisation bidirectionnelle : global → local
useEffect(() => {
if (staffCheck?.isStaff && isValidUUID(globalSelectedOrgId)) {
setSelectedOrgId(globalSelectedOrgId);
}
}, [globalSelectedOrgId, staffCheck?.isStaff]);
// --- Synchronisation URL <-> état des filtres ---
const searchParams = useSearchParams();
const pathname = usePathname();
@ -463,7 +485,20 @@ export default function CotisationsMensuellesPage() {
<select
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
onChange={(e) => {
const newOrgId = e.target.value;
setSelectedOrgId(newOrgId);
// Synchronisation bidirectionnelle : local → global
if (newOrgId) {
const selectedOrg = organizations.find(org => org.id === newOrgId);
if (selectedOrg) {
setGlobalSelectedOrg(newOrgId, selectedOrg.name);
}
} else {
setGlobalSelectedOrg(null, null);
}
}}
>
<option value="">Mon organisation</option>
{organizations.map((org) => (

View file

@ -1,11 +1,12 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, useEffect } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { Loader2, CheckCircle2, XCircle, FileDown, Edit, ChevronLeft, ChevronRight } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
// ---------------- Types ----------------
type SepaInfo = {
@ -151,7 +152,7 @@ function Pagination({ page, totalPages, total, limit, onPageChange, onLimitChang
}
// -------------- Data hook --------------
function useBilling(page: number, limit: number) {
function useBilling(page: number, limit: number, selectedOrg?: { id: string; name: string; api_name?: string } | null) {
// Récupération dynamique des infos client via /api/me
const { data: clientInfo } = useQuery({
queryKey: ["client-info"],
@ -177,11 +178,16 @@ function useBilling(page: number, limit: number) {
staleTime: 30_000, // Cache 30s
});
// Si selectedOrg est fourni (staff), créer un clientInfo override avec toutes les infos
const effectiveClientInfo = selectedOrg
? { id: selectedOrg.id, name: selectedOrg.name, api_name: selectedOrg.api_name } as ClientInfo
: clientInfo;
return useQuery<BillingResponse>({
queryKey: ["billing", page, limit, clientInfo?.id], // Inclure l'ID client dans la queryKey
queryFn: () => api(`/facturation?page=${page}&limit=${limit}`, {}, clientInfo), // Passer clientInfo au helper api()
queryKey: ["billing", page, limit, effectiveClientInfo?.id], // Inclure l'ID client dans la queryKey
queryFn: () => api(`/facturation?page=${page}&limit=${limit}`, {}, effectiveClientInfo), // Passer clientInfo au helper api()
staleTime: 15_000,
enabled: !!clientInfo, // Ne pas exécuter si pas d'infos client
enabled: !!effectiveClientInfo, // Ne pas exécuter si pas d'infos client
});
}
@ -189,9 +195,69 @@ function useBilling(page: number, limit: number) {
export default function FacturationPage() {
usePageTitle("Facturation");
// 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, setLimit] = useState(10);
const { data, isLoading, isError, error, isFetching } = useBilling(page, limit);
// État local initialisé avec la valeur globale si elle est un UUID valide
const [selectedOrgId, setSelectedOrgId] = useState<string>(
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ""
);
// 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 à useBilling)
const selectedOrgData = selectedOrgId
? organizations?.find((org: any) => org.id === selectedOrgId)
: null;
const { data, isLoading, isError, error, isFetching } = useBilling(page, limit, selectedOrgData || null);
const items = data?.factures.items ?? [];
const hasMore = data?.factures.hasMore ?? false;
@ -200,6 +266,47 @@ export default function FacturationPage() {
return (
<main className="space-y-5">
{/* Sélecteur d'organisation (visible uniquement par le staff) */}
{meData?.is_staff && (
<Section title="Sélection de l'organisation">
<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>
)}
{/* SEPA / IBAN */}
<Section title="Prélèvement automatique SEPA">
{isLoading && (

View file

@ -5,6 +5,7 @@ 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;
@ -66,9 +67,26 @@ function fmtDateFR(d?: string | null) {
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"],
@ -94,19 +112,66 @@ export default function InformationsPage() {
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", clientInfo?.id], // Inclure l'ID client dans la queryKey
queryFn: () => api<{ infos: StructureInfos }>("/informations", {}, clientInfo), // Passer clientInfo au helper api()
enabled: !!clientInfo, // Ne pas exécuter si pas d'infos client
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, clientInfo?.id], // Inclure l'ID client dans la queryKey
queryFn: () => api<{ items: Spectacle[]; total: number; hasMore: boolean }>(`/informations/productions?page=${page}&limit=${limit}`, {}, clientInfo), // Passer clientInfo au helper api()
enabled: !!clientInfo, // Ne pas exécuter si pas d'infos client
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;
@ -118,9 +183,50 @@ export default function InformationsPage() {
<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 dinfo si besoin.
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">

View file

@ -9,6 +9,7 @@ import { api } from "@/lib/fetcher";
import { Loader2, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useDemoMode } from "@/hooks/useDemoMode";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
/* ===== Types ===== */
type SalarieRow = {
@ -304,6 +305,18 @@ export default function SalariesPage() {
// 🎭 Détection du mode démo
const { isDemoMode } = useDemoMode();
// 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 router = useRouter();
const searchParams = useSearchParams();
@ -336,7 +349,10 @@ export default function SalariesPage() {
});
const [orgs, setOrgs] = useState<Array<{ id: string; name: string }>>([]);
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
// État local initialisé avec la valeur globale si elle est un UUID valide
const [selectedOrg, setSelectedOrg] = useState<string | null>(
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : null
);
const { data, isLoading, isFetching } = useSalaries(page, limit, query, selectedOrg, isDemoMode);
const rows: SalarieRow[] = data?.items ?? [];
@ -359,6 +375,13 @@ export default function SalariesPage() {
return () => { mounted = false; };
}, []);
// Synchronisation bidirectionnelle : global → local
useEffect(() => {
if (meData?.is_staff && isValidUUID(globalSelectedOrgId)) {
setSelectedOrg(globalSelectedOrgId);
}
}, [globalSelectedOrgId, meData?.is_staff]);
// Modal "Nouveau contrat" déclenché depuis la liste
const [newContratOpen, setNewContratOpen] = useState(false);
const [selectedMatricule, setSelectedMatricule] = useState<string | null>(null);
@ -398,7 +421,21 @@ export default function SalariesPage() {
{meData?.is_staff && (
<select
value={selectedOrg || ""}
onChange={(e) => { setSelectedOrg(e.target.value || null); setPage(1); }}
onChange={(e) => {
const newOrgId = e.target.value || null;
setSelectedOrg(newOrgId);
setPage(1);
// Synchronisation bidirectionnelle : local → global
if (newOrgId) {
const selectedOrgData = orgs.find(o => o.id === newOrgId);
if (selectedOrgData) {
setGlobalSelectedOrg(newOrgId, selectedOrgData.name);
}
} else {
setGlobalSelectedOrg(null, null);
}
}}
className="ml-3 px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="">Toutes les structures</option>

View file

@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query';
import { useDemoMode } from '@/hooks/useDemoMode';
import { createPortal } from 'react-dom';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useStaffOrgSelection } from '@/hooks/useStaffOrgSelection';
type AirtableRecord = {
id: string;
@ -149,6 +150,18 @@ export default function SignaturesElectroniques() {
// 🎭 Détection du mode démo
const { isDemoMode } = useDemoMode();
// 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 [contrats, setContrats] = useState<ContratWithSignatures[]>([]);
const [loading, setLoading] = useState(true);
const [reloadingAfterSignatureChange, setReloadingAfterSignatureChange] = useState(false);
@ -181,7 +194,10 @@ export default function SignaturesElectroniques() {
const [loadingRelance, setLoadingRelance] = useState<Record<string, boolean>>({});
// État pour le sélecteur d'organisation (staff uniquement)
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
// Initialisé avec la valeur globale si elle est un UUID valide
const [selectedOrgId, setSelectedOrgId] = useState<string>(
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ""
);
// Référence pour le conteneur DocuSeal
const docusealContainerRef = useRef<HTMLDivElement>(null);
@ -217,6 +233,13 @@ export default function SignaturesElectroniques() {
const { data: userInfo } = useUserInfo();
const { data: organizations } = useOrganizations();
// Synchronisation bidirectionnelle : global → local
useEffect(() => {
if (userInfo?.isStaff && isValidUUID(globalSelectedOrgId)) {
setSelectedOrgId(globalSelectedOrgId);
}
}, [globalSelectedOrgId, userInfo?.isStaff]);
// Suppression de pollActive et pollTimer car le polling a été retiré
// Normaliser le format de la signature (ajouter le préfixe si nécessaire)
@ -1054,7 +1077,20 @@ export default function SignaturesElectroniques() {
<select
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
onChange={(e) => {
const newOrgId = e.target.value;
setSelectedOrgId(newOrgId);
// 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="">

View file

@ -31,7 +31,7 @@ export default async function StaffContractsPage() {
);
}
// initial fetch: server-side list of latest contracts (limited)
// initial fetch: server-side list of latest contracts (sans limite)
// Utiliser une jointure pour récupérer le nom depuis la table salaries
// Par défaut, afficher tous les contrats (terminés et non terminés) - l'utilisateur peut filtrer ensuite
const { data: contracts, error } = await sb
@ -41,8 +41,7 @@ export default async function StaffContractsPage() {
salaries!employee_id(salarie, nom, prenom, adresse_mail, code_salarie),
organizations!org_id(organization_details(code_employeur))`
)
.order("start_date", { ascending: false })
.limit(200);
.order("start_date", { ascending: false });
// Server-side debug logging to help diagnose empty results (will appear in Next.js server logs)
try {

View file

@ -6,6 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Search, Download, ExternalLink, Info, Copy, Check, X } from "lucide-react";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useDemoMode } from "@/hooks/useDemoMode";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
// --- Types ---
type PeriodKey =
@ -362,6 +363,18 @@ export default function VirementsPage() {
// 🎭 Détection du mode démo
const { isDemoMode } = useDemoMode();
// 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 now = new Date();
const [filters, setFilters] = useState<Filters>({
year: now.getFullYear(),
@ -370,7 +383,10 @@ export default function VirementsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [aboutOpen, setAboutOpen] = useState(false);
const [copiedField, setCopiedField] = useState<null | 'iban' | 'bic' | 'benef'>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
// État local initialisé avec la valeur globale si elle est un UUID valide
const [selectedOrgId, setSelectedOrgId] = useState<string>(
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ""
);
const [pdfModalOpen, setPdfModalOpen] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string>("");
const [undoModalOpen, setUndoModalOpen] = useState(false);
@ -396,6 +412,13 @@ export default function VirementsPage() {
const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
const queryClient = useQueryClient();
// Synchronisation bidirectionnelle : global → local
useEffect(() => {
if (userInfo?.isStaff && isValidUUID(globalSelectedOrgId)) {
setSelectedOrgId(globalSelectedOrgId);
}
}, [globalSelectedOrgId, userInfo?.isStaff]);
const years = useMemo(() => {
const base = now.getFullYear();
return Array.from({ length: 7 }, (_, i) => base - i);
@ -856,7 +879,20 @@ export default function VirementsPage() {
<select
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
onChange={(e) => {
const newOrgId = e.target.value;
setSelectedOrgId(newOrgId);
// 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="">

View file

@ -12,6 +12,7 @@ import { Tooltip } from '@/components/ui/tooltip'
import { supabase } from '@/lib/supabaseClient'
import { DocumentViewModal } from '@/components/DocumentViewModal'
import Cookies from 'js-cookie'
import { useStaffOrgSelection } from '@/hooks/useStaffOrgSelection'
type DocumentItem = {
id: string
@ -590,8 +591,24 @@ function SectionComptables() {
export default function VosDocumentsPage() {
usePageTitle("Vos documents");
// 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 [activeTab, setActiveTab] = React.useState('comptables');
const [selectedOrgId, setSelectedOrgId] = React.useState<string>('');
// État local initialisé avec la valeur globale si elle est un UUID valide
const [selectedOrgId, setSelectedOrgId] = React.useState<string>(
isValidUUID(globalSelectedOrgId) ? globalSelectedOrgId : ''
);
const [isStaff, setIsStaff] = React.useState(false);
const [isCheckingStaff, setIsCheckingStaff] = React.useState(true);
@ -649,6 +666,13 @@ export default function VosDocumentsPage() {
enabled: isStaff && !isCheckingStaff
});
// Synchronisation bidirectionnelle : global → local
React.useEffect(() => {
if (isStaff && isValidUUID(globalSelectedOrgId)) {
setSelectedOrgId(globalSelectedOrgId);
}
}, [globalSelectedOrgId, isStaff]);
// Mettre à jour le cookie active_org_id quand l'organisation sélectionnée change
React.useEffect(() => {
if (selectedOrgId && isStaff) {
@ -691,7 +715,20 @@ export default function VosDocumentsPage() {
) : organizations && organizations.length > 0 ? (
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
onChange={(e) => {
const newOrgId = e.target.value;
setSelectedOrgId(newOrgId);
// Synchronisation bidirectionnelle : local → global
if (newOrgId) {
const selectedOrg = organizations.find(org => org.id === newOrgId);
if (selectedOrg) {
setGlobalSelectedOrg(newOrgId, selectedOrg.name);
}
} else {
setGlobalSelectedOrg(null, null);
}
}}
className="w-full px-3 py-2 rounded-lg border bg-white text-sm"
>
<option value="">-- Sélectionner une organisation --</option>

View file

@ -315,7 +315,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
// Mapper les champs du formulaire vers la table Supabase
if (requestBody.production !== undefined) {
supabaseData.production_name = requestBody.production;
supabaseData.structure = requestBody.production;
// Ne PAS écraser structure avec la production - structure doit rester l'organisation
}
if (requestBody.numero_objet !== undefined) {
supabaseData.objet_spectacle = requestBody.numero_objet;
@ -426,6 +426,35 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
throw new Error(`Supabase update error: ${updateResult.error.message}`);
}
// Si une note a été fournie lors de la modification, créer une entrée dans la table `notes`
try {
const rawNote = typeof requestBody.notes === 'string' ? requestBody.notes.trim() : '';
if (rawNote) {
const notePayload = {
contract_id: contractId,
organization_id: org.id,
content: rawNote,
source: 'Client',
};
let noteInsertResult;
if (org.isStaff) {
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
noteInsertResult = await admin.from('notes').insert([notePayload]);
} else {
noteInsertResult = await supabase.from('notes').insert([notePayload]);
}
if (noteInsertResult.error) {
console.warn('⚠️ Erreur insertion note lors de la modification:', noteInsertResult.error);
} else {
console.log('✅ Note créée avec succès lors de la modification:', { contractId, noteLength: rawNote.length, requestId });
}
}
} catch (noteCatchErr) {
console.error('Exception lors de la création de la note de modification:', noteCatchErr);
}
// Envoyer les notifications email après la mise à jour réussie (sauf si explicitement désactivé)
const shouldSendEmail = requestBody.send_email_confirmation !== false;

View file

@ -113,6 +113,17 @@ export async function GET(req: Request) {
return NextResponse.json({ error: 'forbidden', message }, { status: 403 });
}
// 🔧 OVERRIDE pour le staff : si header x-active-org-id est présent, l'utiliser
const headerOrgId = req.headers.get('x-active-org-id');
if (clientInfo.isStaff && headerOrgId) {
const { data: orgData } = await supabase.from('organizations').select('structure_api').eq('id', headerOrgId).maybeSingle();
clientInfo = {
id: headerOrgId,
name: orgData?.structure_api || 'Staff Access',
isStaff: true
};
}
// 1) SEPA info from organization_details
let details: any = null;
let detailsError: any = null;

View file

@ -80,6 +80,17 @@ export async function GET(req: Request) {
return NextResponse.json({ error: 'forbidden', message }, { status: 403 });
}
// 🔧 OVERRIDE pour le staff : si header x-active-org-id est présent, l'utiliser
const headerOrgId = req.headers.get('x-active-org-id');
if (clientInfo.isStaff && headerOrgId) {
const { data: orgData } = await supabase.from('organizations').select('structure_api').eq('id', headerOrgId).maybeSingle();
clientInfo = {
id: headerOrgId,
name: orgData?.structure_api || 'Staff Access',
isStaff: true
};
}
// Query productions for this organization
let query: any = supabase.from('productions').select('*', { count: 'exact' });
if (clientInfo.id) {

View file

@ -76,14 +76,15 @@ export async function GET(req: Request) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
// Vérifier si un org_id est passé en query
// Vérifier si un org_id est passé en query ou via header
const url = new URL(req.url);
const queryOrgId = url.searchParams.get('org_id');
const headerOrgId = req.headers.get('x-active-org-id');
let clientInfo;
let targetOrgId = queryOrgId;
let targetOrgId = queryOrgId || headerOrgId;
// Si pas d'org_id en query, récupérer l'org de l'utilisateur
// Si pas d'org_id en query/header, récupérer l'org de l'utilisateur
if (!targetOrgId) {
try {
clientInfo = await getClientInfoFromSession(session, supabase);

View file

@ -30,7 +30,7 @@ export async function GET(req: Request) {
const end_to = url.searchParams.get("end_to");
const sort = url.searchParams.get("sort") || "created_at";
const order = (url.searchParams.get("order") || "desc").toLowerCase() === "asc" ? "asc" : "desc";
const limit = Math.min(500, parseInt(url.searchParams.get("limit") || "100", 10));
const limit = parseInt(url.searchParams.get("limit") || "10000", 10); // Limite par défaut élevée pour récupérer tous les contrats
const offset = Math.max(0, parseInt(url.searchParams.get("offset") || "0", 10));
// Build base query with salaries join

View file

@ -50,6 +50,7 @@ export async function POST(req: NextRequest) {
notes,
selection_mode, // 'period' (default) or 'manual'
payslip_ids, // Array of payslip IDs (required if selection_mode = 'manual')
custom_amounts, // Object mapping payslip_id to custom amount (optional)
} = body;
// 4) Validate required fields
@ -166,6 +167,7 @@ export async function POST(req: NextRequest) {
const links = payslip_ids.map(payslip_id => ({
salary_transfer_id: newTransfer.id,
payslip_id,
custom_amount: custom_amounts?.[payslip_id] || null,
}));
const { error: linksError } = await supabase

View file

@ -194,6 +194,7 @@ export async function POST(req: NextRequest) {
.from("salary_transfer_payslips")
.select(`
payslip_id,
custom_amount,
payslips (
*,
cddu_contracts (
@ -214,11 +215,21 @@ export async function POST(req: NextRequest) {
payslipsError = linkedError;
if (!linkedError && linkedPayslips) {
// Flatten the structure (extract payslips from the join)
// Flatten the structure and add custom_amount to each payslip
payslips = linkedPayslips
.map(lp => lp.payslips)
.map(lp => {
if (!lp.payslips) return null;
return {
...lp.payslips,
custom_amount: lp.custom_amount, // Ajouter le montant personnalisé
};
})
.filter(p => p !== null);
console.log("[generate-pdf] Found", payslips.length, "linked payslips");
// Log custom amounts for debugging
const withCustomAmounts = payslips.filter(p => p.custom_amount !== null).length;
console.log("[generate-pdf] Payslips with custom amounts:", withCustomAmounts);
}
} else {
// Mode période: récupérer toutes les paies du mois (comportement actuel)
@ -307,20 +318,27 @@ export async function POST(req: NextRequest) {
// Get employee name
const employee_name = `${salarie?.prenom || ""} ${salarie?.nom || ""}`.trim();
// Utiliser le montant personnalisé si disponible, sinon le montant de la paie
const montant = p.custom_amount !== null && p.custom_amount !== undefined
? parseFloat(p.custom_amount)
: parseFloat(p.net_after_withholding || p.net_amount || 0);
console.log("[generate-pdf] 👤 Processing payslip:", {
payslip_id: p.id,
has_contract: !!contract,
has_salarie: !!salarie,
employee_name,
net_amount: p.net_amount,
net_after_withholding: p.net_after_withholding
net_after_withholding: p.net_after_withholding,
custom_amount: p.custom_amount,
final_montant: montant
});
return {
employee_name,
matricule: contract?.employee_matricule || "",
contrat: contract?.contract_number || "",
montant: parseFloat(p.net_after_withholding || p.net_amount || 0),
montant,
analytique: contract?.analytique || "",
profession: contract?.profession || "",
};
@ -333,10 +351,13 @@ export async function POST(req: NextRequest) {
// Calculate totals
const totalNet = (payslips || []).reduce((sum: number, p: any) => {
const net = typeof p.net_after_withholding === "string"
? parseFloat(p.net_after_withholding)
: (p.net_after_withholding || (typeof p.net_amount === "string" ? parseFloat(p.net_amount) : p.net_amount) || 0);
return sum + net;
// Utiliser le montant personnalisé si disponible, sinon le montant de la paie
const amount = p.custom_amount !== null && p.custom_amount !== undefined
? (typeof p.custom_amount === "string" ? parseFloat(p.custom_amount) : p.custom_amount)
: (typeof p.net_after_withholding === "string"
? parseFloat(p.net_after_withholding)
: (p.net_after_withholding || (typeof p.net_amount === "string" ? parseFloat(p.net_amount) : p.net_amount) || 0));
return sum + amount;
}, 0);
console.log("[generate-pdf] Total net amount:", totalNet);

View file

@ -21,6 +21,7 @@ interface DatesQuantityModalProps {
globalQuantity?: number;
globalDuration?: "3" | "4";
totalHours?: number; // Total des heures saisies (pour jours_travail)
totalQuantities?: number; // Somme de toutes les quantités saisies par jour
}) => void;
selectedDates: string[]; // format input "12/10, 13/10, ..."
dateType: "representations" | "repetitions" | "jours_travail" | "heures_repetitions"; // Type de dates pour déterminer le libellé
@ -247,6 +248,7 @@ export default function DatesQuantityModal({
let globalQty: number | undefined = undefined;
let globalDur: "3" | "4" | undefined = undefined;
let totalHrs: number | undefined = undefined;
let totalQtys: number | undefined = undefined;
// Si on ne veut pas d'heures par jour, valider le nombre global
if (skipHoursByDay) {
@ -262,7 +264,7 @@ export default function DatesQuantityModal({
globalDur = globalDuration;
}
} else {
// Vérifier que toutes les quantités sont > 0
// Vérifier que toutes les quantités sont > 0 ET calculer la somme totale
let sum = 0;
for (const iso of selectedIsos) {
const qty = quantities[iso];
@ -270,12 +272,18 @@ export default function DatesQuantityModal({
setValidationError("Toutes les quantités doivent être >= 1");
return;
}
// Calculer le total pour jours_travail
if (dateType === "jours_travail" && typeof qty === "number") {
sum += qty;
// Calculer le total de TOUTES les quantités
const qtyNum = typeof qty === "number" ? qty : parseInt(String(qty), 10);
if (!isNaN(qtyNum)) {
sum += qtyNum;
}
}
// Stocker la somme totale pour les représentations/répétitions
if (dateType === "representations" || dateType === "repetitions") {
totalQtys = sum;
}
// Si c'est des jours de travail, on retourne le total d'heures
if (dateType === "jours_travail" && sum > 0) {
totalHrs = sum;
@ -294,6 +302,7 @@ export default function DatesQuantityModal({
globalQuantity: globalQty,
globalDuration: dateType === "repetitions" ? globalDuration : globalDur,
totalHours: totalHrs,
totalQuantities: totalQtys,
});
onClose();

View file

@ -9,6 +9,7 @@ import LogoutButton from "@/components/LogoutButton";
import { MaintenanceButton } from "@/components/MaintenanceButton";
import { useDemoMode } from "@/hooks/useDemoMode";
import { usePendingSignatures } from "@/hooks/usePendingSignatures";
import { StaffOrgBadge } from "@/components/StaffOrgBadge";
function AccessLink({ disabled, fullWidth = false }: { disabled: boolean; fullWidth?: boolean }) {
const btnRef = useRef<HTMLElement | null>(null);
@ -306,6 +307,10 @@ export default function Sidebar({ clientInfo, isStaff = false, mobile = false, o
<div className="mb-2 text-xs uppercase tracking-wide">
{isDemoMode ? 'DEMO' : (isStaff ? 'STAFF' : (userRole || '—'))}
</div>
{/* Badge orga active (uniquement pour le staff) */}
{isStaff && <div className="mb-2"><StaffOrgBadge /></div>}
{!isStaff && (
<>
<div className="text-xs text-slate-500">Votre gestionnaire</div>

View file

@ -0,0 +1,44 @@
'use client'
import { useStaffOrgSelection } from '@/hooks/useStaffOrgSelection'
import { Building2, X } from 'lucide-react'
/**
* Badge affichant l'organisation actuellement sélectionnée par le staff
*
* Fonctionnalités :
* - Affiche le nom de l'organisation active
* - Bouton pour réinitialiser (retour à "Toutes les organisations")
* - Masqué automatiquement si aucune organisation n'est sélectionnée
* - Uniquement visible pour le staff
*
* Placement : Dans la sidebar, sous "Votre structure" et "Niveau d'accès"
*/
export function StaffOrgBadge() {
const { selectedOrgName, clearSelection } = useStaffOrgSelection()
// Ne rien afficher si aucune organisation n'est sélectionnée
if (!selectedOrgName) {
return null
}
return (
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg">
<Building2 className="w-4 h-4 text-indigo-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-indigo-600 font-medium">Orga active</div>
<div className="text-sm font-semibold text-indigo-900 truncate" title={selectedOrgName}>
{selectedOrgName}
</div>
</div>
<button
onClick={clearSelection}
className="flex-shrink-0 p-1 text-indigo-400 hover:text-indigo-600 hover:bg-indigo-100 rounded transition-colors"
title="Voir toutes les organisations"
aria-label="Réinitialiser la sélection"
>
<X className="w-4 h-4" />
</button>
</div>
)
}

View file

@ -599,9 +599,10 @@ export function NouveauCDDUForm({
globalQuantity?: number;
globalDuration?: "3" | "4";
totalHours?: number;
totalQuantities?: number;
}) => {
// Si un nombre global est fourni, l'utiliser; sinon calculer le nombre de dates
const quantity = result.globalQuantity || result.selectedDates.length;
// Si un nombre global est fourni, l'utiliser; sinon utiliser la somme des quantités; sinon calculer le nombre de dates
const quantity = result.globalQuantity || result.totalQuantities || result.selectedDates.length;
// Convertir les dates sélectionnées en ISO (result.selectedDates sont au format "12/10, 13/10...")
const currentYearContext = dateDebut || new Date().toISOString().slice(0, 10);

View file

@ -284,7 +284,7 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
const [sortField, setSortField] = useState<string>(savedFilters?.sortField || "start_date");
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>(savedFilters?.sortOrder || 'desc');
const [page, setPage] = useState(0);
const [limit, setLimit] = useState(200); // Augmenté à 200 pour gérer plus de contrats
const [limit, setLimit] = useState(10000); // Limite élevée pour récupérer tous les contrats
const [showFilters, setShowFilters] = useState(savedFilters?.showFilters || false);
const totalCountRef = useRef<number | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
@ -1931,7 +1931,12 @@ function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract
{/* Filtres rapides toujours visibles */}
<select
value={structureFilter ?? ""}
onChange={(e) => setStructureFilter(e.target.value || null)}
onChange={(e) => {
const value = e.target.value || null;
setStructureFilter(value);
// Note: structureFilter est un nom de structure (string), pas un UUID
// On ne synchronise pas avec le store global ici car il attend des UUIDs
}}
className="rounded border px-2 py-1 text-sm max-w-[200px]"
title={structureFilter || "Toutes structures"}
>

View file

@ -5,6 +5,7 @@ import { supabase } from "@/lib/supabaseClient";
import { RefreshCw, Check, X, Bell, Trash2, Plus, Edit2, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "sonner";
import BulkNotifyModal from "./cotisations/BulkNotifyModal";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
// Utility function to format dates as DD/MM/YYYY
function formatDate(dateString: string | null | undefined): string {
@ -144,9 +145,17 @@ export default function CotisationsGrid({
// Load saved filters or use defaults
const savedFilters = loadFiltersFromStorage();
// Zustand store pour la sélection d'organisation
const { selectedOrgId, setSelectedOrg } = useStaffOrgSelection();
// Synchroniser le filtre local avec le store global quand selectedOrgId change
useEffect(() => {
setOrgFilter(selectedOrgId);
}, [selectedOrgId]);
// Filters state
const [q, setQ] = useState(savedFilters?.q || "");
const [orgFilter, setOrgFilter] = useState<string | null>(savedFilters?.orgFilter || null);
const [orgFilter, setOrgFilter] = useState<string | null>(savedFilters?.orgFilter || selectedOrgId); // Initialiser avec la sélection globale
const [statusFilter, setStatusFilter] = useState<string | null>(savedFilters?.statusFilter || null);
const [yearFilter, setYearFilter] = useState<string | null>(savedFilters?.yearFilter || null);
const [monthFilter, setMonthFilter] = useState<string | null>(savedFilters?.monthFilter || null);
@ -550,7 +559,17 @@ export default function CotisationsGrid({
{/* Quick filters always visible */}
<select
value={orgFilter ?? ""}
onChange={(e) => setOrgFilter(e.target.value || null)}
onChange={(e) => {
const value = e.target.value || null;
setOrgFilter(value);
// Synchroniser avec le store global
if (value) {
const org = organizations.find(o => o.id === value);
setSelectedOrg(value, org?.name || null);
} else {
setSelectedOrg(null, null);
}
}}
className="rounded border px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Toutes organisations</option>

View file

@ -631,7 +631,16 @@ export default function PayslipsGrid({ initialData, activeOrgId }: { initialData
</div>
<div className="flex items-center gap-2">
{/* Filtres rapides toujours visibles */}
<select value={structureFilter ?? ""} onChange={(e) => setStructureFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
<select
value={structureFilter ?? ""}
onChange={(e) => {
const value = e.target.value || null;
setStructureFilter(value);
// Note: structureFilter est un nom de structure (string), pas un UUID
// On ne synchronise pas avec le store global ici car il attend des UUIDs
}}
className="rounded border px-2 py-1 text-sm"
>
<option value="">Toutes structures</option>
{structures.map((s) => (<option key={s} value={s}>{s}</option>))}
</select>

View file

@ -8,6 +8,7 @@ import { toast } from "sonner";
import NotifyClientModal from "./salary-transfers/NotifyClientModal";
import NotifyPaymentSentModal from "./salary-transfers/NotifyPaymentSentModal";
import PayslipsSelectionModal from "./salary-transfers/PayslipsSelectionModal";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
// Utility function to format dates as DD/MM/YYYY
function formatDate(dateString: string | null | undefined): string {
@ -79,14 +80,22 @@ export default function SalaryTransfersGrid({
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
// Zustand store pour la sélection d'organisation
const { selectedOrgId, setSelectedOrg } = useStaffOrgSelection();
// Debug log pour vérifier que les organisations sont bien passées
useEffect(() => {
console.log("[SalaryTransfersGrid] Organizations received:", organizations?.length, organizations);
}, [organizations]);
// Synchroniser le filtre local avec le store global quand selectedOrgId change
useEffect(() => {
setOrgFilter(selectedOrgId);
}, [selectedOrgId]);
// filters / sorting / pagination
const [q, setQ] = useState("");
const [orgFilter, setOrgFilter] = useState<string | null>(null);
const [orgFilter, setOrgFilter] = useState<string | null>(selectedOrgId); // Initialiser avec la sélection globale
const [modeFilter, setModeFilter] = useState<string | null>(null);
const [notificationSentFilter, setNotificationSentFilter] = useState<string | null>(null);
const [notificationOkFilter, setNotificationOkFilter] = useState<string | null>(null);
@ -115,6 +124,7 @@ export default function SalaryTransfersGrid({
notes: "",
selection_mode: "period" as "period" | "manual",
payslip_ids: [] as string[],
custom_amounts: {} as Record<string, number>,
});
const [creating, setCreating] = useState(false);
@ -357,6 +367,7 @@ export default function SalaryTransfersGrid({
notes: "",
selection_mode: "period",
payslip_ids: [],
custom_amounts: {},
});
setShowCreateModal(false);
@ -1011,7 +1022,17 @@ export default function SalaryTransfersGrid({
<label className="text-xs font-medium text-slate-700">Organisation:</label>
<select
value={orgFilter ?? ""}
onChange={(e) => setOrgFilter(e.target.value || null)}
onChange={(e) => {
const value = e.target.value || null;
setOrgFilter(value);
// Synchroniser avec le store global
if (value) {
const org = organizations?.find(o => o.id === value);
setSelectedOrg(value, org?.name || null);
} else {
setSelectedOrg(null, null);
}
}}
className="rounded border px-3 py-2 text-sm bg-white min-w-[200px]"
disabled={!organizations || organizations.length === 0}
>
@ -2180,11 +2201,12 @@ export default function SalaryTransfersGrid({
setShowPayslipsModal(false);
setSelectedOrgForPayslips(null);
}}
onConfirm={(payslipIds, totalNet) => {
onConfirm={(payslipIds, totalNet, customAmounts) => {
setCreateForm({
...createForm,
payslip_ids: payslipIds,
total_net: totalNet.toFixed(2),
custom_amounts: customAmounts || {},
});
setShowPayslipsModal(false);
setSelectedOrgForPayslips(null);

View file

@ -99,8 +99,9 @@ export default function AmendmentDureeForm({
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
totalQuantities?: number;
}) => {
const nbDates = result.selectedDates.length;
const nbDates = result.totalQuantities || result.selectedDates.length;
switch (quantityModalType) {
case "representations":

View file

@ -645,9 +645,10 @@ export default function ContractEditor({
selectedDates: string[];
hasMultiMonth: boolean;
pdfFormatted: string;
totalQuantities?: number;
}) => {
// Calculer le nombre de jours/dates sélectionnées
const nbDates = result.selectedDates.length;
// Calculer le nombre de jours/dates sélectionnées ou utiliser totalQuantities si disponible
const nbDates = result.totalQuantities || result.selectedDates.length;
switch (quantityModalType) {
case "representations":
@ -1870,16 +1871,24 @@ export default function ContractEditor({
{contract.production_name} {contract.contract_number}
</p>
</div>
<div className="flex gap-2">
</div>
</header>
{/* Carte flottante avec boutons d'actions + Enregistrer */}
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-3">
<Card className="rounded-3xl shadow-lg">
<CardContent className="p-3 space-y-2">
<Button
onClick={() => setShowCancelModal(true)}
disabled={isCancelling || form.etat_de_la_demande === "Annulée"}
variant="outline"
className="rounded-2xl px-5 text-red-600 border-red-300 hover:bg-red-50"
size="sm"
className="w-auto justify-start rounded-2xl px-4 text-red-600 border-red-300 hover:bg-red-50"
>
<Ban className="size-4 mr-2" />
{form.etat_de_la_demande === "Annulée" ? "Contrat annulé" : "Annuler le contrat"}
{form.etat_de_la_demande === "Annulée" ? "Annulé" : "Annuler"}
</Button>
<Button
onClick={async () => {
setIsRefreshing(true);
@ -1897,48 +1906,46 @@ export default function ContractEditor({
}}
disabled={isRefreshing}
variant="ghost"
className="rounded-2xl px-3"
size="sm"
className="w-auto justify-start rounded-2xl px-4"
>
<RefreshCw className="size-4 mr-2" />
{isRefreshing ? 'Rafraîchissement...' : 'Régénérer'}
</Button>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button
onClick={generatePdf}
disabled={isGeneratingPdf}
variant="outline"
className="rounded-2xl px-5"
>
<FileDown className="size-4 mr-2" />
{isGeneratingPdf ? "Génération..." : "Créer le PDF"}
</Button>
<Button
onClick={generatePdf}
disabled={isGeneratingPdf}
variant="outline"
size="sm"
className="w-auto justify-start rounded-2xl px-4"
>
<FileDown className="size-4 mr-2" />
{isGeneratingPdf ? "Génération..." : "Créer PDF"}
</Button>
<Button
onClick={() => {
console.log("🖱️ [SIGNATURE] Bouton cliqué");
console.log("🖱️ [SIGNATURE] isLaunchingSignature:", isLaunchingSignature);
console.log("🖱️ [SIGNATURE] contract.contract_pdf_filename:", contract.contract_pdf_filename);
handleESignButtonClick();
}}
disabled={isLaunchingSignature || !contract.contract_pdf_filename}
variant="secondary"
className="rounded-2xl px-5"
>
<PenTool className="size-4 mr-2" />
{isLaunchingSignature ? "Lancement..." : "Lancer l'e-signature"}
</Button>
</div>
</header>
<Button
onClick={() => {
console.log("🖱️ [SIGNATURE] Bouton cliqué");
console.log("🖱️ [SIGNATURE] isLaunchingSignature:", isLaunchingSignature);
console.log("🖱️ [SIGNATURE] contract.contract_pdf_filename:", contract.contract_pdf_filename);
handleESignButtonClick();
}}
disabled={isLaunchingSignature || !contract.contract_pdf_filename}
variant="secondary"
size="sm"
className="w-auto justify-start rounded-2xl px-4"
>
<PenTool className="size-4 mr-2" />
{isLaunchingSignature ? "Lancement..." : "E-signature"}
</Button>
</CardContent>
</Card>
{/* Bouton flottant pour enregistrer */}
<div className="fixed bottom-6 right-6 z-50">
<Button
onClick={saveContract}
disabled={isSaving}
className="rounded-full shadow-lg hover:shadow-xl transition-all duration-200 px-8 py-7 bg-green-600 hover:bg-green-700 text-black text-base font-semibold ring-2 ring-green-400 ring-offset-2"
className="rounded-full shadow-lg hover:shadow-xl transition-all duration-200 px-6 py-6 bg-green-600 hover:bg-green-700 text-black text-base font-semibold ring-2 ring-green-400 ring-offset-2"
>
<Save className="size-5 mr-2 text-black" />
{isSaving ? "Sauvegarde..." : "Enregistrer"}

View file

@ -33,7 +33,7 @@ type PayslipsSelectionModalProps = {
organizationId: string;
organizationName: string;
onClose: () => void;
onConfirm: (payslipIds: string[], totalNet: number) => void;
onConfirm: (payslipIds: string[], totalNet: number, customAmounts?: Record<string, number>) => void;
};
function formatDate(dateString: string | null | undefined): string {
@ -85,6 +85,7 @@ export default function PayslipsSelectionModal({
const [payslips, setPayslips] = useState<Payslip[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [customAmounts, setCustomAmounts] = useState<Record<string, number>>({});
// Filtres
const [searchQuery, setSearchQuery] = useState("");
@ -224,14 +225,23 @@ export default function PayslipsSelectionModal({
const selectedTotal = useMemo(() => {
return filteredPayslips
.filter(p => selectedIds.has(p.id))
.reduce((sum, p) => sum + (p.net_after_withholding || p.net_amount || 0), 0);
}, [filteredPayslips, selectedIds]);
.reduce((sum, p) => {
// Utiliser le montant personnalisé s'il existe, sinon le montant de la paie
const customAmount = customAmounts[p.id];
const defaultAmount = p.net_after_withholding || p.net_amount || 0;
return sum + (customAmount !== undefined ? customAmount : defaultAmount);
}, 0);
}, [filteredPayslips, selectedIds, customAmounts]);
// Sélection/désélection
const toggleSelection = (id: string) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
// Supprimer le montant personnalisé si on désélectionne
const newCustomAmounts = { ...customAmounts };
delete newCustomAmounts[id];
setCustomAmounts(newCustomAmounts);
} else {
newSet.add(id);
}
@ -245,6 +255,20 @@ export default function PayslipsSelectionModal({
const deselectAll = () => {
setSelectedIds(new Set());
setCustomAmounts({});
};
// Mettre à jour le montant personnalisé
const updateCustomAmount = (id: string, value: string) => {
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0) {
setCustomAmounts(prev => ({ ...prev, [id]: numValue }));
} else if (value === "") {
// Si on efface le champ, supprimer l'override
const newCustomAmounts = { ...customAmounts };
delete newCustomAmounts[id];
setCustomAmounts(newCustomAmounts);
}
};
const handleConfirm = () => {
@ -252,7 +276,7 @@ export default function PayslipsSelectionModal({
toast.error("Veuillez sélectionner au moins une paie");
return;
}
onConfirm(Array.from(selectedIds), selectedTotal);
onConfirm(Array.from(selectedIds), selectedTotal, customAmounts);
};
return (
@ -423,6 +447,7 @@ export default function PayslipsSelectionModal({
<th className="text-left px-3 py-2">Période</th>
<th className="text-left px-3 py-2">Date de paie</th>
<th className="text-right px-3 py-2">Net à payer</th>
<th className="text-right px-3 py-2">Montant personnalisé</th>
<th className="text-center px-3 py-2">Statut</th>
</tr>
</thead>
@ -433,11 +458,14 @@ export default function PayslipsSelectionModal({
? `${payslip.cddu_contracts.salaries.prenom || ""} ${payslip.cddu_contracts.salaries.nom || ""}`.trim()
: payslip.cddu_contracts?.employee_name || "—";
const defaultAmount = payslip.net_after_withholding || payslip.net_amount || 0;
const customAmount = customAmounts[payslip.id];
const displayAmount = customAmount !== undefined ? customAmount : defaultAmount;
return (
<tr
key={payslip.id}
onClick={() => toggleSelection(payslip.id)}
className={`border-b cursor-pointer transition-colors ${
className={`border-b transition-colors ${
isSelected ? "bg-indigo-50 hover:bg-indigo-100" : "hover:bg-slate-50"
}`}
>
@ -466,10 +494,26 @@ export default function PayslipsSelectionModal({
<div className="text-sm">{formatDate(payslip.pay_date)}</div>
</td>
<td className="px-3 py-3 text-right">
<div className="font-semibold text-sm">
{formatAmount(payslip.net_after_withholding || payslip.net_amount)}
<div className="font-semibold text-sm text-slate-500">
{formatAmount(defaultAmount)}
</div>
</td>
<td className="px-3 py-3 text-right">
{isSelected ? (
<input
type="number"
step="0.01"
min="0"
placeholder={defaultAmount.toFixed(2)}
value={customAmount !== undefined ? customAmount : ""}
onChange={(e) => updateCustomAmount(payslip.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-28 px-2 py-1 text-sm text-right border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
) : (
<span className="text-sm text-slate-400"></span>
)}
</td>
<td className="px-3 py-3 text-center">
{payslip.processed ? (
<CheckCircle2 className="w-5 h-5 text-green-600 inline-block" />

View file

@ -0,0 +1,101 @@
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Interface pour l'état de sélection d'organisation du staff
*/
interface StaffOrgSelectionState {
/** ID de l'organisation sélectionnée (null = toutes les organisations) */
selectedOrgId: string | null
/** Nom de l'organisation sélectionnée (pour affichage dans le badge) */
selectedOrgName: string | null
/** Définir l'organisation sélectionnée */
setSelectedOrg: (id: string | null, name: string | null) => void
/** Réinitialiser la sélection (retour à "Toutes les organisations") */
clearSelection: () => void
}
/**
* Hook Zustand pour gérer la sélection d'organisation du staff
*
* Fonctionnalités :
* - État global accessible partout dans l'application
* - Persistence automatique dans localStorage
* - Synchronisation entre tous les composants
* - Validation UUID pour éviter les noms de structure
*
* Usage :
* ```tsx
* const { selectedOrgId, selectedOrgName, setSelectedOrg, clearSelection } = useStaffOrgSelection()
*
* // Sélectionner une orga
* setSelectedOrg("uuid-123", "Théâtre du Soleil")
*
* // Réinitialiser (toutes les orgas)
* clearSelection()
*
* // Filtrer des données
* const filtered = selectedOrgId
* ? data.filter(item => item.org_id === selectedOrgId)
* : data
* ```
*/
// Helper: vérifier si une string est un UUID valide
const isValidUUID = (str: string | null | undefined): boolean => {
if (!str) return false;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
export const useStaffOrgSelection = create<StaffOrgSelectionState>()(
persist(
(set, get) => ({
selectedOrgId: null,
selectedOrgName: null,
setSelectedOrg: (id, name) => {
// Valider que l'ID est bien un UUID (pas un nom de structure)
if (id && !isValidUUID(id)) {
console.warn('[useStaffOrgSelection] Invalid UUID provided:', id, '- Clearing selection');
set({ selectedOrgId: null, selectedOrgName: null });
return;
}
set({ selectedOrgId: id, selectedOrgName: name });
},
clearSelection: () => set({
selectedOrgId: null,
selectedOrgName: null
}),
}),
{
name: 'staff-org-selection', // Clé dans localStorage
// Ne persister que les données essentielles
partialize: (state) => ({
selectedOrgId: state.selectedOrgId,
selectedOrgName: state.selectedOrgName,
}),
// Migration : nettoyer les valeurs invalides
migrate: (persistedState: any, version: number) => {
if (persistedState && typeof persistedState === 'object') {
const { selectedOrgId } = persistedState;
// Si l'ID n'est pas un UUID valide, le nettoyer
if (selectedOrgId && !isValidUUID(selectedOrgId)) {
console.warn('[useStaffOrgSelection] Cleaning invalid UUID from localStorage:', selectedOrgId);
return {
selectedOrgId: null,
selectedOrgName: null,
};
}
}
return persistedState;
},
}
)
)

View file

@ -0,0 +1,27 @@
-- Migration: Ajout du champ custom_amount à salary_transfer_payslips
-- Date: 2025-12-01
-- Description: Permet de spécifier un montant personnalisé pour chaque paie dans un virement
-- 1. Ajouter le champ custom_amount
ALTER TABLE salary_transfer_payslips
ADD COLUMN IF NOT EXISTS custom_amount DECIMAL(10, 2);
-- 2. Commentaire pour documenter le champ
COMMENT ON COLUMN salary_transfer_payslips.custom_amount IS
'Montant personnalisé pour cette paie dans le virement. Si NULL, le montant de la paie (net_after_withholding ou net_amount) sera utilisé.';
-- 3. Vérification
DO $$
BEGIN
-- Vérifier que la colonne custom_amount existe
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'salary_transfer_payslips'
AND column_name = 'custom_amount'
) THEN
RAISE EXCEPTION 'Colonne custom_amount non créée';
END IF;
RAISE NOTICE 'Migration réussie: custom_amount ajouté à salary_transfer_payslips';
END;
$$;

View file

@ -0,0 +1,33 @@
-- Migration pour corriger les contrats où structure = production_name
-- Ces contrats ont été créés/modifiés avec un bug qui mettait la production dans structure
-- Mettre à jour le contrat "La Nuit avant Noël" spécifique
UPDATE cddu_contracts
SET structure = organizations.name
FROM organizations
WHERE cddu_contracts.org_id = organizations.id
AND cddu_contracts.id = '9240863d-be8a-449a-918f-68a77eff3bed'
AND cddu_contracts.structure = 'La Nuit avant Noël';
-- Corriger tous les autres contrats potentiellement affectés
-- (où structure = production_name et structure != organization.name)
UPDATE cddu_contracts
SET structure = organizations.name
FROM organizations
WHERE cddu_contracts.org_id = organizations.id
AND cddu_contracts.structure = cddu_contracts.production_name
AND cddu_contracts.structure != organizations.name;
-- Vérification: afficher les contrats mis à jour
SELECT
id,
contract_number,
structure,
production_name,
org_id,
employee_name,
start_date
FROM cddu_contracts
WHERE structure != (SELECT name FROM organizations WHERE id = cddu_contracts.org_id)
ORDER BY created_at DESC
LIMIT 20;

32
package-lock.json generated
View file

@ -55,7 +55,8 @@
"sharp": "^0.34.4",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"use-debounce": "^10.0.6"
"use-debounce": "^10.0.6",
"zustand": "^5.0.8"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
@ -12483,6 +12484,35 @@
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View file

@ -60,7 +60,8 @@
"sharp": "^0.34.4",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"use-debounce": "^10.0.6"
"use-debounce": "^10.0.6",
"zustand": "^5.0.8"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",