diff --git a/client/src/App.tsx b/client/src/App.tsx index 2f4325a..cc6d6b3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -26,6 +26,7 @@ const ImportReport = lazy(() => import("./pages/ImportReport")); const LearningSettings = lazy(() => import("./pages/LearningSettings")); const VentilationFreePro = lazy(() => import("./pages/VentilationFreePro")); const WebImportSources = lazy(() => import("./pages/WebImportSources")); +const RealBudget = lazy(() => import("./pages/RealBudget")); function SubscriptionInvoices() { return ; @@ -49,6 +50,7 @@ function Router() { + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index a9fa311..3f89d3e 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -54,6 +54,7 @@ const menuStructure: MenuItem[] = [ { icon: FileText, label: "Factures", path: "/invoices" }, { icon: FileText, label: "Factures BAP", path: "/invoices-bap" }, { icon: FileText, label: "Factures abonnements", path: "/invoices-subscriptions" }, + { icon: BarChart2, label: "Budget réel", path: "/real-budget" }, ], }, { diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 115dd0f..86ee180 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -148,6 +148,23 @@ export default function Dashboard() { + + + Récapitulatif annuel + Volumes et montants des factures finalisées, séparés entre BAP (hors abonnement) et abonnements. + + + {stats?.annualSummary?.length ? ( +
+ + + {stats.annualSummary.map((row) => )} +
AnnéeFactures BAPMontant BAPAbonnementsMontant abonnementsTotal annuel
{row.year}{row.bapCount}{formatCurrency(row.bapAmount)}{row.subscriptionCount}{formatCurrency(row.subscriptionAmount)}{formatCurrency(row.totalAmount)}
+
+ ) :
Aucune facture finalisée avec une date disponible.
} +
+
+ {/* Detailed Stats */} {showDetailedStats && ( <> diff --git a/client/src/pages/RealBudget.tsx b/client/src/pages/RealBudget.tsx new file mode 100644 index 0000000..496512d --- /dev/null +++ b/client/src/pages/RealBudget.tsx @@ -0,0 +1,90 @@ +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { trpc } from "@/lib/trpc"; +import { buildSupplierBudgetSummary } from "@shared/invoiceAnalytics"; +import { CalendarDays, Euro, FileText } from "lucide-react"; +import { useMemo, useState } from "react"; + +const MONTHS = [ + ["01", "Janvier"], ["02", "Février"], ["03", "Mars"], ["04", "Avril"], + ["05", "Mai"], ["06", "Juin"], ["07", "Juillet"], ["08", "Août"], + ["09", "Septembre"], ["10", "Octobre"], ["11", "Novembre"], ["12", "Décembre"], +] as const; + +function formatCurrency(value: number) { + return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value); +} + +export default function RealBudget() { + const currentYear = new Date().getFullYear(); + const [selectedYear, setSelectedYear] = useState(String(currentYear)); + const [selectedMonth, setSelectedMonth] = useState("all"); + const { data: invoices, isLoading } = trpc.invoices.list.useQuery(); + + const availableYears = useMemo(() => { + const years = new Set([currentYear]); + (invoices || []).forEach((invoice) => { + const value = invoice.invoiceDate ?? invoice.createdAt; + if (!value) return; + const date = new Date(value); + if (!Number.isNaN(date.getTime())) years.add(date.getFullYear()); + }); + return Array.from(years).sort((a, b) => b - a); + }, [invoices, currentYear]); + + const rows = useMemo( + () => buildSupplierBudgetSummary(invoices || [], selectedYear, selectedMonth), + [invoices, selectedYear, selectedMonth], + ); + const totals = useMemo(() => rows.reduce((total, row) => ({ + subscriptionAmount: total.subscriptionAmount + row.subscriptionAmount, + nonSubscriptionAmount: total.nonSubscriptionAmount + row.nonSubscriptionAmount, + totalAmount: total.totalAmount + row.totalAmount, + }), { subscriptionAmount: 0, nonSubscriptionAmount: 0, totalAmount: 0 }), [rows]); + + return ( + +
+
+

Budget réel

+

Montants réellement facturés, regroupés par fournisseur.

+
+ + + +
Période :
+ + +
+
+ +
+ Abonnements
{formatCurrency(totals.subscriptionAmount)}
+ Hors abonnement
{formatCurrency(totals.nonSubscriptionAmount)}
+ Total facturé
{formatCurrency(totals.totalAmount)}
+
+ + + Budget réel par fournisseurLes factures finalisées sont séparées entre abonnements et hors abonnement. + + {isLoading ?
Chargement du budget…
: rows.length === 0 ?
Aucune facture finalisée pour cette période.
: ( +
+ + {rows.map((row) => )} + +
FournisseurAbonnementsMontant abonnementsHors abonnementMontant hors abonnementTotal
{row.supplierName}{row.subscriptionCount}{formatCurrency(row.subscriptionAmount)}{row.nonSubscriptionCount}{formatCurrency(row.nonSubscriptionAmount)}{formatCurrency(row.totalAmount)}
Total{formatCurrency(totals.subscriptionAmount)}{formatCurrency(totals.nonSubscriptionAmount)}{formatCurrency(totals.totalAmount)}
+
+ )} +
+
+
+
+ ); +} diff --git a/server/db.ts b/server/db.ts index 1292f59..096af1a 100644 --- a/server/db.ts +++ b/server/db.ts @@ -50,6 +50,7 @@ import { WebImportSource } from "../drizzle/schema"; import { ENV } from './_core/env'; +import { buildAnnualInvoiceSummary } from "@shared/invoiceAnalytics"; let _db: ReturnType | null = null; @@ -332,7 +333,7 @@ export async function searchInvoices(userId: number | null, query: string): Prom .orderBy(desc(invoices.createdAt)); } -export async function getInvoiceStats(userId: number) { +export async function getInvoiceStats(userId?: number) { const db = await getDb(); if (!db) return { total: 0, @@ -346,10 +347,12 @@ export async function getInvoiceStats(userId: number) { topSuppliers: [], suppliersList: [], topRecipients: [], - recipientsList: [] + recipientsList: [], + annualSummary: [], }; - const allInvoices = await getInvoicesByUserId(userId); + // Le tableau de bord est global pour les administrateurs, comme les listes Factures. + const allInvoices = userId ? await getInvoicesByUserId(userId) : await getAllInvoices(); // Calculate basic stats const completed = allInvoices.filter(i => i.status === "completed"); @@ -458,7 +461,8 @@ export async function getInvoiceStats(userId: number) { topSuppliers, suppliersList, topRecipients, - recipientsList + recipientsList, + annualSummary: buildAnnualInvoiceSummary(allInvoices), }; } diff --git a/server/invoiceAnalytics.test.ts b/server/invoiceAnalytics.test.ts new file mode 100644 index 0000000..7df881c --- /dev/null +++ b/server/invoiceAnalytics.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { buildAnnualInvoiceSummary, buildSupplierBudgetSummary } from "@shared/invoiceAnalytics"; + +const invoices = [ + { invoiceDate: "2026-05-15", createdAt: "2026-05-16", status: "completed", isSubscription: 0, supplierName: "SFR", totalAmount: "100" }, + { invoiceDate: "2026-05-20", createdAt: "2026-05-21", status: "completed", isSubscription: 1, supplierName: "SFR", totalAmount: "20" }, + { invoiceDate: "2025-03-10", createdAt: "2025-03-11", status: "completed", isSubscription: 1, supplierName: "Microsoft", totalAmount: "50" }, + { invoiceDate: "2026-05-01", createdAt: "2026-05-01", status: "error", isSubscription: 0, supplierName: "Ignorée", totalAmount: "999" }, +]; + +describe("agrégats de facturation", () => { + it("calcule les volumes et montants BAP et abonnements par année", () => { + expect(buildAnnualInvoiceSummary(invoices)).toEqual([ + { year: 2026, bapCount: 1, bapAmount: 100, subscriptionCount: 1, subscriptionAmount: 20, totalAmount: 120 }, + { year: 2025, bapCount: 0, bapAmount: 0, subscriptionCount: 1, subscriptionAmount: 50, totalAmount: 50 }, + ]); + }); + + it("filtre le budget par période et ventile chaque fournisseur par type", () => { + expect(buildSupplierBudgetSummary(invoices, "2026", "05")).toEqual([ + { supplierName: "SFR", subscriptionCount: 1, subscriptionAmount: 20, nonSubscriptionCount: 1, nonSubscriptionAmount: 100, totalAmount: 120 }, + ]); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 5e43d2f..5b904d5 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1045,7 +1045,8 @@ export const appRouter = router({ }), getStats: protectedProcedure.query(async ({ ctx }) => { - return getInvoiceStats(ctx.user.id); + // Les administrateurs consultent les mêmes données globales que les listes Factures. + return getInvoiceStats(ctx.user.role === "admin" ? undefined : ctx.user.id); }), }), diff --git a/shared/invoiceAnalytics.ts b/shared/invoiceAnalytics.ts new file mode 100644 index 0000000..a311cdb --- /dev/null +++ b/shared/invoiceAnalytics.ts @@ -0,0 +1,108 @@ +import { getInvoicePeriodDate, matchesInvoicePeriod } from "./invoicePeriod"; + +export type AnalyticsInvoice = { + invoiceDate?: Date | string | number | null; + createdAt?: Date | string | number | null; + isSubscription?: number | null; + status?: string | null; + supplierName?: string | null; + totalAmount?: number | string | null; +}; + +export type AnnualInvoiceSummary = { + year: number; + bapCount: number; + bapAmount: number; + subscriptionCount: number; + subscriptionAmount: number; + totalAmount: number; +}; + +export type SupplierBudgetSummary = { + supplierName: string; + subscriptionCount: number; + subscriptionAmount: number; + nonSubscriptionCount: number; + nonSubscriptionAmount: number; + totalAmount: number; +}; + +function getAmount(value: AnalyticsInvoice["totalAmount"]): number { + const amount = Number(value); + return Number.isFinite(amount) ? amount : 0; +} + +/** + * Agrège les factures finalisées par année métier. « BAP » désigne ici les + * factures hors abonnement, éligibles au circuit BAP, validées ou non. + */ +export function buildAnnualInvoiceSummary(invoices: AnalyticsInvoice[]): AnnualInvoiceSummary[] { + const summaryByYear = new Map(); + + for (const invoice of invoices) { + if (invoice.status !== "completed") continue; + const date = getInvoicePeriodDate(invoice); + if (!date) continue; + + const year = date.getFullYear(); + const current = summaryByYear.get(year) ?? { + year, + bapCount: 0, + bapAmount: 0, + subscriptionCount: 0, + subscriptionAmount: 0, + totalAmount: 0, + }; + const amount = getAmount(invoice.totalAmount); + + if (invoice.isSubscription === 1) { + current.subscriptionCount += 1; + current.subscriptionAmount += amount; + } else { + current.bapCount += 1; + current.bapAmount += amount; + } + current.totalAmount += amount; + summaryByYear.set(year, current); + } + + return Array.from(summaryByYear.values()).sort((a, b) => b.year - a.year); +} + +/** Agrège le budget réellement facturé par fournisseur sur la période choisie. */ +export function buildSupplierBudgetSummary( + invoices: AnalyticsInvoice[], + year: string, + month: string, +): SupplierBudgetSummary[] { + const summaryBySupplier = new Map(); + + for (const invoice of invoices) { + if (invoice.status !== "completed" || !matchesInvoicePeriod(invoice, year, month)) continue; + + const supplierName = invoice.supplierName?.trim() || "Fournisseur inconnu"; + const current = summaryBySupplier.get(supplierName) ?? { + supplierName, + subscriptionCount: 0, + subscriptionAmount: 0, + nonSubscriptionCount: 0, + nonSubscriptionAmount: 0, + totalAmount: 0, + }; + const amount = getAmount(invoice.totalAmount); + + if (invoice.isSubscription === 1) { + current.subscriptionCount += 1; + current.subscriptionAmount += amount; + } else { + current.nonSubscriptionCount += 1; + current.nonSubscriptionAmount += amount; + } + current.totalAmount += amount; + summaryBySupplier.set(supplierName, current); + } + + return Array.from(summaryBySupplier.values()).sort((a, b) => + b.totalAmount - a.totalAmount || a.supplierName.localeCompare(b.supplierName, "fr"), + ); +} diff --git a/todo.md b/todo.md index bbeaee6..952a4f2 100644 --- a/todo.md +++ b/todo.md @@ -807,3 +807,16 @@ - [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox - [x] Déployer et vérifier le correctif en recette - [x] Déployer et vérifier le correctif en production + +## Synthèse annuelle du tableau de bord +- [x] Analyser les données et le tableau de bord existant +- [x] Calculer par année les volumes et montants BAP et abonnements +- [x] Afficher le nombre de factures et les montants par type, puis le total +- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox + +## Budget réel par fournisseur +- [x] Définir l’agrégation des montants par fournisseur et statut Abonnement +- [x] Ajouter les filtres Année et Mois à l’écran Budget réel +- [x] Créer l’écran Budget réel à la fin du menu Facturation +- [x] Afficher par fournisseur les montants Abonnement, Hors abonnement et le total +- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox