feat(web): /factures/import — page focused d'import via bouton topbar

Le bouton '+ Importer factures' du topbar avait un Button inerte. Il
ouvre maintenant une vraie page focused dédiée :

- Route /factures/import (factures_.import.tsx) avec breadcrumb,
  eyebrow, H1 'Importer *plusieurs* factures.', lede explicatif,
  dropzone full-page avec mutation upload câblée
- Drop-catcher de page comme sur /factures (drop n'importe où marche)
- 3 hints discrets en bas (Formats / Confidentiel / Reprenable) pour
  rassurer le user au moment décisif de l'upload

Routing nesting fix :
- Renommé factures_.import.\$batchId.tsx → factures_.import_.\$batchId.tsx
- Trailing underscore sur 'import_' escape la nouvelle landing parent
- Les 2 routes sont maintenant siblings sous _app :
  · /factures/import → factures_.import.tsx
  · /factures/import/\$batchId → factures_.import_.\$batchId.tsx

Topbar AppLayout :
- '+ Importer factures' = Button asChild + Link to /factures/import
  (middle-click / cmd-click / right-click ouvrent un nouvel onglet)
- '+ Saisir' reste disabled (placeholder modale 2.3, prochaine étape)

Bundle prod : 117.56 KB gzip core (stable, +0.06 vs avant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 11:35:59 +02:00
parent 86dae64eb4
commit 965a92da8f
3 changed files with 130 additions and 4 deletions

View File

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
import { AppSidebar } from "./AppSidebar";
import { AppTopbar } from "./AppTopbar";
import { MobileTabBar } from "./MobileTabBar";
import { Link } from "@tanstack/react-router";
/**
* Shell de l'app authentifiée :
@ -51,11 +52,13 @@ export function AppLayout({ children, title, subtitle, actions }: AppLayoutProps
// chaque route gère ses propres CTA en tête de contenu (cf. wireframe 4.3).
const defaultActions = (
<div className="hidden lg:flex items-center gap-2">
<Button size="sm" variant="secondary">
<Button size="sm" variant="secondary" disabled>
<Plus size={14} aria-hidden="true" /> Saisir
</Button>
<Button size="sm">
<Upload size={14} aria-hidden="true" /> Importer factures
<Button size="sm" asChild>
<Link to="/factures/import">
<Upload size={14} aria-hidden="true" /> Importer factures
</Link>
</Button>
</div>
);

View File

@ -0,0 +1,123 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import { ArrowLeft, FilePlus } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/Button";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Dropzone } from "@/components/factures/Dropzone";
type ImportBatchResponse = {
id: string;
drafts: Array<{ id: string; filename: string }>;
};
export const Route = createFileRoute("/_app/factures_/import")({
component: ImportLandingPage,
});
function ImportLandingPage() {
const navigate = useNavigate();
const upload = useMutation({
mutationFn: (files: File[]) =>
api.post<ImportBatchResponse>("/api/v1/invoices/upload", {
filenames: files.map((f) => f.name),
}),
onSuccess: (batch) => {
toast.success(
`${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${
batch.drafts.length > 1 ? "s" : ""
}. Vérifions ensemble.`,
);
void navigate({
to: "/factures/import/$batchId",
params: { batchId: batch.id },
});
},
onError: () => {
toast.error("L'upload a échoué. Réessayez dans un instant.");
},
});
// Drop-catcher de page (au cas où le user dropperait à côté de la dropzone).
const onPageDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
};
const onPageDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
const files = Array.from(e.dataTransfer.files).filter(
(f) => /\.(pdf|png|jpe?g)$/iu.test(f.name) || f.type !== "",
);
if (files.length > 0 && !upload.isPending) upload.mutate(files);
};
return (
<div
className="flex flex-col gap-6"
onDragOver={onPageDragOver}
onDrop={onPageDrop}
>
<Link
to="/factures"
className="inline-flex items-center gap-1.5 self-start text-[12.5px] text-ink-3 hover:text-rubis"
>
<ArrowLeft size={13} aria-hidden="true" /> Factures
</Link>
<header className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0 max-w-2xl">
<Eyebrow>Import</Eyebrow>
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Importer <em className="text-rubis">plusieurs</em> factures.
</h1>
<p className="mt-3 text-[15px] leading-relaxed text-ink-2">
Glissez vos PDFs et images de factures l&apos;OCR extrait les
champs, vous validez en 30 secondes, on programme les relances.
Jusqu&apos;à 20 fichiers en un seul drop.
</p>
</div>
<Button variant="ghost" size="sm" disabled>
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
<span className="ml-1 text-[11px] italic text-ink-3">(bientôt)</span>
</Button>
</header>
<Dropzone
variant="full"
onFiles={(files) => upload.mutate(files)}
isUploading={upload.isPending}
/>
{/* Aide / rappel des règles d'OCR — discret en bas */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 mt-2">
<Hint
eyebrow="Formats"
body="PDF natif, scan PDF, photo PNG ou JPEG. Pas besoin de pré-traiter."
/>
<Hint
eyebrow="Confidentiel"
body="Vos PDFs ne quittent pas notre infrastructure souveraine. Hébergement français."
/>
<Hint
eyebrow="Reprenable"
body="Si vous quittez en cours de review, vous reprenez là où vous en étiez."
/>
</div>
</div>
);
}
function Hint({ eyebrow, body }: { eyebrow: string; body: string }) {
return (
<div className="rounded-card border border-line bg-cream-2/40 px-4 py-3">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-rubis">
{eyebrow}
</p>
<p className="mt-1 text-[12.5px] leading-relaxed text-ink-2">{body}</p>
</div>
);
}

View File

@ -63,7 +63,7 @@ type ImportBatch = {
createdAt: string;
};
export const Route = createFileRoute("/_app/factures_/import/$batchId")({
export const Route = createFileRoute("/_app/factures_/import_/$batchId")({
component: ImportReviewPage,
loader: ({ context, params }) => {
void context.queryClient.prefetchQuery({