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>
);
}