Compare commits

...

4 Commits

6 changed files with 143 additions and 38 deletions

View File

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

View File

@@ -53,6 +53,7 @@ const menuStructure: MenuItem[] = [
{ icon: Upload, label: "Import", path: "/upload" }, { icon: Upload, label: "Import", path: "/upload" },
{ icon: FileText, label: "Factures", path: "/invoices" }, { icon: FileText, label: "Factures", path: "/invoices" },
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" }, { 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 DashboardLayout from "@/components/DashboardLayout";
import { useAuth } from "@/_core/hooks/useAuth"; import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -34,17 +34,28 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 * as XLSX from 'xlsx';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner"; import { toast } from "sonner";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { matchesInvoicePeriod } from "@shared/invoicePeriod";
type SortField = "invoiceDate" | "createdAt"; type SortField = "invoiceDate" | "createdAt";
type SortDir = "asc" | "desc"; 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 [, setLocation] = useLocation();
const currentYear = new Date().getFullYear();
const isSubscriptionPage = scope === "subscriptions";
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [compactMode, setCompactMode] = useState(true); const [compactMode, setCompactMode] = useState(true);
const [sortField, setSortField] = useState<SortField>("createdAt"); const [sortField, setSortField] = useState<SortField>("createdAt");
@@ -52,9 +63,11 @@ export default function Invoices() {
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all"); const [statusFilter, setStatusFilter] = useState<string>("all");
const [recipientFilter, setRecipientFilter] = 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 [entityFilter, setEntityFilter] = useState<string>("all"); // all | santinova | itinova
const [ventilationFilter, setVentilationFilter] = useState<string>("all"); 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 [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null); const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -194,17 +207,20 @@ export default function Invoices() {
}; };
// Collect unique recipients for the filter dropdown // Collect unique recipients for the filter dropdown
const uniqueRecipients = Array.from( const scopedInvoices = useMemo(
new Set( () => (invoices || []).filter(inv => !isSubscriptionPage || inv.isSubscription === 1),
(invoices || []).map(inv => (inv as any).recipientName).filter(Boolean) [invoices, isSubscriptionPage],
) );
).sort();
const uniqueVentilations = Array.from( const uniqueRecipients = useMemo(
new Set( () => Array.from(new Set(scopedInvoices.map(inv => (inv as any).recipientName).filter(Boolean))).sort(),
(invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean) [scopedInvoices],
) );
).sort();
const uniqueVentilations = useMemo(
() => Array.from(new Set(scopedInvoices.map(inv => (inv as any).ventilationComptable).filter(Boolean))).sort(),
[scopedInvoices],
);
const handleSort = (field: SortField) => { const handleSort = (field: SortField) => {
if (sortField === field) { 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" />; 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 // Filter by search query
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
@@ -230,6 +246,9 @@ export default function Invoices() {
if (!matchesSearch) return false; 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 // Filter by export status
if (statusFilter !== "all") { if (statusFilter !== "all") {
if (statusFilter === "exported" && inv.exportStatus !== "exported") return false; if (statusFilter === "exported" && inv.exportStatus !== "exported") return false;
@@ -268,22 +287,17 @@ export default function Invoices() {
return true; return true;
}); });
const statusCounts = { const invoicesInPeriod = useMemo(
all: invoices?.length || 0, () => scopedInvoices.filter(inv => matchesInvoicePeriod(inv, selectedYear, selectedMonth)),
exported: invoices?.filter(inv => inv.exportStatus === "exported").length || 0, [scopedInvoices, selectedYear, selectedMonth],
not_exported: invoices?.filter(inv => inv.exportStatus === "not_exported").length || 0,
export_error: invoices?.filter(inv => inv.exportStatus === "export_error").length || 0,
};
// 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 statusCounts = {
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,
};
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { if (checked) {
@@ -370,8 +384,10 @@ export default function Invoices() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold">Factures</h1> <h1 className="text-3xl font-bold">{isSubscriptionPage ? "Factures abonnements" : "Factures"}</h1>
<p className="text-gray-500 mt-1">Gérez toutes vos factures importées</p> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -456,9 +472,30 @@ export default function Invoices() {
</div> </div>
</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"> <div className="flex gap-2 items-center flex-wrap mb-3">
<Filter className="w-4 h-4 text-blue-400 shrink-0" /> <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}> <Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm"> <SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Destinataire" /> <SelectValue placeholder="Destinataire" />
@@ -471,7 +508,7 @@ export default function Invoices() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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"> <SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Abonnement" /> <SelectValue placeholder="Abonnement" />
</SelectTrigger> </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);
}

22
todo.md
View File

@@ -789,7 +789,21 @@
- [x] Ajouter les tests de seuil et valider TypeScript, tests et build - [x] Ajouter les tests de seuil et valider TypeScript, tests et build
## Déploiement — seuil BAP à 90 % ## Déploiement — seuil BAP à 90 %
- [ ] Pousser le correctif vers Gitea recette - [x] Pousser le correctif vers Gitea recette
- [ ] Déployer et vérifier le seuil BAP à 90 % en recette - [x] Déployer et vérifier le seuil BAP à 90 % en recette
- [ ] Pousser la version validée vers Gitea production - [x] Pousser la version validée vers Gitea production
- [ ] Déployer et vérifier le seuil BAP à 90 % en production - [x] Déployer et vérifier le seuil BAP à 90 % en production
## 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