espace-paie-odentas/components/staff/salary-transfers/PayslipsSelectionModal.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

564 lines
22 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo } from "react";
import { X, Search, Filter, CheckCircle2, XCircle, Calendar } from "lucide-react";
import { supabase } from "@/lib/supabaseClient";
import { toast } from "sonner";
type Payslip = {
id: string;
contract_id: string | null;
period_month: string | null;
period_start: string | null;
period_end: string | null;
pay_date: string | null;
net_amount: number | null;
net_after_withholding: number | null;
processed: boolean | null;
organization_id: string | null;
cddu_contracts?: {
id: string;
contract_number: string | null;
employee_name: string | null;
employee_id: string | null;
employee_matricule: string | null;
salaries?: {
nom: string | null;
prenom: string | null;
};
} | null;
};
type PayslipsSelectionModalProps = {
organizationId: string;
organizationName: string;
onClose: () => void;
onConfirm: (payslipIds: string[], totalNet: number, customAmounts?: Record<string, number>) => void;
};
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 "—";
}
}
function formatMonthYear(dateString: string | null | undefined): string {
if (!dateString) return "—";
try {
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
});
} catch {
return "—";
}
}
function formatAmount(amount: string | number | null | undefined): string {
if (!amount) return "0,00 €";
try {
const num = typeof amount === "string" ? parseFloat(amount) : amount;
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(num);
} catch {
return "0,00 €";
}
}
export default function PayslipsSelectionModal({
organizationId,
organizationName,
onClose,
onConfirm,
}: PayslipsSelectionModalProps) {
const [payslips, setPayslips] = useState<Payslip[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [customAmounts, setCustomAmounts] = useState<Record<string, number>>({});
// Filtres
const [searchQuery, setSearchQuery] = useState("");
const [periodFilter, setPeriodFilter] = useState<string | null>(null);
const [processedFilter, setProcessedFilter] = useState<string | null>(null);
const [showFilters, setShowFilters] = useState(false);
// Charger les paies de l'organisation
useEffect(() => {
async function loadPayslips() {
setLoading(true);
try {
console.log("[PayslipsSelectionModal] Loading payslips for organization:", organizationId);
// Utiliser l'API route côté serveur au lieu d'une requête client-side
// Cela évite les problèmes de RLS avec la clé anon
const res = await fetch(`/api/staff/payslips/available?org_id=${organizationId}`);
if (!res.ok) {
const error = await res.json();
console.error("[PayslipsSelectionModal] API error:", error);
toast.error(error.error || "Erreur lors du chargement des paies");
return;
}
const result = await res.json();
console.log("[PayslipsSelectionModal] API result:", {
success: result.success,
total: result.total,
stats: result.stats
});
if (result.payslips && result.payslips.length > 0) {
console.log("[PayslipsSelectionModal] First payslip sample:", result.payslips[0]);
}
setPayslips(result.payslips || []);
} catch (err) {
console.error("[PayslipsSelectionModal] Error:", err);
toast.error("Erreur lors du chargement");
} finally {
setLoading(false);
}
}
loadPayslips();
}, [organizationId]);
// Périodes disponibles (pour le filtre)
const availablePeriods = useMemo(() => {
const periods = new Set<string>();
payslips.forEach(p => {
if (p.period_month) {
periods.add(formatMonthYear(p.period_month));
}
});
return Array.from(periods).sort().reverse();
}, [payslips]);
// Paies filtrées
const filteredPayslips = useMemo(() => {
console.log("[PayslipsSelectionModal] Filtering payslips:", {
total: payslips.length,
searchQuery,
periodFilter,
processedFilter
});
return payslips.filter(p => {
// Filtre de recherche (nom employé, matricule, contrat)
if (searchQuery) {
const query = searchQuery.toLowerCase().trim();
// Essayer différentes sources pour le nom
const prenom = p.cddu_contracts?.salaries?.prenom || "";
const nom = p.cddu_contracts?.salaries?.nom || "";
const employeeName = `${prenom} ${nom}`.toLowerCase().trim();
const employeeNameAlt = p.cddu_contracts?.employee_name?.toLowerCase() || "";
const contractNumber = p.cddu_contracts?.contract_number?.toLowerCase() || "";
const matricule = p.cddu_contracts?.employee_matricule?.toLowerCase() || "";
// Log pour debug (seulement pour les 3 premières paies)
if (payslips.indexOf(p) < 3) {
console.log("[PayslipsSelectionModal] Checking payslip:", {
id: p.id,
employeeName,
employeeNameAlt,
contractNumber,
matricule,
query
});
}
// Vérifier si la query apparaît dans l'un des champs
const matches =
employeeName.includes(query) ||
employeeNameAlt.includes(query) ||
contractNumber.includes(query) ||
matricule.includes(query);
if (!matches) {
return false;
}
}
// Filtre par période
if (periodFilter && p.period_month) {
if (formatMonthYear(p.period_month) !== periodFilter) {
return false;
}
}
// Filtre par statut processed
if (processedFilter !== null) {
const isProcessed = p.processed === true;
if (processedFilter === "processed" && !isProcessed) return false;
if (processedFilter === "not_processed" && isProcessed) return false;
}
return true;
});
}, [payslips, searchQuery, periodFilter, processedFilter]);
// Log du résultat du filtrage
useEffect(() => {
console.log("[PayslipsSelectionModal] Filtered results:", {
total: payslips.length,
filtered: filteredPayslips.length,
searchQuery,
periodFilter,
processedFilter
});
}, [filteredPayslips, payslips.length, searchQuery, periodFilter, processedFilter]);
// Calcul du total sélectionné
const selectedTotal = useMemo(() => {
return filteredPayslips
.filter(p => selectedIds.has(p.id))
.reduce((sum, p) => {
// Utiliser le montant personnalisé s'il existe, sinon le montant de la paie
const customAmount = customAmounts[p.id];
const defaultAmount = p.net_after_withholding || p.net_amount || 0;
return sum + (customAmount !== undefined ? customAmount : defaultAmount);
}, 0);
}, [filteredPayslips, selectedIds, customAmounts]);
// Sélection/désélection
const toggleSelection = (id: string) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
// Supprimer le montant personnalisé si on désélectionne
const newCustomAmounts = { ...customAmounts };
delete newCustomAmounts[id];
setCustomAmounts(newCustomAmounts);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
};
const selectAll = () => {
const allIds = new Set(filteredPayslips.map(p => p.id));
setSelectedIds(allIds);
};
const deselectAll = () => {
setSelectedIds(new Set());
setCustomAmounts({});
};
// Mettre à jour le montant personnalisé
const updateCustomAmount = (id: string, value: string) => {
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0) {
setCustomAmounts(prev => ({ ...prev, [id]: numValue }));
} else if (value === "") {
// Si on efface le champ, supprimer l'override
const newCustomAmounts = { ...customAmounts };
delete newCustomAmounts[id];
setCustomAmounts(newCustomAmounts);
}
};
const handleConfirm = () => {
if (selectedIds.size === 0) {
toast.error("Veuillez sélectionner au moins une paie");
return;
}
onConfirm(Array.from(selectedIds), selectedTotal, customAmounts);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between bg-gradient-to-r from-indigo-50 to-slate-50">
<div>
<h3 className="text-2xl font-bold text-slate-800">
Sélection des paies
</h3>
<p className="text-sm text-slate-600 mt-1">
{organizationName}
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-6 h-6 text-slate-600" />
</button>
</div>
{/* Barre de recherche et filtres */}
<div className="px-6 py-4 border-b border-slate-200 space-y-3">
<div className="flex items-center gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Rechercher par nom ou n° de contrat..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${
showFilters ? "bg-indigo-100 text-indigo-700" : "bg-slate-100 text-slate-700 hover:bg-slate-200"
}`}
>
<Filter className="w-5 h-5" />
Filtres
</button>
</div>
{showFilters && (
<div className="flex items-center gap-3">
<select
value={periodFilter || ""}
onChange={(e) => setPeriodFilter(e.target.value || null)}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="">Toutes les périodes</option>
{availablePeriods.map(period => (
<option key={period} value={period}>{period}</option>
))}
</select>
<select
value={processedFilter || ""}
onChange={(e) => setProcessedFilter(e.target.value || null)}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="">Tous les statuts</option>
<option value="processed">Paies traitées</option>
<option value="not_processed">Paies non traitées</option>
</select>
{(periodFilter || processedFilter) && (
<button
onClick={() => {
setPeriodFilter(null);
setProcessedFilter(null);
}}
className="px-3 py-2 text-sm text-slate-600 hover:text-slate-800 underline"
>
Réinitialiser
</button>
)}
</div>
)}
</div>
{/* Barre d'actions */}
<div className="px-6 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={selectAll}
className="px-3 py-1.5 text-sm bg-white border rounded-lg hover:bg-slate-50 transition-colors"
>
Tout sélectionner ({filteredPayslips.length})
</button>
<button
onClick={deselectAll}
className="px-3 py-1.5 text-sm bg-white border rounded-lg hover:bg-slate-50 transition-colors"
>
Tout désélectionner
</button>
</div>
<div className="text-sm text-slate-600">
<span className="font-semibold text-indigo-600">{selectedIds.size}</span> paie(s) sélectionnée(s)
{selectedIds.size > 0 && (
<span className="ml-2">
Total: <span className="font-semibold text-slate-800">{formatAmount(selectedTotal)}</span>
</span>
)}
</div>
</div>
{/* Liste des paies */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-slate-600">Chargement des paies...</div>
</div>
) : payslips.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-slate-600 font-medium">Aucune paie trouvée pour cette organisation</p>
<p className="text-sm text-slate-500 mt-2">
Organisation: <span className="font-mono">{organizationName}</span>
</p>
<p className="text-xs text-slate-400 mt-2">
ID: <span className="font-mono">{organizationId}</span>
</p>
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-left">
<p className="text-sm text-slate-700">
<strong>Vérifications :</strong>
</p>
<ul className="text-xs text-slate-600 mt-2 space-y-1 list-disc list-inside">
<li>Vérifiez que des paies existent dans la table <code className="bg-slate-100 px-1 rounded">payslips</code></li>
<li>Vérifiez que <code className="bg-slate-100 px-1 rounded">organization_id</code> correspond bien à cette organisation</li>
<li>Vérifiez les permissions RLS sur la table <code className="bg-slate-100 px-1 rounded">payslips</code></li>
</ul>
</div>
</div>
</div>
) : filteredPayslips.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-slate-600">Aucune paie disponible</p>
<p className="text-sm text-slate-500 mt-1">
{searchQuery || periodFilter || processedFilter
? "Essayez de modifier les filtres"
: "Toutes les paies sont déjà incluses dans des virements"}
</p>
<p className="text-xs text-slate-400 mt-2">
({payslips.length} paie(s) au total, {filteredPayslips.length} après filtres)
</p>
</div>
</div>
) : (
<table className="w-full">
<thead className="bg-slate-50 sticky top-0 z-10">
<tr className="border-b">
<th className="text-left px-3 py-2 w-12">
<input
type="checkbox"
checked={filteredPayslips.length > 0 && filteredPayslips.every(p => selectedIds.has(p.id))}
onChange={(e) => e.target.checked ? selectAll() : deselectAll()}
className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
/>
</th>
<th className="text-left px-3 py-2">Employé</th>
<th className="text-left px-3 py-2">N° Contrat</th>
<th className="text-left px-3 py-2">Période</th>
<th className="text-left px-3 py-2">Date de paie</th>
<th className="text-right px-3 py-2">Net à payer</th>
<th className="text-right px-3 py-2">Montant personnalisé</th>
<th className="text-center px-3 py-2">Statut</th>
</tr>
</thead>
<tbody>
{filteredPayslips.map((payslip) => {
const isSelected = selectedIds.has(payslip.id);
const employeeName = payslip.cddu_contracts?.salaries
? `${payslip.cddu_contracts.salaries.prenom || ""} ${payslip.cddu_contracts.salaries.nom || ""}`.trim()
: payslip.cddu_contracts?.employee_name || "—";
const defaultAmount = payslip.net_after_withholding || payslip.net_amount || 0;
const customAmount = customAmounts[payslip.id];
const displayAmount = customAmount !== undefined ? customAmount : defaultAmount;
return (
<tr
key={payslip.id}
className={`border-b transition-colors ${
isSelected ? "bg-indigo-50 hover:bg-indigo-100" : "hover:bg-slate-50"
}`}
>
<td className="px-3 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(payslip.id)}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
/>
</td>
<td className="px-3 py-3">
<div className="font-medium text-sm">{employeeName}</div>
</td>
<td className="px-3 py-3">
<div className="font-mono text-sm">{payslip.cddu_contracts?.contract_number || "—"}</div>
</td>
<td className="px-3 py-3">
<div className="text-sm">{formatMonthYear(payslip.period_month)}</div>
<div className="text-xs text-slate-500">
{formatDate(payslip.period_start)} - {formatDate(payslip.period_end)}
</div>
</td>
<td className="px-3 py-3">
<div className="text-sm">{formatDate(payslip.pay_date)}</div>
</td>
<td className="px-3 py-3 text-right">
<div className="font-semibold text-sm text-slate-500">
{formatAmount(defaultAmount)}
</div>
</td>
<td className="px-3 py-3 text-right">
{isSelected ? (
<input
type="number"
step="0.01"
min="0"
placeholder={defaultAmount.toFixed(2)}
value={customAmount !== undefined ? customAmount : ""}
onChange={(e) => updateCustomAmount(payslip.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-28 px-2 py-1 text-sm text-right border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
) : (
<span className="text-sm text-slate-400"></span>
)}
</td>
<td className="px-3 py-3 text-center">
{payslip.processed ? (
<CheckCircle2 className="w-5 h-5 text-green-600 inline-block" />
) : (
<XCircle className="w-5 h-5 text-slate-400 inline-block" />
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Footer avec actions */}
<div className="px-6 py-4 border-t border-slate-200 bg-slate-50 flex items-center justify-between">
<div className="text-sm text-slate-600">
{selectedIds.size > 0 ? (
<>
<span className="font-semibold text-indigo-600">{selectedIds.size}</span> paie(s) sélectionnée(s)
<span className="mx-2"></span>
Total: <span className="font-semibold text-slate-800">{formatAmount(selectedTotal)}</span>
</>
) : (
<span>Aucune paie sélectionnée</span>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
>
Annuler
</button>
<button
onClick={handleConfirm}
disabled={selectedIds.size === 0}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
Confirmer la sélection
</button>
</div>
</div>
</div>
</div>
);
}