- 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
918 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|