espace-paie-odentas/components/staff/PayslipsGrid.tsx
odentas 266eb3598a feat: Implémenter store global Zustand + calcul total quantités + fix structure field + montants personnalisés virements
- 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
2025-12-01 21:51:57 +01:00

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>
</>
);
}