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:
parent
8ba984af1d
commit
266eb3598a
32 changed files with 964 additions and 106 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 d’info 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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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="">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
44
components/StaffOrgBadge.tsx
Normal file
44
components/StaffOrgBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
101
hooks/useStaffOrgSelection.ts
Normal file
101
hooks/useStaffOrgSelection.ts
Normal 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;
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
27
migrations/add_custom_amount_to_salary_transfer_payslips.sql
Normal file
27
migrations/add_custom_amount_to_salary_transfer_payslips.sql
Normal 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;
|
||||
$$;
|
||||
33
migrations/fix_structure_field_from_production.sql
Normal file
33
migrations/fix_structure_field_from_production.sql
Normal 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
32
package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue