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:
parent
86dae64eb4
commit
965a92da8f
@ -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>
|
||||
);
|
||||
|
||||
123
apps/web/src/routes/_app/factures_.import.tsx
Normal file
123
apps/web/src/routes/_app/factures_.import.tsx
Normal 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'OCR extrait les
|
||||
champs, vous validez en 30 secondes, on programme les relances.
|
||||
Jusqu'à 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>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
Loading…
x
Reference in New Issue
Block a user