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:
@@ -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} />
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 statusCounts = {
|
||||||
const _oldFilteredInvoices = invoices?.filter((inv) => {
|
all: invoicesInPeriod.length,
|
||||||
if (!searchQuery) return true;
|
exported: invoicesInPeriod.filter(inv => inv.exportStatus === "exported").length,
|
||||||
const query = searchQuery.toLowerCase();
|
not_exported: invoicesInPeriod.filter(inv => inv.exportStatus === "not_exported").length,
|
||||||
return (
|
export_error: invoicesInPeriod.filter(inv => inv.exportStatus === "export_error").length,
|
||||||
inv.supplierName?.toLowerCase().includes(query) ||
|
};
|
||||||
inv.invoiceNumber?.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
17
server/invoicePeriod.test.ts
Normal file
17
server/invoicePeriod.test.ts
Normal 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
27
shared/invoicePeriod.ts
Normal 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
10
todo.md
@@ -797,3 +797,13 @@
|
|||||||
## Rétablissement du serveur de recette
|
## Rétablissement du serveur de recette
|
||||||
- [x] Redémarrer le serveur de recette autorisé par l’utilisateur
|
- [x] Redémarrer le serveur de recette autorisé par l’utilisateur
|
||||||
- [x] Rétablir le conteneur applicatif et terminer le déploiement BAP à 90 %
|
- [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 l’entré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
|
||||||
|
|||||||
Reference in New Issue
Block a user