espace-paie-odentas/components/staff/ContractsGrid.tsx

1923 lines
74 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import Link from "next/link";
import { RefreshCw, FileDown, Eye, FileSignature } from "lucide-react";
import { toast } from "sonner";
import BulkPdfProgressModal from "./BulkPdfProgressModal";
import PdfVerificationModal from "./PdfVerificationModal";
import BulkESignProgressModal from "./BulkESignProgressModal";
import BulkESignConfirmModal from "./BulkESignConfirmModal";
// Utility function to format dates as DD/MM/YYYY
function formatDate(dateString: string | null | undefined): string {
if (!dateString) return "—";
try {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} catch {
return "—";
}
}
// Utility function to format employee name as "NOM Prénom"
// Priorité : utiliser salaries.salarie si disponible, sinon formater employee_name
function formatEmployeeName(contract: { employee_name?: string | null; salaries?: { salarie?: string | null; nom?: string | null; prenom?: string | null; } | null }): string {
// 1. Priorité : utiliser salaries.salarie (toujours à jour)
if (contract.salaries?.salarie) {
return contract.salaries.salarie;
}
// 2. Construire depuis nom + prénom si disponibles
if (contract.salaries?.nom || contract.salaries?.prenom) {
const nom = (contract.salaries.nom || '').toUpperCase().trim();
const prenom = (contract.salaries.prenom || '').trim();
return [nom, prenom].filter(Boolean).join(' ');
}
// 3. Fallback : utiliser employee_name (ancien format)
if (!contract.employee_name) return "—";
const parts = contract.employee_name.trim().split(' ');
if (parts.length < 2) return contract.employee_name;
const prenom = parts[0];
const nom = parts.slice(1).join(' ');
return `${nom.toUpperCase()} ${prenom}`;
}
// Utility function to extract last name for sorting
// Priorité : utiliser salaries.nom si disponible, sinon parser employee_name
function getLastName(contract: { employee_name?: string | null; salaries?: { nom?: string | null } | null }): string {
// 1. Priorité : utiliser salaries.nom (toujours à jour)
if (contract.salaries?.nom) {
return contract.salaries.nom.toUpperCase();
}
// 2. Fallback : parser employee_name
if (!contract.employee_name) return "";
const parts = contract.employee_name.trim().split(' ');
if (parts.length < 2) return contract.employee_name;
return parts.slice(1).join(' ');
}
type Contract = {
id: string;
contract_number?: string | null;
employee_name?: string | null;
employee_id?: string | null;
structure?: string | null;
type_de_contrat?: string | null;
start_date?: string | null;
end_date?: string | null;
created_at?: string | null;
etat_demande?: string | null;
etat_de_la_demande?: string | null;
etat_de_la_paie?: string | null;
dpae?: string | null;
gross_pay?: number | null;
org_id?: string | null;
salaries?: {
salarie?: string | null;
nom?: string | null;
prenom?: string | null;
} | null;
};
type ContractProgress = {
id: string;
contractNumber?: string;
employeeName?: string;
status: 'pending' | 'processing' | 'success' | 'error';
error?: string;
filename?: string;
};
type ContractPdf = {
id: string;
contractNumber?: string;
employeeName?: string;
pdfUrl?: string;
hasError: boolean;
errorMessage?: string;
};
export default function ContractsGrid({ initialData, activeOrgId }: { initialData: Contract[]; activeOrgId?: string | null }) {
const [rows, setRows] = useState<Contract[]>(initialData || []);
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
// Selection state
const [selectedContractIds, setSelectedContractIds] = useState<Set<string>>(new Set());
// Key for localStorage
const FILTERS_STORAGE_KEY = 'staff-contracts-filters';
// Helper functions for localStorage
const saveFiltersToStorage = (filters: any) => {
try {
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
} catch (error) {
console.warn('Failed to save filters to localStorage:', error);
}
};
const loadFiltersFromStorage = () => {
try {
const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (error) {
console.warn('Failed to load filters from localStorage:', error);
return null;
}
};
// Load saved filters or use defaults
const savedFilters = loadFiltersFromStorage();
// filters / sorting / pagination - with localStorage persistence
const [q, setQ] = useState(savedFilters?.q || "");
const [structureFilter, setStructureFilter] = useState<string | null>(savedFilters?.structureFilter || null);
const [typeFilter, setTypeFilter] = useState<string | null>(savedFilters?.typeFilter || null);
const [etatContratFilter, setEtatContratFilter] = useState<string | null>(savedFilters?.etatContratFilter || null);
const [etatPaieFilter, setEtatPaieFilter] = useState<string | null>(savedFilters?.etatPaieFilter || null);
const [dpaeFilter, setDpaeFilter] = useState<string | null>(savedFilters?.dpaeFilter || null);
const [startFrom, setStartFrom] = useState<string | null>(savedFilters?.startFrom || null);
const [startTo, setStartTo] = useState<string | null>(savedFilters?.startTo || null);
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(50);
const [showFilters, setShowFilters] = useState(savedFilters?.showFilters || false);
const totalCountRef = useRef<number | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// Selection helpers
const selectedContracts = rows.filter(contract => selectedContractIds.has(contract.id));
const isAllSelected = rows.length > 0 && selectedContractIds.size === rows.length;
const isPartiallySelected = selectedContractIds.size > 0 && selectedContractIds.size < rows.length;
// Modal states
const [showDpaeModal, setShowDpaeModal] = useState(false);
const [showEtatContratModal, setShowEtatContratModal] = useState(false);
const [showEtatPaieModal, setShowEtatPaieModal] = useState(false);
const [showSalaryModal, setShowSalaryModal] = useState(false);
const [showActionMenu, setShowActionMenu] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
// PDF generation state
const [isGeneratingPdfs, setIsGeneratingPdfs] = useState(false);
const [showProgressModal, setShowProgressModal] = useState(false);
const [contractsProgress, setContractsProgress] = useState<ContractProgress[]>([]);
const [isCancelled, setIsCancelled] = useState(false);
// PDF verification state
const [showPdfVerificationModal, setShowPdfVerificationModal] = useState(false);
const [contractsPdfs, setContractsPdfs] = useState<ContractPdf[]>([]);
const [isLoadingPdfs, setIsLoadingPdfs] = useState(false);
// E-Sign state
const [isGeneratingESign, setIsGeneratingESign] = useState(false);
const [showESignConfirmModal, setShowESignConfirmModal] = useState(false);
const [showESignProgressModal, setShowESignProgressModal] = useState(false);
const [contractsESignProgress, setContractsESignProgress] = useState<ContractProgress[]>([]);
const [isESignCancelled, setIsESignCancelled] = useState(false);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = {
q,
structureFilter,
typeFilter,
etatContratFilter,
etatPaieFilter,
dpaeFilter,
startFrom,
startTo,
sortField,
sortOrder,
showFilters
};
saveFiltersToStorage(filters);
}, [q, structureFilter, typeFilter, etatContratFilter, etatPaieFilter, dpaeFilter, startFrom, startTo, sortField, sortOrder, showFilters]);
// optimistic update helper
const selectedRow = useMemo(() => rows.find((r) => r.id === selectedId) ?? null, [rows, selectedId]);
// Realtime subscription: listen to INSERT / UPDATE / DELETE on contracts
useEffect(() => {
// Debug: log incoming initialData when component mounts/hydrates
try {
console.log("ContractsGrid initialData (client):", Array.isArray(initialData) ? initialData.length : typeof initialData, initialData?.slice?.(0, 5));
} catch (err) {
console.log("ContractsGrid initialData (client) - could not log:", err);
}
let channel: any = null;
let mounted = true;
(async () => {
try {
channel = supabase.channel("public:cddu_contracts");
channel.on(
"postgres_changes",
{ event: "*", schema: "public", table: "cddu_contracts" },
(payload: any) => {
try {
const event = payload.event || payload.eventType || payload.type;
const record = payload.new ?? payload.record ?? payload.payload ?? payload;
if (event === "INSERT") {
const newRec = record as Contract;
setRows((rs) => {
if (rs.find((r) => r.id === newRec.id)) return rs;
return [newRec, ...rs];
});
} else if (event === "UPDATE") {
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as Contract) } : r)));
} else if (event === "DELETE") {
const id = record?.id ?? payload.old?.id;
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
}
} catch (err) {
console.error("Realtime handler error", err);
}
}
);
// subscribe and await result (some SDKs return a promise)
const sub = await channel.subscribe();
// If subscribe returned an object with error info, warn and stop
if (!mounted) return;
if (sub && (sub.status === "timed_out" || sub.status === "closed" || sub?.error)) {
console.warn("Realtime subscribe returned unexpected status", sub);
}
} catch (err: any) {
// Common cause: table not published for realtime / not in schema cache
console.warn("Realtime subscription failed for public.cddu_contracts — falling back to polling.", err?.message ?? err);
}
})();
return () => {
mounted = false;
try {
if (channel) {
// supabase.removeChannel exists in v2
// @ts-ignore
if (supabase.removeChannel) supabase.removeChannel(channel);
else channel.unsubscribe && channel.unsubscribe();
}
} catch (err) {
console.warn("Error unsubscribing realtime channel", err);
}
};
}, []);
// Helper: fetch server-side with current filters
async function fetchServer(pageIndex = 0) {
setLoading(true);
try {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (structureFilter) params.set('structure', structureFilter);
if (typeFilter) params.set('type_de_contrat', typeFilter);
if (etatContratFilter) params.set('etat_de_la_demande', etatContratFilter);
if (etatPaieFilter) params.set('etat_de_la_paie', etatPaieFilter);
if (dpaeFilter) params.set('dpae', dpaeFilter);
if (startFrom) params.set('start_from', startFrom);
if (startTo) params.set('start_to', startTo);
params.set('sort', sortField);
params.set('order', sortOrder === 'asc' ? 'asc' : 'desc');
params.set('limit', String(limit));
params.set('offset', String(pageIndex * limit));
const res = await fetch(`/api/staff/contracts/search?${params.toString()}`);
if (!res.ok) throw new Error('Search failed');
const j = await res.json();
totalCountRef.current = j.count ?? (j.rows ? j.rows.length : 0);
setRows(j.rows ?? []);
setPage(pageIndex);
} catch (err) {
console.error('Search error', err);
} finally {
setLoading(false);
}
}
// Manual refresh function for the refresh button
const handleRefresh = () => {
fetchServer(0);
};
// Reset selection when data changes
useEffect(() => {
setSelectedContractIds(new Set());
}, [rows]);
// Client-side sorting function
const sortContractsLocally = (contracts: Contract[], field: string, order: 'asc' | 'desc'): Contract[] => {
return [...contracts].sort((a, b) => {
let valueA: any;
let valueB: any;
switch (field) {
case 'employee_name':
// Tri par nom de famille
valueA = getLastName(a);
valueB = getLastName(b);
break;
case 'start_date':
case 'end_date':
case 'created_at':
valueA = a[field as keyof Contract] ? new Date(a[field as keyof Contract] as string) : new Date(0);
valueB = b[field as keyof Contract] ? new Date(b[field as keyof Contract] as string) : new Date(0);
break;
default:
valueA = a[field as keyof Contract] || '';
valueB = b[field as keyof Contract] || '';
}
if (typeof valueA === 'string') valueA = valueA.toLowerCase();
if (typeof valueB === 'string') valueB = valueB.toLowerCase();
if (valueA < valueB) return order === 'asc' ? -1 : 1;
if (valueA > valueB) return order === 'asc' ? 1 : -1;
return 0;
});
};
// Apply local sorting when using initial data
const sortedRows = useMemo(() => {
const noFilters = !q && !structureFilter && !typeFilter && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo;
if (noFilters) {
// Utiliser le tri local pour les données initiales
return sortContractsLocally(rows, sortField, sortOrder);
}
// Pour les données filtrées, utiliser les données telles qu'elles viennent du serveur
return rows;
}, [rows, sortField, sortOrder, q, structureFilter, typeFilter, etatContratFilter, etatPaieFilter, dpaeFilter, startFrom, startTo]);
// Selection functions (updated to use sortedRows for selection)
const toggleSelectAll = () => {
if (isAllSelected) {
setSelectedContractIds(new Set());
} else {
setSelectedContractIds(new Set(sortedRows.map(r => r.id)));
}
};
const toggleSelectContract = (contractId: string) => {
setSelectedContractIds(prev => {
const newSet = new Set(prev);
if (newSet.has(contractId)) {
newSet.delete(contractId);
} else {
newSet.add(contractId);
}
return newSet;
});
};
// Function to generate PDFs for selected contracts individually
const generateBatchPdfs = async () => {
if (selectedContractIds.size === 0) {
toast.error("Aucun contrat sélectionné");
return;
}
const contractIds = Array.from(selectedContractIds);
// Initialiser les contrats avec leurs informations
const initialProgress: ContractProgress[] = contractIds.map(id => {
const contract = rows.find(r => r.id === id);
return {
id,
contractNumber: contract?.contract_number || undefined,
employeeName: contract?.employee_name || undefined,
status: 'pending' as const
};
});
// Ouvrir le modal et initialiser les états
setContractsProgress(initialProgress);
setShowProgressModal(true);
setIsGeneratingPdfs(true);
setIsCancelled(false);
let successCount = 0;
let errorCount = 0;
try {
// Traiter chaque contrat individuellement
for (let i = 0; i < contractIds.length; i++) {
if (isCancelled) {
// Si annulé, marquer tous les contrats restants comme "en attente"
break;
}
const contractId = contractIds[i];
// Marquer le contrat comme "en cours"
setContractsProgress(prev =>
prev.map(c =>
c.id === contractId
? { ...c, status: 'processing' as const }
: c
)
);
try {
// Appeler l'endpoint individuel existant
const response = await fetch(`/api/contrats/${contractId}/generate-pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const result = await response.json();
successCount++;
// Marquer comme succès
setContractsProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'success' as const,
filename: result.filename
}
: c
)
);
} else {
const errorData = await response.json();
errorCount++;
// Marquer comme erreur
setContractsProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'error' as const,
error: errorData.error || `Erreur ${response.status}`
}
: c
)
);
}
} catch (error) {
errorCount++;
// Marquer comme erreur
setContractsProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'error' as const,
error: error instanceof Error ? error.message : 'Erreur de réseau'
}
: c
)
);
}
// Petit délai entre chaque contrat pour éviter la surcharge
if (i < contractIds.length - 1 && !isCancelled) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
// Message final si non annulé
if (!isCancelled) {
if (successCount > 0) {
toast.success(`${successCount} PDF${successCount > 1 ? 's' : ''} créé${successCount > 1 ? 's' : ''} avec succès !`);
}
if (errorCount > 0) {
toast.error(`${errorCount} erreur${errorCount > 1 ? 's' : ''} lors de la génération`);
}
// Déselectionner les contrats
setSelectedContractIds(new Set());
// Rafraîchir après un délai
setTimeout(() => {
window.location.reload();
}, 3000);
}
} catch (error) {
console.error("Erreur lors de la génération des PDFs:", error);
toast.error("Erreur lors de la génération des PDFs");
} finally {
setIsGeneratingPdfs(false);
}
};
// Fonction pour annuler la génération
const cancelPdfGeneration = () => {
setIsCancelled(true);
setIsGeneratingPdfs(false);
toast.info("Génération annulée");
};
// Fonction pour fermer le modal
const closeProgressModal = () => {
setShowProgressModal(false);
setContractsProgress([]);
setIsCancelled(false);
};
// Fonction pour vérifier les PDFs des contrats sélectionnés
const verifySelectedPdfs = async () => {
if (selectedContractIds.size === 0) {
toast.error("Aucun contrat sélectionné");
return;
}
const contractIds = Array.from(selectedContractIds);
// Ouvrir le modal et commencer le chargement
setShowPdfVerificationModal(true);
setIsLoadingPdfs(true);
setContractsPdfs([]);
try {
// Récupérer les URLs des PDFs pour chaque contrat
const pdfPromises = contractIds.map(async (contractId) => {
const contract = rows.find(r => r.id === contractId);
try {
const response = await fetch(`/api/contrats/${contractId}/pdf-url`);
if (response.ok) {
const result = await response.json();
return {
id: contractId,
contractNumber: contract?.contract_number || undefined,
employeeName: contract?.employee_name || undefined,
pdfUrl: result.pdfUrl,
hasError: false
} as ContractPdf;
} else {
const errorData = await response.json();
return {
id: contractId,
contractNumber: contract?.contract_number || undefined,
employeeName: contract?.employee_name || undefined,
pdfUrl: undefined,
hasError: true,
errorMessage: errorData.error || 'PDF non trouvé'
} as ContractPdf;
}
} catch (error) {
return {
id: contractId,
contractNumber: contract?.contract_number || undefined,
employeeName: contract?.employee_name || undefined,
pdfUrl: undefined,
hasError: true,
errorMessage: 'Erreur de réseau'
} as ContractPdf;
}
});
const pdfs = await Promise.all(pdfPromises);
setContractsPdfs(pdfs);
const successCount = pdfs.filter(pdf => !pdf.hasError).length;
const errorCount = pdfs.filter(pdf => pdf.hasError).length;
if (successCount > 0) {
toast.success(`${successCount} PDF${successCount > 1 ? 's' : ''} chargé${successCount > 1 ? 's' : ''}`);
}
if (errorCount > 0) {
toast.warning(`${errorCount} PDF${errorCount > 1 ? 's' : ''} non disponible${errorCount > 1 ? 's' : ''}`);
}
} catch (error) {
console.error("Erreur lors du chargement des PDFs:", error);
toast.error("Erreur lors du chargement des PDFs");
} finally {
setIsLoadingPdfs(false);
}
};
// Fonction pour fermer le modal de vérification PDF
const closePdfVerificationModal = () => {
setShowPdfVerificationModal(false);
setContractsPdfs([]);
setIsLoadingPdfs(false);
};
// Fonction pour ouvrir le modal de confirmation
const handleBulkESignClick = () => {
if (selectedContractIds.size === 0) {
toast.error("Aucun contrat sélectionné");
return;
}
setShowESignConfirmModal(true);
};
// Fonction pour générer les e-signatures en masse (après confirmation)
const generateBatchESign = async (sendNotification: boolean) => {
// Fermer le modal de confirmation
setShowESignConfirmModal(false);
const contractIds = Array.from(selectedContractIds);
// Initialiser les contrats avec leurs informations
const initialProgress: ContractProgress[] = contractIds.map(id => {
const contract = rows.find(r => r.id === id);
return {
id,
contractNumber: contract?.contract_number || undefined,
employeeName: contract?.employee_name || undefined,
status: 'pending' as const
};
});
// Ouvrir le modal et initialiser les états
setContractsESignProgress(initialProgress);
setShowESignProgressModal(true);
setIsGeneratingESign(true);
setIsESignCancelled(false);
let successCount = 0;
let errorCount = 0;
const successfulContracts: any[] = [];
try {
// Traiter chaque contrat individuellement
for (let i = 0; i < contractIds.length; i++) {
if (isESignCancelled) {
break;
}
const contractId = contractIds[i];
// Marquer le contrat comme "en cours"
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? { ...c, status: 'processing' as const }
: c
)
);
try {
// Appeler l'endpoint de signature individuel via l'API bulk
const response = await fetch(`/api/staff/contracts/bulk-esign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ contractIds: [contractId] }),
});
if (response.ok) {
const result = await response.json();
const contractResult = result.results?.[0];
if (contractResult?.success) {
successCount++;
// Utiliser les données du résultat de l'API qui contient org_id
const contract = rows.find(r => r.id === contractId);
successfulContracts.push({
...contract,
org_id: contractResult.org_id // Utiliser l'org_id de la réponse API
});
// Marquer comme succès
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'success' as const,
filename: contractResult.submissionId
}
: c
)
);
} else {
errorCount++;
// Marquer comme erreur
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'error' as const,
error: contractResult?.error || 'Erreur inconnue'
}
: c
)
);
}
} else {
const errorData = await response.json();
errorCount++;
// Marquer comme erreur
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'error' as const,
error: errorData.error || `Erreur ${response.status}`
}
: c
)
);
}
} catch (error) {
errorCount++;
// Marquer comme erreur
setContractsESignProgress(prev =>
prev.map(c =>
c.id === contractId
? {
...c,
status: 'error' as const,
error: error instanceof Error ? error.message : 'Erreur de réseau'
}
: c
)
);
}
// Petit délai entre chaque contrat
if (i < contractIds.length - 1 && !isESignCancelled) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Message final et envoi du mail récapitulatif si non annulé
if (!isESignCancelled) {
if (successCount > 0) {
toast.success(`${successCount} signature${successCount > 1 ? 's' : ''} électronique${successCount > 1 ? 's' : ''} créée${successCount > 1 ? 's' : ''} !`);
// Envoyer un mail récapitulatif au client (email_notifs + CC) si demandé
if (sendNotification && successfulContracts.length > 0) {
try {
console.log('📧 [E-SIGN] Début envoi emails récapitulatifs...');
console.log('📧 [E-SIGN] successfulContracts:', successfulContracts);
// Regrouper les contrats par organisation
const contractsByOrg = successfulContracts.reduce((acc: any, contract: any) => {
const orgId = contract.org_id;
console.log('📧 [E-SIGN] Contract:', contract.id, 'org_id:', orgId);
if (!acc[orgId]) {
acc[orgId] = [];
}
acc[orgId].push(contract);
return acc;
}, {});
console.log('📧 [E-SIGN] Contrats groupés par organisation:', Object.keys(contractsByOrg).length);
// Envoyer un email récapitulatif pour chaque organisation
for (const [orgId, contracts] of Object.entries(contractsByOrg)) {
const count = (contracts as any[]).length;
console.log(`📧 [E-SIGN] Envoi email pour org ${orgId}, ${count} contrats`);
const response = await fetch('/api/staff/contracts/send-esign-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
organizationId: orgId,
contractCount: count
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error('❌ [E-SIGN] Erreur envoi email:', errorData);
throw new Error(errorData.error || `Erreur ${response.status}`);
}
const result = await response.json();
console.log('✅ [E-SIGN] Email envoyé:', result);
}
toast.success('Email récapitulatif envoyé au client');
} catch (emailError) {
console.error("Erreur lors de l'envoi du mail récapitulatif:", emailError);
toast.warning("Signatures créées mais erreur lors de l'envoi de l'email récapitulatif");
}
} else if (!sendNotification) {
console.log('📧 [E-SIGN] Email récapitulatif désactivé par l\'utilisateur');
}
}
if (errorCount > 0) {
toast.error(`${errorCount} erreur${errorCount > 1 ? 's' : ''} lors de la création des signatures`);
}
// Déselectionner les contrats après succès
if (successCount > 0) {
setSelectedContractIds(new Set());
}
}
} catch (error) {
console.error("Erreur lors de la génération des e-signatures:", error);
toast.error("Erreur lors de la génération des e-signatures");
} finally {
setIsGeneratingESign(false);
}
};
// Fonction pour annuler la génération e-sign
const cancelESignGeneration = () => {
setIsESignCancelled(true);
setIsGeneratingESign(false);
toast.info("Génération annulée");
};
// Fonction pour fermer le modal e-sign
const closeESignProgressModal = () => {
setShowESignProgressModal(false);
setContractsESignProgress([]);
setIsESignCancelled(false);
};
// Debounce searches when filters change
useEffect(() => {
// if no filters applied, prefer initial data
const noFilters = !q && !structureFilter && !typeFilter && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo && sortField === 'start_date' && sortOrder === 'desc';
if (noFilters) {
setRows(initialData || []);
return;
}
const t = setTimeout(() => fetchServer(0), 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, structureFilter, typeFilter, etatContratFilter, etatPaieFilter, dpaeFilter, startFrom, startTo, sortField, sortOrder, limit]);
// derive options from initialData for simple selects
const structures = useMemo(() => {
const uniqueStructures = Array.from(new Set((initialData || []).map((r) => r.structure).filter(Boolean) as string[]));
return uniqueStructures.sort((a, b) => a.localeCompare(b, 'fr')).slice(0, 50);
}, [initialData]);
const types = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.type_de_contrat).filter(Boolean) as string[])).slice(0,50), [initialData]);
const etatsContrat = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.etat_de_la_demande).filter(Boolean) as string[])).slice(0,50), [initialData]);
const etatsPaie = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.etat_de_la_paie).filter(Boolean) as string[])).slice(0,50), [initialData]);
const etatsDpae = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.dpae).filter(Boolean) as string[])).slice(0,50), [initialData]);
return (
<div className="relative">
{/* Filters */}
<div className="mb-3">
{/* Ligne du haut: recherche + bouton filtres */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
<div className="flex-1">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Recherche (n° contrat, salarié)"
className="w-full rounded border px-2 py-1 text-sm"
/>
</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">
<option value="">Toutes structures</option>
{structures.map((s) => (<option key={s} value={s}>{s}</option>))}
</select>
<select value={typeFilter ?? ""} onChange={(e) => setTypeFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
<option value="">Tous types</option>
<option value="CDD d'usage">CDDU</option>
<option value="RG">RG (CDD/CDI droit commun)</option>
{types.filter(t => t !== "CDD d'usage" && t !== "CDD de droit commun" && t !== "CDI").map((t) => (<option key={t} value={t}>{t}</option>))}
</select>
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 px-2 py-1 rounded border"
>
{showFilters ? "Masquer" : "Plus de"} filtres
</button>
<button
onClick={handleRefresh}
disabled={loading}
className="inline-flex items-center gap-1 text-sm text-emerald-600 hover:text-emerald-800 px-2 py-1 rounded border disabled:opacity-50 disabled:cursor-not-allowed"
title="Actualiser les données"
>
<RefreshCw className={`w-3 h-3 ${loading ? 'animate-spin' : ''}`} />
Actualiser
</button>
<button
className="rounded border px-3 py-1 text-sm"
onClick={() => {
setQ('');
setStructureFilter(null);
setTypeFilter(null);
setEtatContratFilter(null);
setEtatPaieFilter(null);
setDpaeFilter(null);
setStartFrom(null);
setStartTo(null);
setSortField('start_date');
setSortOrder('desc');
setRows(initialData || []);
}}
>
Réinitialiser
</button>
</div>
</div>
{/* Filtres avancés (masquables) */}
{showFilters && (
<div className="border rounded-lg p-4 bg-slate-50">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Filtre État contrat */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
État contrat
</label>
<select
value={etatContratFilter ?? ""}
onChange={(e) => setEtatContratFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous les états</option>
{etatsContrat.map((etat) => (
<option key={etat} value={etat}>{etat}</option>
))}
</select>
</div>
{/* Filtre État paie */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
État paie
</label>
<select
value={etatPaieFilter ?? ""}
onChange={(e) => setEtatPaieFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous les états</option>
{etatsPaie.map((etat) => (
<option key={etat} value={etat}>{etat}</option>
))}
</select>
</div>
{/* Filtre DPAE */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
DPAE
</label>
<select
value={dpaeFilter ?? ""}
onChange={(e) => setDpaeFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous les états</option>
<option value="À faire">À faire</option>
<option value="Faite">Faite</option>
</select>
</div>
{/* Filtre date de début */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Date début - De
</label>
<input
type="date"
value={startFrom ?? ""}
onChange={(e) => setStartFrom(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
/>
</div>
{/* Filtre date de fin */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Date début - À
</label>
<input
type="date"
value={startTo ?? ""}
onChange={(e) => setStartTo(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
/>
</div>
</div>
</div>
)}
</div>
{/* Boutons d'actions groupées */}
{selectedContractIds.size > 0 && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-blue-800 font-medium">
{selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''} sélectionné{selectedContractIds.size > 1 ? 's' : ''}
</span>
<div className="flex gap-2">
{/* Menu dropdown pour les actions groupées */}
<div className="relative">
<button
onClick={() => setShowActionMenu(!showActionMenu)}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors flex items-center gap-1"
>
Action groupée
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showActionMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowActionMenu(false)}
/>
<div className="absolute right-0 mt-1 w-48 bg-white rounded-md shadow-lg z-20 border border-gray-200">
<div className="py-1">
<button
onClick={() => {
setShowDpaeModal(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
Modifier DPAE
</button>
<button
onClick={() => {
setShowEtatContratModal(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
Modifier État Contrat
</button>
<button
onClick={() => {
setShowEtatPaieModal(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
Modifier État Paie
</button>
<div className="border-t border-gray-200 my-1"></div>
<button
onClick={() => {
setShowDeleteModal(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Supprimer les contrats
</button>
</div>
</div>
</>
)}
</div>
<button
onClick={() => setShowSalaryModal(true)}
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
Saisir brut
</button>
<button
onClick={generateBatchPdfs}
disabled={isGeneratingPdfs}
className="px-3 py-1 text-sm bg-orange-600 text-white rounded hover:bg-orange-700 transition-colors disabled:bg-orange-400 flex items-center gap-1"
>
<FileDown className="size-3" />
{isGeneratingPdfs ? "Génération..." : "Créer les PDF"}
</button>
<button
onClick={verifySelectedPdfs}
disabled={isLoadingPdfs}
className="px-3 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors disabled:bg-purple-400 flex items-center gap-1"
>
<Eye className="size-3" />
{isLoadingPdfs ? "Chargement..." : "Vérifier PDFs"}
</button>
<button
onClick={handleBulkESignClick}
disabled={isGeneratingESign}
className="px-3 py-1 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors disabled:bg-indigo-400 flex items-center gap-1"
>
<FileSignature className="size-3" />
{isGeneratingESign ? "Envoi..." : "Envoyer e-sign"}
</button>
<button
onClick={() => setSelectedContractIds(new Set())}
className="px-3 py-1 text-sm bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
>
Désélectionner tout
</button>
</div>
</div>
</div>
)}
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-3 py-2 w-12">
<input
type="checkbox"
checked={isAllSelected}
ref={(el) => {
if (el) el.indeterminate = isPartiallySelected;
}}
onChange={toggleSelectAll}
className="rounded border-gray-300"
/>
</th>
<th className="text-left px-3 py-2">État contrat</th>
<th className="text-left px-3 py-2">État paie</th>
<th className="text-left px-3 py-2">DPAE</th>
<th className="text-left px-3 py-2">N° contrat</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('employee_name'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Salarié {sortField === 'employee_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2">Structure</th>
<th className="text-left px-3 py-2">Type</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('start_date'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Date début {sortField === 'start_date' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('end_date'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Date fin {sortField === 'end_date' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('created_at'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
Créé le {sortField === 'created_at' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
</tr>
</thead>
<tbody>
{sortedRows.map((r) => (
<tr key={r.id} className={`border-t ${selectedContractIds.has(r.id) ? 'bg-blue-50' : ''}`}>
<td className="px-3 py-2">
<input
type="checkbox"
checked={selectedContractIds.has(r.id)}
onChange={() => toggleSelectContract(r.id)}
className="rounded border-gray-300"
/>
</td>
<td className="px-3 py-2">
{(() => {
const s = String(r.etat_de_la_demande || r.etat_demande || "").toLowerCase();
const map: Record<string, { label: string; className: string }> = {
receptee: { label: "Reçue", className: "bg-sky-100 text-sky-800" },
recue: { label: "Reçue", className: "bg-sky-100 text-sky-800" },
"reçue": { label: "Reçue", className: "bg-sky-100 text-sky-800" },
"en cours": { label: "En cours", className: "bg-amber-100 text-amber-800" },
encours: { label: "En cours", className: "bg-amber-100 text-amber-800" },
traitee: { label: "Traitée", className: "bg-emerald-100 text-emerald-800" },
"traitée": { label: "Traitée", className: "bg-emerald-100 text-emerald-800" },
annulee: { label: "Annulée", className: "bg-rose-100 text-rose-800" },
"annulée": { label: "Annulée", className: "bg-rose-100 text-rose-800" },
};
const key = Object.keys(map).find((k) => s.includes(k)) || '';
const badge = map[key] ?? { label: (r.etat_de_la_demande ?? r.etat_demande) ?? '—', className: 'bg-slate-100 text-slate-700' };
return (<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium whitespace-nowrap ${badge.className}`}>{badge.label}</span>);
})()}
</td>
<td className="px-3 py-2">
{(() => {
const s = String(r.etat_de_la_paie || "").toLowerCase();
const map: Record<string, { label: string; className: string }> = {
"à traiter": { label: "À traiter", className: "bg-orange-100 text-orange-800" },
"a traiter": { label: "À traiter", className: "bg-orange-100 text-orange-800" },
traiter: { label: "À traiter", className: "bg-orange-100 text-orange-800" },
"en cours": { label: "En cours", className: "bg-blue-100 text-blue-800" },
"en_cours": { label: "En cours", className: "bg-blue-100 text-blue-800" },
encours: { label: "En cours", className: "bg-blue-100 text-blue-800" },
cours: { label: "En cours", className: "bg-blue-100 text-blue-800" },
"traitée": { label: "Traitée", className: "bg-green-100 text-green-800" },
"traitee": { label: "Traitée", className: "bg-green-100 text-green-800" },
traite: { label: "Traitée", className: "bg-green-100 text-green-800" },
};
const key = Object.keys(map).find((k) => s.includes(k)) || '';
const badge = map[key] ?? { label: r.etat_de_la_paie ?? '—', className: 'bg-slate-100 text-slate-700' };
return (<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium whitespace-nowrap ${badge.className}`}>{badge.label}</span>);
})()}
</td>
<td className="px-3 py-2">
{(() => {
const dpae = r.dpae || "";
const map: Record<string, { label: string; className: string }> = {
"À faire": { label: "À faire", className: "bg-orange-100 text-orange-800" },
"Faite": { label: "Faite", className: "bg-green-100 text-green-800" },
};
const badge = map[dpae] ?? { label: dpae || '—', className: 'bg-slate-100 text-slate-700' };
return (<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium whitespace-nowrap ${badge.className}`}>{badge.label}</span>);
})()}
</td>
<td className="px-3 py-2">
<Link href={`/staff/contrats/${r.id}`}>{r.contract_number || "—"}</Link>
</td>
<td className="px-3 py-2">{formatEmployeeName(r)}</td>
<td className="px-3 py-2">{r.structure || "—"}</td>
<td className="px-3 py-2">{
r.type_de_contrat === "CDD d'usage" ? "CDDU" : (r.type_de_contrat || "—")
}</td>
<td className="px-3 py-2">{formatDate(r.start_date)}</td>
<td className="px-3 py-2">{formatDate(r.end_date)}</td>
<td className="px-3 py-2">{r.created_at ? new Date(r.created_at).toLocaleDateString() : "—"}</td>
</tr>
))}
</tbody>
</table>
{sortedRows.length === 0 && (
<div className="p-4 text-sm text-slate-600">
<div>Aucun contrat trouvé.</div>
<div className="mt-2">
<button
className="text-xs underline"
onClick={() => setShowRaw((s) => !s)}
>
{showRaw ? "Cacher le payload" : "Voir le payload reçu"}
</button>
</div>
{showRaw && (
<pre className="mt-2 max-h-48 overflow-auto text-xs bg-slate-50 p-2 rounded border">{JSON.stringify(initialData, null, 2)}</pre>
)}
</div>
)}
</div>
{/* Pagination / info */}
<div className="mt-3 flex items-center justify-between text-xs text-slate-600">
<div>{loading ? 'Chargement…' : `Affichage ${sortedRows.length}${totalCountRef.current ? ` / ${totalCountRef.current}` : ''}`}</div>
<div className="flex items-center gap-2">
<button className="text-xs px-2 py-1 rounded border" onClick={() => { if (page > 0) fetchServer(page - 1); }}>Préc</button>
<button className="text-xs px-2 py-1 rounded border" onClick={() => { fetchServer(page + 1); }}>Suiv</button>
</div>
</div>
{/* slide removed: navigation now goes to /staff/contrats/[id] */}
{/* Modal Action groupée DPAE */}
{showDpaeModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Action groupée - DPAE</h3>
<p className="text-sm text-gray-600 mb-4">
Modifier l'état DPAE pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
</p>
<DpaeActionModal
selectedContracts={selectedContracts}
onClose={() => setShowDpaeModal(false)}
onSuccess={(updatedContracts) => {
// Mettre à jour les contrats dans la liste
setRows(prev => prev.map(row => {
const updated = updatedContracts.find(u => u.id === row.id);
return updated ? { ...row, dpae: updated.dpae } : row;
}));
setShowDpaeModal(false);
setSelectedContractIds(new Set());
}}
/>
</div>
</div>
)}
{/* Modal Action groupée État Contrat */}
{showEtatContratModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Action groupée - État Contrat</h3>
<p className="text-sm text-gray-600 mb-4">
Modifier l'état du contrat pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
</p>
<EtatContratActionModal
selectedContracts={selectedContracts}
onClose={() => setShowEtatContratModal(false)}
onSuccess={(updatedContracts) => {
// Mettre à jour les contrats dans la liste
setRows(prev => prev.map(row => {
const updated = updatedContracts.find(u => u.id === row.id);
return updated ? { ...row, etat_de_la_demande: updated.etat_de_la_demande } : row;
}));
setShowEtatContratModal(false);
setSelectedContractIds(new Set());
}}
/>
</div>
</div>
)}
{/* Modal Action groupée État Paie */}
{showEtatPaieModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Action groupée - État Paie</h3>
<p className="text-sm text-gray-600 mb-4">
Modifier l'état de la paie pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
</p>
<EtatPaieActionModal
selectedContracts={selectedContracts}
onClose={() => setShowEtatPaieModal(false)}
onSuccess={(updatedContracts) => {
// Mettre à jour les contrats dans la liste
setRows(prev => prev.map(row => {
const updated = updatedContracts.find(u => u.id === row.id);
return updated ? { ...row, etat_de_la_paie: updated.etat_de_la_paie } : row;
}));
setShowEtatPaieModal(false);
setSelectedContractIds(new Set());
}}
/>
</div>
</div>
)}
{/* Modal Saisir brut */}
{showSalaryModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">Saisir brut</h3>
<p className="text-sm text-gray-600 mb-4">
Saisir le salaire brut pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
</p>
<SalaryInputModal
selectedContracts={selectedContracts}
onClose={() => setShowSalaryModal(false)}
onSuccess={(updatedContracts) => {
// Mettre à jour les contrats dans la liste
setRows(prev => prev.map(row => {
const updated = updatedContracts.find(u => u.id === row.id);
return updated ? { ...row, gross_pay: updated.gross_pay } : row;
}));
setShowSalaryModal(false);
setSelectedContractIds(new Set());
}}
/>
</div>
</div>
)}
{/* Modal de progression pour la génération des PDFs */}
<BulkPdfProgressModal
isOpen={showProgressModal}
onClose={closeProgressModal}
contracts={contractsProgress}
onCancel={isGeneratingPdfs ? cancelPdfGeneration : undefined}
isProcessing={isGeneratingPdfs}
totalCount={contractsProgress.length}
completedCount={contractsProgress.filter(c => c.status === 'success' || c.status === 'error').length}
successCount={contractsProgress.filter(c => c.status === 'success').length}
errorCount={contractsProgress.filter(c => c.status === 'error').length}
/>
{/* Modal de vérification des PDFs */}
<PdfVerificationModal
isOpen={showPdfVerificationModal}
onClose={closePdfVerificationModal}
contracts={contractsPdfs}
isLoading={isLoadingPdfs}
/>
{/* Modal de confirmation pour l'envoi des e-signatures */}
<BulkESignConfirmModal
isOpen={showESignConfirmModal}
onClose={() => setShowESignConfirmModal(false)}
onConfirm={generateBatchESign}
contractCount={selectedContractIds.size}
/>
{/* Modal de progression pour l'envoi des e-signatures */}
<BulkESignProgressModal
isOpen={showESignProgressModal}
onClose={closeESignProgressModal}
contracts={contractsESignProgress}
onCancel={isGeneratingESign ? cancelESignGeneration : undefined}
isProcessing={isGeneratingESign}
totalCount={contractsESignProgress.length}
completedCount={contractsESignProgress.filter(c => c.status === 'success' || c.status === 'error').length}
successCount={contractsESignProgress.filter(c => c.status === 'success').length}
errorCount={contractsESignProgress.filter(c => c.status === 'error').length}
/>
{/* Modal de confirmation de suppression */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4 text-red-600">Confirmation de suppression</h3>
<p className="text-sm text-gray-600 mb-4">
Êtes-vous sûr de vouloir supprimer {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''} ?
</p>
<DeleteConfirmModal
selectedContracts={selectedContracts}
onClose={() => setShowDeleteModal(false)}
onSuccess={(deletedIds) => {
// Retirer les contrats supprimés de la liste
setRows(prev => prev.filter(row => !deletedIds.includes(row.id)));
setShowDeleteModal(false);
setSelectedContractIds(new Set());
toast.success(`${deletedIds.length} contrat${deletedIds.length > 1 ? 's' : ''} supprimé${deletedIds.length > 1 ? 's' : ''}`);
}}
/>
</div>
</div>
)}
</div>
);
}
// Modal pour l'action groupée DPAE
function DpaeActionModal({
selectedContracts,
onClose,
onSuccess
}: {
selectedContracts: Contract[];
onClose: () => void;
onSuccess: (contracts: { id: string; dpae: string }[]) => void;
}) {
const [newDpaeStatus, setNewDpaeStatus] = useState<string>('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!newDpaeStatus) return;
setLoading(true);
try {
const response = await fetch('/api/staff/contracts/bulk-update-dpae', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contractIds: selectedContracts.map(c => c.id),
dpaeStatus: newDpaeStatus
})
});
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
const result = await response.json();
onSuccess(result.contracts);
} catch (error) {
console.error('Erreur:', error);
alert('Erreur lors de la mise à jour des contrats');
} finally {
setLoading(false);
}
};
return (
<>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nouvel état DPAE
</label>
<select
value={newDpaeStatus}
onChange={(e) => setNewDpaeStatus(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Sélectionner un état</option>
<option value="À faire">À faire</option>
<option value="Faite">Faite</option>
</select>
</div>
<div className="border rounded-lg p-3 max-h-40 overflow-y-auto">
<p className="text-sm font-medium text-gray-700 mb-2">Contrats sélectionnés :</p>
{selectedContracts.map(contract => (
<div key={contract.id} className="text-sm text-gray-600 py-1">
{contract.contract_number || contract.id} - {formatEmployeeName(contract)}
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
disabled={loading}
>
Annuler
</button>
<button
onClick={handleSubmit}
disabled={!newDpaeStatus || loading}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Mise à jour...' : 'Appliquer'}
</button>
</div>
</>
);
}
// Modal pour l'état du contrat
function EtatContratActionModal({
selectedContracts,
onClose,
onSuccess
}: {
selectedContracts: Contract[];
onClose: () => void;
onSuccess: (contracts: { id: string; etat_de_la_demande: string }[]) => void;
}) {
const [newEtatContrat, setNewEtatContrat] = useState<string>('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!newEtatContrat) return;
setLoading(true);
try {
const response = await fetch('/api/staff/contracts/bulk-update-etat-contrat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contractIds: selectedContracts.map(c => c.id),
etatContrat: newEtatContrat
})
});
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
const result = await response.json();
onSuccess(result.contracts);
} catch (error) {
console.error('Erreur:', error);
alert('Erreur lors de la mise à jour des contrats');
} finally {
setLoading(false);
}
};
return (
<>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nouvel état du contrat
</label>
<select
value={newEtatContrat}
onChange={(e) => setNewEtatContrat(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Sélectionner un état</option>
<option value="Reçue">Reçue</option>
<option value="En cours">En cours</option>
<option value="Traitée">Traitée</option>
<option value="Refusée">Refusée</option>
</select>
</div>
<div className="border rounded-lg p-3 max-h-40 overflow-y-auto">
<p className="text-sm font-medium text-gray-700 mb-2">Contrats sélectionnés :</p>
{selectedContracts.map(contract => (
<div key={contract.id} className="text-sm text-gray-600 py-1">
{contract.contract_number || contract.id} - {formatEmployeeName(contract)}
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
disabled={loading}
>
Annuler
</button>
<button
onClick={handleSubmit}
disabled={!newEtatContrat || loading}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Mise à jour...' : 'Appliquer'}
</button>
</div>
</>
);
}
// Modal pour l'état de la paie
function EtatPaieActionModal({
selectedContracts,
onClose,
onSuccess
}: {
selectedContracts: Contract[];
onClose: () => void;
onSuccess: (contracts: { id: string; etat_de_la_paie: string }[]) => void;
}) {
const [newEtatPaie, setNewEtatPaie] = useState<string>('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!newEtatPaie) return;
setLoading(true);
try {
const response = await fetch('/api/staff/contracts/bulk-update-etat-paie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contractIds: selectedContracts.map(c => c.id),
etatPaie: newEtatPaie
})
});
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
const result = await response.json();
onSuccess(result.contracts);
} catch (error) {
console.error('Erreur:', error);
alert('Erreur lors de la mise à jour des contrats');
} finally {
setLoading(false);
}
};
return (
<>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nouvel état de la paie
</label>
<select
value={newEtatPaie}
onChange={(e) => setNewEtatPaie(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Sélectionner un état</option>
<option value="À traiter">À traiter</option>
<option value="En cours">En cours</option>
<option value="Traitée">Traitée</option>
</select>
</div>
<div className="border rounded-lg p-3 max-h-40 overflow-y-auto">
<p className="text-sm font-medium text-gray-700 mb-2">Contrats sélectionnés :</p>
{selectedContracts.map(contract => (
<div key={contract.id} className="text-sm text-gray-600 py-1">
{contract.contract_number || contract.id} - {formatEmployeeName(contract)}
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
disabled={loading}
>
Annuler
</button>
<button
onClick={handleSubmit}
disabled={!newEtatPaie || loading}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Mise à jour...' : 'Appliquer'}
</button>
</div>
</>
);
}
// Modal pour saisir le brut
function SalaryInputModal({
selectedContracts,
onClose,
onSuccess
}: {
selectedContracts: Contract[];
onClose: () => void;
onSuccess: (contracts: { id: string; gross_pay: number }[]) => void;
}) {
const [salaries, setSalaries] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const handleSalaryChange = (contractId: string, value: string) => {
setSalaries(prev => ({ ...prev, [contractId]: value }));
};
const handleSubmit = async () => {
const updates = Object.entries(salaries)
.filter(([_, value]) => value.trim() !== '')
.map(([contractId, value]) => ({
contractId,
grossPay: parseFloat(value.replace(',', '.'))
}))
.filter(update => !isNaN(update.grossPay));
if (updates.length === 0) {
alert('Veuillez saisir au moins un montant valide');
return;
}
setLoading(true);
try {
const response = await fetch('/api/staff/contracts/bulk-update-salary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates })
});
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
const result = await response.json();
onSuccess(result.contracts);
} catch (error) {
console.error('Erreur:', error);
alert('Erreur lors de la mise à jour des salaires');
} finally {
setLoading(false);
}
};
return (
<>
<div className="space-y-4">
<div className="space-y-3">
{selectedContracts.map(contract => (
<div key={contract.id} className="flex items-center gap-3 p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium text-sm">
{contract.contract_number || contract.id}
</div>
<div className="text-sm text-gray-600">
{formatEmployeeName(contract)}
</div>
</div>
<div className="w-32">
<input
type="number"
step="0.01"
placeholder="0.00"
value={salaries[contract.id] || ''}
onChange={(e) => handleSalaryChange(contract.id, e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div className="text-sm text-gray-500"></div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
disabled={loading}
>
Annuler
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Mise à jour...' : 'Enregistrer'}
</button>
</div>
</>
);
}
// Modal pour confirmer la suppression des contrats
function DeleteConfirmModal({
selectedContracts,
onClose,
onSuccess
}: {
selectedContracts: Contract[];
onClose: () => void;
onSuccess: (deletedIds: string[]) => void;
}) {
const [loading, setLoading] = useState(false);
const [confirmText, setConfirmText] = useState('');
const handleSubmit = async () => {
if (confirmText.toLowerCase() !== 'supprimer') {
toast.error('Veuillez taper "supprimer" pour confirmer');
return;
}
setLoading(true);
try {
const response = await fetch('/api/staff/contracts/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contractIds: selectedContracts.map(c => c.id)
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erreur lors de la suppression');
}
const result = await response.json();
onSuccess(result.deletedIds);
} catch (error) {
console.error('Erreur:', error);
toast.error(error instanceof Error ? error.message : 'Erreur lors de la suppression des contrats');
} finally {
setLoading(false);
}
};
return (
<>
<div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800 font-medium">
Attention : Cette action est irréversible !
</p>
<p className="text-xs text-red-700 mt-2">
Les contrats suivants seront définitivement supprimés de la base de données.
</p>
</div>
<div className="border rounded-lg p-3 max-h-40 overflow-y-auto">
<p className="text-sm font-medium text-gray-700 mb-2">Contrats à supprimer :</p>
{selectedContracts.map(contract => (
<div key={contract.id} className="text-sm text-gray-600 py-1">
{contract.contract_number || contract.id} - {formatEmployeeName(contract)}
</div>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Pour confirmer, tapez <span className="font-bold text-red-600">supprimer</span>
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="supprimer"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
disabled={loading}
>
Annuler
</button>
<button
onClick={handleSubmit}
disabled={confirmText.toLowerCase() !== 'supprimer' || loading}
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Suppression...' : 'Supprimer définitivement'}
</button>
</div>
</>
);
}