feat: ajouter les factures litiges et leurs commentaires (recette)
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m14s

This commit is contained in:
Manus
2026-11-03 14:45:05 +00:00
parent 5f6c61d2f0
commit a6d90a90c3
16 changed files with 3320 additions and 13 deletions

View File

@@ -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() {
<Route path="/invoices" component={AllInvoices} />
<Route path="/invoices-bap" component={InvoicesBAP} />
<Route path="/invoices-subscriptions" component={SubscriptionInvoices} />
<Route path="/invoices-disputes" component={DisputedInvoices} />
<Route path="/real-budget" component={RealBudget} />
<Route path="/invoices/:id" component={InvoiceDetail} />
<Route path="/settings" component={Settings} />

View File

@@ -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" },
],
},

View File

@@ -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 (
<section
className="space-y-4 rounded-xl border border-amber-200 bg-amber-50/60 p-4 text-foreground"
aria-label="Gestion du litige"
aria-busy={mutation.isPending}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Flag className="h-4 w-4 text-amber-700" aria-hidden="true" />
<h3 className="font-semibold">Litige</h3>
<Badge
className={
isDisputed
? "bg-amber-100 text-amber-800 hover:bg-amber-100"
: "bg-slate-100 text-slate-700 hover:bg-slate-100"
}
>
{isDisputed ? "En litige" : "Aucun litige"}
</Badge>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={toggleDispute}
disabled={disabled}
className="border-amber-300 bg-white text-amber-800 hover:bg-amber-100"
>
{toggling ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Flag className="mr-2 h-4 w-4" aria-hidden="true" />
)}
{toggling
? "Mise à jour..."
: isDisputed
? "Retirer le litige"
: "Marquer en litige"}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{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."}
</p>
{isReadOnly ? (
<p className="text-sm font-medium text-amber-800">
Mode lecture seule : aucune modification autorisée.
</p>
) : null}
<div className="space-y-2">
<Label htmlFor={commentId}>Commentaire du litige</Label>
<Textarea
id={commentId}
value={comment}
onChange={event => setComment(event.target.value)}
maxLength={MAX_COMMENT_LENGTH}
disabled={disabled || !isDisputed}
rows={5}
placeholder="Précisez le motif du litige et les échanges avec le fournisseur..."
className="min-h-32 bg-white border-amber-200"
aria-describedby={`${commentId}-help`}
/>
<p id={`${commentId}-help`} className="text-xs text-muted-foreground">
{comment.length} / {MAX_COMMENT_LENGTH} caractères. Le commentaire est
enregistré séparément du statut.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
onClick={saveComment}
disabled={
disabled ||
!isDisputed ||
!hasChanges ||
!comment.trim() ||
comment.length > MAX_COMMENT_LENGTH
}
className="bg-amber-700 text-white hover:bg-amber-800"
>
{savingComment && mutation.variables?.comment !== null ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Save className="mr-2 h-4 w-4" aria-hidden="true" />
)}
{savingComment && mutation.variables?.comment !== null
? "Enregistrement..."
: "Enregistrer le commentaire"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={clearComment}
disabled={disabled || !isDisputed || !invoice.disputeComment}
className="bg-white border-rose-200 text-rose-700 hover:bg-rose-50"
>
{savingComment && mutation.variables?.comment === null ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
)}
{savingComment && mutation.variables?.comment === null
? "Effacement..."
: "Effacer le commentaire"}
</Button>
</div>
{mutation.error ? (
<div
role="alert"
className="flex items-start gap-2 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>
{mutation.error.message ||
"Impossible de mettre à jour le litige. Réessayez."}
</p>
</div>
) : null}
</section>
);
}

View File

