- 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
564 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|