feat: ajouter les factures litiges et leurs commentaires (recette)
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m14s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m14s
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
245
client/src/components/InvoiceDisputePanel.tsx
Normal file
245
client/src/components/InvoiceDisputePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
300
client/src/pages/DisputedInvoices.tsx
Normal file
300
client/src/pages/DisputedInvoices.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
2
drizzle/0043_flashy_post.sql
Normal file
2
drizzle/0043_flashy_post.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `invoices` ADD `isDisputed` int DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `invoices` ADD `disputeComment` text;
|
||||
2532
drizzle/meta/0043_snapshot.json
Normal file
2532
drizzle/meta/0043_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
64
server/disputeNavigation.test.ts
Normal file
64
server/disputeNavigation.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
55
server/invoiceDisputes.test.ts
Normal file
55
server/invoiceDisputes.test.ts
Normal 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
31
server/invoiceDisputes.ts
Normal 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;
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user