Checkpoint: Ajout de la page Factures abonnements, accessible après Factures BAP, partageant les recherches, filtres et actions de la page Factures. Filtres Année et Mois ajoutés aux deux listes, avec règle date de facture puis réception.

This commit is contained in:
Manus
2026-08-29 18:32:12 +00:00
parent 0b01ebe331
commit e2d067ff4b
6 changed files with 135 additions and 34 deletions

View File

@@ -27,6 +27,14 @@ const LearningSettings = lazy(() => import("./pages/LearningSettings"));
const VentilationFreePro = lazy(() => import("./pages/VentilationFreePro"));
const WebImportSources = lazy(() => import("./pages/WebImportSources"));
function SubscriptionInvoices() {
return <Invoices scope="subscriptions" />;
}
function AllInvoices() {
return <Invoices />;
}
function RouteFallback() {
return <div className="min-h-screen bg-background" aria-busy="true" aria-label="Chargement" />;
}
@@ -38,8 +46,9 @@ function Router() {
<Route path="/login" component={Login} />
<Route path="/dashboard" component={Dashboard} />
<Route path="/upload" component={Upload} />
<Route path="/invoices" component={Invoices} />
<Route path="/invoices" component={AllInvoices} />
<Route path="/invoices-bap" component={InvoicesBAP} />
<Route path="/invoices-subscriptions" component={SubscriptionInvoices} />
<Route path="/invoices/:id" component={InvoiceDetail} />
<Route path="/settings" component={Settings} />
<Route path="/import-settings" component={ImportSettings} />

View File

@@ -53,6 +53,7 @@ const menuStructure: MenuItem[] = [
{ icon: Upload, label: "Import", path: "/upload" },
{ icon: FileText, label: "Factures", path: "/invoices" },
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
{ icon: FileText, label: "Factures abonnements", path: "/invoices-subscriptions" },
],
},
{

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent } from "@/components/ui/card";
@@ -34,17 +34,28 @@ import {
} from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, Filter, LayoutList, LayoutGrid, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, Filter, LayoutList, LayoutGrid, ArrowUpDown, ArrowUp, ArrowDown, CalendarDays } from "lucide-react";
import * as XLSX from 'xlsx';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { useLocation } from "wouter";
import { matchesInvoicePeriod } from "@shared/invoicePeriod";
type SortField = "invoiceDate" | "createdAt";
type SortDir = "asc" | "desc";
export default function Invoices() {
type InvoiceScope = "all" | "subscriptions";
const MONTHS = [
["01", "Janvier"], ["02", "Février"], ["03", "Mars"], ["04", "Avril"],
["05", "Mai"], ["06", "Juin"], ["07", "Août"], ["08", "Septembre"],
["09", "Septembre"], ["10", "Octobre"], ["11", "Novembre"], ["12", "Décembre"],
] as const;
export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) {
const [, setLocation] = useLocation();
const currentYear = new Date().getFullYear();
const isSubscriptionPage = scope === "subscriptions";
const [searchQuery, setSearchQuery] = useState("");
const [compactMode, setCompactMode] = useState(true);
const [sortField, setSortField] = useState<SortField>("createdAt");
@@ -52,9 +63,11 @@ export default function Invoices() {
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all");
const [recipientFilter, setRecipientFilter] = useState<string>("all");
const [subscriptionFilter, setSubscriptionFilter] = useState<string>("all"); // all | yes | no
const [subscriptionFilter, setSubscriptionFilter] = useState<string>(isSubscriptionPage ? "yes" : "all"); // all | yes | no
const [entityFilter, setEntityFilter] = useState<string>("all"); // all | santinova | itinova
const [ventilationFilter, setVentilationFilter] = useState<string>("all");
const [selectedYear, setSelectedYear] = useState<string>(String(currentYear));
const [selectedMonth, setSelectedMonth] = useState<string>("all");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -194,17 +207,20 @@ export default function Invoices() {
};
// Collect unique recipients for the filter dropdown
const uniqueRecipients = Array.from(
new Set(
(invoices || []).map(inv => (inv as any).recipientName).filter(Boolean)
)
).sort();
const scopedInvoices = useMemo(
() => (invoices || []).filter(inv => !isSubscriptionPage || inv.isSubscription === 1),
[invoices, isSubscriptionPage],
);
const uniqueVentilations = Array.from(
new Set(
(invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean)
)
).sort();
const uniqueRecipients = useMemo(
() => Array.from(new Set(scopedInvoices.map(inv => (inv as any).recipientName).filter(Boolean))).sort(),
[scopedInvoices],
);
const uniqueVentilations = useMemo(
() => Array.from(new Set(scopedInvoices.map(inv => (inv as any).ventilationComptable).filter(Boolean))).sort(),
[scopedInvoices],
);
const handleSort = (field: SortField) => {
if (sortField === field) {
@@ -220,7 +236,7 @@ export default function Invoices() {
return sortDir === "asc" ? <ArrowUp className="w-3 h-3 ml-1" /> : <ArrowDown className="w-3 h-3 ml-1" />;
};
const filteredInvoices = invoices?.filter((inv) => {
const filteredInvoices = scopedInvoices.filter((inv) => {
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
@@ -230,6 +246,9 @@ export default function Invoices() {
if (!matchesSearch) return false;
}
// Même convention que Factures BAP : date de facture, sinon réception.
if (!matchesInvoicePeriod(inv, selectedYear, selectedMonth)) return false;
// Filter by export status
if (statusFilter !== "all") {
if (statusFilter === "exported" && inv.exportStatus !== "exported") return false;
@@ -268,22 +287,17 @@ export default function Invoices() {
return true;
});
const invoicesInPeriod = useMemo(
() => scopedInvoices.filter(inv => matchesInvoicePeriod(inv, selectedYear, selectedMonth)),
[scopedInvoices, selectedYear, selectedMonth],
);
const statusCounts = {
all: invoices?.length || 0,
exported: invoices?.filter(inv => inv.exportStatus === "exported").length || 0,
not_exported: invoices?.filter(inv => inv.exportStatus === "not_exported").length || 0,
export_error: invoices?.filter(inv => inv.exportStatus === "export_error").length || 0,
all: invoicesInPeriod.length,
exported: invoicesInPeriod.filter(inv => inv.exportStatus === "exported").length,
not_exported: invoicesInPeriod.filter(inv => inv.exportStatus === "not_exported").length,
export_error: invoicesInPeriod.filter(inv => inv.exportStatus === "export_error").length,
};
// Old filter logic (to be removed)
const _oldFilteredInvoices = invoices?.filter((inv) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
inv.supplierName?.toLowerCase().includes(query) ||
inv.invoiceNumber?.toLowerCase().includes(query)
);
});
const handleSelectAll = (checked: boolean) => {
if (checked) {
@@ -370,8 +384,10 @@ export default function Invoices() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Factures</h1>
<p className="text-gray-500 mt-1">Gérez toutes vos factures importées</p>
<h1 className="text-3xl font-bold">{isSubscriptionPage ? "Factures abonnements" : "Factures"}</h1>
<p className="text-gray-500 mt-1">
{isSubscriptionPage ? "Gérez les factures identifiées comme abonnements" : "Gérez toutes vos factures importées"}
</p>
</div>
<div className="flex gap-2">
<Button
@@ -456,9 +472,30 @@ export default function Invoices() {
</div>
</div>
{/* Ligne 2 : Filtres destinataire, abonnement, entité, ventilation */}
{/* Ligne 2 : Filtres de période, destinataire, abonnement, entité, ventilation */}
<div className="flex gap-2 items-center flex-wrap mb-3">
<Filter className="w-4 h-4 text-blue-400 shrink-0" />
<div className="flex items-center gap-1.5 text-sm font-medium text-blue-600">
<CalendarDays className="w-4 h-4" />
Période :
</div>
<Select value={selectedYear} onValueChange={(value) => { setSelectedYear(value); if (value === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 bg-white border-blue-200 text-sm"><SelectValue placeholder="Année" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{Array.from({ length: 5 }, (_, index) => currentYear - index).map(year => <SelectItem key={year} value={String(year)}>{year}</SelectItem>)}
</SelectContent>
</Select>
<Select value={selectedMonth} onValueChange={setSelectedMonth} disabled={selectedYear === "all"}>
<SelectTrigger className="w-36 h-8 bg-white border-blue-200 text-sm"><SelectValue placeholder="Mois" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{MONTHS.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}
</SelectContent>
</Select>
{(selectedYear !== String(currentYear) || selectedMonth !== "all") && (
<button onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }} className="text-xs text-blue-600 hover:text-blue-800 underline">Réinitialiser</button>
)}
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Destinataire" />
@@ -471,7 +508,7 @@ export default function Invoices() {
))}
</SelectContent>
</Select>
<Select value={subscriptionFilter} onValueChange={setSubscriptionFilter}>
<Select value={isSubscriptionPage ? "yes" : subscriptionFilter} onValueChange={setSubscriptionFilter} disabled={isSubscriptionPage}>
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Abonnement" />
</SelectTrigger>

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { getInvoicePeriodDate, matchesInvoicePeriod } from "@shared/invoicePeriod";
describe("filtres de période des factures", () => {
it("privilégie la date de facture et accepte le mois demandé", () => {
const invoice = { invoiceDate: "2026-05-11T00:00:00.000Z", createdAt: "2026-06-02T00:00:00.000Z" };
expect(getInvoicePeriodDate(invoice)?.getFullYear()).toBe(2026);
expect(matchesInvoicePeriod(invoice, "2026", "05")).toBe(true);
expect(matchesInvoicePeriod(invoice, "2026", "06")).toBe(false);
});
it("utilise la date de réception lorsque la date de facture est absente", () => {
const invoice = { invoiceDate: null, createdAt: "2025-01-31T00:00:00.000Z" };
expect(matchesInvoicePeriod(invoice, "2025", "01")).toBe(true);
expect(matchesInvoicePeriod(invoice, "2026", "all")).toBe(false);
});
});

27
shared/invoicePeriod.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Date métier utilisée pour les listes de factures : date de facture si elle
* est connue, sinon date de réception. Cette règle est partagée avec BAP.
*/
export function getInvoicePeriodDate(invoice: {
invoiceDate?: Date | string | number | null;
createdAt?: Date | string | number | null;
}): Date | null {
const value = invoice.invoiceDate ?? invoice.createdAt;
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
/** Indique si une facture correspond au filtre Année/Mois sélectionné. */
export function matchesInvoicePeriod(
invoice: Parameters<typeof getInvoicePeriodDate>[0],
year: string,
month: string,
): boolean {
if (year === "all") return true;
const date = getInvoicePeriodDate(invoice);
if (!date || date.getFullYear() !== Number(year)) return false;
return month === "all" || date.getMonth() + 1 === Number(month);
}

10
todo.md
View File

@@ -797,3 +797,13 @@
## Rétablissement du serveur de recette
- [x] Redémarrer le serveur de recette autorisé par lutilisateur
- [x] Rétablir le conteneur applicatif et terminer le déploiement BAP à 90 %
## Factures abonnements et filtres de période
- [x] Analyser les listes Factures, Factures BAP et le menu Facturation
- [x] Ajouter les filtres Année et Mois à la page Factures
- [x] Créer la page Factures abonnements affichant uniquement les abonnements
- [x] Reprendre les recherches, filtres et actions de la page Factures
- [x] Ajouter lentrée Factures abonnements après Factures BAP dans le menu
- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox
- [ ] Déployer et vérifier le correctif en recette
- [ ] Déployer et vérifier le correctif en production