espace-paie-odentas/components/staff/CotisationsGrid.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

918 lines
34 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, useRef } from "react";
import { supabase } from "@/lib/supabaseClient";
import { RefreshCw, Check, X, Bell, Trash2, Plus, Edit2, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "sonner";
import BulkNotifyModal from "./cotisations/BulkNotifyModal";
import { useStaffOrgSelection } from "@/hooks/useStaffOrgSelection";
// 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: number | null | undefined): string {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
}
// Liste des caisses (colonnes du tableau)
const FUNDS = [
{ key: "URSSAF", label: "URSSAF" },
{ key: "PE_SPECTACLE", label: "FT Spectacle" },
{ key: "AUDIENS_RETRAITE", label: "Audiens Ret." },
{ key: "AUDIENS_PREVOYANCE", label: "Audiens Prév." },
{ key: "CONGES_SPECTACLES", label: "Congés Spect." },
{ key: "PREVOYANCE_RG", label: "Prévoyance RG" },
{ key: "MUTUELLE_RG", label: "Mutuelle RG" },
{ key: "PAS", label: "PAS" },
];
type Cotisation = {
id: string;
org_id: string;
fund: string;
contrib_type: string | null;
reference: string | null;
period_label: string;
due_date: string | null;
paid_date: string | null;
status: string;
amount_due: number;
amount_paid: number | null;
amount_diff: number | null;
notes: string | null;
created_at: string;
};
type Organization = {
id: string;
name: string;
code_employeur?: string | null;
};
type PeriodRow = {
org_id: string;
org_name: string;
org_code: string | null;
period_label: string;
due_date: string | null;
status: string;
funds: {
[fundName: string]: {
id: string;
amount_due: number;
amount_paid: number | null;
amount_diff: number | null;
paid_date: string | null;
notes: string | null;
};
};
total_due: number;
total_paid: number;
is_notified?: boolean;
notification_date?: string | null;
};
type NotificationInfo = {
id: string;
org_id: string;
period_label: string;
notified_at: string;
notified_by: string;
email_sent_to: string;
email_cc: string | null;
};
type CotisationsGridProps = {
initialCotisations: Cotisation[];
organizations: Organization[];
notifications: NotificationInfo[];
onEditCell: (cellData: any) => void;
onEditPeriod: (period: PeriodRow) => void;
onNotifyPeriod: (period: PeriodRow) => void;
onDeletePeriod: (period: PeriodRow) => void;
onCreatePeriod: () => void;
};
export default function CotisationsGrid({
initialCotisations,
organizations,
notifications,
onEditCell,
onEditPeriod,
onNotifyPeriod,
onDeletePeriod,
onCreatePeriod,
}: CotisationsGridProps) {
const [cotisations, setCotisations] = useState<Cotisation[]>(initialCotisations || []);
const [loading, setLoading] = useState(false);
// Key for localStorage
const FILTERS_STORAGE_KEY = 'staff-cotisations-filters';
// Helper functions for localStorage
const saveFiltersToStorage = (filters: any) => {
try {
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
} catch (error) {
console.warn('Failed to save filters to localStorage:', error);
}
};
const loadFiltersFromStorage = () => {
try {
const saved = localStorage.getItem(FILTERS_STORAGE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (error) {
console.warn('Failed to load filters from localStorage:', error);
return null;
}
};
// Load saved filters or use defaults
const savedFilters = loadFiltersFromStorage();
// Zustand store pour la sélection d'organisation
const { selectedOrgId, setSelectedOrg } = useStaffOrgSelection();
// Synchroniser le filtre local avec le store global quand selectedOrgId change
useEffect(() => {
setOrgFilter(selectedOrgId);
}, [selectedOrgId]);
// Filters state
const [q, setQ] = useState(savedFilters?.q || "");
const [orgFilter, setOrgFilter] = useState<string | null>(savedFilters?.orgFilter || selectedOrgId); // Initialiser avec la sélection globale
const [statusFilter, setStatusFilter] = useState<string | null>(savedFilters?.statusFilter || null);
const [yearFilter, setYearFilter] = useState<string | null>(savedFilters?.yearFilter || null);
const [monthFilter, setMonthFilter] = useState<string | null>(savedFilters?.monthFilter || null);
const [sortField, setSortField] = useState<string>(savedFilters?.sortField || "due_date");
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(savedFilters?.sortOrder || 'desc');
const [showFilters, setShowFilters] = useState(savedFilters?.showFilters || false);
// Selection state
const [selectedPeriodKeys, setSelectedPeriodKeys] = useState<Set<string>>(new Set());
// Modal states
const [showBulkNotifyModal, setShowBulkNotifyModal] = useState(false);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = {
q,
orgFilter,
statusFilter,
yearFilter,
monthFilter,
sortField,
sortOrder,
showFilters
};
saveFiltersToStorage(filters);
}, [q, orgFilter, statusFilter, yearFilter, monthFilter, sortField, sortOrder, showFilters]);
// Update cotisations when props change
useEffect(() => {
setCotisations(initialCotisations || []);
}, [initialCotisations]);
// Realtime subscription for cotisations
useEffect(() => {
let channel: any = null;
let mounted = true;
(async () => {
try {
channel = supabase.channel("public:monthly_contributions");
channel.on(
"postgres_changes",
{ event: "*", schema: "public", table: "monthly_contributions" },
(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 Cotisation;
setCotisations((rs) => {
if (rs.find((r) => r.id === newRec.id)) return rs;
return [newRec, ...rs];
});
} else if (event === "UPDATE") {
setCotisations((rs) => rs.map((r) => (r.id === record.id ? { ...r, ...(record as Cotisation) } : r)));
} else if (event === "DELETE") {
const id = record?.id ?? payload.old?.id;
if (id) setCotisations((rs) => rs.filter((r) => r.id !== id));
}
} 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 monthly_contributions — 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);
}
};
}, []);
// Transform data into period rows
const periodRows = useMemo(() => {
const orgMap = new Map(organizations.map(org => [org.id, { name: org.name, code: org.code_employeur }]));
const notifMap = new Map(notifications.map(n => [`${n.org_id}|${n.period_label}`, n]));
// Group by org_id + period_label
const grouped = new Map<string, PeriodRow>();
cotisations.forEach((cotisation) => {
// Ignorer les lignes "TOTAL"
if (cotisation.fund === "TOTAL") return;
const key = `${cotisation.org_id}|${cotisation.period_label}`;
if (!grouped.has(key)) {
const notificationInfo = notifMap.get(key);
const orgInfo = orgMap.get(cotisation.org_id);
grouped.set(key, {
org_id: cotisation.org_id,
org_name: orgInfo?.name || cotisation.org_id,
org_code: orgInfo?.code || null,
period_label: cotisation.period_label,
due_date: cotisation.due_date,
status: cotisation.status,
funds: {},
total_due: 0,
total_paid: 0,
is_notified: !!notificationInfo,
notification_date: notificationInfo?.notified_at || null,
});
}
const row = grouped.get(key)!;
row.funds[cotisation.fund] = {
id: cotisation.id,
amount_due: cotisation.amount_due,
amount_paid: cotisation.amount_paid,
amount_diff: cotisation.amount_diff,
paid_date: cotisation.paid_date,
notes: cotisation.notes,
};
row.total_due += cotisation.amount_due;
row.total_paid += cotisation.amount_paid || 0;
});
return Array.from(grouped.values());
}, [cotisations, organizations, notifications]);
// Apply filters
const filteredRows = useMemo(() => {
let rows = [...periodRows];
// Text search
if (q) {
const qLower = q.toLowerCase();
rows = rows.filter(row =>
row.org_name.toLowerCase().includes(qLower) ||
row.period_label.toLowerCase().includes(qLower)
);
}
// Organization filter
if (orgFilter) {
rows = rows.filter(row => row.org_id === orgFilter);
}
// Status filter
if (statusFilter) {
rows = rows.filter(row => row.status === statusFilter);
}
// Year filter
if (yearFilter) {
rows = rows.filter(row => row.period_label.includes(yearFilter));
}
// Month filter
if (monthFilter) {
rows = rows.filter(row => {
const monthNames = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"];
const monthAbbr = ["Jan", "Fév", "Mars", "Avr", "Mai", "Juin", "Juil", "Août", "Sept", "Oct", "Nov", "Déc"];
const monthIndex = parseInt(monthFilter) - 1;
const monthName = monthNames[monthIndex];
const monthShort = monthAbbr[monthIndex];
return row.period_label.toLowerCase().includes(monthName.toLowerCase()) ||
row.period_label.includes(monthShort);
});
}
return rows;
}, [periodRows, q, orgFilter, statusFilter, yearFilter, monthFilter]);
// Apply sorting
const sortedRows = useMemo(() => {
const rows = [...filteredRows];
rows.sort((a, b) => {
let valueA: any;
let valueB: any;
switch (sortField) {
case 'org_name':
valueA = a.org_name.toLowerCase();
valueB = b.org_name.toLowerCase();
break;
case 'period_label':
valueA = a.period_label.toLowerCase();
valueB = b.period_label.toLowerCase();
break;
case 'due_date':
valueA = a.due_date ? new Date(a.due_date) : new Date(0);
valueB = b.due_date ? new Date(b.due_date) : new Date(0);
break;
case 'status':
valueA = a.status.toLowerCase();
valueB = b.status.toLowerCase();
break;
case 'total_due':
valueA = a.total_due;
valueB = b.total_due;
break;
case 'total_paid':
valueA = a.total_paid;
valueB = b.total_paid;
break;
default:
valueA = '';
valueB = '';
}
if (valueA < valueB) return sortOrder === 'asc' ? -1 : 1;
if (valueA > valueB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return rows;
}, [filteredRows, sortField, sortOrder]);
// Selection helpers
const isAllSelected = sortedRows.length > 0 && selectedPeriodKeys.size === sortedRows.length;
const isPartiallySelected = selectedPeriodKeys.size > 0 && selectedPeriodKeys.size < sortedRows.length;
const toggleSelectAll = () => {
if (isAllSelected) {
setSelectedPeriodKeys(new Set());
} else {
setSelectedPeriodKeys(new Set(sortedRows.map(r => `${r.org_id}|${r.period_label}`)));
}
};
const toggleSelectPeriod = (periodKey: string) => {
setSelectedPeriodKeys(prev => {
const newSet = new Set(prev);
if (newSet.has(periodKey)) {
newSet.delete(periodKey);
} else {
newSet.add(periodKey);
}
return newSet;
});
};
// Reset selection when data changes
useEffect(() => {
setSelectedPeriodKeys(new Set());
}, [sortedRows]);
// Sorting handler
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Get sort icon
const getSortIcon = (field: string) => {
if (sortField !== field) return null;
return sortOrder === 'asc' ? <ChevronUp className="w-3 h-3 inline ml-1" /> : <ChevronDown className="w-3 h-3 inline ml-1" />;
};
// Get unique years from period labels
const availableYears = useMemo(() => {
const years = new Set<string>();
periodRows.forEach(row => {
const match = row.period_label.match(/\d{4}/);
if (match) years.add(match[0]);
});
return Array.from(years).sort((a, b) => parseInt(b) - parseInt(a));
}, [periodRows]);
// Get unique months from period labels
const availableMonths = useMemo(() => {
const monthNames = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"];
const monthAbbr = ["Jan", "Fév", "Mars", "Avr", "Mai", "Juin", "Juil", "Août", "Sept", "Oct", "Nov", "Déc"];
const months = new Set<number>();
periodRows.forEach(row => {
const labelLower = row.period_label.toLowerCase();
monthNames.forEach((name, index) => {
if (labelLower.includes(name.toLowerCase()) || row.period_label.includes(monthAbbr[index])) {
months.add(index + 1);
}
});
});
return Array.from(months).sort((a, b) => a - b);
}, [periodRows]);
// Get unique statuses
const availableStatuses = useMemo(() => {
const statuses = new Set<string>();
periodRows.forEach(row => {
if (row.status) statuses.add(row.status);
});
return Array.from(statuses).sort();
}, [periodRows]);
// Handle refresh
const handleRefresh = () => {
setLoading(true);
window.location.reload();
};
// Handle reset filters
const handleResetFilters = () => {
setQ('');
setOrgFilter(null);
setStatusFilter(null);
setYearFilter(null);
setMonthFilter(null);
setSortField('due_date');
setSortOrder('desc');
};
// Bulk actions
const selectedPeriods = sortedRows.filter(row => selectedPeriodKeys.has(`${row.org_id}|${row.period_label}`));
const handleBulkNotify = () => {
if (selectedPeriods.length === 0) {
toast.error("Aucune période sélectionnée");
return;
}
setShowBulkNotifyModal(true);
};
const handleBulkDelete = () => {
if (selectedPeriods.length === 0) {
toast.error("Aucune période sélectionnée");
return;
}
toast.info("Suppression groupée non implémentée pour le moment");
// TODO: Implémenter la suppression groupée
};
const handleConfirmBulkNotify = async (periodIds: string[]) => {
// Envoyer les notifications pour chaque période
const promises = periodIds.map(async (firstFundId) => {
try {
const res = await fetch(`/api/staff/cotisations/${firstFundId}/notify-client`, {
method: 'POST',
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "Erreur lors de l'envoi");
}
return { success: true, id: firstFundId };
} catch (error) {
console.error(`Erreur pour ${firstFundId}:`, error);
return { success: false, id: firstFundId, error };
}
});
const results = await Promise.all(promises);
const successCount = results.filter(r => r.success).length;
const errorCount = results.filter(r => !r.success).length;
if (successCount > 0) {
toast.success(`${successCount} notification${successCount > 1 ? 's envoyées' : ' envoyée'} avec succès`);
}
if (errorCount > 0) {
toast.error(`${errorCount} notification${errorCount > 1 ? 's' : ''} en erreur`);
}
// Désélectionner les périodes
setSelectedPeriodKeys(new Set());
};
return (
<div className="relative">
{/* Filters */}
<div className="mb-3">
{/* Top row: search + filter buttons */}
<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 (organisation, période)"
className="w-full rounded border px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Quick filters always visible */}
<select
value={orgFilter ?? ""}
onChange={(e) => {
const value = e.target.value || null;
setOrgFilter(value);
// Synchroniser avec le store global
if (value) {
const org = organizations.find(o => o.id === value);
setSelectedOrg(value, org?.name || null);
} else {
setSelectedOrg(null, null);
}
}}
className="rounded border px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Toutes organisations</option>
{organizations.map((org) => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 px-3 py-2 rounded border hover:bg-blue-50 transition"
>
{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-3 py-2 rounded border hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed transition"
title="Actualiser les données"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Actualiser
</button>
<button
className="rounded border px-3 py-2 text-sm hover:bg-slate-50 transition"
onClick={handleResetFilters}
>
Réinitialiser
</button>
<button
onClick={onCreatePeriod}
className="inline-flex items-center gap-1 px-3 py-2 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded hover:from-purple-700 hover:to-blue-700 text-sm transition"
>
<Plus className="w-4 h-4" />
Nouvelle période
</button>
</div>
</div>
{/* Additional filters row (collapsible) */}
{showFilters && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 p-3 bg-slate-50 rounded border">
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Statut</label>
<select
value={statusFilter ?? ""}
onChange={(e) => setStatusFilter(e.target.value || null)}
className="w-full rounded border px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Tous statuts</option>
{availableStatuses.map((status) => (
<option key={status} value={status}>{status}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Mois</label>
<select
value={monthFilter ?? ""}
onChange={(e) => setMonthFilter(e.target.value || null)}
className="w-full rounded border px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Tous les mois</option>
{availableMonths.map((month) => (
<option key={month} value={month}>
{["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"][month - 1]}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Année</label>
<select
value={yearFilter ?? ""}
onChange={(e) => setYearFilter(e.target.value || null)}
className="w-full rounded border px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Toutes années</option>
{availableYears.map((year) => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
<div className="flex items-end">
<div className="text-sm text-slate-600">
{filteredRows.length} période{filteredRows.length > 1 ? 's' : ''} trouvée{filteredRows.length > 1 ? 's' : ''}
</div>
</div>
</div>
)}
</div>
{/* Bulk actions bar */}
{selectedPeriodKeys.size > 0 && (
<div className="mb-3 p-3 bg-purple-50 border border-purple-200 rounded flex items-center justify-between">
<div className="text-sm font-medium text-purple-900">
{selectedPeriodKeys.size} période{selectedPeriodKeys.size > 1 ? 's' : ''} sélectionnée{selectedPeriodKeys.size > 1 ? 's' : ''}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleBulkNotify}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-amber-500 text-white rounded hover:bg-amber-600 text-sm transition"
>
<Bell className="w-4 h-4" />
Notifier
</button>
<button
onClick={handleBulkDelete}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-red-500 text-white rounded hover:bg-red-600 text-sm transition"
>
<Trash2 className="w-4 h-4" />
Supprimer
</button>
<button
onClick={() => setSelectedPeriodKeys(new Set())}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-200 text-slate-700 rounded hover:bg-slate-300 text-sm transition"
>
<X className="w-4 h-4" />
Annuler
</button>
</div>
</div>
)}
{/* Table */}
<div className="rounded-lg border bg-white overflow-hidden">
<div className="overflow-x-auto">
{sortedRows.length === 0 ? (
<div className="py-12 text-center text-slate-500">
Aucune cotisation trouvée
</div>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-slate-50">
<th className="px-2 py-2 text-left">
<input
type="checkbox"
checked={isAllSelected}
ref={(input) => {
if (input) input.indeterminate = isPartiallySelected;
}}
onChange={toggleSelectAll}
className="rounded border-slate-300"
/>
</th>
<th
className="text-left font-medium px-2 py-2 cursor-pointer hover:bg-slate-100 transition"
onClick={() => handleSort('org_name')}
>
Code {getSortIcon('org_name')}
</th>
<th
className="text-left font-medium px-2 py-2 cursor-pointer hover:bg-slate-100 transition"
onClick={() => handleSort('period_label')}
>
Période {getSortIcon('period_label')}
</th>
<th
className="text-left font-medium px-2 py-2 cursor-pointer hover:bg-slate-100 transition"
onClick={() => handleSort('due_date')}
>
Échéance {getSortIcon('due_date')}
</th>
<th
className="text-left font-medium px-2 py-2 cursor-pointer hover:bg-slate-100 transition"
onClick={() => handleSort('status')}
>
Statut {getSortIcon('status')}
</th>
{FUNDS.map((fundObj) => (
<th key={fundObj.key} className="text-right font-medium px-2 py-2 whitespace-nowrap">
{fundObj.label}
</th>
))}
<th
className="text-right font-medium px-2 py-2 bg-slate-100 cursor-pointer hover:bg-slate-200 transition"
onClick={() => handleSort('total_due')}
>
Total {getSortIcon('total_due')}
</th>
<th className="text-center font-medium px-2 py-2">Actions</th>
</tr>
</thead>
<tbody>
{sortedRows.map((row) => {
const periodKey = `${row.org_id}|${row.period_label}`;
const isSelected = selectedPeriodKeys.has(periodKey);
return (
<tr
key={periodKey}
className={`border-b hover:bg-slate-50 transition ${isSelected ? 'bg-purple-50' : ''}`}
>
<td className="px-2 py-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelectPeriod(periodKey)}
className="rounded border-slate-300"
/>
</td>
<td className="px-2 py-2 font-medium text-slate-900" title={row.org_name}>
{row.org_code || row.org_name}
</td>
<td className="px-2 py-2">
<div className="flex items-center gap-1">
<span className="text-slate-900">{row.period_label}</span>
{row.is_notified && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700"
title={`Notifié le ${formatDate(row.notification_date)}`}
>
<Bell className="w-3 h-3" />
</span>
)}
</div>
</td>
<td className="px-2 py-2 text-slate-600 whitespace-nowrap">
{formatDate(row.due_date)}
</td>
<td className="px-2 py-2">
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${
row.status === "Fait"
? "bg-emerald-100 text-emerald-700"
: row.status === "En retard"
? "bg-red-100 text-red-700"
: "bg-amber-100 text-amber-700"
}`}
>
{row.status === "Fait" && <Check className="w-3 h-3" />}
{row.status === "En retard" && <X className="w-3 h-3" />}
{row.status}
</span>
</td>
{FUNDS.map((fundObj) => {
const fundData = row.funds[fundObj.key];
return (
<td
key={fundObj.key}
className="px-2 py-2 text-right cursor-pointer hover:bg-purple-50 transition"
onClick={() => {
if (fundData) {
onEditCell({
id: fundData.id,
org_id: row.org_id,
org_name: row.org_name,
period_label: row.period_label,
fund: fundObj.key,
amount_due: fundData.amount_due,
amount_paid: fundData.amount_paid,
amount_diff: fundData.amount_diff,
paid_date: fundData.paid_date,
due_date: row.due_date,
status: row.status,
notes: fundData.notes,
});
}
}}
>
{fundData ? (
<div className="flex flex-col items-end">
<span className="font-medium text-slate-900 whitespace-nowrap">
{formatCurrency(fundData.amount_due)}
</span>
{fundData.amount_paid !== null && (
<span className="text-[10px] text-emerald-600 whitespace-nowrap">
Payé: {formatCurrency(fundData.amount_paid)}
</span>
)}
</div>
) : (
<span className="text-slate-400"></span>
)}
</td>
);
})}
<td className="px-2 py-2 text-right font-semibold text-slate-900 bg-slate-50">
<div className="flex flex-col items-end">
<span className="whitespace-nowrap">{formatCurrency(row.total_due)}</span>
{row.total_paid > 0 && (
<span className="text-[10px] text-emerald-600 whitespace-nowrap">
Payé: {formatCurrency(row.total_paid)}
</span>
)}
</div>
</td>
<td className="px-2 py-2">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => onEditPeriod(row)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition"
title="Modifier la période"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => onNotifyPeriod(row)}
className={`p-1.5 rounded-lg hover:bg-amber-50 transition ${
row.is_notified ? 'text-green-600' : 'text-amber-600'
}`}
title={row.is_notified ? "Notifier à nouveau" : "Notifier le client"}
>
<Bell className="w-4 h-4" />
</button>
<button
onClick={() => onDeletePeriod(row)}
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg transition"
title="Supprimer la période"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{/* Summary */}
<div className="mt-3 text-sm text-slate-600">
Affichage de {sortedRows.length} période{sortedRows.length > 1 ? 's' : ''} sur {periodRows.length} au total
</div>
{/* Bulk Notify Modal */}
{showBulkNotifyModal && (
<BulkNotifyModal
periods={selectedPeriods}
onClose={() => setShowBulkNotifyModal(false)}
onConfirm={handleConfirmBulkNotify}
/>
)}
</div>
);
}