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 Upload = lazy(() => import("./pages/Upload"));
|
||||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
const Invoices = lazy(() => import("./pages/Invoices"));
|
||||||
const InvoicesBAP = lazy(() => import("./pages/InvoicesBAP"));
|
const InvoicesBAP = lazy(() => import("./pages/InvoicesBAP"));
|
||||||
|
const DisputedInvoices = lazy(() => import("./pages/DisputedInvoices"));
|
||||||
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
||||||
const Settings = lazy(() => import("./pages/Settings"));
|
const Settings = lazy(() => import("./pages/Settings"));
|
||||||
const ImportSettings = lazy(() => import("./pages/ImportSettings"));
|
const ImportSettings = lazy(() => import("./pages/ImportSettings"));
|
||||||
@@ -50,6 +51,7 @@ function Router() {
|
|||||||
<Route path="/invoices" component={AllInvoices} />
|
<Route path="/invoices" component={AllInvoices} />
|
||||||
<Route path="/invoices-bap" component={InvoicesBAP} />
|
<Route path="/invoices-bap" component={InvoicesBAP} />
|
||||||
<Route path="/invoices-subscriptions" component={SubscriptionInvoices} />
|
<Route path="/invoices-subscriptions" component={SubscriptionInvoices} />
|
||||||
|
<Route path="/invoices-disputes" component={DisputedInvoices} />
|
||||||
<Route path="/real-budget" component={RealBudget} />
|
<Route path="/real-budget" component={RealBudget} />
|
||||||
<Route path="/invoices/:id" component={InvoiceDetail} />
|
<Route path="/invoices/:id" component={InvoiceDetail} />
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/settings" component={Settings} />
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const menuStructure: MenuItem[] = [
|
|||||||
{ icon: FileText, label: "Factures", path: "/invoices" },
|
{ icon: FileText, label: "Factures", path: "/invoices" },
|
||||||
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
|
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
|
||||||
{ icon: FileText, label: "Factures abonnements", path: "/invoices-subscriptions" },
|
{ 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" },
|
{ 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()}`;
|
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 {
|
export function getBapReturnLocation(search: string): string {
|
||||||
const params = asSearchParams(search);
|
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";
|
if (params.get("returnTo") !== "bap") return "/invoices";
|
||||||
|
|
||||||
const allowed = new URLSearchParams();
|
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 { useState, useEffect } from "react";
|
||||||
import { useParams, useLocation, useSearch } from "wouter";
|
import { useParams, useLocation, useSearch } from "wouter";
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
import DashboardLayout from "@/components/DashboardLayout";
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
import InvoiceDisputePanel from "@/components/InvoiceDisputePanel";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { trpc } from "@/lib/trpc";
|
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 { toast } from "sonner";
|
||||||
import { getBapReturnLocation } from "@/lib/bapNavigation";
|
import { getBapReturnLocation } from "@/lib/bapNavigation";
|
||||||
|
|
||||||
@@ -17,6 +20,9 @@ export default function InvoiceDetail() {
|
|||||||
const detailSearch = useSearch();
|
const detailSearch = useSearch();
|
||||||
const returnLocation = getBapReturnLocation(detailSearch);
|
const returnLocation = getBapReturnLocation(detailSearch);
|
||||||
const invoiceId = parseInt(id || "0");
|
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: invoice, isLoading } = trpc.invoices.getById.useQuery({ id: invoiceId });
|
||||||
const { data: departments } = trpc.departments.getByUser.useQuery();
|
const { data: departments } = trpc.departments.getByUser.useQuery();
|
||||||
@@ -24,6 +30,7 @@ export default function InvoiceDetail() {
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
supplierName: "",
|
supplierName: "",
|
||||||
invoiceNumber: "",
|
invoiceNumber: "",
|
||||||
@@ -39,7 +46,7 @@ export default function InvoiceDetail() {
|
|||||||
|
|
||||||
// Initialize form data when invoice loads
|
// Initialize form data when invoice loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoice) {
|
if (invoice && !isEditing) {
|
||||||
setFormData({
|
setFormData({
|
||||||
supplierName: invoice.supplierName || "",
|
supplierName: invoice.supplierName || "",
|
||||||
invoiceNumber: invoice.invoiceNumber || "",
|
invoiceNumber: invoice.invoiceNumber || "",
|
||||||
@@ -55,7 +62,7 @@ export default function InvoiceDetail() {
|
|||||||
ventilationComptable: invoice.ventilationComptable || "",
|
ventilationComptable: invoice.ventilationComptable || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [invoice]);
|
}, [invoice, isEditing]);
|
||||||
|
|
||||||
const upsertLearningMutation = trpc.learnings.upsert.useMutation();
|
const upsertLearningMutation = trpc.learnings.upsert.useMutation();
|
||||||
|
|
||||||
@@ -65,6 +72,7 @@ export default function InvoiceDetail() {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
utils.invoices.getById.invalidate({ id: invoiceId });
|
utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
utils.invoices.list.invalidate();
|
utils.invoices.list.invalidate();
|
||||||
|
utils.invoices.listDisputes.invalidate();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Erreur lors de la mise à jour");
|
toast.error(error.message || "Erreur lors de la mise à jour");
|
||||||
@@ -72,6 +80,7 @@ export default function InvoiceDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
if (!canEdit || updateMutation.isPending) return;
|
||||||
// Apprentissage : enregistrer les corrections manuelles sur les champs clés
|
// Apprentissage : enregistrer les corrections manuelles sur les champs clés
|
||||||
// On compare les valeurs actuelles (invoice) avec les nouvelles valeurs (formData)
|
// On compare les valeurs actuelles (invoice) avec les nouvelles valeurs (formData)
|
||||||
if (invoice && formData.supplierName) {
|
if (invoice && formData.supplierName) {
|
||||||
@@ -175,7 +184,7 @@ export default function InvoiceDetail() {
|
|||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" onClick={() => setLocation(returnLocation)}>
|
<Button variant="ghost" onClick={() => setLocation(returnLocation)}>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
@@ -188,19 +197,24 @@ export default function InvoiceDetail() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={updateMutation.isPending}>
|
<Button onClick={handleSave} disabled={!canEdit || updateMutation.isPending}>
|
||||||
<Save className="w-4 h-4 mr-2" />
|
<Save className="w-4 h-4 mr-2" />
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={() => setIsEditing(true)}>
|
<Button onClick={() => setIsEditing(true)} disabled={!canEdit}>
|
||||||
Modifier
|
Modifier
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -253,6 +267,22 @@ export default function InvoiceDetail() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Metadata Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -468,6 +498,15 @@ export default function InvoiceDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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,
|
"when": 1790960584104,
|
||||||
"tag": "0042_deleted_invoices_blacklist",
|
"tag": "0042_deleted_invoices_blacklist",
|
||||||
"breakpoints": true
|
"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
|
// Subscription flag
|
||||||
isSubscription: int("isSubscription").default(0).notNull(), // 0 = NON, 1 = OUI
|
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
|
// BAP Validation
|
||||||
bapValidated: int("bapValidated").default(0).notNull(), // 0 = non validé, 1 = validé BAP
|
bapValidated: int("bapValidated").default(0).notNull(), // 0 = non validé, 1 = validé BAP
|
||||||
bapValidatedAt: timestamp("bapValidatedAt"), // Date de validation 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));
|
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) {
|
export async function deleteInvoice(id: number) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { join } from "node:path";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("routage et persistance de déploiement", () => {
|
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");
|
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
|
||||||
|
|
||||||
expect(composeFile).toContain(
|
const rule = composeFile.split("\n").find(line => line.includes("traefik.http.routers.demat-facturation.rule="));
|
||||||
"Host(`${APP_DOMAIN:-demat-facturation.recette.santinova-soft.org}`)"
|
expect(rule?.split(".rule=")[1]).toBe('Host(`demat-facturation-santinova.recette.santinova-soft.org`)"');
|
||||||
);
|
|
||||||
expect(composeFile).toContain("source: demat_facturation_storage");
|
expect(composeFile).toContain("source: demat_facturation_storage");
|
||||||
expect(composeFile).toContain("target: /app/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 { normalizeImportLogPagination } from "@shared/importLogPagination";
|
||||||
import { getImportLogRetentionCutoff, isImportLogRetentionMonths } from "@shared/importLogRetention";
|
import { getImportLogRetentionCutoff, isImportLogRetentionMonths } from "@shared/importLogRetention";
|
||||||
import { webPortalUrlSchema } from "./webPortalUrl";
|
import { webPortalUrlSchema } from "./webPortalUrl";
|
||||||
|
import { listDisputes, setDispute } from "./invoiceDisputes";
|
||||||
|
|
||||||
interface Condition {
|
interface Condition {
|
||||||
field: string;
|
field: string;
|
||||||
@@ -195,6 +196,8 @@ export const appRouter = router({
|
|||||||
|
|
||||||
// ============= INVOICE ROUTES =============
|
// ============= INVOICE ROUTES =============
|
||||||
invoices: router({
|
invoices: router({
|
||||||
|
listDisputes,
|
||||||
|
setDispute,
|
||||||
// Upload and process PDF file
|
// Upload and process PDF file
|
||||||
upload: protectedProcedure
|
upload: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user