espace-paie-odentas/app/(app)/staff/facturation/page.tsx
2025-10-12 17:05:46 +02:00

1085 lines
No EOL
45 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/fetcher";
import { Loader2, CheckCircle2, XCircle, FileDown, Edit, Plus, Eye, ExternalLink, Filter, X, ChevronUp, ChevronDown, Calendar, CreditCard, Send } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { usePageTitle } from "@/hooks/usePageTitle";
// ---------------- Types ----------------
type Invoice = {
id: string;
numero: string | null;
periode: string | null;
date: string | null;
montant_ht: number;
montant_ttc: number;
statut: "payee" | "annulee" | "prete" | "emise" | "en_cours" | string | null;
pdf?: string | null;
org_id: string;
organization_name?: string;
};
type StaffBillingResponse = {
factures: {
items: Invoice[];
page: number;
limit: number;
hasMore: boolean;
};
};
// -------------- Helpers --------------
const fmtEUR = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" });
function fmtDateFR(iso?: string) {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function Badge({ tone = "default", children }: { tone?: "ok" | "warn" | "error" | "default"; children: React.ReactNode }) {
const cls =
tone === "ok"
? "bg-emerald-100 text-emerald-800"
: tone === "warn"
? "bg-amber-100 text-amber-800"
: tone === "error"
? "bg-rose-100 text-rose-800"
: "bg-slate-100 text-slate-700";
return <span className={`text-xs px-2 py-1 rounded-full ${cls}`}>{children}</span>;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border bg-white">
<div className="px-4 py-3 border-b font-medium text-slate-700 bg-slate-50/60">
{title}
</div>
<div className="p-4">{children}</div>
</section>
);
}
// -------------- Data hook --------------
function useStaffBilling(page: number, limit: number) {
return useQuery<StaffBillingResponse>({
queryKey: ["staff-billing", page, limit],
queryFn: () => api(`/staff/facturation?page=${page}&limit=${limit}`),
staleTime: 15_000,
});
}
// -------------- Page --------------
export default function StaffFacturationPage() {
usePageTitle("Facturation (Staff)");
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
const [clientFilter, setClientFilter] = useState<string>("");
const [periodFilter, setPeriodFilter] = useState<string>("");
const [dateFromFilter, setDateFromFilter] = useState<string>("");
const [dateToFilter, setDateToFilter] = useState<string>("");
const [showFilters, setShowFilters] = useState(false);
const [sortField, setSortField] = useState<"numero" | "date" | "client" | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set());
const [showSepaModal, setShowSepaModal] = useState(false);
const [showInvoiceDateModal, setShowInvoiceDateModal] = useState(false);
const [showDueDateModal, setShowDueDateModal] = useState(false);
const [showBulkGoCardlessModal, setShowBulkGoCardlessModal] = useState(false);
const [newSepaDate, setNewSepaDate] = useState("");
const [newInvoiceDate, setNewInvoiceDate] = useState("");
const [newDueDate, setNewDueDate] = useState("");
const limit = 25;
const { data, isLoading, isError, error } = useStaffBilling(page, limit);
const queryClient = useQueryClient();
const items = data?.factures.items ?? [];
const hasMore = data?.factures.hasMore ?? false;
// Fonctions pour la gestion de sélection
const toggleInvoiceSelection = (invoiceId: string) => {
setSelectedInvoices(prev => {
const newSet = new Set(prev);
if (newSet.has(invoiceId)) {
newSet.delete(invoiceId);
} else {
newSet.add(invoiceId);
}
return newSet;
});
};
const toggleSelectAll = () => {
if (selectedInvoices.size === filteredAndSortedItems.length) {
setSelectedInvoices(new Set());
} else {
setSelectedInvoices(new Set(filteredAndSortedItems.map(item => item.id)));
}
};
const clearSelection = () => {
setSelectedInvoices(new Set());
};
// Mutation pour mise à jour en masse des dates SEPA
const updateSepaDatesMutation = useMutation({
mutationFn: async ({ invoiceIds, sepaDate }: { invoiceIds: string[], sepaDate: string }) => {
return api('/staff/facturation/bulk-update-sepa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ invoiceIds, sepaDate }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
setShowSepaModal(false);
setNewSepaDate("");
clearSelection();
alert("Dates de prélèvement SEPA mises à jour avec succès !");
},
onError: (error: any) => {
console.error("Erreur lors de la mise à jour:", error);
alert(`Erreur lors de la mise à jour : ${error.message || "erreur inconnue"}`);
},
});
// Mutation pour mise à jour en masse des dates de facture
const updateInvoiceDatesMutation = useMutation({
mutationFn: async ({ invoiceIds, invoiceDate }: { invoiceIds: string[], invoiceDate: string }) => {
return api('/staff/facturation/bulk-update-invoice-date', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ invoiceIds, invoiceDate }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
setShowInvoiceDateModal(false);
setNewInvoiceDate("");
clearSelection();
alert("Dates de facture mises à jour avec succès !");
},
onError: (error: any) => {
console.error("Erreur lors de la mise à jour:", error);
alert(`Erreur lors de la mise à jour : ${error.message || "erreur inconnue"}`);
},
});
// Mutation pour mise à jour en masse des dates d'échéance
const updateDueDatesMutation = useMutation({
mutationFn: async ({ invoiceIds, dueDate }: { invoiceIds: string[], dueDate: string }) => {
return api('/staff/facturation/bulk-update-due-date', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ invoiceIds, dueDate }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
setShowDueDateModal(false);
setNewDueDate("");
clearSelection();
alert("Dates d'échéance mises à jour avec succès !");
},
onError: (error: any) => {
console.error("Erreur lors de la mise à jour:", error);
alert(`Erreur lors de la mise à jour : ${error.message || "erreur inconnue"}`);
},
});
// Mutation pour création en masse des paiements GoCardless
const bulkGoCardlessMutation = useMutation({
mutationFn: async ({ invoiceIds }: { invoiceIds: string[] }) => {
return api('/staff/facturation/bulk-gocardless', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ invoiceIds }),
});
},
onSuccess: (data: any) => {
queryClient.invalidateQueries({ queryKey: ["staff-billing"] });
clearSelection();
const { summary } = data;
if (summary.failed === 0) {
alert(`${summary.success} paiement(s) GoCardless créé(s) avec succès !`);
} else {
alert(`${summary.success} paiement(s) créé(s), ${summary.failed} échec(s). Consultez la console pour plus de détails.`);
console.log('Détails des échecs:', data.errors);
}
},
onError: (error: any) => {
console.error("Erreur lors de la création GoCardless:", error);
alert(`Erreur lors de la création GoCardless : ${error.message || "erreur inconnue"}`);
},
});
const handleBulkUpdateSepa = () => {
if (selectedInvoices.size === 0) {
alert("Veuillez sélectionner au moins une facture.");
return;
}
if (!newSepaDate) {
alert("Veuillez sélectionner une date.");
return;
}
updateSepaDatesMutation.mutate({
invoiceIds: Array.from(selectedInvoices),
sepaDate: newSepaDate
});
};
const handleBulkUpdateInvoiceDate = () => {
if (selectedInvoices.size === 0) {
alert("Veuillez sélectionner au moins une facture.");
return;
}
if (!newInvoiceDate) {
alert("Veuillez sélectionner une date.");
return;
}
updateInvoiceDatesMutation.mutate({
invoiceIds: Array.from(selectedInvoices),
invoiceDate: newInvoiceDate
});
};
const handleBulkUpdateDueDate = () => {
if (selectedInvoices.size === 0) {
alert("Veuillez sélectionner au moins une facture.");
return;
}
if (!newDueDate) {
alert("Veuillez sélectionner une date.");
return;
}
updateDueDatesMutation.mutate({
invoiceIds: Array.from(selectedInvoices),
dueDate: newDueDate
});
};
const handleBulkGoCardless = () => {
if (selectedInvoices.size === 0) {
alert("Veuillez sélectionner au moins une facture.");
return;
}
// Vérifier que toutes les factures sélectionnées sont en brouillon ou en cours
const selectedInvoicesArray = items.filter(invoice => selectedInvoices.has(invoice.id));
const hasInvalidInvoices = selectedInvoicesArray.some(invoice =>
invoice.statut !== 'brouillon' && invoice.statut !== 'en_cours'
);
if (hasInvalidInvoices) {
alert('Seules les factures en brouillon ou en cours peuvent être traitées.');
return;
}
setShowBulkGoCardlessModal(true);
};
const confirmBulkGoCardless = () => {
bulkGoCardlessMutation.mutate({ invoiceIds: Array.from(selectedInvoices) });
setShowBulkGoCardlessModal(false);
};
// Filtrer et trier les éléments côté client
const filteredAndSortedItems = useMemo(() => {
// D'abord filtrer
let filtered = items.filter(invoice => {
const matchesStatus = !statusFilter || invoice.statut === statusFilter;
const matchesClient = !clientFilter ||
(invoice.organization_name?.toLowerCase().includes(clientFilter.toLowerCase()) ?? false);
const matchesPeriod = !periodFilter ||
(invoice.periode?.toLowerCase().includes(periodFilter.toLowerCase()) ?? false);
// Filtrage par date
let matchesDateRange = true;
if (dateFromFilter || dateToFilter) {
if (invoice.date) {
const invoiceDate = new Date(invoice.date);
if (dateFromFilter) {
const fromDate = new Date(dateFromFilter);
matchesDateRange = matchesDateRange && invoiceDate >= fromDate;
}
if (dateToFilter) {
const toDate = new Date(dateToFilter);
matchesDateRange = matchesDateRange && invoiceDate <= toDate;
}
} else {
// Si la facture n'a pas de date et qu'on filtre par date, l'exclure
matchesDateRange = false;
}
}
return matchesStatus && matchesClient && matchesPeriod && matchesDateRange;
});
// Ensuite trier
if (sortField) {
filtered.sort((a, b) => {
let aValue: string | Date | null = null;
let bValue: string | Date | null = null;
switch (sortField) {
case "numero":
aValue = a.numero || "";
bValue = b.numero || "";
break;
case "date":
aValue = a.date ? new Date(a.date) : new Date(0);
bValue = b.date ? new Date(b.date) : new Date(0);
break;
case "client":
aValue = a.organization_name || "";
bValue = b.organization_name || "";
break;
}
if (aValue === null && bValue === null) return 0;
if (aValue === null) return 1;
if (bValue === null) return -1;
let comparison = 0;
if (aValue instanceof Date && bValue instanceof Date) {
comparison = aValue.getTime() - bValue.getTime();
} else {
comparison = String(aValue).localeCompare(String(bValue));
}
return sortDirection === "desc" ? -comparison : comparison;
});
}
return filtered;
}, [items, statusFilter, clientFilter, periodFilter, dateFromFilter, dateToFilter, sortField, sortDirection]);
// Fonction pour gérer le tri
const handleSort = (field: "numero" | "date" | "client") => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("asc");
}
};
// Obtenir les listes uniques pour les filtres
const uniqueClients = useMemo(() => {
const clients = [...new Set(items.map(f => f.organization_name).filter(Boolean))];
return clients.sort();
}, [items]);
const uniquePeriods = useMemo(() => {
const periods = [...new Set(items.map(f => f.periode).filter(Boolean))];
return periods.sort();
}, [items]);
const hasActiveFilters = statusFilter || clientFilter || periodFilter || dateFromFilter || dateToFilter;
const clearFilters = () => {
setStatusFilter("");
setClientFilter("");
setPeriodFilter("");
setDateFromFilter("");
setDateToFilter("");
};
// Statistiques rapides (sur toutes les factures, pas les filtrées)
const stats = useMemo(() => {
if (!items.length) return { total: 0, enCours: 0, emises: 0, impayes: 0 };
// Compter toutes les factures (non filtrées)
const emises = items.filter(f => f.statut === "emise" || f.statut === "prete").length;
const enCours = items.filter(f => f.statut === "en_cours").length;
// Les impayés = émises qui ne sont pas payées ni annulées (on considère les émises et prêtes comme impayées)
const impayes = emises;
return {
total: items.length,
enCours,
emises,
impayes,
};
}, [items]);
// Calculer les totaux HT et TTC des factures filtrées
const filteredTotals = useMemo(() => {
const totalHT = filteredAndSortedItems.reduce((sum: number, invoice: Invoice) => sum + (invoice.montant_ht || 0), 0);
const totalTTC = filteredAndSortedItems.reduce((sum: number, invoice: Invoice) => sum + (invoice.montant_ttc || 0), 0);
return { totalHT, totalTTC };
}, [filteredAndSortedItems]);
return (
<main className="space-y-5">
{/* En-tête avec bouton de création */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Facturation</h1>
<p className="text-slate-600">Gestion des factures de tous les clients</p>
</div>
<Link
href="/staff/facturation/create"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Créer une facture
</Link>
</div>
{/* Filtres */}
<div className="bg-white rounded-xl border">
<div className="px-4 py-3 border-b flex items-center justify-between">
<h3 className="font-medium text-slate-700">Filtres</h3>
<div className="flex items-center gap-2">
{hasActiveFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1 text-xs px-2 py-1 text-slate-600 hover:text-slate-800"
>
<X className="w-3 h-3" />
Effacer
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
>
<Filter className="w-4 h-4" />
{showFilters ? "Masquer" : "Afficher"} les filtres
</button>
</div>
</div>
{showFilters && (
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Filtre par statut */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Statut
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous les statuts</option>
<option value="emise">Émise</option>
<option value="en_cours">En cours</option>
<option value="payee">Payée</option>
<option value="annulee">Annulée</option>
<option value="prete">Prête</option>
</select>
</div>
{/* Filtre par client */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Client
</label>
<select
value={clientFilter}
onChange={(e) => setClientFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Tous les clients</option>
{uniqueClients.map(client => (
<option key={client || 'empty'} value={client || ''}>{client || 'Non défini'}</option>
))}
</select>
</div>
{/* Filtre par période */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Période
</label>
<select
value={periodFilter}
onChange={(e) => setPeriodFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
>
<option value="">Toutes les périodes</option>
{uniquePeriods.map(period => (
<option key={period || 'empty'} value={period || ''}>{period || 'Non définie'}</option>
))}
</select>
</div>
{/* Filtre date de début */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Date de début
</label>
<input
type="date"
value={dateFromFilter}
onChange={(e) => setDateFromFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
/>
</div>
{/* Filtre date de fin */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Date de fin
</label>
<input
type="date"
value={dateToFilter}
onChange={(e) => setDateToFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white text-sm"
/>
</div>
</div>
{/* Indicateur de filtres actifs */}
{hasActiveFilters && (
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="text-sm text-slate-600">Filtres actifs :</span>
{statusFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
Statut: {statusFilter}
<button onClick={() => setStatusFilter("")} className="hover:text-blue-600">
<X className="w-3 h-3" />
</button>
</span>
)}
{clientFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded text-xs">
Client: {clientFilter}
<button onClick={() => setClientFilter("")} className="hover:text-green-600">
<X className="w-3 h-3" />
</button>
</span>
)}
{periodFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded text-xs">
Période: {periodFilter}
<button onClick={() => setPeriodFilter("")} className="hover:text-purple-600">
<X className="w-3 h-3" />
</button>
</span>
)}
{dateFromFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-orange-100 text-orange-800 rounded text-xs">
Depuis: {new Date(dateFromFilter).toLocaleDateString("fr-FR")}
<button onClick={() => setDateFromFilter("")} className="hover:text-orange-600">
<X className="w-3 h-3" />
</button>
</span>
)}
{dateToFilter && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-cyan-100 text-cyan-800 rounded text-xs">
Jusqu'au: {new Date(dateToFilter).toLocaleDateString("fr-FR")}
<button onClick={() => setDateToFilter("")} className="hover:text-cyan-600">
<X className="w-3 h-3" />
</button>
</span>
)}
</div>
)}
</div>
)}
</div>
{/* Statistiques rapides */}
{data && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-slate-900">{stats.total}</div>
<div className="text-sm text-slate-600">Total factures</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-blue-600">{stats.enCours}</div>
<div className="text-sm text-slate-600">En cours</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-emerald-600">{stats.emises}</div>
<div className="text-sm text-slate-600">Émises</div>
</div>
<div className="bg-white rounded-xl border p-4">
<div className="text-2xl font-bold text-rose-600">{stats.impayes}</div>
<div className="text-sm text-slate-600">Impayées</div>
</div>
</div>
)}
{/* Liste des factures */}
<Section title="Toutes les factures">
<div className="text-xs text-slate-500 mb-3 flex items-center gap-4">
<div className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-emerald-500 inline-block"/> Facture payée</div>
<div className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-slate-400 inline-block"/> Facture émise</div>
<div className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-rose-500 inline-block"/> Facture annulée</div>
<div className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-blue-500 inline-block"/> Facture en cours</div>
</div>
{!data && isLoading && (
<div className="text-sm text-slate-500 flex items-center gap-2"><Loader2 className="w-4 h-4 animate-spin"/> Chargement…</div>
)}
{isError && (
<div className="text-sm text-rose-600">Impossible de charger les factures : {(error as any)?.message || "erreur"}</div>
)}
{data && (
<div className="overflow-x-auto">
{/* Bouton d'action en masse */}
{selectedInvoices.size > 0 && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-sm text-blue-800">
{selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowInvoiceDateModal(true)}
className="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm"
>
<Calendar className="w-4 h-4" />
Modifier date facture
</button>
<button
onClick={() => setShowDueDateModal(true)}
className="inline-flex items-center gap-2 px-3 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm"
>
<Calendar className="w-4 h-4" />
Modifier date échéance
</button>
<button
onClick={() => setShowSepaModal(true)}
className="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
<Calendar className="w-4 h-4" />
Modifier date SEPA
</button>
<button
onClick={handleBulkGoCardless}
disabled={bulkGoCardlessMutation.isPending}
className="inline-flex items-center gap-2 px-3 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<CreditCard className="w-4 h-4" />
{bulkGoCardlessMutation.isPending ? 'En cours...' : '+ GoCardless'}
</button>
<button
onClick={clearSelection}
className="inline-flex items-center gap-2 px-3 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors text-sm"
>
<X className="w-4 h-4" />
Annuler
</button>
</div>
</div>
</div>
)}
<table className="w-full text-sm">
<thead className="text-left bg-slate-50">
<tr className="border-b">
<th className="px-3 py-2 w-12">
<input
type="checkbox"
checked={selectedInvoices.size === filteredAndSortedItems.length && filteredAndSortedItems.length > 0}
onChange={toggleSelectAll}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</th>
<th className="px-3 py-2 w-24">Statut</th>
<th className="px-3 py-2 w-32">
<button
onClick={() => handleSort("numero")}
className="flex items-center gap-1 hover:text-blue-600"
>
Numéro
{sortField === "numero" && (
sortDirection === "asc" ?
<ChevronUp className="w-3 h-3" /> :
<ChevronDown className="w-3 h-3" />
)}
</button>
</th>
<th className="px-3 py-2">
<button
onClick={() => handleSort("client")}
className="flex items-center gap-1 hover:text-blue-600"
>
Client
{sortField === "client" && (
sortDirection === "asc" ?
<ChevronUp className="w-3 h-3" /> :
<ChevronDown className="w-3 h-3" />
)}
</button>
</th>
<th className="px-3 py-2">Période concernée</th>
<th className="px-3 py-2">
<button
onClick={() => handleSort("date")}
className="flex items-center gap-1 hover:text-blue-600"
>
Date
{sortField === "date" && (
sortDirection === "asc" ?
<ChevronUp className="w-3 h-3" /> :
<ChevronDown className="w-3 h-3" />
)}
</button>
</th>
<th className="px-3 py-2 text-right">HT</th>
<th className="px-3 py-2 text-right">TTC</th>
<th className="px-3 py-2">PDF</th>
<th className="px-3 py-2">Actions</th>
</tr>
{/* Sous-header avec les totaux */}
{filteredAndSortedItems.length > 0 && (
<tr className="border-b bg-blue-50">
<td className="px-3 py-2"></td>
<td colSpan={5} className="px-3 py-2 text-sm font-medium text-blue-900">
Total affiché ({filteredAndSortedItems.length} facture{filteredAndSortedItems.length > 1 ? "s" : ""})
</td>
<td className="px-3 py-2 text-right font-bold text-blue-900">
{fmtEUR.format(filteredTotals.totalHT)}
</td>
<td className="px-3 py-2 text-right font-bold text-blue-900">
{fmtEUR.format(filteredTotals.totalTTC)}
</td>
<td colSpan={2} className="px-3 py-2"></td>
</tr>
)}
</thead>
<tbody>
{filteredAndSortedItems.length === 0 && (
<tr>
<td colSpan={10} className="px-3 py-6 text-center text-slate-500">
{hasActiveFilters ? "Aucune facture ne correspond aux filtres." : "Aucune facture."}
</td>
</tr>
)}
{filteredAndSortedItems.map((f: Invoice) => (
<tr key={f.id} className="border-b last:border-b-0 hover:bg-slate-50">
<td className="px-3 py-2">
<input
type="checkbox"
checked={selectedInvoices.has(f.id)}
onChange={() => toggleInvoiceSelection(f.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-3 py-2">
{f.statut === "payee" ? (
<span className="inline-flex items-center gap-1 text-xs"><span className="w-2 h-2 rounded-full bg-emerald-500 flex-shrink-0"/> Payée</span>
) : f.statut === "annulee" ? (
<span className="inline-flex items-center gap-1 text-xs"><span className="w-2 h-2 rounded-full bg-rose-500 flex-shrink-0"/> Annulée</span>
) : f.statut === "en_cours" ? (
<span className="inline-flex items-center gap-1 text-xs"><span className="w-2 h-2 rounded-full bg-slate-500 flex-shrink-0"/> En cours</span>
) : f.statut === "prete" ? (
<span className="inline-flex items-center gap-1 text-xs"><span className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"/> Prête</span>
) : (
<span className="inline-flex items-center gap-1 text-xs"><span className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"/> Émise</span>
)}
</td>
<td className="px-3 py-2 font-medium text-nowrap">{f.numero || "—"}</td>
<td className="px-3 py-2">
<span className="font-medium">{f.organization_name || "—"}</span>
</td>
<td className="px-3 py-2">{f.periode || "—"}</td>
<td className="px-3 py-2">{fmtDateFR(f.date || undefined)}</td>
<td className="px-3 py-2 text-right">{fmtEUR.format(f.montant_ht || 0)}</td>
<td className="px-3 py-2 text-right">{fmtEUR.format(f.montant_ttc || 0)}</td>
<td className="px-3 py-2">
{f.pdf ? (
<a href={`/api/staff/facturation/${f.id}/view-pdf`} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 underline text-blue-600 hover:text-blue-800">
<ExternalLink className="w-4 h-4"/> Ouvrir
</a>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<Link
href={`/staff/facturation/${f.id}`}
className="inline-flex items-center gap-1 text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
>
<Eye className="w-3 h-3" />
Voir
</Link>
<Link
href={`/staff/facturation/${f.id}?edit=true`}
className="inline-flex items-center gap-1 text-xs px-2 py-1 bg-amber-100 text-amber-700 rounded hover:bg-amber-200 transition-colors"
>
<Edit className="w-3 h-3" />
Modifier
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{data && (
<div className="mt-4 flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<div>
Page {data.factures.page} {items.length} facture{items.length > 1 ? "s" : ""} total{items.length > 1 ? "es" : "e"}
</div>
{hasActiveFilters && (
<div className="text-blue-600">
{filteredAndSortedItems.length} facture{filteredAndSortedItems.length > 1 ? "s" : ""} après filtrage
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
className="px-3 py-1 rounded-md border disabled:opacity-40 hover:bg-slate-50"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Précédent
</button>
<button
className="px-3 py-1 rounded-md border disabled:opacity-40 hover:bg-slate-50"
disabled={!hasMore}
onClick={() => setPage((p) => p + 1)}
>
Suivant
</button>
</div>
</div>
)}
</Section>
{/* Modal pour modification en masse de la date SEPA */}
{showSepaModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Modifier la date de prélèvement SEPA</h3>
<p className="text-sm text-slate-600 mb-4">
Cette action va modifier la date de prélèvement SEPA pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Nouvelle date de prélèvement
</label>
<input
type="date"
value={newSepaDate}
onChange={(e) => setNewSepaDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white"
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowSepaModal(false);
setNewSepaDate("");
}}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={updateSepaDatesMutation.isPending}
>
Annuler
</button>
<button
onClick={handleBulkUpdateSepa}
disabled={updateSepaDatesMutation.isPending || !newSepaDate}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{updateSepaDatesMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Confirmer
</button>
</div>
</div>
</div>
)}
{/* Modal pour modification en masse de la date de facture */}
{showInvoiceDateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Modifier la date de facture</h3>
<p className="text-sm text-slate-600 mb-4">
Cette action va modifier la date de facture pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Nouvelle date de facture
</label>
<input
type="date"
value={newInvoiceDate}
onChange={(e) => setNewInvoiceDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white"
/>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowInvoiceDateModal(false);
setNewInvoiceDate("");
}}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={updateInvoiceDatesMutation.isPending}
>
Annuler
</button>
<button
onClick={handleBulkUpdateInvoiceDate}
disabled={updateInvoiceDatesMutation.isPending || !newInvoiceDate}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{updateInvoiceDatesMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Confirmer
</button>
</div>
</div>
</div>
)}
{/* Modal pour modification en masse de la date d'échéance */}
{showDueDateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4">Modifier la date d'échéance</h3>
<p className="text-sm text-slate-600 mb-4">
Cette action va modifier la date d'échéance pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Nouvelle date d'échéance
</label>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-white"
/>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => {
setShowDueDateModal(false);
setNewDueDate("");
}}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
disabled={updateDueDatesMutation.isPending}
>
Annuler
</button>
<button
onClick={handleBulkUpdateDueDate}
disabled={updateDueDatesMutation.isPending || !newDueDate}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{updateDueDatesMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Confirmer
</button>
</div>
</div>
</div>
)}
{/* Modal de confirmation GoCardless bulk */}
<Dialog open={showBulkGoCardlessModal} onOpenChange={setShowBulkGoCardlessModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="text-orange-500" size={20} />
Créer paiements GoCardless
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-slate-600">
<p className="mb-3">
Êtes-vous sûr de vouloir créer des paiements GoCardless pour {selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""} ?
</p>
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="font-medium text-slate-900">
{selectedInvoices.size} facture{selectedInvoices.size > 1 ? "s" : ""} sélectionnée{selectedInvoices.size > 1 ? "s" : ""}
</div>
<div className="text-slate-700">
{(() => {
const selectedArray = items.filter(invoice => selectedInvoices.has(invoice.id));
const totalTTC = selectedArray.reduce((sum, invoice) => sum + (invoice.montant_ttc || 0), 0);
return `Montant total : ${totalTTC.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}`;
})()}
</div>
</div>
<div className="bg-orange-50 rounded-lg p-3 mt-3">
<div className="text-orange-800 text-sm font-medium mb-2">
💳 Cette action va :
</div>
<ul className="text-orange-700 text-sm space-y-1 list-disc list-inside">
<li>Créer les prélèvements dans GoCardless</li>
<li>Marquer les factures comme émises</li>
<li>Ignorer les factures sans mandat SEPA</li>
<li>Ne PAS envoyer de notifications email</li>
</ul>
</div>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowBulkGoCardlessModal(false)}
disabled={bulkGoCardlessMutation.isPending}
>
Annuler
</Button>
<Button
onClick={confirmBulkGoCardless}
disabled={bulkGoCardlessMutation.isPending}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{bulkGoCardlessMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Création...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Créer {selectedInvoices.size} paiement{selectedInvoices.size > 1 ? "s" : ""}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}