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

1763 lines
68 KiB
TypeScript

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