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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user