@@ -81,9 +81,23 @@ export function buildBapDetailLocation(invoiceId: number, filters: BapFilters):
return `/invoices/${invoiceId}?${params.toString()}`;
}
/** Retourne la liste d’origine. Sans contexte BAP, le comportement historique est conservé. */
/** Conserve la recherche de la liste Litiges lors de l’ouverture d’une facture. */
export function buildDisputeDetailLocation(invoiceId: number, searchQuery = ""): string {
const params = new URLSearchParams({ returnTo: "disputes" });
if (searchQuery) params.set("q", searchQuery);
return `/invoices/${invoiceId}?${params.toString()}`;
}
/** Retourne une liste d’origine autorisée (BAP ou Litiges), sinon la liste historique. */
export function getBapReturnLocation(search: string): string {
const params = asSearchParams(search);
if (params.get("returnTo") === "disputes") {
const allowed = new URLSearchParams();
const query = params.get("q");
if (query) allowed.set("q", query);
const suffix = allowed.toString();
return suffix ? `/invoices-disputes?${suffix}` : "/invoices-disputes";
}
if (params.get("returnTo") !== "bap") return "/invoices";
const allowed = new URLSearchParams();

View File

@@ -0,0 +1,300 @@
import { useMemo, useState } from "react";
import { useLocation, useSearch } from "wouter";
import { useAuth } from "@/_core/hooks/useAuth";
import DashboardLayout from "@/components/DashboardLayout";
import InvoiceDisputePanel from "@/components/InvoiceDisputePanel";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { buildDisputeDetailLocation } from "@/lib/bapNavigation";
import { trpc } from "@/lib/trpc";
import {
AlertCircle,
Edit,
FileText,
Flag,
Loader2,
RefreshCw,
Search,
} from "lucide-react";
const amountFormatter = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
});
function formatAmount(amount: string | null): string {
if (amount === null || amount === "") return "—";
const value = Number(amount);
return Number.isFinite(value) ? amountFormatter.format(value) : "—";
}
function formatDate(date: Date | null): string {
if (!date) return "—";
const value = new Date(date);
return Number.isNaN(value.getTime())
? "—"
: value.toLocaleDateString("fr-FR");
}
export default function DisputedInvoices() {
const [, setLocation] = useLocation();
const search = useSearch();
const { user, loading: authLoading } = useAuth();
const [searchQuery, setSearchQuery] = useState(
() => new URLSearchParams(search).get("q") ?? ""
);
const [editingInvoiceId, setEditingInvoiceId] = useState<number | null>(null);
const {
data: invoices,
isLoading,
error,
refetch,
isFetching,
} = trpc.invoices.listDisputes.useQuery(undefined, {
enabled: Boolean(user),
});
const isReadOnly = String(user?.role) === "readonly";
const filteredInvoices = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase("fr-FR");
return (invoices ?? []).filter(
invoice =>
!query ||
[
invoice.supplierName,
invoice.invoiceNumber,
invoice.disputeComment,
].some(value => value?.toLocaleLowerCase("fr-FR").includes(query))
);
}, [invoices, searchQuery]);
// Le dialogue utilise toujours la dernière ligne en cache après une mutation.
const editingInvoice = invoices?.find(
invoice => invoice.id === editingInvoiceId
);
return (
<DashboardLayout>
<div className="space-y-4">
<div>
<h1 className="text-3xl font-bold">Factures litiges</h1>
<p className="mt-1 text-muted-foreground">
Suivez les factures en litige et les commentaires associés.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-amber-100 bg-amber-50/60 px-4 py-3 shadow-sm">
<Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">
<Flag className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{invoices?.length ?? 0} facture(s) en litige
</Badge>
<div className="relative min-w-[200px] flex-1">
<Search
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-amber-600"
aria-hidden="true"
/>
<Input
value={searchQuery}
onChange={event => setSearchQuery(event.target.value)}
placeholder="Rechercher par fournisseur, numéro ou commentaire..."
aria-label="Rechercher les factures en litige"
className="border-amber-200 bg-white pl-10"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void refetch()}
disabled={isFetching || authLoading || !user}
className="border-amber-200 bg-white text-amber-800 hover:bg-amber-100"
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
aria-hidden="true"
/>
Actualiser
</Button>
</div>
{isReadOnly ? (
<p className="text-sm text-muted-foreground">
Mode lecture seule : les factures et commentaires sont consultables,
sans modification.
</p>
) : null}
<Card>
<CardContent className="p-0">
{isLoading || authLoading ? (
<div
role="status"
className="flex items-center justify-center gap-2 py-16 text-muted-foreground"
>
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
Chargement des factures en litige...
</div>
) : error ? (
<div role="alert" className="space-y-3 p-8 text-center">
<AlertCircle
className="mx-auto h-10 w-10 text-rose-500"
aria-hidden="true"
/>
<p className="font-medium">
Impossible de charger les factures en litige
</p>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button
type="button"
variant="outline"
onClick={() => void refetch()}
disabled={isFetching}
>
Réessayer
</Button>
</div>
) : filteredInvoices.length === 0 ? (
<div className="space-y-3 px-4 py-16 text-center">
<Flag
className="mx-auto h-12 w-12 text-amber-200"
aria-hidden="true"
/>
<h2 className="text-lg font-semibold">
{invoices?.length
? "Aucune facture ne correspond à votre recherche"
: "Aucune facture en litige"}
</h2>
<p className="text-sm text-muted-foreground">
{invoices?.length
? "Essayez un autre fournisseur, numéro ou commentaire."
: "Depuis la modification d’une facture, utilisez le bouton Litige pour la retrouver ici."}
</p>
{searchQuery ? (
<Button
type="button"
variant="outline"
onClick={() => setSearchQuery("")}
>
Effacer la recherche
</Button>
) : null}
</div>
) : (
<Table className="min-w-[950px]">
<TableHeader>
<TableRow className="bg-amber-50/60 hover:bg-amber-50/60">
<TableHead>Fournisseur</TableHead>
<TableHead>N° de facture</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Montant</TableHead>
<TableHead className="w-[36%]">
Commentaire du litige
</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInvoices.map(invoice => (
<TableRow key={invoice.id}>
<TableCell className="max-w-52 whitespace-normal break-words font-medium">
{invoice.supplierName || "Fournisseur inconnu"}
</TableCell>
<TableCell className="max-w-40 whitespace-normal break-words">
{invoice.invoiceNumber || "—"}
</TableCell>
<TableCell>{formatDate(invoice.invoiceDate)}</TableCell>
<TableCell className="text-right tabular-nums">
{formatAmount(invoice.totalAmount)}
</TableCell>
<TableCell className="align-top">
{invoice.disputeComment ? (
<p className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm">
{invoice.disputeComment}
</p>
) : (
<span className="text-sm italic text-muted-foreground">
Aucun commentaire
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-col items-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditingInvoiceId(invoice.id)}
disabled={isReadOnly || !user}
className="border-amber-200 bg-white text-amber-800 hover:bg-amber-50"
>
<Edit className="mr-2 h-4 w-4" aria-hidden="true" />
Modifier litige
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setLocation(
buildDisputeDetailLocation(
invoice.id,
searchQuery
)
)
}
>
<FileText
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Voir facture
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog
open={Boolean(editingInvoice)}
onOpenChange={open => {
if (!open) setEditingInvoiceId(null);
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Modifier litige</DialogTitle>
<DialogDescription>
{editingInvoice?.supplierName || "Fournisseur inconnu"} •{" "}
{editingInvoice?.invoiceNumber || "N° inconnu"}
</DialogDescription>
</DialogHeader>
{editingInvoice ? (
<InvoiceDisputePanel
key={editingInvoice.id}
invoice={editingInvoice}
readOnly={isReadOnly}
/>
) : null}
</DialogContent>
</Dialog>
</div>
</DashboardLayout>
);
}

View File

@@ -1,13 +1,16 @@
import { useState, useEffect } from "react";
import { useParams, useLocation, useSearch } from "wouter";
import { useAuth } from "@/_core/hooks/useAuth";
import DashboardLayout from "@/components/DashboardLayout";
import InvoiceDisputePanel from "@/components/InvoiceDisputePanel";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { trpc } from "@/lib/trpc";
import { ArrowLeft, Save, FileText } from "lucide-react";
import { ArrowLeft, Save, FileText, Flag } from "lucide-react";
import { toast } from "sonner";
import { getBapReturnLocation } from "@/lib/bapNavigation";
@@ -17,6 +20,9 @@ export default function InvoiceDetail() {
const detailSearch = useSearch();
const returnLocation = getBapReturnLocation(detailSearch);
const invoiceId = parseInt(id || "0");
const { user } = useAuth();
const isReadOnly = String(user?.role) === "readonly";
const canEdit = Boolean(user) && !isReadOnly;
const { data: invoice, isLoading } = trpc.invoices.getById.useQuery({ id: invoiceId });
const { data: departments } = trpc.departments.getByUser.useQuery();
@@ -24,6 +30,7 @@ export default function InvoiceDetail() {
const utils = trpc.useUtils();
const [isEditing, setIsEditing] = useState(false);
const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);
const [formData, setFormData] = useState({
supplierName: "",
invoiceNumber: "",
@@ -39,7 +46,7 @@ export default function InvoiceDetail() {
// Initialize form data when invoice loads
useEffect(() => {
if (invoice) {
if (invoice && !isEditing) {
setFormData({
supplierName: invoice.supplierName || "",
invoiceNumber: invoice.invoiceNumber || "",
@@ -55,7 +62,7 @@ export default function InvoiceDetail() {
ventilationComptable: invoice.ventilationComptable || "",
});
}
}, [invoice]);
}, [invoice, isEditing]);
const upsertLearningMutation = trpc.learnings.upsert.useMutation();
@@ -65,6 +72,7 @@ export default function InvoiceDetail() {
setIsEditing(false);
utils.invoices.getById.invalidate({ id: invoiceId });
utils.invoices.list.invalidate();
utils.invoices.listDisputes.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la mise à jour");
@@ -72,6 +80,7 @@ export default function InvoiceDetail() {
});
const handleSave = () => {
if (!canEdit || updateMutation.isPending) return;
// Apprentissage : enregistrer les corrections manuelles sur les champs clés
// On compare les valeurs actuelles (invoice) avec les nouvelles valeurs (formData)
if (invoice && formData.supplierName) {
@@ -175,7 +184,7 @@ export default function InvoiceDetail() {
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => setLocation(returnLocation)}>
<ArrowLeft className="w-4 h-4 mr-2" />
@@ -188,19 +197,24 @@ export default function InvoiceDetail() {
</p>
</div>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setDisputeDialogOpen(true)} className="border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100">
<Flag className="w-4 h-4 mr-2" aria-hidden="true" />
Litige
{invoice.isDisputed === 1 ? <Badge className="ml-2 bg-amber-100 text-amber-800 hover:bg-amber-100">En cours</Badge> : null}
</Button>
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>
Annuler
</Button>
<Button onClick={handleSave} disabled={updateMutation.isPending}>
<Button onClick={handleSave} disabled={!canEdit || updateMutation.isPending}>
<Save className="w-4 h-4 mr-2" />
Enregistrer
</Button>
</>
) : (
<Button onClick={() => setIsEditing(true)}>
<Button onClick={() => setIsEditing(true)} disabled={!canEdit}>
Modifier
</Button>
)}
@@ -253,6 +267,22 @@ export default function InvoiceDetail() {
</CardContent>
</Card>
<Card className="border-amber-100 bg-amber-50/40">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-amber-800">
<Flag className="h-4 w-4" aria-hidden="true" />
Litige
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Badge className={invoice.isDisputed === 1 ? "bg-amber-100 text-amber-800 hover:bg-amber-100" : "bg-slate-100 text-slate-700 hover:bg-slate-100"}>
{invoice.isDisputed === 1 ? "En litige" : "Aucun litige"}
</Badge>
{invoice.disputeComment ? <p className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm">{invoice.disputeComment}</p> : <p className="text-sm text-muted-foreground">Aucun commentaire de litige.</p>}
{isReadOnly ? <p className="text-xs text-muted-foreground">Mode lecture seule.</p> : null}
</CardContent>
</Card>
{/* Metadata Card */}
<Card>
<CardHeader>
@@ -468,6 +498,15 @@ export default function InvoiceDetail() {
</div>
</div>
</div>
<Dialog open={disputeDialogOpen} onOpenChange={setDisputeDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Litige de la facture</DialogTitle>
<DialogDescription>{invoice.supplierName || "Fournisseur inconnu"} • {invoice.invoiceNumber || "N° inconnu"}</DialogDescription>
</DialogHeader>
<InvoiceDisputePanel key={invoice.id} invoice={invoice} readOnly={isReadOnly} />
</DialogContent>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE `invoices` ADD `isDisputed` int DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `invoices` ADD `disputeComment` text;

File diff suppressed because it is too large Load Diff

View File

@@ -302,6 +302,13 @@
"when": 1790960584104,
"tag": "0042_deleted_invoices_blacklist",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1793716807177,
"tag": "0043_flashy_post",
"breakpoints": true
}
]
}
}

View File

@@ -104,6 +104,10 @@ export const invoices = mysqlTable("invoices", {
// Subscription flag
isSubscription: int("isSubscription").default(0).notNull(), // 0 = NON, 1 = OUI
// Le litige est indépendant de l’abonnement, du BAP et de l’export.
isDisputed: int("isDisputed").default(0).notNull(),
disputeComment: text("disputeComment"),
// BAP Validation
bapValidated: int("bapValidated").default(0).notNull(), // 0 = non validé, 1 = validé BAP
bapValidatedAt: timestamp("bapValidatedAt"), // Date de validation BAP

View File

@@ -296,6 +296,15 @@ export async function updateInvoice(id: number, data: Partial<Invoice>) {
await db.update(invoices).set(data).where(eq(invoices.id, id));
}
/** Les admins consultent tous les litiges ; les autres restent limités à leurs factures. */
export async function getDisputedInvoices(userId?: number): Promise<Invoice[]> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.select().from(invoices)
.where(userId === undefined ? eq(invoices.isDisputed, 1) : and(eq(invoices.isDisputed, 1), eq(invoices.userId, userId)))
.orderBy(desc(invoices.updatedAt), desc(invoices.id));
}
export async function deleteInvoice(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");

View File

@@ -3,12 +3,11 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("routage et persistance de déploiement", () => {
it("utilise le domaine fourni par l’environnement avec la recette comme repli", () => {
it("utilise le domaine canonique recette sans alias historique", () => {
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
expect(composeFile).toContain(
"Host(`${APP_DOMAIN:-demat-facturation.recette.santinova-soft.org}`)"
);
const rule = composeFile.split("\n").find(line => line.includes("traefik.http.routers.demat-facturation.rule="));
expect(rule?.split(".rule=")[1]).toBe('Host(`demat-facturation-santinova.recette.santinova-soft.org`)"');
expect(composeFile).toContain("source: demat_facturation_storage");
expect(composeFile).toContain("target: /app/storage");
});

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
buildDisputeDetailLocation,
getBapReturnLocation,
} from "../client/src/lib/bapNavigation";
describe("navigation Factures litiges", () => {
it("ouvre le détail avec un contexte explicite de retour Litiges", () => {
expect(buildDisputeDetailLocation(42)).toBe(
"/invoices/42?returnTo=disputes"
);
expect(getBapReturnLocation("?returnTo=disputes")).toBe(
"/invoices-disputes"
);
});
it("conserve une recherche fournisseur, numéro ou commentaire avec caractères spéciaux", () => {
const location = buildDisputeDetailLocation(
18,
"Facture été & livraison\nà revoir"
);
const search = location.slice(location.indexOf("?"));
expect(new URLSearchParams(search).get("q")).toBe(
"Facture été & livraison\nà revoir"
);
expect(getBapReturnLocation(search)).toBe(
"/invoices-disputes?q=Facture+%C3%A9t%C3%A9+%26+livraison%0A%C3%A0+revoir"
);
});
it("ignore les paramètres étrangers au contexte Litiges", () => {
expect(
getBapReturnLocation(
"returnTo=disputes&q=retard&status=exported&year=2026&redirect=https%3A%2F%2Fexample.com"
)
).toBe("/invoices-disputes?q=retard");
});
it("ne propage pas une recherche vide", () => {
expect(getBapReturnLocation("?returnTo=disputes&q=")).toBe(
"/invoices-disputes"
);
});
it("refuse les destinations de retour arbitraires", () => {
expect(getBapReturnLocation("?returnTo=https%3A%2F%2Fexample.com")).toBe(
"/invoices"
);
expect(getBapReturnLocation("?returnTo=%2Finvoices-disputes")).toBe(
"/invoices"
);
expect(getBapReturnLocation("")).toBe("/invoices");
});
it("conserve le retour historique aux factures BAP", () => {
expect(
getBapReturnLocation(
"?returnTo=bap&q=fournisseur&year=2026&month=05&sort=invoiceDate&dir=asc"
)
).toBe(
"/invoices-bap?q=fournisseur&year=2026&month=05&sort=invoiceDate&dir=asc"
);
});
});

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TrpcContext } from "./_core/context";
const db = vi.hoisted(() => ({ getDisputedInvoices: vi.fn(), getInvoiceById: vi.fn(), updateInvoice: vi.fn() }));
vi.mock("./db", () => db);
import { listDisputes, setDispute } from "./invoiceDisputes";
import { router } from "./_core/trpc";
const routes = router({ listDisputes, setDispute });
function caller(role = "user", id = 2) {
return routes.createCaller({ user: { id, role }, req: {}, res: {} } as TrpcContext);
}
beforeEach(() => {
vi.clearAllMocks();
db.getInvoiceById.mockResolvedValue({ id: 10, userId: 2, isDisputed: 0, disputeComment: "existant", bapValidated: 1, exportStatus: "exported" });
db.getDisputedInvoices.mockResolvedValue([]);
});
describe("Factures litiges", () => {
it("ajoute le litige et son commentaire sans modifier BAP, score ou export", async () => {
await caller().setDispute({ id: 10, isDisputed: true, comment: " À vérifier " });
expect(db.updateInvoice).toHaveBeenCalledWith(10, { isDisputed: 1, disputeComment: "À vérifier" });
});
it("modifie le commentaire", async () => {
await caller().setDispute({ id: 10, isDisputed: true, comment: "Nouveau commentaire" });
expect(db.updateInvoice).toHaveBeenCalledWith(10, { isDisputed: 1, disputeComment: "Nouveau commentaire" });
});
it.each([null, "", " "])("efface le commentaire avec %s sans retirer le litige", async comment => {
await caller().setDispute({ id: 10, isDisputed: true, comment });
expect(db.updateInvoice).toHaveBeenCalledWith(10, { isDisputed: 1, disputeComment: null });
});
it("retire le litige sans supprimer la facture ni son commentaire", async () => {
await caller().setDispute({ id: 10, isDisputed: false });
expect(db.updateInvoice).toHaveBeenCalledWith(10, { isDisputed: 0 });
});
it("empêche l’accès à une facture d’un autre utilisateur", async () => {
await expect(caller("user", 3).setDispute({ id: 10, isDisputed: true })).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(db.updateInvoice).not.toHaveBeenCalled();
});
it("autorise l’administrateur sur les factures globales", async () => {
await caller("admin", 3).setDispute({ id: 10, isDisputed: true });
expect(db.updateInvoice).toHaveBeenCalledOnce();
await caller("admin", 3).listDisputes();
expect(db.getDisputedInvoices).toHaveBeenCalledWith(undefined);
});
it("limite la liste aux factures de l’utilisateur", async () => {
await caller().listDisputes();
expect(db.getDisputedInvoices).toHaveBeenCalledWith(2);
});
it("interdit la modification en lecture seule", async () => {
await expect(caller("readonly").setDispute({ id: 10, isDisputed: true })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.updateInvoice).not.toHaveBeenCalled();
});
it("exige une session et limite les commentaires à 5000 caractères", async () => {
await expect(routes.createCaller({ user: null } as TrpcContext).listDisputes()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
await expect(caller().setDispute({ id: 10, isDisputed: true, comment: "x".repeat(5001) })).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});

31
server/invoiceDisputes.ts Normal file
View File

@@ -0,0 +1,31 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { protectedProcedure } from "./_core/trpc";
import { getDisputedInvoices, getInvoiceById, updateInvoice } from "./db";
/** Le litige ne modifie ni le score, ni la validation BAP, ni l’export. */
export const disputeInput = z.object({
id: z.number().int().positive(),
isDisputed: z.boolean(),
comment: z.string().max(5000, "Le commentaire est limité à 5 000 caractères.").nullable().optional(),
});
export const listDisputes = protectedProcedure.query(({ ctx }) =>
getDisputedInvoices(ctx.user.role === "admin" ? undefined : ctx.user.id),
);
export const setDispute = protectedProcedure.input(disputeInput).mutation(async ({ ctx, input }) => {
if (String(ctx.user.role) === "readonly") {
throw new TRPCError({ code: "FORBIDDEN", message: "Votre profil permet uniquement la consultation." });
}
const invoice = await getInvoiceById(input.id);
if (!invoice || (ctx.user.role !== "admin" && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND", message: "Facture introuvable." });
}
await updateInvoice(input.id, {
isDisputed: input.isDisputed ? 1 : 0,
// L’absence du champ conserve le commentaire ; null ou une chaîne vide l’efface.
...(input.comment !== undefined ? { disputeComment: input.comment?.trim() || null } : {}),
});
return { success: true } as const;
});

View File

@@ -6,6 +6,7 @@ import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@sha
import { normalizeImportLogPagination } from "@shared/importLogPagination";
import { getImportLogRetentionCutoff, isImportLogRetentionMonths } from "@shared/importLogRetention";
import { webPortalUrlSchema } from "./webPortalUrl";
import { listDisputes, setDispute } from "./invoiceDisputes";
interface Condition {
field: string;
@@ -195,6 +196,8 @@ export const appRouter = router({
// ============= INVOICE ROUTES =============
invoices: router({
listDisputes,
setDispute,
// Upload and process PDF file
upload: protectedProcedure
.input(z.object({