1085 lines
No EOL
45 KiB
TypeScript
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>
|
|
);
|
|
}
|