- Créer hook useStaffOrgSelection avec persistence localStorage - Ajouter badge StaffOrgBadge dans Sidebar - Synchroniser filtres org dans toutes les pages (contrats, cotisations, facturation, etc.) - Fix calcul cachets: utiliser totalQuantities au lieu de dates.length - Fix structure field bug: ne plus écraser avec production_name - Ajouter création note lors modification contrat - Implémenter montants personnalisés pour virements salaires - Migrations SQL: custom_amount + fix_structure_field - Réorganiser boutons ContractEditor en carte flottante droite
3651 lines
145 KiB
TypeScript
3651 lines
145 KiB
TypeScript
"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, Calendar } 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;
|
||
nombre_d_heures?: number | null;
|
||
n_objet?: string | null;
|
||
objet_spectacle?: string | null;
|
||
salaries?: {
|
||
salarie?: string | null;
|
||
nom?: string | null;
|
||
prenom?: string | null;
|
||
adresse_mail?: string | null;
|
||
code_salarie?: 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 [productionFilter, setProductionFilter] = useState<string | null>(savedFilters?.productionFilter || 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(10000); // Limite élevée pour récupérer tous les contrats
|
||
const [showFilters, setShowFilters] = useState(savedFilters?.showFilters || false);
|
||
const totalCountRef = useRef<number | null>(null);
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
|
||
// 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);
|
||
const [showJoursTechnicienModal, setShowJoursTechnicienModal] = useState(false);
|
||
const [showSignatureDateModal, setShowSignatureDateModal] = 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
|
||
setProductionFilter(null); // Reset production
|
||
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
|
||
setProductionFilter(null); // Reset production
|
||
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
|
||
setProductionFilter(null); // Reset production
|
||
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
|
||
setProductionFilter(null); // Reset production
|
||
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
|
||
setProductionFilter(null); // Reset production
|
||
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
|
||
setProductionFilter(null); // Reset production
|
||
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,
|
||
productionFilter,
|
||
etatContratFilters: Array.from(etatContratFilters),
|
||
etatPaieFilter,
|
||
dpaeFilter,
|
||
signatureFilter,
|
||
startFrom,
|
||
startTo,
|
||
endFrom,
|
||
endTo,
|
||
sortField,
|
||
sortOrder,
|
||
showFilters
|
||
};
|
||
saveFiltersToStorage(filters);
|
||
}, [q, structureFilter, typeFilter, productionFilter, etatContratFilters, etatPaieFilter, dpaeFilter, signatureFilter, startFrom, startTo, endFrom, endTo, sortField, sortOrder, showFilters]);
|
||
|
||
// Réinitialiser le filtre de production quand la structure change
|
||
useEffect(() => {
|
||
// Si une structure est sélectionnée et que le filtre de production actuel n'existe pas dans la nouvelle liste
|
||
if (structureFilter && productionFilter) {
|
||
const availableProductions = (initialData || [])
|
||
.filter(r => r.structure === structureFilter)
|
||
.map(r => r.production_name)
|
||
.filter(Boolean);
|
||
|
||
if (!availableProductions.includes(productionFilter)) {
|
||
setProductionFilter(null);
|
||
}
|
||
}
|
||
// Si aucune structure n'est sélectionnée, réinitialiser le filtre de production
|
||
if (!structureFilter && productionFilter) {
|
||
setProductionFilter(null);
|
||
}
|
||
}, [structureFilter, initialData, productionFilter]);
|
||
|
||
// 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);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// Fetch server-side data if filters are active on mount
|
||
useEffect(() => {
|
||
const hasActiveFilters = q || structureFilter || typeFilter || etatContratFilters.size > 0 || etatPaieFilter || dpaeFilter || signatureFilter || startFrom || startTo || endFrom || endTo;
|
||
|
||
if (hasActiveFilters) {
|
||
// Si des filtres sont actifs au chargement (depuis localStorage), fetch les données filtrées
|
||
fetchServer(0);
|
||
}
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
// Note: On veut que cela s'exécute uniquement au montage, pas quand les filtres changent
|
||
|
||
// 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);
|
||
if (productionFilter) params.set('production_name', productionFilter);
|
||
// 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 'production_name':
|
||
valueA = a.production_name || '';
|
||
valueB = b.production_name || '';
|
||
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 && !productionFilter && 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, productionFilter, 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 exporter les contrats sélectionnés en TSV (format sPAIEctacle)
|
||
const exportSelectedToTSV = async () => {
|
||
if (selectedContractIds.size === 0) {
|
||
toast.error("Aucun contrat sélectionné");
|
||
return;
|
||
}
|
||
|
||
const selectedRows = rows.filter(contract => selectedContractIds.has(contract.id));
|
||
|
||
// Charger les fichiers JSON de professions pour la correspondance
|
||
let professionsMap = new Map<string, string>();
|
||
try {
|
||
const [artistesRes, techniciensRes] = await Promise.all([
|
||
fetch('/data/professions-feminisations.json'),
|
||
fetch('/data/professions-techniciens.json')
|
||
]);
|
||
|
||
const artistes = await artistesRes.json();
|
||
const techniciens = await techniciensRes.json();
|
||
|
||
// Construire une map label -> code pour les artistes
|
||
artistes.forEach((prof: any) => {
|
||
if (prof.profession_label && prof.profession_code) {
|
||
professionsMap.set(prof.profession_label.toLowerCase(), prof.profession_code);
|
||
}
|
||
if (prof.profession_feminine && prof.profession_code) {
|
||
professionsMap.set(prof.profession_feminine.toLowerCase(), prof.profession_code);
|
||
}
|
||
});
|
||
|
||
// Construire une map label -> code pour les techniciens
|
||
techniciens.forEach((prof: any) => {
|
||
if (prof.label && prof.code) {
|
||
professionsMap.set(prof.label.toLowerCase(), prof.code);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors du chargement des professions:", error);
|
||
toast.error("Erreur lors du chargement des professions");
|
||
return;
|
||
}
|
||
|
||
// En-têtes TSV (format sPAIEctacle)
|
||
const headers = [
|
||
"Code societe",
|
||
"Code contrat",
|
||
"Matricule",
|
||
"Categorie salariale",
|
||
"Code profession",
|
||
"Type de contrat",
|
||
"Debut de contrat",
|
||
"Fin de contrat",
|
||
"Code rubrique",
|
||
"Quantite rubrique",
|
||
"Base rubrique",
|
||
"Cout employeur",
|
||
"Compte analytique",
|
||
"Compte analytique multiple",
|
||
"Repartition analytique multiple",
|
||
"Numero d'objet",
|
||
"Date travaillee debut",
|
||
"Date travaillee fin",
|
||
"Contrat"
|
||
];
|
||
|
||
// Fonction pour formater une date en DD/MM/YYYY
|
||
const formatDateForTSV = (dateString: string | null | undefined): 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 year = date.getFullYear();
|
||
return `${day}/${month}/${year}`;
|
||
} catch {
|
||
return "";
|
||
}
|
||
};
|
||
|
||
// Fonction pour obtenir le code profession depuis le label
|
||
const getProfessionCode = (professionLabel: string | null | undefined): string => {
|
||
if (!professionLabel) return "";
|
||
const code = professionsMap.get(professionLabel.toLowerCase());
|
||
return code || "";
|
||
};
|
||
|
||
// Construire les lignes de données
|
||
const dataRows = selectedRows.map(contract => {
|
||
const codeEmployeur = contract.organizations?.organization_details?.code_employeur || "";
|
||
const contractNumber = contract.contract_number || "";
|
||
const matricule = contract.salaries?.code_salarie || contract.employee_id || "";
|
||
const categorieSalariale = "Cas General"; // Fixe
|
||
const codeProfession = getProfessionCode(contract.profession);
|
||
const typeContrat = "Intermittent"; // Fixe pour CDDU
|
||
const dateDebut = formatDateForTSV(contract.start_date);
|
||
const dateFin = formatDateForTSV(contract.end_date);
|
||
const codeRubrique = "He"; // Fixe
|
||
const quantiteRubrique = contract.nombre_d_heures ? contract.nombre_d_heures.toString() : ""; // Heures de travail au total
|
||
const baseRubrique = ""; // Vide
|
||
const coutEmployeur = contract.gross_pay ? contract.gross_pay.toFixed(2).replace('.', ',') : "";
|
||
const compteAnalytique = "COURSCLEMENT"; // Fixe pour l'instant
|
||
const compteAnalytiqueMultiple = ""; // Vide
|
||
const repartitionAnalytiqueMultiple = ""; // Vide
|
||
const numeroObjet = contract.n_objet || contract.objet_spectacle || ""; // Numéro d'objet de la production
|
||
const dateTravailleeDebut = ""; // Vide
|
||
const dateTravailleeFin = ""; // Vide
|
||
const contrat = contractNumber;
|
||
|
||
return [
|
||
codeEmployeur,
|
||
contractNumber,
|
||
matricule,
|
||
categorieSalariale,
|
||
codeProfession,
|
||
typeContrat,
|
||
dateDebut,
|
||
dateFin,
|
||
codeRubrique,
|
||
quantiteRubrique,
|
||
baseRubrique,
|
||
coutEmployeur,
|
||
compteAnalytique,
|
||
compteAnalytiqueMultiple,
|
||
repartitionAnalytiqueMultiple,
|
||
numeroObjet,
|
||
dateTravailleeDebut,
|
||
dateTravailleeFin,
|
||
contrat
|
||
];
|
||
});
|
||
|
||
// Construire le contenu TSV
|
||
const tsvContent = [
|
||
headers.join('\t'),
|
||
...dataRows.map(row => row.join('\t'))
|
||
].join('\n');
|
||
|
||
// Créer et télécharger le fichier
|
||
const blob = new Blob([tsvContent], { type: 'text/tab-separated-values;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
// Générer un nom de fichier avec la date
|
||
const now = new Date();
|
||
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||
const filename = `export_contrats_spaiectacle_${dateStr}.tsv`;
|
||
|
||
link.setAttribute('href', url);
|
||
link.setAttribute('download', filename);
|
||
link.style.visibility = 'hidden';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
|
||
toast.success(`${selectedRows.length} contrat(s) exporté(s) en TSV`);
|
||
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 && !productionFilter && 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, productionFilter, 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]);
|
||
// Filtrer les productions par structure sélectionnée
|
||
const productions = useMemo(() => {
|
||
let filteredData = initialData || [];
|
||
|
||
// Si une structure est sélectionnée, filtrer les contrats par cette structure
|
||
if (structureFilter) {
|
||
filteredData = filteredData.filter(r => r.structure === structureFilter);
|
||
}
|
||
|
||
const uniqueProductions = Array.from(new Set(filteredData.map((r) => r.production_name).filter(Boolean) as string[]));
|
||
return uniqueProductions.sort((a, b) => a.localeCompare(b, 'fr')).slice(0, 50);
|
||
}, [initialData, structureFilter]);
|
||
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) => {
|
||
const value = e.target.value || null;
|
||
setStructureFilter(value);
|
||
// Note: structureFilter est un nom de structure (string), pas un UUID
|
||
// On ne synchronise pas avec le store global ici car il attend des UUIDs
|
||
}}
|
||
className="rounded border px-2 py-1 text-sm max-w-[200px]"
|
||
title={structureFilter || "Toutes structures"}
|
||
>
|
||
<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>
|
||
{/* Afficher le dropdown de production uniquement si une structure est sélectionnée */}
|
||
{structureFilter && (
|
||
<select
|
||
value={productionFilter ?? ""}
|
||
onChange={(e) => setProductionFilter(e.target.value || null)}
|
||
className="rounded border px-2 py-1 text-sm max-w-[200px]"
|
||
title={productionFilter || "Toutes productions"}
|
||
>
|
||
<option value="">Toutes productions</option>
|
||
{productions.map((p) => (<option key={p} value={p}>{p}</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);
|
||
setProductionFilter(null);
|
||
setEtatContratFilters(new Set());
|
||
setEtatPaieFilter(null);
|
||
setDpaeFilter(null);
|
||
setSignatureFilter(null);
|
||
setStartFrom(null);
|
||
setStartTo(null);
|
||
setEndFrom(null);
|
||
setEndTo(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>
|
||
<button
|
||
onClick={() => {
|
||
exportSelectedToTSV();
|
||
}}
|
||
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"
|
||
>
|
||
<FileDown className="w-4 h-4" />
|
||
Exporter TSV (sPAIEctacle)
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowJoursTechnicienModal(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"
|
||
>
|
||
<Calendar className="w-4 h-4" />
|
||
Jours technicien
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowSignatureDateModal(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"
|
||
>
|
||
<FileSignature className="w-4 h-4" />
|
||
Modifier date de signature
|
||
</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 cursor-pointer" onClick={() => { setSortField('production_name'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
||
Production {sortField === 'production_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||
</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 Action groupée Jours Technicien */}
|
||
{showJoursTechnicienModal && (
|
||
<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">
|
||
<h3 className="text-lg font-semibold mb-4">Action groupée - Jours Technicien</h3>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
Modifier les jours de travail pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
|
||
</p>
|
||
<JoursTechnicienModal
|
||
selectedContracts={selectedContracts}
|
||
onClose={() => setShowJoursTechnicienModal(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, jours_travail: updated.jours_travail, jours_travail_non_artiste: updated.jours_travail_non_artiste } : row;
|
||
}));
|
||
setShowJoursTechnicienModal(false);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal Action groupée - Modifier date de signature */}
|
||
{showSignatureDateModal && (
|
||
<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 - Date de signature</h3>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
Modifier la date de signature pour {selectedContractIds.size} contrat{selectedContractIds.size > 1 ? 's' : ''}
|
||
</p>
|
||
<SignatureDateModal
|
||
selectedContracts={selectedContracts}
|
||
onClose={() => setShowSignatureDateModal(false)}
|
||
onSuccess={(updatedContracts) => {
|
||
setRows(prev => prev.map(row => {
|
||
const updated = updatedContracts.find(u => u.id === row.id);
|
||
return updated ? { ...row, date_signature: updated.date_signature } : row;
|
||
}));
|
||
setShowSignatureDateModal(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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Modal pour les jours technicien
|
||
function JoursTechnicienModal({
|
||
selectedContracts,
|
||
onClose,
|
||
onSuccess
|
||
}: {
|
||
selectedContracts: Contract[];
|
||
onClose: () => void;
|
||
onSuccess: (contracts: { id: string; jours_travail: string; jours_travail_non_artiste: string }[]) => void;
|
||
}) {
|
||
const [joursTravailList, setJoursTravailList] = useState<string[]>(
|
||
selectedContracts.map(() => '')
|
||
);
|
||
const [loading, setLoading] = useState(false);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||
e.preventDefault();
|
||
const pastedText = e.clipboardData.getData('text');
|
||
const lines = pastedText.split('\n').filter(line => line.trim() !== '');
|
||
|
||
// Distribuer les lignes collées aux contrats sélectionnés
|
||
const newList = [...joursTravailList];
|
||
lines.forEach((line, index) => {
|
||
if (index < newList.length) {
|
||
newList[index] = line.trim();
|
||
}
|
||
});
|
||
|
||
setJoursTravailList(newList);
|
||
};
|
||
|
||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
const lines = e.target.value.split('\n');
|
||
const newList = lines.map(line => line.trim());
|
||
|
||
// Ajuster la taille du tableau pour correspondre au nombre de contrats
|
||
while (newList.length < selectedContracts.length) {
|
||
newList.push('');
|
||
}
|
||
|
||
setJoursTravailList(newList.slice(0, selectedContracts.length));
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const updates = selectedContracts.map((contract, index) => ({
|
||
id: contract.id,
|
||
joursTravail: joursTravailList[index] || ''
|
||
}));
|
||
|
||
const response = await fetch('/api/staff/contracts/bulk-update-jours-technicien', {
|
||
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 contrats');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Jours de travail (un par ligne)
|
||
</label>
|
||
<p className="text-xs text-gray-500 mb-2">
|
||
Collez les dates ligne par ligne. Chaque ligne correspond à un contrat dans l'ordre ci-dessous.
|
||
</p>
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={joursTravailList.join('\n')}
|
||
onChange={handleTextareaChange}
|
||
onPaste={handlePaste}
|
||
placeholder="Exemple: 01/01/2025, 02/01/2025 03/01/2025, 04/01/2025 ..."
|
||
rows={Math.min(selectedContracts.length, 10)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border rounded-lg p-3 max-h-60 overflow-y-auto">
|
||
<p className="text-sm font-medium text-gray-700 mb-2">Contrats sélectionnés :</p>
|
||
{selectedContracts.map((contract, index) => (
|
||
<div key={contract.id} className="text-sm text-gray-600 py-1 flex items-start gap-2">
|
||
<span className="text-gray-400 min-w-[30px]">{index + 1}.</span>
|
||
<div className="flex-1">
|
||
<div className="font-medium">{contract.contract_number || contract.id} - {formatEmployeeName(contract)}</div>
|
||
{joursTravailList[index] && (
|
||
<div className="text-xs text-green-600 mt-1">
|
||
{joursTravailList[index]}
|
||
</div>
|
||
)}
|
||
</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 || joursTravailList.every(j => !j)}
|
||
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 modifier la date de signature en masse
|
||
function SignatureDateModal({
|
||
selectedContracts,
|
||
onClose,
|
||
onSuccess
|
||
}: {
|
||
selectedContracts: Contract[];
|
||
onClose: () => void;
|
||
onSuccess: (contracts: { id: string; date_signature: string | null }[]) => void;
|
||
}) {
|
||
const [lines, setLines] = useState<string[]>(selectedContracts.map(() => ''));
|
||
const [applyDate, setApplyDate] = useState<string>('');
|
||
const [loading, setLoading] = useState(false);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
useEffect(() => {
|
||
// Resize initial lines to match contracts
|
||
setLines(prev => {
|
||
const copy = [...prev];
|
||
while (copy.length < selectedContracts.length) copy.push('');
|
||
return copy.slice(0, selectedContracts.length);
|
||
});
|
||
}, [selectedContracts.length]);
|
||
|
||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||
e.preventDefault();
|
||
const pasted = e.clipboardData.getData('text');
|
||
const pastedLines = pasted.split('\n').map(l => l.trim()).filter(Boolean);
|
||
const newLines = [...lines];
|
||
pastedLines.forEach((l, i) => {
|
||
if (i < newLines.length) newLines[i] = l;
|
||
});
|
||
setLines(newLines);
|
||
};
|
||
|
||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
const vals = e.target.value.split('\n').map(l => l.trim());
|
||
while (vals.length < selectedContracts.length) vals.push('');
|
||
setLines(vals.slice(0, selectedContracts.length));
|
||
};
|
||
|
||
const handleApplyDateToAll = (d: string) => {
|
||
setApplyDate(d);
|
||
if (d) {
|
||
setLines(selectedContracts.map(() => d));
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const updates = selectedContracts.map((c, idx) => ({ id: c.id, date: lines[idx] ? new Date(lines[idx]).toISOString().split('T')[0] : null }));
|
||
|
||
const res = await fetch('/api/staff/contracts/bulk-update-signature-date', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ updates })
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Erreur lors de la mise à jour');
|
||
const json = await res.json();
|
||
onSuccess(json.contracts || []);
|
||
} catch (err) {
|
||
console.error(err);
|
||
toast.error('Erreur lors de la mise à jour des dates de signature');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Date de signature</label>
|
||
<p className="text-xs text-gray-500 mb-2">Vous pouvez coller une liste de dates (une par ligne) ; chaque ligne correspond à un contrat dans l'ordre ci-dessous. Format recommandé : YYYY-MM-DD ou JJ/MM/YYYY.</p>
|
||
<div className="flex gap-2 mb-2">
|
||
<input
|
||
type="date"
|
||
value={applyDate}
|
||
onChange={e => handleApplyDateToAll(e.target.value)}
|
||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||
/>
|
||
<div className="text-xs text-slate-500 self-center">ou collez ci-dessous</div>
|
||
</div>
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={lines.join('\n')}
|
||
onChange={handleChange}
|
||
onPaste={handlePaste}
|
||
rows={Math.min(selectedContracts.length, 10)}
|
||
placeholder={`2025-01-01\n2025-02-03\n...`}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border rounded-lg p-3 max-h-60 overflow-y-auto">
|
||
<p className="text-sm font-medium text-gray-700 mb-2">Contrats sélectionnés :</p>
|
||
{selectedContracts.map((c, idx) => (
|
||
<div key={c.id} className="text-sm text-gray-600 py-1 flex items-start gap-2">
|
||
<span className="text-gray-400 min-w-[30px]">{idx + 1}.</span>
|
||
<div className="flex-1">
|
||
<div className="font-medium">{c.contract_number || c.id} - {formatEmployeeName(c)}</div>
|
||
{lines[idx] && (
|
||
<div className="text-xs text-green-600 mt-1">{lines[idx]}</div>
|
||
)}
|
||
</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 || lines.every(l => !l)}
|
||
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>
|
||
</>
|
||
);
|
||
}
|