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

3066 lines
122 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, useImperativeHandle, forwardRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import Link from "next/link";
import { RefreshCw, Check, X, Settings, FileText, CheckCircle, BarChart3, Eye, ChevronDown, Trash2, FileDown, FileSignature, Euro, XCircle, BellRing, Clock, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import BulkPdfProgressModal from "./BulkPdfProgressModal";
import PdfVerificationModal from "./PdfVerificationModal";
import ContractDetailsModal from "./ContractDetailsModal";
import BulkESignProgressModal from "./BulkESignProgressModal";
import BulkESignConfirmModal from "./BulkESignConfirmModal";
import { BulkEmployeeReminderModal } from "./contracts/BulkEmployeeReminderModal";
import { EmployeeReminderModal } from "./contracts/EmployeeReminderModal";
import { SmartReminderModal, type SmartReminderContract, type ReminderAction } from "./contracts/SmartReminderModal";
import BulkPayslipModal from "./contracts/BulkPayslipModal";
// 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 notification date as DD/MM HH:MM
function formatNotificationDate(dateString: string | null): string {
if (!dateString) return "—";
try {
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}/${month} ${hours}:${minutes}`;
} catch {
return "—";
}
}
// Utility function to get notification color based on contract start date
function getNotificationColor(startDate: string | null | undefined): string {
if (!startDate) return 'text-slate-400';
const start = new Date(startDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
start.setHours(0, 0, 0, 0);
const diffTime = start.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Rouge si contrat démarre aujourd'hui ou dans le passé
if (diffDays <= 0) return 'text-red-600';
// Orange si moins de 48 heures (moins de 2 jours)
if (diffDays < 2) return 'text-orange-600';
// Vert si plus de 48 heures
return 'text-green-600';
}
// 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(' ');
}
// Utility functions to get icon and color for each state
function getEtatContratIcon(etat: string | null | undefined): { icon: React.ReactNode; tooltip: string } {
const s = String(etat || "").toLowerCase();
if (s.includes('receptee') || s.includes('recue') || s.includes('reçue')) {
return { icon: <FileText className="w-5 h-5 text-sky-600" />, tooltip: "Reçue" };
}
if (s.includes('en cours') || s.includes('encours')) {
return { icon: <Clock className="w-5 h-5 text-amber-600" />, tooltip: "En cours" };
}
if (s.includes('traitee') || s.includes('traitée')) {
return { icon: <CheckCircle className="w-5 h-5 text-emerald-600" />, tooltip: "Traitée" };
}
if (s.includes('annulee') || s.includes('annulée')) {
return { icon: <XCircle className="w-5 h-5 text-rose-600" />, tooltip: "Annulée" };
}
return { icon: <span className="text-slate-400"></span>, tooltip: "Non défini" };
}
function getEtatPaieIcon(etat: string | null | undefined): { icon: React.ReactNode; tooltip: string } {
const s = String(etat || "").toLowerCase();
if (s.includes('à traiter') || s.includes('a traiter') || s.includes('traiter')) {
return { icon: <AlertCircle className="w-5 h-5 text-orange-600" />, tooltip: "À traiter" };
}
if (s.includes('en cours') || s.includes('en_cours') || s.includes('encours') || s.includes('cours')) {
return { icon: <Clock className="w-5 h-5 text-blue-600" />, tooltip: "En cours" };
}
if (s.includes('traitée') || s.includes('traitee') || s.includes('traite')) {
return { icon: <CheckCircle className="w-5 h-5 text-green-600" />, tooltip: "Traitée" };
}
return { icon: <span className="text-slate-400"></span>, tooltip: "Non défini" };
}
function getDpaeIcon(dpae: string | null | undefined): { icon: React.ReactNode; tooltip: string } {
const d = String(dpae || "").toLowerCase();
if (d.includes('à faire') || d.includes('a faire')) {
return { icon: <AlertCircle className="w-5 h-5 text-orange-600" />, tooltip: "À faire" };
}
if (d.includes('en cours') || d.includes('encours')) {
return { icon: <Clock className="w-5 h-5 text-blue-600" />, tooltip: "En cours" };
}
if (d.includes('faite') || d.includes('validée') || d.includes('validee')) {
return { icon: <CheckCircle className="w-5 h-5 text-green-600" />, tooltip: "Faite" };
}
return { icon: <span className="text-slate-400"></span>, tooltip: "Non défini" };
}
type Contract = {
id: string;
contract_number?: string | null;
employee_name?: string | null;
employee_id?: string | null;
structure?: string | null;
type_de_contrat?: string | null;
profession?: 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;
contrat_signe_par_employeur?: string | null;
contrat_signe?: string | null;
last_employer_notification_at?: string | null;
last_employee_notification_at?: string | null;
production_name?: string | null;
analytique?: string | null;
salaries?: {
salarie?: string | null;
nom?: string | null;
prenom?: string | null;
adresse_mail?: string | null;
} | null;
organizations?: {
organization_details?: {
code_employeur?: string | null;
} | null;
} | null;
};
type NotificationInfo = {
employerLastSent: string | null;
employeeLastSent: string | 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 type ContractsGridHandle = {
quickFilterDpaeAFaire: () => void;
quickFilterContratsAFaireMois: () => void;
quickFilterPaieATraiterMoisDernier: () => void;
quickFilterPaieATraiterToutes: () => void;
quickFilterNonSignesDateProche: () => void;
quickFilterContratsEnCours: () => void;
getCountDpaeAFaire: () => number | null;
getCountContratsAFaireMois: () => number | null;
getCountPaieATraiterMoisDernier: () => number | null;
getCountPaieATraiterToutes: () => number | null;
getCountNonSignesDateProche: () => number | null;
};
function ContractsGridImpl({ initialData, activeOrgId }: { initialData: Contract[]; activeOrgId?: string | null }, ref: React.ForwardedRef<ContractsGridHandle>) {
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());
// Notification tracking state
const [notificationMap, setNotificationMap] = useState<Map<string, NotificationInfo>>(new Map());
// 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 [etatContratFilters, setEtatContratFilters] = useState<Set<string>>(new Set(savedFilters?.etatContratFilters || []));
const [etatPaieFilter, setEtatPaieFilter] = useState<string | null>(savedFilters?.etatPaieFilter || null);
const [dpaeFilter, setDpaeFilter] = useState<string | null>(savedFilters?.dpaeFilter || null);
const [signatureFilter, setSignatureFilter] = useState<string | null>(savedFilters?.signatureFilter || null);
const [startFrom, setStartFrom] = useState<string | null>(savedFilters?.startFrom || null);
const [startTo, setStartTo] = useState<string | null>(savedFilters?.startTo || null);
const [endFrom, setEndFrom] = useState<string | null>(savedFilters?.endFrom || null);
const [endTo, setEndTo] = useState<string | null>(savedFilters?.endTo || 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 [showAnalytiqueModal, setShowAnalytiqueModal] = useState(false);
const [showSalaryModal, setShowSalaryModal] = useState(false);
const [showActionMenu, setShowActionMenu] = useState(false);
const [showESignMenu, setShowESignMenu] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showBulkPayslipModal, setShowBulkPayslipModal] = useState(false);
// Quick filter counts
const [countDpaeAFaire, setCountDpaeAFaire] = useState<number | null>(null);
const [countContratsAFaireMois, setCountContratsAFaireMois] = useState<number | null>(null);
const [countPaieATraiterMoisDernier, setCountPaieATraiterMoisDernier] = useState<number | null>(null);
const [countPaieATraiterToutes, setCountPaieATraiterToutes] = useState<number | null>(null);
const [countContratsNonSignesDateProche, setCountContratsNonSignesDateProche] = useState<number | null>(null);
// 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);
// Contract Details Modal state
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [contractDetailsIds, setContractDetailsIds] = useState<string[]>([]);
// Employee Reminder state
const [showEmployeeReminderModal, setShowEmployeeReminderModal] = useState(false);
const [selectedContractForReminder, setSelectedContractForReminder] = useState<any>(null);
const [isLoadingReminder, setIsLoadingReminder] = useState(false);
// Bulk reminder modal state
const [showBulkReminderModal, setShowBulkReminderModal] = useState(false);
const [bulkReminderContracts, setBulkReminderContracts] = useState<Array<{ id: string; reference?: string | null; contract_number?: string | null; employee_name?: string | null; employee_email?: string | null }>>([]);
const [isLoadingReminderEmails, setIsLoadingReminderEmails] = useState(false);
// Smart reminder modal state
const [showSmartReminderModal, setShowSmartReminderModal] = useState(false);
const [smartReminderContracts, setSmartReminderContracts] = useState<SmartReminderContract[]>([]);
const [smartReminderProgress, setSmartReminderProgress] = useState<SmartReminderContract[]>([]);
// Quick filters helpers
const toYMD = (d: Date) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
const applyQuickFilterDpaeAFaire = () => {
const today = new Date();
const in7 = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7);
// Reset tous les filtres pour voir uniquement ce filtre rapide
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setSignatureFilter(null); // Reset signature
setDpaeFilter('À faire');
setEtatContratFilters(new Set()); // Reset état contrat
setEtatPaieFilter(null); // Reset état paie
setStartFrom(null); // inclut le passé
setStartTo(toYMD(in7)); // jusqu'à J+7
setEndFrom(null);
setEndTo(null);
setSortField('start_date');
setSortOrder('asc');
};
const applyQuickFilterContratsAFaireMois = () => {
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
// Reset tous les filtres pour voir uniquement ce filtre rapide
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setSignatureFilter(null); // Reset signature
setEtatContratFilters(new Set(['Reçue', 'En cours'])); // Multiple selections
setDpaeFilter(null); // Reset DPAE
setEtatPaieFilter(null); // Reset état paie
setStartFrom(toYMD(firstDay));
setStartTo(toYMD(lastDay));
setEndFrom(null);
setEndTo(null);
setSortField('start_date');
setSortOrder('asc');
};
const applyQuickFilterPaieATraiterMoisDernier = () => {
const today = new Date();
// Premier jour du mois courant
const firstDayThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
// Avant le premier jour du mois courant = fin du mois dernier
const lastDayLastMonth = new Date(firstDayThisMonth.getFullYear(), firstDayThisMonth.getMonth(), 0);
// Premier jour du mois dernier
const firstDayLastMonth = new Date(lastDayLastMonth.getFullYear(), lastDayLastMonth.getMonth(), 1);
// Reset tous les filtres pour voir uniquement ce filtre rapide
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setSignatureFilter(null); // Reset signature
setEtatPaieFilter('À traiter');
setDpaeFilter(null); // Reset DPAE
setEtatContratFilters(new Set()); // Reset état contrat
// Fin du contrat doit être dans le mois dernier (du 1er au dernier jour)
setStartFrom(null);
setStartTo(null);
setEndFrom(toYMD(firstDayLastMonth));
setEndTo(toYMD(lastDayLastMonth));
setSortField('end_date');
setSortOrder('asc');
};
const applyQuickFilterPaieATraiterToutes = () => {
// Reset tous les filtres pour voir uniquement ce filtre rapide
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setSignatureFilter(null); // Reset signature
setEtatPaieFilter('À traiter');
setDpaeFilter(null); // Reset DPAE
setEtatContratFilters(new Set(['Traitée'])); // Ajouter le filtre "Traitée"
// Date de fin aujourd'hui ou passée
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const todayStr = `${y}-${m}-${day}`;
setStartFrom(null);
setStartTo(null);
setEndFrom(null);
setEndTo(todayStr);
setSortField('end_date');
setSortOrder('asc');
};
const applyQuickFilterNonSignesDateProche = () => {
// Contrats non signés avec date de début dans le passé, aujourd'hui ou demain
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Reset tous les filtres pour voir uniquement ce filtre rapide
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setSignatureFilter('non_signe'); // Filtre pour les contrats non complètement signés
setEtatPaieFilter(null); // Reset état paie
setEtatContratFilters(new Set()); // Reset état contrat
setDpaeFilter(null); // Reset DPAE
setStartFrom(null); // Inclut le passé
setStartTo(toYMD(tomorrow)); // Jusqu'à demain
setEndFrom(null);
setEndTo(null);
setSortField('start_date');
setSortOrder('asc');
};
const applyQuickFilterContratsEnCours = () => {
// Contrats en cours : date de début passée et date de fin future
const today = new Date();
const todayStr = toYMD(today);
// Reset tous les filtres pour voir uniquement ce filtre rapide
setQ(""); // Reset recherche
setStructureFilter(null); // Reset organisation
setTypeFilter(null); // Reset type de contrat
setSignatureFilter(null); // Tous les contrats
setEtatPaieFilter(null); // Reset état paie
setEtatContratFilters(new Set()); // Reset état contrat
setDpaeFilter(null); // Reset DPAE
setStartFrom(null); // Date de début dans le passé
setStartTo(todayStr); // Jusqu'à aujourd'hui
setEndFrom(todayStr); // Date de fin future (à partir d'aujourd'hui)
setEndTo(null);
setSortField('end_date');
setSortOrder('asc');
};
// Expose imperative handlers to parent wrappers
useImperativeHandle(ref, () => ({
quickFilterDpaeAFaire: applyQuickFilterDpaeAFaire,
quickFilterContratsAFaireMois: applyQuickFilterContratsAFaireMois,
quickFilterPaieATraiterMoisDernier: applyQuickFilterPaieATraiterMoisDernier,
quickFilterPaieATraiterToutes: applyQuickFilterPaieATraiterToutes,
quickFilterNonSignesDateProche: applyQuickFilterNonSignesDateProche,
quickFilterContratsEnCours: applyQuickFilterContratsEnCours,
getCountDpaeAFaire: () => countDpaeAFaire,
getCountContratsAFaireMois: () => countContratsAFaireMois,
getCountPaieATraiterMoisDernier: () => countPaieATraiterMoisDernier,
getCountPaieATraiterToutes: () => countPaieATraiterToutes,
getCountNonSignesDateProche: () => countContratsNonSignesDateProche,
}), [applyQuickFilterDpaeAFaire, applyQuickFilterContratsAFaireMois, applyQuickFilterPaieATraiterMoisDernier, applyQuickFilterPaieATraiterToutes, applyQuickFilterNonSignesDateProche, applyQuickFilterContratsEnCours, countDpaeAFaire, countContratsAFaireMois, countPaieATraiterMoisDernier, countPaieATraiterToutes, countContratsNonSignesDateProche]);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = {
q,
structureFilter,
typeFilter,
etatContratFilters: Array.from(etatContratFilters),
etatPaieFilter,
dpaeFilter,
signatureFilter,
startFrom,
startTo,
endFrom,
endTo,
sortField,
sortOrder,
showFilters
};
saveFiltersToStorage(filters);
}, [q, structureFilter, typeFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo, 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: count contracts for a specific filter
async function countContractsForFilter(filterParams: URLSearchParams) {
try {
const res = await fetch(`/api/staff/contracts/search?${filterParams.toString()}`);
if (!res.ok) throw new Error('Count failed');
const j = await res.json();
return j.count ?? 0;
} catch (err) {
console.error('Count error', err);
return 0;
}
}
// 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);
// Handle multiple etat_de_la_demande filters
if (etatContratFilters.size > 0) {
params.set('etat_de_la_demande', Array.from(etatContratFilters).join(','));
}
if (etatPaieFilter) params.set('etat_de_la_paie', etatPaieFilter);
if (dpaeFilter) params.set('dpae', dpaeFilter);
if (signatureFilter) params.set('signature_state', signatureFilter);
if (startFrom) params.set('start_from', startFrom);
if (startTo) params.set('start_to', startTo);
if (endFrom) params.set('end_from', endFrom);
if (endTo) params.set('end_to', endTo);
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);
};
// Note: Removed auto-reset of selection when data changes to preserve selection after bulk actions
// 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 && etatContratFilters.size === 0 && !etatPaieFilter && !dpaeFilter && !signatureFilter && !startFrom && !startTo && !endFrom && !endTo;
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, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo]);
// 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 détails des contrats
const viewSelectedDetails = () => {
if (selectedContractIds.size === 0) {
toast.error("Aucun contrat sélectionné");
return;
}
const contractIds = Array.from(selectedContractIds);
setContractDetailsIds(contractIds);
setShowDetailsModal(true);
setShowActionMenu(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`);
}
// Note: Preserving selection after e-signature generation to allow further bulk actions
}
} 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);
};
// Fonction pour créer les paies en masse
const handleBulkPayslipSubmit = async (payslips: any[]) => {
try {
const response = await fetch('/api/staff/payslips/bulk-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ payslips }),
});
if (!response.ok) {
throw new Error('Erreur lors de la création des paies');
}
const result = await response.json();
toast.success(`${result.created || payslips.length} paie(s) créée(s) avec succès`);
// Rafraîchir les données
handleRefresh();
// Désélectionner les contrats
setSelectedContractIds(new Set());
} catch (error) {
console.error('Erreur création paies:', error);
throw error;
}
};
// Fonction pour extraire les notifications directement des données de contrats
// Plus besoin d'appel API séparé, les timestamps sont maintenant dans cddu_contracts
const updateNotificationsFromRows = (contracts: Contract[]) => {
const map = new Map<string, NotificationInfo>();
contracts.forEach(contract => {
const info: NotificationInfo = {
employerLastSent: contract.last_employer_notification_at || null,
employeeLastSent: contract.last_employee_notification_at || null,
};
// N'ajouter à la map que si au moins une notification existe
if (info.employerLastSent || info.employeeLastSent) {
map.set(contract.id, info);
}
});
setNotificationMap(map);
};
// Debounce searches when filters change
useEffect(() => {
// if no filters applied, prefer initial data
const noFilters = !q && !structureFilter && !typeFilter && etatContratFilters.size === 0 && !etatPaieFilter && !dpaeFilter && !signatureFilter && !startFrom && !startTo && !endFrom && !endTo && 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, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo, sortField, sortOrder, limit]);
// Récupérer les notifications quand les données changent
useEffect(() => {
if (rows.length > 0) {
updateNotificationsFromRows(rows);
}
}, [rows]);
// Calculate counts for quick filters
useEffect(() => {
const calculateCounts = async () => {
// DPAE à faire
const paramsDpae = new URLSearchParams();
paramsDpae.set('dpae', 'À faire');
const today = new Date();
const in7 = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7);
const y = in7.getFullYear();
const m = String(in7.getMonth() + 1).padStart(2, '0');
const day = String(in7.getDate()).padStart(2, '0');
paramsDpae.set('start_to', `${y}-${m}-${day}`);
paramsDpae.set('limit', '1');
const countDpae = await countContractsForFilter(paramsDpae);
setCountDpaeAFaire(countDpae);
// Contrats à faire mois
const paramsContrats = new URLSearchParams();
paramsContrats.set('etat_de_la_demande', 'Reçue,En cours');
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const y1 = firstDay.getFullYear();
const m1 = String(firstDay.getMonth() + 1).padStart(2, '0');
const day1 = String(firstDay.getDate()).padStart(2, '0');
const y2 = lastDay.getFullYear();
const m2 = String(lastDay.getMonth() + 1).padStart(2, '0');
const day2 = String(lastDay.getDate()).padStart(2, '0');
paramsContrats.set('start_from', `${y1}-${m1}-${day1}`);
paramsContrats.set('start_to', `${y2}-${m2}-${day2}`);
paramsContrats.set('limit', '1');
const countContrats = await countContractsForFilter(paramsContrats);
setCountContratsAFaireMois(countContrats);
// Paies à traiter mois dernier
const paramsPaieMois = new URLSearchParams();
paramsPaieMois.set('etat_de_la_paie', 'À traiter');
const firstDayThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDayLastMonth = new Date(firstDayThisMonth.getFullYear(), firstDayThisMonth.getMonth(), 0);
const firstDayLastMonth = new Date(lastDayLastMonth.getFullYear(), lastDayLastMonth.getMonth(), 1);
const y3 = firstDayLastMonth.getFullYear();
const m3 = String(firstDayLastMonth.getMonth() + 1).padStart(2, '0');
const day3 = String(firstDayLastMonth.getDate()).padStart(2, '0');
const y4 = lastDayLastMonth.getFullYear();
const m4 = String(lastDayLastMonth.getMonth() + 1).padStart(2, '0');
const day4 = String(lastDayLastMonth.getDate()).padStart(2, '0');
paramsPaieMois.set('end_from', `${y3}-${m3}-${day3}`);
paramsPaieMois.set('end_to', `${y4}-${m4}-${day4}`);
paramsPaieMois.set('limit', '1');
const countPaieMois = await countContractsForFilter(paramsPaieMois);
setCountPaieATraiterMoisDernier(countPaieMois);
// Paies à traiter toutes périodes
const paramsPaieToutes = new URLSearchParams();
paramsPaieToutes.set('etat_de_la_paie', 'À traiter');
paramsPaieToutes.set('etat_de_la_demande', 'Traitée');
const y5 = today.getFullYear();
const m5 = String(today.getMonth() + 1).padStart(2, '0');
const day5 = String(today.getDate()).padStart(2, '0');
paramsPaieToutes.set('end_to', `${y5}-${m5}-${day5}`);
paramsPaieToutes.set('limit', '1');
const countPaieToutes = await countContractsForFilter(paramsPaieToutes);
setCountPaieATraiterToutes(countPaieToutes);
// Contrats non signés avec date de début proche (passé, aujourd'hui, demain)
const paramsNonSignes = new URLSearchParams();
paramsNonSignes.set('signature_state', 'non_signe');
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const y6 = tomorrow.getFullYear();
const m6 = String(tomorrow.getMonth() + 1).padStart(2, '0');
const day6 = String(tomorrow.getDate()).padStart(2, '0');
paramsNonSignes.set('start_to', `${y6}-${m6}-${day6}`);
paramsNonSignes.set('limit', '1');
const countNonSignes = await countContractsForFilter(paramsNonSignes);
setCountContratsNonSignesDateProche(countNonSignes);
};
calculateCounts();
}, []);
// Fonction pour relancer un salarié spécifique
const handleReminderClick = async (contract: any) => {
setSelectedContractForReminder(contract);
// Récupérer l'email du salarié depuis la base de données
try {
const response = await fetch(`/api/staff/contrats/${contract.id}/employee-email`);
if (response.ok) {
const result = await response.json();
setSelectedContractForReminder({
...contract,
employee_email: result.employee_email
});
}
} catch (error) {
console.error('Erreur récupération email:', error);
}
setShowEmployeeReminderModal(true);
};
// Fonction pour envoyer la relance au salarié
const sendEmployeeReminder = async () => {
if (!selectedContractForReminder) return;
setIsLoadingReminder(true);
try {
const response = await fetch('/api/staff/contrats/relance-salarie', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ contractId: selectedContractForReminder.id }),
});
const result = await response.json();
if (response.ok) {
toast.success(`Email de relance envoyé avec succès à ${selectedContractForReminder.employee_name}`);
setShowEmployeeReminderModal(false);
setSelectedContractForReminder(null);
} else {
toast.error(result.error || result.message || 'Erreur lors de l\'envoi de la relance');
}
} catch (error: any) {
console.error('Erreur relance salarié:', error);
toast.error(error.message || 'Erreur lors de l\'envoi de la relance');
} finally {
setIsLoadingReminder(false);
}
};
// Fonction pour analyser intelligemment les contrats et déterminer qui relancer
const handleSmartReminderClick = async () => {
if (selectedContractIds.size === 0) {
toast.error("Aucun contrat sélectionné");
return;
}
setIsLoadingReminderEmails(true);
try {
// Récupérer les contrats sélectionnés
const selectedContracts = rows.filter(r => selectedContractIds.has(r.id));
// Récupérer les emails employeurs depuis l'API
const orgIds = [...new Set(selectedContracts.map(c => c.org_id).filter(Boolean))];
const employerEmailsMap = new Map<string, string>();
try {
const response = await fetch('/api/staff/organizations/emails', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ org_ids: orgIds }),
});
if (response.ok) {
const data = await response.json();
// data devrait être un array de { org_id, email_signature }
data.forEach((org: any) => {
if (org.org_id && org.email_signature) {
employerEmailsMap.set(org.org_id, org.email_signature);
}
});
}
} catch (error) {
console.error('Erreur lors de la récupération des emails employeurs:', error);
}
// Fonction pour vérifier si une relance a été envoyée il y a moins de 24h
const wasRecentlySent = (timestamp: string | null | undefined): boolean => {
if (!timestamp) return false;
const sentDate = new Date(timestamp);
const now = new Date();
const hoursDiff = (now.getTime() - sentDate.getTime()) / (1000 * 60 * 60);
return hoursDiff < 24;
};
// Analyser chaque contrat pour déterminer l'action appropriée
const analyzedContracts: SmartReminderContract[] = selectedContracts.map(contract => {
const employerSigned = contract.contrat_signe_par_employeur === 'Oui';
const employeeSigned = contract.contrat_signe === 'Oui';
const contractProcessed = String(contract.etat_de_la_demande || contract.etat_demande || "").toLowerCase().includes('traité') ||
String(contract.etat_de_la_demande || contract.etat_demande || "").toLowerCase().includes('traitée');
const employerRecentlySent = wasRecentlySent(contract.last_employer_notification_at);
const employeeRecentlySent = wasRecentlySent(contract.last_employee_notification_at);
let action: ReminderAction;
let reason: string;
let forcedAction: ReminderAction | undefined; // Action qui sera utilisée si forceResend = true
// Logique de décision
if (employerSigned && employeeSigned) {
action = 'already-signed';
reason = 'Contrat entièrement signé';
} else if (!contractProcessed) {
action = 'skip';
reason = 'Contrat non traité (e-signature non envoyée)';
} else if (!employerSigned) {
if (employerRecentlySent) {
action = 'skip';
reason = 'Relance employeur envoyée il y a moins de 24h';
forcedAction = 'employer'; // Forcer vers employeur si demandé
} else {
action = 'employer';
reason = 'Employeur n\'a pas encore signé';
}
} else if (employerSigned && !employeeSigned) {
if (employeeRecentlySent) {
action = 'skip';
reason = 'Relance salarié envoyée il y a moins de 24h';
forcedAction = 'employee'; // Forcer vers salarié si demandé
} else {
action = 'employee';
reason = 'Salarié n\'a pas encore signé';
}
} else {
action = 'skip';
reason = 'État indéterminé';
}
const employerEmail = contract.org_id ? employerEmailsMap.get(contract.org_id) : undefined;
return {
id: contract.id,
reference: (contract as any).reference as string | null | undefined,
contract_number: contract.contract_number,
employee_name: formatEmployeeName(contract as any),
employee_email: (contract as any)?.salaries?.adresse_mail || undefined,
employer_email: employerEmail,
employer_signed: employerSigned,
employee_signed: employeeSigned,
contract_processed: contractProcessed,
action,
reason,
status: 'pending' as const,
forcedAction // Stocker l'action forcée pour usage ultérieur
} as SmartReminderContract & { forcedAction?: ReminderAction };
});
// Trier par action pour une meilleure lisibilité
const sorted = analyzedContracts.sort((a, b) => {
const order = { 'employer': 0, 'employee': 1, 'skip': 2, 'already-signed': 3 };
return order[a.action] - order[b.action];
});
setSmartReminderContracts(sorted);
setShowSmartReminderModal(true);
} finally {
setIsLoadingReminderEmails(false);
}
};
// Fonction pour relancer en masse tous les salariés sélectionnés (ancienne version - conservée pour compatibilité)
const handleBulkReminderClick = async () => {
if (selectedContractIds.size === 0) {
toast.error("Aucun contrat sélectionné");
return;
}
// Prépare et affiche le modal de confirmation avec les emails
setIsLoadingReminderEmails(true);
const contracts = rows.filter(r => selectedContractIds.has(r.id)).map(r => ({
id: r.id,
reference: (r as any).reference as string | null | undefined,
contract_number: r.contract_number,
employee_name: formatEmployeeName(r as any),
employee_email: (r as any)?.salaries?.adresse_mail || undefined,
}));
setBulkReminderContracts(contracts);
setShowBulkReminderModal(true);
// Récupérer les emails manquants via l'API dédiée
const fetchEmail = async (id: string) => {
try {
const resp = await fetch(`/api/staff/contrats/${id}/employee-email`);
if (resp.ok) {
const j = await resp.json();
return j.employee_email as string | undefined;
}
} catch {}
return undefined;
};
try {
const results = await Promise.all(contracts.map(async (c) => ({ id: c.id, email: c.employee_email || await fetchEmail(c.id) })));
setBulkReminderContracts(prev => prev.map(p => {
const found = results.find(r => r.id === p.id);
return { ...p, employee_email: found?.email || p.employee_email };
}));
} finally {
setIsLoadingReminderEmails(false);
}
};
// Envoi des relances intelligentes après confirmation
const confirmSendSmartReminders = async (forceResend: boolean = false) => {
setIsLoadingReminder(true);
// Si forceResend est activé, inclure aussi les contrats en cooldown qui ont une forcedAction
const contractsToProcess = forceResend
? smartReminderContracts.map(c => {
const extended = c as SmartReminderContract & { forcedAction?: ReminderAction };
if (extended.forcedAction) {
return { ...c, action: extended.forcedAction };
}
return c;
})
: smartReminderContracts;
const toSend = contractsToProcess.filter(c => c.action === 'employer' || c.action === 'employee');
// Initialiser le state de progression avec tous les contrats (avec les actions forcées si nécessaire)
setSmartReminderProgress([...contractsToProcess]);
let successCount = 0;
let errorCount = 0;
try {
for (const contract of toSend) {
// Mettre à jour le statut à "sending"
setSmartReminderProgress(prev =>
prev.map(c => c.id === contract.id ? { ...c, status: 'sending' as const } : c)
);
try {
if (contract.action === 'employer') {
// Relancer l'employeur
const response = await fetch(`/api/staff/contrats/${contract.id}/remind-employer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
successCount++;
setSmartReminderProgress(prev =>
prev.map(c => c.id === contract.id ? { ...c, status: 'success' as const } : c)
);
} else {
errorCount++;
setSmartReminderProgress(prev =>
prev.map(c => c.id === contract.id ? { ...c, status: 'error' as const } : c)
);
}
} else if (contract.action === 'employee') {
// Relancer le salarié
const response = await fetch('/api/staff/contrats/relance-salarie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contractId: contract.id }),
});
if (response.ok) {
successCount++;
setSmartReminderProgress(prev =>
prev.map(c => c.id === contract.id ? { ...c, status: 'success' as const } : c)
);
} else {
errorCount++;
setSmartReminderProgress(prev =>
prev.map(c => c.id === contract.id ? { ...c, status: 'error' as const } : c)
);
}
}
} catch {
errorCount++;
setSmartReminderProgress(prev =>
prev.map(c => c.id === contract.id ? { ...c, status: 'error' as const } : c)
);
}
}
if (successCount > 0) {
toast.success(`${successCount} relance${successCount > 1 ? 's' : ''} envoyée${successCount > 1 ? 's' : ''} avec succès`);
}
if (errorCount > 0) {
toast.error(`${errorCount} erreur${errorCount > 1 ? 's' : ''} lors de l'envoi`);
}
// Rafraîchir les données des contrats pour récupérer les nouveaux timestamps
// Les notifications seront automatiquement mises à jour via l'effet useEffect sur rows
setTimeout(() => fetchServer(page), 1000);
} finally {
setIsLoadingReminder(false);
// Ne pas fermer le modal automatiquement pour permettre de voir le résumé
// setShowSmartReminderModal(false);
}
};
// Envoi des relances après confirmation dans le modal (ancienne version)
const confirmSendBulkReminders = async () => {
setIsLoadingReminder(true);
const ids = bulkReminderContracts.map(c => c.id);
let successCount = 0;
let errorCount = 0;
try {
for (const contractId of ids) {
try {
const response = await fetch('/api/staff/contrats/relance-salarie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contractId }),
});
if (response.ok) successCount++; else errorCount++;
} catch {
errorCount++;
}
}
if (successCount > 0) toast.success(`${successCount} email${successCount > 1 ? 's' : ''} de relance envoyé${successCount > 1 ? 's' : ''}`);
if (errorCount > 0) toast.error(`${errorCount} erreur${errorCount > 1 ? 's' : ''} lors de l'envoi`);
} finally {
setIsLoadingReminder(false);
setShowBulkReminderModal(false);
}
};
// 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);
setEtatContratFilters(new Set());
setEtatPaieFilter(null);
setDpaeFilter(null);
setStartFrom(null);
setStartTo(null);
setSortField('start_date');
setSortOrder('desc');
setRows(initialData || []);
}}
>
Réinitialiser
</button>
</div>
</div>
{/* Filtres contrats (masquables) */}
{showFilters && (
<div className="border rounded-lg p-4 bg-slate-50">
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{/* Filtre État contrat - Dropdown with multiple checkboxes */}
<div className="relative group">
<label className="block text-sm font-medium text-slate-700 mb-2">
État contrat
</label>
<button className="w-full px-3 py-2 border rounded-lg bg-white text-sm text-left flex items-center justify-between hover:border-slate-400">
<span className={etatContratFilters.size === 0 ? "text-slate-500" : "text-slate-900"}>
{etatContratFilters.size === 0 ? "Tous les états" : `${etatContratFilters.size} sélectionné${etatContratFilters.size > 1 ? 's' : ''}`}
</span>
<svg className="w-4 h-4 text-slate-600" 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>
<div className="absolute top-full left-0 mt-1 w-full bg-white border rounded-lg shadow-lg z-10 hidden group-hover:block">
<div className="p-2 space-y-2 max-h-48 overflow-y-auto">
<div className="flex items-center px-2 py-1">
<input
type="checkbox"
id="etat-all"
checked={etatContratFilters.size === 0}
onChange={() => setEtatContratFilters(new Set())}
className="rounded border-gray-300"
/>
<label htmlFor="etat-all" className="ml-2 text-sm text-slate-700 cursor-pointer font-medium">
Tous les états
</label>
</div>
<div className="border-t"></div>
{etatsContrat.map((etat) => (
<div key={etat} className="flex items-center px-2 py-1">
<input
type="checkbox"
id={`etat-${etat}`}
checked={etatContratFilters.has(etat)}
onChange={(e) => {
const newSet = new Set(etatContratFilters);
if (e.target.checked) {
newSet.add(etat);
} else {
newSet.delete(etat);
}
setEtatContratFilters(newSet);
}}
className="rounded border-gray-300"
/>
<label htmlFor={`etat-${etat}`} className="ml-2 text-sm text-slate-700 cursor-pointer">
{etat}
</label>
</div>
))}
</div>
</div>
</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 Signature */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
État signature
</label>
<select
value={signatureFilter ?? ""}
onChange={(e) => setSignatureFilter(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="non_signe">Non signé</option>
<option value="employeur_seulement">Signé employeur seulement</option>
<option value="signe_complet">Complètement signé</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 Actions contrat */}
<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"
>
<Settings className="w-4 h-4" />
Actions contrat
<ChevronDown className="w-4 h-4" />
</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={() => {
setShowBulkPayslipModal(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors flex items-center gap-2"
>
<Euro className="w-4 h-4" />
Ajout de paie
</button>
<div className="border-t border-gray-200 my-1"></div>
<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 flex items-center gap-2"
>
<FileText className="w-4 h-4" />
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 flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
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 flex items-center gap-2"
>
<BarChart3 className="w-4 h-4" />
Modifier État Paie
</button>
<button
onClick={() => {
setShowAnalytiqueModal(true);
setShowActionMenu(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Modifier Analytique
</button>
<button
onClick={() => {
viewSelectedDetails();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors flex items-center gap-2"
>
<Eye className="w-4 h-4" />
Voir les détails
</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 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Supprimer les contrats
</button>
</div>
</div>
</>
)}
</div>
{/* Menu dropdown E-signature */}
<div className="relative">
<button
onClick={() => setShowESignMenu(!showESignMenu)}
className="px-3 py-1 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors flex items-center gap-1"
>
<FileSignature className="w-4 h-4" />
E-signature
<ChevronDown className="w-4 h-4" />
</button>
{showESignMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowESignMenu(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={() => {
generateBatchPdfs();
setShowESignMenu(false);
}}
disabled={isGeneratingPdfs}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors disabled:text-gray-400 disabled:hover:bg-white flex items-center gap-2"
>
<FileDown className="w-4 h-4" />
{isGeneratingPdfs ? "Génération..." : "Créer les PDF"}
</button>
<button
onClick={() => {
verifySelectedPdfs();
setShowESignMenu(false);
}}
disabled={isLoadingPdfs}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors disabled:text-gray-400 disabled:hover:bg-white flex items-center gap-2"
>
<Eye className="w-4 h-4" />
{isLoadingPdfs ? "Chargement..." : "Vérifier PDFs"}
</button>
<button
onClick={() => {
handleBulkESignClick();
setShowESignMenu(false);
}}
disabled={isGeneratingESign}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors disabled:text-gray-400 disabled:hover:bg-white flex items-center gap-2"
>
<FileSignature className="w-4 h-4" />
{isGeneratingESign ? "Envoi..." : "Envoyer e-sign"}
</button>
<div className="border-t border-gray-200 my-1"></div>
<button
onClick={() => {
setShowESignMenu(false);
handleSmartReminderClick();
}}
disabled={isLoadingReminder}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors disabled:text-gray-400 disabled:hover:bg-white flex items-center gap-2"
>
<BellRing className="w-4 h-4" />
{isLoadingReminder ? "Analyse..." : "Relances intelligentes"}
</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 flex items-center gap-1"
>
<Euro className="w-4 h-4" />
Saisir brut
</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 flex items-center gap-1"
>
<XCircle className="w-4 h-4" />
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" title="État Contrat">C</th>
<th className="text-left px-3 py-2" title="État Paie">P</th>
<th className="text-left px-3 py-2" title="DPAE">D</th>
<th className="text-left px-3 py-2">Signé</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">Production</th>
<th className="text-left px-3 py-2">Type</th>
<th className="text-left px-3 py-2">Profession</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" title="Dernières notifications de signature électronique">Notif.</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 { icon, tooltip } = getEtatContratIcon(r.etat_de_la_demande || r.etat_demande);
return (<div title={tooltip}>{icon}</div>);
})()}
</td>
<td className="px-3 py-2">
{(() => {
const { icon, tooltip } = getEtatPaieIcon(r.etat_de_la_paie);
return (<div title={tooltip}>{icon}</div>);
})()}
</td>
<td className="px-3 py-2">
{(() => {
const { icon, tooltip } = getDpaeIcon(r.dpae);
return (<div title={tooltip}>{icon}</div>);
})()}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-3">
<div className="flex flex-col items-center gap-1">
<div className="text-xs font-semibold text-slate-600">E</div>
{r.contrat_signe_par_employeur === "Oui" ? (
<Check className="size-4 text-green-600" strokeWidth={3} />
) : r.contrat_signe_par_employeur === "Non" ? (
<X className="size-4 text-red-600" strokeWidth={3} />
) : (
<span className="text-xs text-slate-400"></span>
)}
</div>
<div className="flex flex-col items-center gap-1">
<div className="text-xs font-semibold text-slate-600">S</div>
{r.contrat_signe === "Oui" ? (
<Check className="size-4 text-green-600" strokeWidth={3} />
) : r.contrat_signe === "Non" ? (
<X className="size-4 text-red-600" strokeWidth={3} />
) : (
<span className="text-xs text-slate-400"></span>
)}
</div>
</div>
</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.organizations?.organization_details?.code_employeur || r.structure || "—"}</td>
<td className="px-3 py-2">{r.production_name || "—"}</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">{r.profession || "—"}</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">
{(() => {
// Lire directement depuis le contrat au lieu de la map pour éviter les problèmes de timing
const employerLastSent = r.last_employer_notification_at;
const employeeLastSent = r.last_employee_notification_at;
const color = getNotificationColor(r.start_date);
if (!employerLastSent && !employeeLastSent) {
return <span className="text-xs text-slate-400"></span>;
}
return (
<div className={`flex flex-col gap-1 text-xs ${color}`} title={`E: Employeur • S: Salarié\nCouleur basée sur la date de début du contrat`}>
{employerLastSent && (
<div className="flex items-center gap-1">
<span className="font-semibold">E:</span>
<span>{formatNotificationDate(employerLastSent)}</span>
</div>
)}
{employeeLastSent && (
<div className="flex items-center gap-1">
<span className="font-semibold">S:</span>
<span>{formatNotificationDate(employeeLastSent)}</span>
</div>
)}
</div>
);
})()}
</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);
}}
/>
</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);
}}
/>
</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);
}}
/>
</div>
</div>
)}
{/* Modal Action groupée Analytique */}
{showAnalytiqueModal && (
<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 - Analytique</h3>
<p className="text-sm text-gray-600 mb-4">
Modifier l'analytique pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
</p>
<AnalytiqueActionModal
selectedContracts={selectedContracts}
onClose={() => setShowAnalytiqueModal(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, analytique: updated.analytique, production_name: updated.analytique } : row;
}));
setShowAnalytiqueModal(false);
}}
/>
</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);
}}
/>
</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 détails des contrats */}
<ContractDetailsModal
isOpen={showDetailsModal}
onClose={() => setShowDetailsModal(false)}
contractIds={contractDetailsIds}
contracts={rows}
/>
{/* Modal de confirmation pour l'envoi des e-signatures */}
<BulkESignConfirmModal
isOpen={showESignConfirmModal}
onClose={() => setShowESignConfirmModal(false)}
onConfirm={generateBatchESign}
contractCount={selectedContractIds.size}
selectedContracts={selectedContracts}
/>
{/* Modal de confirmation pour la relance des salariés (bulk) */}
<BulkEmployeeReminderModal
isOpen={showBulkReminderModal}
onClose={() => setShowBulkReminderModal(false)}
onConfirm={confirmSendBulkReminders}
isLoading={isLoadingReminder}
isLoadingEmails={isLoadingReminderEmails}
contracts={bulkReminderContracts}
/>
{/* Modal intelligent pour les relances */}
<SmartReminderModal
isOpen={showSmartReminderModal}
onClose={() => setShowSmartReminderModal(false)}
onConfirm={confirmSendSmartReminders}
isLoading={isLoadingReminder}
contracts={smartReminderContracts}
progressContracts={smartReminderProgress.length > 0 ? smartReminderProgress : undefined}
/>
{/* Modal d'ajout de paie en masse */}
<BulkPayslipModal
isOpen={showBulkPayslipModal}
onClose={() => setShowBulkPayslipModal(false)}
contracts={selectedContracts}
onSubmit={handleBulkPayslipSubmit}
/>
{/* 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>
)}
{/* Modal de relance salarié */}
<EmployeeReminderModal
isOpen={showEmployeeReminderModal}
onClose={() => {
setShowEmployeeReminderModal(false);
setSelectedContractForReminder(null);
}}
onConfirm={sendEmployeeReminder}
isLoading={isLoadingReminder}
contractData={{
reference: selectedContractForReminder?.reference || selectedContractForReminder?.contract_number,
employee_name: selectedContractForReminder?.employee_name,
employee_email: selectedContractForReminder?.employee_email
}}
/>
</div>
);
}
const ContractsGrid = forwardRef(ContractsGridImpl);
export default ContractsGrid;
// 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 l'analytique
function AnalytiqueActionModal({
selectedContracts,
onClose,
onSuccess
}: {
selectedContracts: Contract[];
onClose: () => void;
onSuccess: (contracts: { id: string; analytique: string }[]) => void;
}) {
const [newAnalytique, setNewAnalytique] = useState<string>('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!newAnalytique.trim()) {
toast.error("Veuillez saisir un code analytique");
return;
}
setLoading(true);
try {
const response = await fetch('/api/staff/contracts/bulk-update-analytique', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contractIds: selectedContracts.map(c => c.id),
analytique: newAnalytique.trim()
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erreur lors de la mise à jour');
}
const result = await response.json();
toast.success(`${result.count} contrat(s) mis à jour`);
onSuccess(result.contracts);
} catch (error: any) {
console.error('Erreur:', error);
toast.error(error.message || '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">
Nouveau code analytique
</label>
<input
type="text"
value={newAnalytique}
onChange={(e) => setNewAnalytique(e.target.value)}
placeholder="Ex: Production 2025"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
<p className="text-xs text-gray-500 mt-1">
Ce code sera appliqué au champ "Analytique" de tous les contrats sélectionnés
</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 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)}
{contract.analytique && (
<span className="text-xs text-gray-400 ml-2">
(actuel: {contract.analytique})
</span>
)}
</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={!newAnalytique.trim() || 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>
</>
);
}