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 { AppSidebar } from "./AppSidebar";
|
||||||
import { AppTopbar } from "./AppTopbar";
|
import { AppTopbar } from "./AppTopbar";
|
||||||
import { MobileTabBar } from "./MobileTabBar";
|
import { MobileTabBar } from "./MobileTabBar";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell de l'app authentifiée :
|
* 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).
|
// chaque route gère ses propres CTA en tête de contenu (cf. wireframe 4.3).
|
||||||
const defaultActions = (
|
const defaultActions = (
|
||||||
<div className="hidden lg:flex items-center gap-2">
|
<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
|
<Plus size={14} aria-hidden="true" /> Saisir
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<Button size="sm" asChild>
|
||||||
<Upload size={14} aria-hidden="true" /> Importer factures
|
<Link to="/factures/import">
|
||||||
|
<Upload size={14} aria-hidden="true" /> Importer factures
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/factures_/import/$batchId")({
|
export const Route = createFileRoute("/_app/factures_/import_/$batchId")({
|
||||||
component: ImportReviewPage,
|
component: ImportReviewPage,
|
||||||
loader: ({ context, params }) => {
|
loader: ({ context, params }) => {
|
||||||
void context.queryClient.prefetchQuery({
|
void context.queryClient.prefetchQuery({
|
||||||
Loading…
x
Reference in New Issue
Block a user