- 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
1685 lines
67 KiB
TypeScript
1685 lines
67 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState, useRef } from "react";
|
|
import { supabase } from "@/lib/supabaseClient";
|
|
import Link from "next/link";
|
|
import { RefreshCw, Check, X, Eye, Plus, CheckCircle2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import BulkPayslipUploadModal from "./payslips/BulkPayslipUploadModal";
|
|
import PayslipDetailsModal from "./PayslipDetailsModal";
|
|
import PayslipPdfVerificationModal from "./PayslipPdfVerificationModal";
|
|
import CreatePayslipModal from "./CreatePayslipModal";
|
|
import MissingPayslipsModal from "./MissingPayslipsModal";
|
|
import MissingPayslipsOrganizationsModal from "./MissingPayslipsOrganizationsModal";
|
|
|
|
// 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 currency
|
|
function formatCurrency(value: string | number | null | undefined): string {
|
|
if (!value) return "—";
|
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
if (isNaN(num)) return "—";
|
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(num);
|
|
}
|
|
|
|
// Utility function to format period as text (e.g., "Oct. 2025")
|
|
function formatPeriodText(periodStart: string | null | undefined): string {
|
|
if (!periodStart) return "—";
|
|
try {
|
|
const date = new Date(periodStart);
|
|
const monthNames = ["Jan.", "Fév.", "Mars", "Avr.", "Mai", "Juin", "Juil.", "Août", "Sept.", "Oct.", "Nov.", "Déc."];
|
|
const month = monthNames[date.getMonth()];
|
|
const year = date.getFullYear();
|
|
return `${month} ${year}`;
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
// Utility function to format employee name
|
|
function formatEmployeeName(payslip: { cddu_contracts?: any }): string {
|
|
const contract = payslip.cddu_contracts;
|
|
if (!contract) return "—";
|
|
|
|
// Priorité : utiliser salaries.salarie si disponible
|
|
if (contract.salaries?.salarie) {
|
|
return contract.salaries.salarie;
|
|
}
|
|
|
|
// Construire depuis nom + prénom
|
|
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(' ');
|
|
}
|
|
|
|
// Fallback : utiliser employee_name
|
|
if (contract.employee_name) {
|
|
const parts = contract.employee_name.trim().split(' ');
|
|
if (parts.length >= 2) {
|
|
const prenom = parts[0];
|
|
const nom = parts.slice(1).join(' ');
|
|
return `${nom.toUpperCase()} ${prenom}`;
|
|
}
|
|
return contract.employee_name;
|
|
}
|
|
|
|
return "—";
|
|
}
|
|
|
|
// Utility function to extract last name for sorting
|
|
function getLastName(payslip: { cddu_contracts?: any }): string {
|
|
const contract = payslip.cddu_contracts;
|
|
if (!contract) return "";
|
|
|
|
if (contract.salaries?.nom) {
|
|
return contract.salaries.nom.toUpperCase();
|
|
}
|
|
|
|
if (contract.employee_name) {
|
|
const parts = contract.employee_name.trim().split(' ');
|
|
if (parts.length >= 2) {
|
|
return parts.slice(1).join(' ');
|
|
}
|
|
return contract.employee_name;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
type Payslip = {
|
|
id: string;
|
|
contract_id: string;
|
|
period_start?: string | null;
|
|
period_end?: string | null;
|
|
period_month?: string | null;
|
|
pay_number?: number | null;
|
|
pay_date?: string | null;
|
|
gross_amount?: string | number | null;
|
|
net_amount?: string | number | null;
|
|
net_after_withholding?: string | number | null;
|
|
employer_cost?: string | number | null;
|
|
processed?: boolean | null;
|
|
aem_status?: string | null;
|
|
transfer_done?: boolean | null;
|
|
organization_id?: string | null;
|
|
storage_path?: string | null;
|
|
created_at?: string | null;
|
|
cddu_contracts?: {
|
|
id: string;
|
|
contract_number?: string | null;
|
|
employee_name?: string | null;
|
|
employee_id?: string | null;
|
|
structure?: string | null;
|
|
type_de_contrat?: string | null;
|
|
org_id?: string | null;
|
|
salaries?: {
|
|
salarie?: string | null;
|
|
nom?: string | null;
|
|
prenom?: string | null;
|
|
} | null;
|
|
organizations?: {
|
|
organization_details?: {
|
|
code_employeur?: string | null;
|
|
} | null;
|
|
} | null;
|
|
} | null;
|
|
};
|
|
|
|
export default function PayslipsGrid({ initialData, activeOrgId }: { initialData: Payslip[]; activeOrgId?: string | null }) {
|
|
const [rows, setRows] = useState<Payslip[]>(initialData || []);
|
|
const [showRaw, setShowRaw] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Selection state
|
|
const [selectedPayslipIds, setSelectedPayslipIds] = useState<Set<string>>(new Set());
|
|
|
|
// filters / sorting / pagination
|
|
const [q, setQ] = useState("");
|
|
const [structureFilter, setStructureFilter] = useState<string | null>(null);
|
|
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
|
const [processedFilter, setProcessedFilter] = useState<string | null>(null);
|
|
const [transferFilter, setTransferFilter] = useState<string | null>(null);
|
|
const [aemFilter, setAemFilter] = useState<string | null>(null);
|
|
const [periodFrom, setPeriodFrom] = useState<string | null>(null);
|
|
const [periodTo, setPeriodTo] = useState<string | null>(null);
|
|
const [sortField, setSortField] = useState<string>("period_start");
|
|
const [sortOrder, setSortOrder] = useState<'asc'|'desc'>('desc');
|
|
const [page, setPage] = useState(0);
|
|
const [limit, setLimit] = useState(50);
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const totalCountRef = useRef<number | null>(null);
|
|
|
|
// Selection helpers
|
|
const selectedPayslips = rows.filter(payslip => selectedPayslipIds.has(payslip.id));
|
|
const isAllSelected = rows.length > 0 && selectedPayslipIds.size === rows.length;
|
|
const isPartiallySelected = selectedPayslipIds.size > 0 && selectedPayslipIds.size < rows.length;
|
|
|
|
// Modal states
|
|
const [showProcessedModal, setShowProcessedModal] = useState(false);
|
|
const [showTransferModal, setShowTransferModal] = useState(false);
|
|
const [showAemModal, setShowAemModal] = useState(false);
|
|
const [showActionMenu, setShowActionMenu] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [showBulkUploadModal, setShowBulkUploadModal] = useState(false);
|
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [payslipDetailsIds, setPayslipDetailsIds] = useState<string[]>([]);
|
|
const [showPdfVerificationModal, setShowPdfVerificationModal] = useState(false);
|
|
const [payslipsPdfs, setPayslipsPdfs] = useState<Array<{
|
|
id: string;
|
|
contractNumber?: string;
|
|
employeeName?: string;
|
|
pdfUrl?: string;
|
|
hasError: boolean;
|
|
errorMessage?: string;
|
|
}>>([]);
|
|
const [isLoadingPdfs, setIsLoadingPdfs] = useState(false);
|
|
|
|
// Stats for missing payslips
|
|
const [missingPayslipsCount, setMissingPayslipsCount] = useState<number | null>(null);
|
|
const [missingOrganizationsCount, setMissingOrganizationsCount] = useState<number | null>(null);
|
|
const [totalMissingContracts, setTotalMissingContracts] = useState<number | null>(null);
|
|
const [isLoadingStats, setIsLoadingStats] = useState(false);
|
|
const [showMissingPayslipsModal, setShowMissingPayslipsModal] = useState(false);
|
|
const [showMissingOrganizationsModal, setShowMissingOrganizationsModal] = useState(false);
|
|
const [preselectedContractId, setPreselectedContractId] = useState<string | null>(null);
|
|
const isFirstRender = useRef(true);
|
|
|
|
// Fetch initial au montage du composant
|
|
useEffect(() => {
|
|
fetchServer(0);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Handler pour mettre à jour une paie après édition
|
|
const handlePayslipUpdated = (updatedPayslip: any) => {
|
|
setRows((currentRows) =>
|
|
currentRows.map((row) =>
|
|
row.id === updatedPayslip.id ? { ...row, ...updatedPayslip } : row
|
|
)
|
|
);
|
|
};
|
|
|
|
// Fonction pour vérifier les PDFs des fiches de paie sélectionnées
|
|
const verifySelectedPdfs = async () => {
|
|
if (selectedPayslipIds.size === 0) {
|
|
toast.error("Aucune fiche de paie sélectionnée");
|
|
return;
|
|
}
|
|
|
|
const payslipIds = Array.from(selectedPayslipIds);
|
|
|
|
// Ouvrir le modal et commencer le chargement
|
|
setShowPdfVerificationModal(true);
|
|
setIsLoadingPdfs(true);
|
|
setPayslipsPdfs([]);
|
|
|
|
try {
|
|
// Récupérer les URLs des PDFs pour chaque fiche de paie
|
|
const pdfPromises = payslipIds.map(async (payslipId) => {
|
|
const payslip = rows.find(r => r.id === payslipId);
|
|
|
|
try {
|
|
// Si pas de storage_path, retourner une erreur
|
|
if (!payslip?.storage_path) {
|
|
return {
|
|
id: payslipId,
|
|
contractNumber: payslip?.cddu_contracts?.contract_number || undefined,
|
|
employeeName: formatEmployeeName(payslip as any),
|
|
pdfUrl: undefined,
|
|
hasError: true,
|
|
errorMessage: 'PDF non uploadé'
|
|
};
|
|
}
|
|
|
|
// Générer l'URL presignée
|
|
const response = await fetch('/api/s3-presigned', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: payslip.storage_path })
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return {
|
|
id: payslipId,
|
|
contractNumber: payslip?.cddu_contracts?.contract_number || undefined,
|
|
employeeName: formatEmployeeName(payslip as any),
|
|
pdfUrl: result.url,
|
|
hasError: false
|
|
};
|
|
} else {
|
|
const errorData = await response.json();
|
|
return {
|
|
id: payslipId,
|
|
contractNumber: payslip?.cddu_contracts?.contract_number || undefined,
|
|
employeeName: formatEmployeeName(payslip as any),
|
|
pdfUrl: undefined,
|
|
hasError: true,
|
|
errorMessage: errorData.error || 'PDF non trouvé'
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
id: payslipId,
|
|
contractNumber: payslip?.cddu_contracts?.contract_number || undefined,
|
|
employeeName: formatEmployeeName(payslip as any),
|
|
pdfUrl: undefined,
|
|
hasError: true,
|
|
errorMessage: 'Erreur de réseau'
|
|
};
|
|
}
|
|
});
|
|
|
|
const pdfs = await Promise.all(pdfPromises);
|
|
setPayslipsPdfs(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);
|
|
setPayslipsPdfs([]);
|
|
setIsLoadingPdfs(false);
|
|
};
|
|
|
|
// Fetch missing payslips statistics when structure and period filters change
|
|
useEffect(() => {
|
|
const fetchMissingStats = async () => {
|
|
if (!periodFrom || !periodTo) {
|
|
setMissingPayslipsCount(null);
|
|
setMissingOrganizationsCount(null);
|
|
setTotalMissingContracts(null);
|
|
return;
|
|
}
|
|
|
|
setIsLoadingStats(true);
|
|
try {
|
|
if (structureFilter) {
|
|
// Si une organisation est sélectionnée
|
|
setMissingOrganizationsCount(null);
|
|
setTotalMissingContracts(null);
|
|
|
|
const params = new URLSearchParams({
|
|
structure: structureFilter,
|
|
period_from: periodFrom,
|
|
period_to: periodTo,
|
|
});
|
|
|
|
const response = await fetch(`/api/staff/payslips/missing-stats?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
console.error("Error fetching missing stats:", data.error);
|
|
setMissingPayslipsCount(null);
|
|
return;
|
|
}
|
|
|
|
setMissingPayslipsCount(data.missing_count || 0);
|
|
} else {
|
|
// Si "Toutes structures"
|
|
setMissingPayslipsCount(null);
|
|
|
|
const params = new URLSearchParams({
|
|
period_from: periodFrom,
|
|
period_to: periodTo,
|
|
});
|
|
|
|
const response = await fetch(`/api/staff/payslips/missing-organizations?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
console.error("Error fetching missing organizations:", data.error);
|
|
setMissingOrganizationsCount(null);
|
|
setTotalMissingContracts(null);
|
|
return;
|
|
}
|
|
|
|
setMissingOrganizationsCount(data.total_organizations || 0);
|
|
setTotalMissingContracts(data.total_missing_contracts || 0);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching missing stats:", error);
|
|
setMissingPayslipsCount(null);
|
|
setMissingOrganizationsCount(null);
|
|
setTotalMissingContracts(null);
|
|
} finally {
|
|
setIsLoadingStats(false);
|
|
}
|
|
};
|
|
|
|
fetchMissingStats();
|
|
}, [structureFilter, periodFrom, periodTo]);
|
|
|
|
// Create a callback ref to refetch stats (for use in realtime subscription)
|
|
const refetchStatsRef = useRef<(() => void) | undefined>(undefined);
|
|
useEffect(() => {
|
|
refetchStatsRef.current = async () => {
|
|
if (!structureFilter || !periodFrom || !periodTo) return;
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
structure: structureFilter,
|
|
period_from: periodFrom,
|
|
period_to: periodTo,
|
|
});
|
|
|
|
const response = await fetch(`/api/staff/payslips/missing-stats?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
setMissingPayslipsCount(data.missing_count || 0);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error refetching missing stats:", error);
|
|
}
|
|
};
|
|
}, [structureFilter, periodFrom, periodTo]);
|
|
|
|
// Realtime subscription: listen to INSERT / UPDATE / DELETE on payslips
|
|
useEffect(() => {
|
|
try {
|
|
console.log("PayslipsGrid initialData (client):", Array.isArray(initialData) ? initialData.length : typeof initialData, initialData?.slice?.(0, 3));
|
|
} catch (err) {
|
|
console.log("PayslipsGrid initialData (client) - could not log:", err);
|
|
}
|
|
|
|
let channel: any = null;
|
|
let mounted = true;
|
|
|
|
(async () => {
|
|
try {
|
|
channel = supabase.channel("public:payslips");
|
|
channel.on(
|
|
"postgres_changes",
|
|
{ event: "*", schema: "public", table: "payslips" },
|
|
(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 Payslip;
|
|
setRows((rs) => {
|
|
if (rs.find((r) => r.id === newRec.id)) return rs;
|
|
return [newRec, ...rs];
|
|
});
|
|
// Refetch stats when a new payslip is created
|
|
refetchStatsRef.current?.();
|
|
} else if (event === "UPDATE") {
|
|
setRows((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as Payslip) } : r)));
|
|
} else if (event === "DELETE") {
|
|
const id = record?.id ?? payload.old?.id;
|
|
if (id) setRows((rs) => rs.filter((r) => r.id !== id));
|
|
// Refetch stats when a payslip is deleted
|
|
refetchStatsRef.current?.();
|
|
}
|
|
} catch (err) {
|
|
console.error("Realtime handler error", err);
|
|
}
|
|
}
|
|
);
|
|
|
|
const sub = await channel.subscribe();
|
|
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) {
|
|
console.warn("Realtime subscription failed for public.payslips — falling back to polling.", err?.message ?? err);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
try {
|
|
if (channel) {
|
|
// @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 (processedFilter) params.set('processed', processedFilter);
|
|
if (transferFilter) params.set('transfer_done', transferFilter);
|
|
if (aemFilter) params.set('aem_status', aemFilter);
|
|
if (periodFrom) params.set('period_from', periodFrom);
|
|
if (periodTo) params.set('period_to', periodTo);
|
|
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/payslips/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(() => {
|
|
setSelectedPayslipIds(new Set());
|
|
}, [rows]);
|
|
|
|
// Client-side sorting function
|
|
const sortPayslipsLocally = (payslips: Payslip[], field: string, order: 'asc' | 'desc'): Payslip[] => {
|
|
return [...payslips].sort((a, b) => {
|
|
let valueA: any;
|
|
let valueB: any;
|
|
|
|
switch (field) {
|
|
case 'employee_name':
|
|
valueA = getLastName(a);
|
|
valueB = getLastName(b);
|
|
break;
|
|
case 'period_start':
|
|
case 'period_end':
|
|
case 'pay_date':
|
|
case 'created_at':
|
|
valueA = a[field as keyof Payslip] ? new Date(a[field as keyof Payslip] as string) : new Date(0);
|
|
valueB = b[field as keyof Payslip] ? new Date(b[field as keyof Payslip] as string) : new Date(0);
|
|
break;
|
|
case 'pay_number':
|
|
case 'gross_amount':
|
|
case 'net_after_withholding':
|
|
valueA = Number(a[field as keyof Payslip]) || 0;
|
|
valueB = Number(b[field as keyof Payslip]) || 0;
|
|
break;
|
|
case 'contract_number':
|
|
valueA = a.cddu_contracts?.contract_number || '';
|
|
valueB = b.cddu_contracts?.contract_number || '';
|
|
break;
|
|
case 'structure':
|
|
valueA = a.cddu_contracts?.structure || '';
|
|
valueB = b.cddu_contracts?.structure || '';
|
|
break;
|
|
default:
|
|
valueA = a[field as keyof Payslip] || '';
|
|
valueB = b[field as keyof Payslip] || '';
|
|
}
|
|
|
|
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 && !processedFilter && !transferFilter && !aemFilter && !periodFrom && !periodTo;
|
|
|
|
if (noFilters) {
|
|
return sortPayslipsLocally(rows, sortField, sortOrder);
|
|
}
|
|
|
|
return rows;
|
|
}, [rows, sortField, sortOrder, q, structureFilter, typeFilter, processedFilter, transferFilter, aemFilter, periodFrom, periodTo]);
|
|
|
|
// Selection functions
|
|
const toggleSelectAll = () => {
|
|
if (isAllSelected) {
|
|
setSelectedPayslipIds(new Set());
|
|
} else {
|
|
setSelectedPayslipIds(new Set(sortedRows.map(r => r.id)));
|
|
}
|
|
};
|
|
|
|
const toggleSelectPayslip = (payslipId: string) => {
|
|
setSelectedPayslipIds(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(payslipId)) {
|
|
newSet.delete(payslipId);
|
|
} else {
|
|
newSet.add(payslipId);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
// Debounce searches when filters change
|
|
useEffect(() => {
|
|
// Skip le premier rendu car le fetch initial est géré par un autre useEffect
|
|
if (isFirstRender.current) {
|
|
isFirstRender.current = false;
|
|
return;
|
|
}
|
|
|
|
const t = setTimeout(() => fetchServer(0), 300);
|
|
return () => clearTimeout(t);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [q, structureFilter, typeFilter, processedFilter, transferFilter, aemFilter, periodFrom, periodTo, sortField, sortOrder, limit]);
|
|
|
|
// derive options from initialData for simple selects
|
|
const structures = useMemo(() => {
|
|
const uniqueStructures = Array.from(new Set((initialData || []).map((r) => r.cddu_contracts?.structure).filter(Boolean) as string[]));
|
|
return uniqueStructures.sort((a, b) => a.localeCompare(b, 'fr')).slice(0, 50);
|
|
}, [initialData]);
|
|
|
|
const aemStatuses = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.aem_status).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"
|
|
>
|
|
<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>
|
|
</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
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="inline-flex items-center gap-1 text-sm text-white bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded border border-blue-600"
|
|
title="Créer une nouvelle fiche de paie"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Créer une paie
|
|
</button>
|
|
|
|
<button
|
|
className="rounded border px-3 py-1 text-sm"
|
|
onClick={() => {
|
|
setQ('');
|
|
setStructureFilter(null);
|
|
setTypeFilter(null);
|
|
setProcessedFilter(null);
|
|
setTransferFilter(null);
|
|
setAemFilter(null);
|
|
setPeriodFrom(null);
|
|
setPeriodTo(null);
|
|
setSortField('period_start');
|
|
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 de traitement */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
État de traitement
|
|
</label>
|
|
<select
|
|
value={processedFilter ?? ""}
|
|
onChange={(e) => setProcessedFilter(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
>
|
|
<option value="">Tous</option>
|
|
<option value="true">Traité</option>
|
|
<option value="false">Non traité</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Filtre État de virement */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
État de virement
|
|
</label>
|
|
<select
|
|
value={transferFilter ?? ""}
|
|
onChange={(e) => setTransferFilter(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
>
|
|
<option value="">Tous</option>
|
|
<option value="true">Virement effectué</option>
|
|
<option value="false">Virement en attente</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Filtre AEM */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
AEM
|
|
</label>
|
|
<select
|
|
value={aemFilter ?? ""}
|
|
onChange={(e) => setAemFilter(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
>
|
|
<option value="">Tous les états</option>
|
|
{aemStatuses.map((status) => (
|
|
<option key={status} value={status}>{status}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Filtre période - De */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Période - De
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={periodFrom ?? ""}
|
|
onChange={(e) => setPeriodFrom(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filtre période - À */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
Période - À
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={periodTo ?? ""}
|
|
onChange={(e) => setPeriodTo(e.target.value || null)}
|
|
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Card showing missing payslips statistics */}
|
|
{periodFrom && periodTo && (structureFilter || (!structureFilter && missingOrganizationsCount !== null)) && (
|
|
<div className={`mt-4 p-4 border rounded-lg ${
|
|
(structureFilter ? missingPayslipsCount : missingOrganizationsCount) === 0
|
|
? 'bg-emerald-50 border-emerald-200'
|
|
: 'bg-amber-50 border-amber-200'
|
|
}`}>
|
|
{isLoadingStats ? (
|
|
<div className="flex items-center gap-2 text-sm text-amber-800">
|
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-amber-600 border-t-transparent"></div>
|
|
<span>Calcul en cours...</span>
|
|
</div>
|
|
) : structureFilter && missingPayslipsCount !== null ? (
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
{missingPayslipsCount === 0 ? (
|
|
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
|
) : (
|
|
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
<p className={`text-sm font-medium ${
|
|
missingPayslipsCount === 0
|
|
? 'text-emerald-900'
|
|
: 'text-amber-900'
|
|
}`}>
|
|
{missingPayslipsCount === 0 ? (
|
|
<>Tous les contrats de cette période ont au moins une fiche de paie</>
|
|
) : (
|
|
<>{missingPayslipsCount} contrat{missingPayslipsCount > 1 ? 's' : ''} sans fiche de paie pour cette période</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
{missingPayslipsCount > 0 && (
|
|
<button
|
|
onClick={() => setShowMissingPayslipsModal(true)}
|
|
className="flex-shrink-0 inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-amber-900 bg-amber-100 hover:bg-amber-200 border border-amber-300 rounded-lg transition-colors"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
Voir la liste
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : !structureFilter && missingOrganizationsCount !== null ? (
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
{missingOrganizationsCount === 0 ? (
|
|
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
|
) : (
|
|
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
<p className={`text-sm font-medium ${
|
|
missingOrganizationsCount === 0
|
|
? 'text-emerald-900'
|
|
: 'text-amber-900'
|
|
}`}>
|
|
{missingOrganizationsCount === 0 ? (
|
|
<>Toutes les organisations ont des fiches de paie pour cette période</>
|
|
) : (
|
|
<>{missingOrganizationsCount} organisation{missingOrganizationsCount > 1 ? 's' : ''} avec {totalMissingContracts} contrat{totalMissingContracts && totalMissingContracts > 1 ? 's' : ''} sans paie</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
{missingOrganizationsCount > 0 && (
|
|
<button
|
|
onClick={() => setShowMissingOrganizationsModal(true)}
|
|
className="flex-shrink-0 inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-amber-900 bg-amber-100 hover:bg-amber-200 border border-amber-300 rounded-lg transition-colors"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
Voir la liste
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Boutons d'actions groupées */}
|
|
{selectedPayslipIds.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">
|
|
{selectedPayslipIds.size} fiche{selectedPayslipIds.size > 1 ? 's' : ''} de paie sélectionnée{selectedPayslipIds.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={() => {
|
|
setPayslipDetailsIds(Array.from(selectedPayslipIds));
|
|
setShowDetailsModal(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"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
Voir le détail
|
|
</button>
|
|
<div className="border-t border-gray-200 my-1"></div>
|
|
<button
|
|
onClick={() => {
|
|
setShowProcessedModal(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 traitement
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowTransferModal(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 virement
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowAemModal(true);
|
|
setShowActionMenu(false);
|
|
}}
|
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
|
>
|
|
Modifier AEM
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowBulkUploadModal(true);
|
|
setShowActionMenu(false);
|
|
}}
|
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
|
>
|
|
Ajouter documents
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
verifySelectedPdfs();
|
|
setShowActionMenu(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>
|
|
<div className="border-t border-gray-200 my-1"></div>
|
|
<button
|
|
onClick={() => {
|
|
setShowDeleteModal(true);
|
|
setShowActionMenu(false);
|
|
}}
|
|
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setSelectedPayslipIds(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-xs">
|
|
<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 traitement">T</th>
|
|
<th className="text-left px-3 py-2" title="État virement">V</th>
|
|
<th className="text-left px-3 py-2" title="AEM">A</th>
|
|
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('contract_number'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
N° contrat {sortField === 'contract_number' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-left px-3 py-2 cursor-pointer" onClick={() => { setSortField('pay_number'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
N° Paie {sortField === 'pay_number' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</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 cursor-pointer" onClick={() => { setSortField('structure'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Structure {sortField === 'structure' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-left px-3 py-2 cursor-pointer whitespace-nowrap" onClick={() => { setSortField('period_start'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Période {sortField === 'period_start' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('gross_amount'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Brut {sortField === 'gross_amount' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('net_amount'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Net avant PAS {sortField === 'net_amount' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('net_after_withholding'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Net à payer {sortField === 'net_after_withholding' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
<th className="text-right px-3 py-2 cursor-pointer" onClick={() => { setSortField('employer_cost'); setSortOrder((o) => o === 'asc' ? 'desc' : 'asc'); }}>
|
|
Coût employeur {sortField === 'employer_cost' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</th>
|
|
</tr>
|
|
<tr className="bg-indigo-50 border-t border-slate-200 font-semibold">
|
|
<td className="px-3 py-2" colSpan={9}>
|
|
<span className="text-slate-700">
|
|
Totaux ({selectedPayslipIds.size > 0 ? `${selectedPayslipIds.size} ligne${selectedPayslipIds.size > 1 ? 's' : ''} sélectionnée${selectedPayslipIds.size > 1 ? 's' : ''}` : `${sortedRows.length} ligne${sortedRows.length > 1 ? 's' : ''}`})
|
|
</span>
|
|
</td>
|
|
<td className="text-right px-3 py-2 text-slate-700">
|
|
{formatCurrency((selectedPayslipIds.size > 0 ? sortedRows.filter(r => selectedPayslipIds.has(r.id)) : sortedRows).reduce((sum, r) => {
|
|
const val = typeof r.gross_amount === 'string' ? parseFloat(r.gross_amount) : (r.gross_amount || 0);
|
|
return sum + (isNaN(val) ? 0 : val);
|
|
}, 0))}
|
|
</td>
|
|
<td className="text-right px-3 py-2 text-slate-700">
|
|
{formatCurrency((selectedPayslipIds.size > 0 ? sortedRows.filter(r => selectedPayslipIds.has(r.id)) : sortedRows).reduce((sum, r) => {
|
|
const val = typeof r.net_amount === 'string' ? parseFloat(r.net_amount) : (r.net_amount || 0);
|
|
return sum + (isNaN(val) ? 0 : val);
|
|
}, 0))}
|
|
</td>
|
|
<td className="text-right px-3 py-2 text-slate-700">
|
|
{formatCurrency((selectedPayslipIds.size > 0 ? sortedRows.filter(r => selectedPayslipIds.has(r.id)) : sortedRows).reduce((sum, r) => {
|
|
const val = typeof r.net_after_withholding === 'string' ? parseFloat(r.net_after_withholding) : (r.net_after_withholding || 0);
|
|
return sum + (isNaN(val) ? 0 : val);
|
|
}, 0))}
|
|
</td>
|
|
<td className="text-right px-3 py-2 text-slate-700">
|
|
{formatCurrency((selectedPayslipIds.size > 0 ? sortedRows.filter(r => selectedPayslipIds.has(r.id)) : sortedRows).reduce((sum, r) => {
|
|
const val = typeof r.employer_cost === 'string' ? parseFloat(r.employer_cost) : (r.employer_cost || 0);
|
|
return sum + (isNaN(val) ? 0 : val);
|
|
}, 0))}
|
|
</td>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedRows.map((r) => (
|
|
<tr
|
|
key={r.id}
|
|
className={`border-t hover:bg-slate-50 ${selectedPayslipIds.has(r.id) ? 'bg-blue-50' : ''}`}
|
|
>
|
|
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedPayslipIds.has(r.id)}
|
|
onChange={() => toggleSelectPayslip(r.id)}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
{r.processed ? (
|
|
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
|
|
) : (
|
|
<X className="w-4 h-4 text-orange-600" strokeWidth={3} />
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
{r.transfer_done ? (
|
|
<Check className="w-4 h-4 text-green-600" strokeWidth={3} />
|
|
) : (
|
|
<X className="w-4 h-4 text-orange-600" strokeWidth={3} />
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
{(() => {
|
|
// Les contrats RG (CDD de droit commun + CDI) ne sont pas concernés par l'AEM
|
|
const contractType = r.cddu_contracts?.type_de_contrat;
|
|
const isRG = contractType === "CDD de droit commun" || contractType === "CDI";
|
|
|
|
if (isRG) {
|
|
return <span className="text-xs text-slate-400">N/A</span>;
|
|
}
|
|
|
|
const status = r.aem_status || "À traiter";
|
|
if (status === "Traité") {
|
|
return <Check className="w-4 h-4 text-green-600" strokeWidth={3} />;
|
|
} else {
|
|
return <X className="w-4 h-4 text-orange-600" strokeWidth={3} />;
|
|
}
|
|
})()}
|
|
</td>
|
|
<td
|
|
className="px-3 py-2 cursor-pointer hover:text-blue-600"
|
|
onClick={() => {
|
|
if (r.contract_id) {
|
|
window.location.href = `/staff/contrats/${r.contract_id}`;
|
|
}
|
|
}}
|
|
>
|
|
{r.cddu_contracts?.contract_number || "—"}
|
|
</td>
|
|
<td className="px-3 py-2">{r.pay_number ?? "—"}</td>
|
|
<td className="px-3 py-2">{formatEmployeeName(r)}</td>
|
|
<td className="px-3 py-2">
|
|
{r.cddu_contracts?.organizations?.organization_details?.code_employeur || "—"}
|
|
</td>
|
|
<td className="px-3 py-2 whitespace-nowrap">
|
|
{formatPeriodText(r.period_start)}
|
|
</td>
|
|
<td className="px-3 py-2 text-right">{formatCurrency(r.gross_amount)}</td>
|
|
<td className="px-3 py-2 text-right">{formatCurrency(r.net_amount)}</td>
|
|
<td className="px-3 py-2 text-right">{formatCurrency(r.net_after_withholding)}</td>
|
|
<td className="px-3 py-2 text-right">{formatCurrency(r.employer_cost)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{sortedRows.length === 0 && (
|
|
<div className="p-4 text-sm text-slate-600">
|
|
<div>Aucune fiche de paie trouvée.</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 className="flex items-center gap-3">
|
|
<div>{loading ? 'Chargement…' : `Affichage ${sortedRows.length}${totalCountRef.current ? ` / ${totalCountRef.current}` : ''}`}</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs">Afficher :</label>
|
|
<select
|
|
value={limit}
|
|
onChange={(e) => setLimit(Number(e.target.value))}
|
|
className="text-xs px-2 py-1 rounded border bg-white"
|
|
>
|
|
<option value={25}>25</option>
|
|
<option value={50}>50</option>
|
|
<option value={100}>100</option>
|
|
</select>
|
|
</div>
|
|
</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>
|
|
|
|
{/* Modal Action groupée État de traitement */}
|
|
{showProcessedModal && (
|
|
<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 de traitement</h3>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Modifier l'état de traitement pour {selectedPayslipIds.size} fiche{selectedPayslipIds.size > 1 ? 's' : ''} de paie
|
|
</p>
|
|
<ProcessedActionModal
|
|
selectedPayslips={selectedPayslips}
|
|
onClose={() => setShowProcessedModal(false)}
|
|
onSuccess={(updatedPayslips) => {
|
|
setRows(prev => prev.map(row => {
|
|
const updated = updatedPayslips.find(u => u.id === row.id);
|
|
return updated ? { ...row, processed: updated.processed } : row;
|
|
}));
|
|
setShowProcessedModal(false);
|
|
setSelectedPayslipIds(new Set());
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal Action groupée État de virement */}
|
|
{showTransferModal && (
|
|
<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 de virement</h3>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Modifier l'état de virement pour {selectedPayslipIds.size} fiche{selectedPayslipIds.size > 1 ? 's' : ''} de paie
|
|
</p>
|
|
<TransferActionModal
|
|
selectedPayslips={selectedPayslips}
|
|
onClose={() => setShowTransferModal(false)}
|
|
onSuccess={(updatedPayslips) => {
|
|
setRows(prev => prev.map(row => {
|
|
const updated = updatedPayslips.find(u => u.id === row.id);
|
|
return updated ? { ...row, transfer_done: updated.transfer_done } : row;
|
|
}));
|
|
setShowTransferModal(false);
|
|
setSelectedPayslipIds(new Set());
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal Action groupée AEM */}
|
|
{showAemModal && (
|
|
<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 - AEM</h3>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Modifier l'état AEM pour {selectedPayslipIds.size} fiche{selectedPayslipIds.size > 1 ? 's' : ''} de paie
|
|
</p>
|
|
<AemActionModal
|
|
selectedPayslips={selectedPayslips}
|
|
onClose={() => setShowAemModal(false)}
|
|
onSuccess={(updatedPayslips) => {
|
|
setRows(prev => prev.map(row => {
|
|
const updated = updatedPayslips.find(u => u.id === row.id);
|
|
return updated ? { ...row, aem_status: updated.aem_status } : row;
|
|
}));
|
|
setShowAemModal(false);
|
|
setSelectedPayslipIds(new Set());
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal 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 {selectedPayslipIds.size} fiche{selectedPayslipIds.size > 1 ? 's' : ''} de paie ?
|
|
</p>
|
|
<p className="text-sm text-red-600 font-medium mb-4">
|
|
Cette action est irréversible.
|
|
</p>
|
|
<DeleteActionModal
|
|
selectedPayslips={selectedPayslips}
|
|
onClose={() => setShowDeleteModal(false)}
|
|
onSuccess={() => {
|
|
// Supprimer les payslips de la liste
|
|
setRows(prev => prev.filter(row => !selectedPayslipIds.has(row.id)));
|
|
setShowDeleteModal(false);
|
|
setSelectedPayslipIds(new Set());
|
|
toast.success(`${selectedPayslipIds.size} fiche${selectedPayslipIds.size > 1 ? 's' : ''} de paie supprimée${selectedPayslipIds.size > 1 ? 's' : ''}`);
|
|
}}
|
|
isDeleting={isDeleting}
|
|
setIsDeleting={setIsDeleting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal Upload en masse */}
|
|
{showBulkUploadModal && (
|
|
<BulkPayslipUploadModal
|
|
isOpen={showBulkUploadModal}
|
|
onClose={() => setShowBulkUploadModal(false)}
|
|
payslips={selectedPayslips}
|
|
onSuccess={() => {
|
|
// Rafraîchir les données
|
|
fetchServer(page);
|
|
setSelectedPayslipIds(new Set());
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal Détails des paies */}
|
|
<PayslipDetailsModal
|
|
isOpen={showDetailsModal}
|
|
onClose={() => setShowDetailsModal(false)}
|
|
payslipIds={payslipDetailsIds}
|
|
payslips={rows}
|
|
onPayslipUpdated={handlePayslipUpdated}
|
|
/>
|
|
|
|
{/* Modal Créer une paie */}
|
|
<CreatePayslipModal
|
|
isOpen={showCreateModal}
|
|
onClose={() => {
|
|
setShowCreateModal(false);
|
|
setPreselectedContractId(null);
|
|
}}
|
|
onPayslipCreated={() => {
|
|
// Rafraîchir les données
|
|
fetchServer(page);
|
|
// Rafraîchir les stats aussi
|
|
refetchStatsRef.current?.();
|
|
}}
|
|
preselectedContractId={preselectedContractId}
|
|
/>
|
|
|
|
{/* Modal Liste des contrats sans paie */}
|
|
{showMissingPayslipsModal && structureFilter && periodFrom && periodTo && (
|
|
<MissingPayslipsModal
|
|
isOpen={showMissingPayslipsModal}
|
|
onClose={() => setShowMissingPayslipsModal(false)}
|
|
structureId={structureFilter}
|
|
periodFrom={periodFrom}
|
|
periodTo={periodTo}
|
|
onContractSelect={(contractId) => {
|
|
setPreselectedContractId(contractId);
|
|
setShowCreateModal(true);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal Liste des organisations avec contrats sans paie */}
|
|
{showMissingOrganizationsModal && periodFrom && periodTo && (
|
|
<MissingPayslipsOrganizationsModal
|
|
isOpen={showMissingOrganizationsModal}
|
|
onClose={() => setShowMissingOrganizationsModal(false)}
|
|
periodFrom={periodFrom}
|
|
periodTo={periodTo}
|
|
onOrganizationSelect={(orgId) => {
|
|
setStructureFilter(orgId);
|
|
setShowMissingOrganizationsModal(false);
|
|
setShowMissingPayslipsModal(true);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Modal de vérification des PDFs */}
|
|
<PayslipPdfVerificationModal
|
|
isOpen={showPdfVerificationModal}
|
|
onClose={closePdfVerificationModal}
|
|
payslips={payslipsPdfs}
|
|
isLoading={isLoadingPdfs}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Modal pour l'action groupée État de traitement
|
|
function ProcessedActionModal({
|
|
selectedPayslips,
|
|
onClose,
|
|
onSuccess
|
|
}: {
|
|
selectedPayslips: Payslip[];
|
|
onClose: () => void;
|
|
onSuccess: (payslips: { id: string; processed: boolean }[]) => void;
|
|
}) {
|
|
const [newProcessed, setNewProcessed] = useState<string>('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async () => {
|
|
if (newProcessed === '') return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch('/api/staff/payslips/bulk-update-processed', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
payslipIds: selectedPayslips.map(p => p.id),
|
|
processed: newProcessed === 'true'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
|
|
|
|
const result = await response.json();
|
|
onSuccess(result.payslips);
|
|
toast.success('États de traitement mis à jour');
|
|
} catch (error) {
|
|
console.error('Erreur:', error);
|
|
toast.error('Erreur lors de la mise à jour');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
État de traitement
|
|
</label>
|
|
<select
|
|
value={newProcessed}
|
|
onChange={(e) => setNewProcessed(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="true">Traité</option>
|
|
<option value="false">À traiter</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">Fiches de paie sélectionnées :</p>
|
|
{selectedPayslips.map(payslip => (
|
|
<div key={payslip.id} className="text-sm text-gray-600 py-1">
|
|
{payslip.cddu_contracts?.contract_number || payslip.id} - {formatEmployeeName(payslip)}
|
|
</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={newProcessed === '' || 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 virement
|
|
function TransferActionModal({
|
|
selectedPayslips,
|
|
onClose,
|
|
onSuccess
|
|
}: {
|
|
selectedPayslips: Payslip[];
|
|
onClose: () => void;
|
|
onSuccess: (payslips: { id: string; transfer_done: boolean }[]) => void;
|
|
}) {
|
|
const [newTransfer, setNewTransfer] = useState<string>('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async () => {
|
|
if (newTransfer === '') return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch('/api/staff/payslips/bulk-update-transfer', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
payslipIds: selectedPayslips.map(p => p.id),
|
|
transferDone: newTransfer === 'true'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
|
|
|
|
const result = await response.json();
|
|
onSuccess(result.payslips);
|
|
toast.success('États de virement mis à jour');
|
|
} catch (error) {
|
|
console.error('Erreur:', error);
|
|
toast.error('Erreur lors de la mise à jour');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
État de virement
|
|
</label>
|
|
<select
|
|
value={newTransfer}
|
|
onChange={(e) => setNewTransfer(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="true">Effectué</option>
|
|
<option value="false">En attente</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">Fiches de paie sélectionnées :</p>
|
|
{selectedPayslips.map(payslip => (
|
|
<div key={payslip.id} className="text-sm text-gray-600 py-1">
|
|
{payslip.cddu_contracts?.contract_number || payslip.id} - {formatEmployeeName(payslip)}
|
|
</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={newTransfer === '' || 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 AEM
|
|
function AemActionModal({
|
|
selectedPayslips,
|
|
onClose,
|
|
onSuccess
|
|
}: {
|
|
selectedPayslips: Payslip[];
|
|
onClose: () => void;
|
|
onSuccess: (payslips: { id: string; aem_status: string }[]) => void;
|
|
}) {
|
|
const [newAem, setNewAem] = useState<string>('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!newAem) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch('/api/staff/payslips/bulk-update-aem', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
payslipIds: selectedPayslips.map(p => p.id),
|
|
aemStatus: newAem
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
|
|
|
|
const result = await response.json();
|
|
onSuccess(result.payslips);
|
|
toast.success('États AEM mis à jour');
|
|
} catch (error) {
|
|
console.error('Erreur:', error);
|
|
toast.error('Erreur lors de la mise à jour');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
État AEM
|
|
</label>
|
|
<select
|
|
value={newAem}
|
|
onChange={(e) => setNewAem(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é">Traité</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">Fiches de paie sélectionnées :</p>
|
|
{selectedPayslips.map(payslip => (
|
|
<div key={payslip.id} className="text-sm text-gray-600 py-1">
|
|
{payslip.cddu_contracts?.contract_number || payslip.id} - {formatEmployeeName(payslip)}
|
|
</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={!newAem || 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 la suppression
|
|
function DeleteActionModal({
|
|
selectedPayslips,
|
|
onClose,
|
|
onSuccess,
|
|
isDeleting,
|
|
setIsDeleting
|
|
}: {
|
|
selectedPayslips: Payslip[];
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
isDeleting: boolean;
|
|
setIsDeleting: (value: boolean) => void;
|
|
}) {
|
|
const handleDelete = async () => {
|
|
setIsDeleting(true);
|
|
try {
|
|
const response = await fetch('/api/staff/payslips/bulk-delete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
payslipIds: selectedPayslips.map(p => p.id)
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Erreur lors de la suppression');
|
|
|
|
const result = await response.json();
|
|
onSuccess();
|
|
} catch (error) {
|
|
console.error('Erreur:', error);
|
|
toast.error('Erreur lors de la suppression');
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-4">
|
|
<div className="border rounded-lg p-3 max-h-40 overflow-y-auto bg-red-50">
|
|
<p className="text-sm font-medium text-gray-700 mb-2">Fiches de paie à supprimer :</p>
|
|
{selectedPayslips.map(payslip => (
|
|
<div key={payslip.id} className="text-sm text-gray-600 py-1">
|
|
{payslip.cddu_contracts?.contract_number || payslip.id} - {formatEmployeeName(payslip)}
|
|
</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={isDeleting}
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={handleDelete}
|
|
disabled={isDeleting}
|
|
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isDeleting ? 'Suppression...' : 'Supprimer définitivement'}
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|