From a6d90a90c31ec7b47b668ee804d7e48fb1201aaf Mon Sep 17 00:00:00 2001 From: Manus Date: Tue, 3 Nov 2026 14:45:05 +0000 Subject: [PATCH] feat: ajouter les factures litiges et leurs commentaires (recette) --- client/src/App.tsx | 2 + client/src/components/DashboardLayout.tsx | 1 + client/src/components/InvoiceDisputePanel.tsx | 245 ++ client/src/lib/bapNavigation.ts | 16 +- client/src/pages/DisputedInvoices.tsx | 300 ++ client/src/pages/InvoiceDetail.tsx | 53 +- drizzle/0043_flashy_post.sql | 2 + drizzle/meta/0043_snapshot.json | 2532 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- drizzle/schema.ts | 4 + server/db.ts | 9 + server/deploymentRouting.test.ts | 7 +- server/disputeNavigation.test.ts | 64 + server/invoiceDisputes.test.ts | 55 + server/invoiceDisputes.ts | 31 + server/routers.ts | 3 + 16 files changed, 3320 insertions(+), 13 deletions(-) create mode 100644 client/src/components/InvoiceDisputePanel.tsx create mode 100644 client/src/pages/DisputedInvoices.tsx create mode 100644 drizzle/0043_flashy_post.sql create mode 100644 drizzle/meta/0043_snapshot.json create mode 100644 server/disputeNavigation.test.ts create mode 100644 server/invoiceDisputes.test.ts create mode 100644 server/invoiceDisputes.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index cc6d6b3..776ba0c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ const Dashboard = lazy(() => import("./pages/Dashboard")); const Upload = lazy(() => import("./pages/Upload")); const Invoices = lazy(() => import("./pages/Invoices")); const InvoicesBAP = lazy(() => import("./pages/InvoicesBAP")); +const DisputedInvoices = lazy(() => import("./pages/DisputedInvoices")); const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); const Settings = lazy(() => import("./pages/Settings")); const ImportSettings = lazy(() => import("./pages/ImportSettings")); @@ -50,6 +51,7 @@ function Router() { + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index fcf5043..502494a 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: FileText, label: "Factures litiges", path: "/invoices-disputes" }, { icon: BarChart2, label: "Budget réel", path: "/real-budget" }, ], }, diff --git a/client/src/components/InvoiceDisputePanel.tsx b/client/src/components/InvoiceDisputePanel.tsx new file mode 100644 index 0000000..ccb2039 --- /dev/null +++ b/client/src/components/InvoiceDisputePanel.tsx @@ -0,0 +1,245 @@ +import { useEffect, useId, useState } from "react"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { trpc } from "@/lib/trpc"; +import { AlertCircle, Flag, Loader2, Save, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +export type DisputeInvoice = { + id: number; + isDisputed: number; + disputeComment: string | null; +}; + +export type InvoiceDisputePanelProps = { + invoice: DisputeInvoice; + readOnly?: boolean; + onUpdated?: () => void; +}; + +const MAX_COMMENT_LENGTH = 5000; + +/** Le statut et le commentaire sont enregistrés séparément, sans supprimer la facture. */ +export default function InvoiceDisputePanel({ + invoice, + readOnly = false, + onUpdated, +}: InvoiceDisputePanelProps) { + const { user, loading: authLoading } = useAuth(); + const utils = trpc.useUtils(); + const commentId = useId(); + const [comment, setComment] = useState(invoice.disputeComment ?? ""); + const isDisputed = invoice.isDisputed === 1; + const isReadOnly = readOnly || String(user?.role) === "readonly"; + + useEffect(() => { + setComment(invoice.disputeComment ?? ""); + }, [invoice.id, invoice.disputeComment]); + + const mutation = trpc.invoices.setDispute.useMutation({ + onSuccess: async (_, variables) => { + const patch = { + isDisputed: variables.isDisputed ? 1 : 0, + ...(variables.comment !== undefined + ? { + disputeComment: variables.comment?.trim() || null, + } + : {}), + }; + // Mettre immédiatement à jour les données déjà chargées ; la relecture + // serveur confirme ensuite le résultat et les droits de visibilité. + utils.invoices.getById.setData({ id: variables.id }, current => + current ? { ...current, ...patch } : current + ); + utils.invoices.list.setData(undefined, current => + current?.map(item => + item.id === variables.id ? { ...item, ...patch } : item + ) + ); + utils.invoices.listDisputes.setData(undefined, current => + current + ?.map(item => + item.id === variables.id ? { ...item, ...patch } : item + ) + .filter(item => item.isDisputed === 1) + ); + if (variables.comment !== undefined) + setComment(patch.disputeComment ?? ""); + toast.success( + variables.comment !== undefined + ? patch.disputeComment + ? "Commentaire du litige enregistré" + : "Commentaire du litige effacé" + : variables.isDisputed + ? "Facture marquée en litige" + : "Litige retiré : la facture est conservée" + ); + onUpdated?.(); + await Promise.all([ + utils.invoices.getById.invalidate({ id: variables.id }), + utils.invoices.list.invalidate(), + utils.invoices.listDisputes.invalidate(), + ]); + }, + onError: error => { + toast.error(error.message || "Impossible de mettre à jour le litige"); + }, + }); + + const disabled = isReadOnly || authLoading || !user || mutation.isPending; + const hasChanges = comment !== (invoice.disputeComment ?? ""); + const savingComment = + mutation.isPending && mutation.variables?.comment !== undefined; + const toggling = + mutation.isPending && mutation.variables?.comment === undefined; + + const toggleDispute = () => { + if (disabled) return; + // Commentaire absent : l’API conserve le commentaire enregistré. + mutation.mutate({ id: invoice.id, isDisputed: !isDisputed }); + }; + + const saveComment = () => { + if ( + disabled || + !isDisputed || + !hasChanges || + !comment.trim() || + comment.length > MAX_COMMENT_LENGTH + ) + return; + mutation.mutate({ id: invoice.id, isDisputed: true, comment }); + }; + + const clearComment = () => { + if (disabled || !isDisputed || !invoice.disputeComment) return; + mutation.mutate({ id: invoice.id, isDisputed: true, comment: null }); + }; + + return ( +
+
+
+
+ +
+

+ {isDisputed + ? "Retirer le litige conserve la facture et le commentaire enregistré." + : "Marquez cette facture en litige pour ajouter ou modifier un commentaire. La facture ne sera pas supprimée."} +

+ {isReadOnly ? ( +

+ Mode lecture seule : aucune modification autorisée. +

+ ) : null} +
+ +