diff --git a/apps/web/src/components/factures/ClientCombobox.tsx b/apps/web/src/components/factures/ClientCombobox.tsx
new file mode 100644
index 0000000..243c638
--- /dev/null
+++ b/apps/web/src/components/factures/ClientCombobox.tsx
@@ -0,0 +1,182 @@
+import { useState, useRef, useEffect } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Search, UserPlus, ChevronDown } from "lucide-react";
+
+import type { Client } from "@rubis/shared";
+import { api } from "@/lib/api";
+import { queryKeys } from "@/lib/queryKeys";
+import { cn } from "@/lib/utils";
+
+/**
+ * ClientCombobox — autocomplete des clients existants avec création
+ * à la volée si aucun ne match.
+ *
+ * Pas Radix Combobox (n'existe pas) ni cmdk (overkill pour V1) — version
+ * maison minimale :
+ * - Input + dropdown qui s'affiche au focus / typing
+ * - Filtre case-insensitive sur le nom
+ * - Click sur une suggestion = sélection (renvoie le clientId au parent)
+ * - Si rien ne match, option "Créer un nouveau client : « X »"
+ * - Escape ferme le dropdown
+ */
+type ClientComboboxProps = {
+ value: string;
+ /** clientId si un client existant a été sélectionné, sinon null. */
+ selectedClientId: string | null;
+ onChange: (input: { value: string; clientId: string | null }) => void;
+ placeholder?: string;
+ id?: string;
+ className?: string;
+};
+
+export function ClientCombobox({
+ value,
+ selectedClientId,
+ onChange,
+ placeholder = "Rechercher ou créer un client…",
+ id,
+ className,
+}: ClientComboboxProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ const { data: clients = [] } = useQuery({
+ queryKey: queryKeys.clients.all(),
+ queryFn: () => api.get("/api/v1/clients"),
+ staleTime: 60_000,
+ });
+
+ // Filtre les clients selon la valeur tapée. Si l'utilisateur a déjà
+ // sélectionné un client (selectedClientId), on ne filtre pas.
+ const matches = value.trim().length > 0
+ ? clients.filter((c) =>
+ c.name.toLowerCase().includes(value.toLowerCase()),
+ )
+ : clients;
+
+ const exactMatch = clients.some(
+ (c) => c.name.toLowerCase() === value.toLowerCase(),
+ );
+
+ // Click outside → close
+ useEffect(() => {
+ if (!isOpen) return;
+ const onClickOutside = (e: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", onClickOutside);
+ return () => document.removeEventListener("mousedown", onClickOutside);
+ }, [isOpen]);
+
+ const selectClient = (client: Client) => {
+ onChange({ value: client.name, clientId: client.id });
+ setIsOpen(false);
+ };
+
+ const createNew = () => {
+ // L'utilisateur valide la valeur tapée comme nom de nouveau client.
+ onChange({ value: value.trim(), clientId: null });
+ setIsOpen(false);
+ };
+
+ return (
+
+
+
+ setIsOpen(true)}
+ onChange={(e) => {
+ // Si l'utilisateur retape, on perd la sélection client précédente.
+ onChange({ value: e.target.value, clientId: null });
+ setIsOpen(true);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") setIsOpen(false);
+ }}
+ className={cn(
+ "block w-full rounded-default border border-line bg-white",
+ "pl-10 pr-9 py-3 font-sans text-[15px] text-ink placeholder:text-ink-3",
+ "transition-[border-color,box-shadow] duration-150",
+ "focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
+ selectedClientId && "border-rubis bg-rubis-glow/30",
+ )}
+ />
+
+
+
+ {isOpen && (
+
+ {matches.length === 0 && value.trim().length === 0 && (
+
+ Aucun client encore. Tapez pour en créer un.
+
+ )}
+
+ {matches.map((c) => (
+
+ ))}
+
+ {value.trim().length >= 2 && !exactMatch && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/factures/Dropzone.tsx b/apps/web/src/components/factures/Dropzone.tsx
index e183296..d841c29 100644
--- a/apps/web/src/components/factures/Dropzone.tsx
+++ b/apps/web/src/components/factures/Dropzone.tsx
@@ -24,6 +24,8 @@ type DropzoneProps = {
onFiles?: (files: File[]) => void;
/** Mode chargement : disable input + remplace le call to action par un spinner. */
isUploading?: boolean;
+ /** Callback du bouton "Saisir manuellement" (variant full uniquement). */
+ onManualEntry?: () => void;
className?: string;
};
@@ -52,6 +54,7 @@ export function Dropzone({
maxFiles = 20,
onFiles,
isUploading = false,
+ onManualEntry,
className,
}: DropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
@@ -169,8 +172,13 @@ export function Dropzone({
>
Parcourir mes fichiers
- {isFull && !isUploading && (
-
+
+ {/* === Montant + Échéance relative === */}
+
+
+ {(field) => (
+
+ {/* Input non-contrôlé (defaultValue) pour ne pas reformatter
+ à chaque frappe — sinon "1.00" écrase "10000" et l'user
+ ne peut pas taper de gros montants. La form state reçoit
+ les centimes via onChange. Re-monté à chaque ouverture
+ du Dialog (DialogContent unmounted on close), donc
+ defaultValue est ré-évalué à chaque réouverture. */}
+ {
+ const raw = e.target.value.replace(",", ".");
+ const val = parseFloat(raw);
+ field.handleChange(
+ Number.isNaN(val) ? 0 : Math.round(val * 100),
+ );
+ }}
+ />
+
+ )}
+
+
+
+ {(field) => (
+
+
+
+ )}
+
+
+
+ {/* === Plan === */}
+
+ {(field) => (
+
+
+
+ )}
+
+
+
+
+ Joindre le PDF
+ (bientôt)
+
+
+
+ Annuler
+
+
+
+ Créer la facture
+
+
+
+
+
+ );
+}
+
+/**
+ * Petit helper pour extraire le 1er message d'erreur sans dépendre de la
+ * shape exacte du retour TanStack Form (qui peut être Issue[] ou string[]
+ * selon la version).
+ */
+function firstError(errors: unknown[]): string | undefined {
+ const first = errors[0];
+ if (!first) return undefined;
+ if (typeof first === "string") return first;
+ if (typeof first === "object" && first !== null && "message" in first) {
+ const msg = (first as { message?: unknown }).message;
+ if (typeof msg === "string") return msg;
+ }
+ return undefined;
+}
+
+function todayISO(): string {
+ const d = new Date();
+ d.setHours(9, 0, 0, 0);
+ return d.toISOString();
+}
diff --git a/apps/web/src/components/layout/AppLayout.tsx b/apps/web/src/components/layout/AppLayout.tsx
index 1dac995..660ba15 100644
--- a/apps/web/src/components/layout/AppLayout.tsx
+++ b/apps/web/src/components/layout/AppLayout.tsx
@@ -8,6 +8,10 @@ import { AppSidebar } from "./AppSidebar";
import { AppTopbar } from "./AppTopbar";
import { MobileTabBar } from "./MobileTabBar";
import { Link } from "@tanstack/react-router";
+import {
+ ManualInvoiceProvider,
+ useManualInvoice,
+} from "@/hooks/useManualInvoiceDialog";
/**
* Shell de l'app authentifiée :
@@ -40,6 +44,18 @@ type DashboardKpis = {
};
export function AppLayout({ children, title, subtitle, actions }: AppLayoutProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function AppLayoutInner({ children, title, subtitle, actions }: AppLayoutProps) {
+ const manual = useManualInvoice();
+
// KPIs partagés layout ↔ dashboard : on les charge ici pour que le sidebar
// affiche le compteur sans attendre le rendu du dashboard.
const { data: kpis } = useQuery({
@@ -52,7 +68,7 @@ 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 = (
-
+
Saisir
diff --git a/apps/web/src/components/ui/Dialog.tsx b/apps/web/src/components/ui/Dialog.tsx
new file mode 100644
index 0000000..e9f88b1
--- /dev/null
+++ b/apps/web/src/components/ui/Dialog.tsx
@@ -0,0 +1,131 @@
+import { forwardRef } from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Dialog stylé maison à partir des primitives Radix.
+ *
+ * Personnalité :
+ * - Backdrop : ink-2 à 35% d'opacité avec blur léger (pas un noir pur 50%)
+ * - Content : bg-cream (pas blanc pur), border-line, rounded-card, shadow-card
+ * - Anim : fade + zoom léger en open (pas de slide agressif)
+ * - Close button : croix discrète en haut-droite, hover bg-cream-2
+ */
+export const Dialog = DialogPrimitive.Root;
+export const DialogTrigger = DialogPrimitive.Trigger;
+export const DialogPortal = DialogPrimitive.Portal;
+export const DialogClose = DialogPrimitive.Close;
+
+export const DialogOverlay = forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+export const DialogContent = forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ /** Largeur max. Default 540px (cf. wireframe 2.3). */
+ maxWidth?: number;
+ }
+>(({ className, children, maxWidth = 540, ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+export function DialogHeader({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export const DialogTitle = forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+export const DialogDescription = forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export function DialogFooter({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/hooks/useManualInvoiceDialog.tsx b/apps/web/src/hooks/useManualInvoiceDialog.tsx
new file mode 100644
index 0000000..19fafb8
--- /dev/null
+++ b/apps/web/src/hooks/useManualInvoiceDialog.tsx
@@ -0,0 +1,48 @@
+import { createContext, useContext, useState, type ReactNode } from "react";
+
+import { ManualInvoiceDialog } from "@/components/factures/ManualInvoiceDialog";
+
+/**
+ * Provider + hook pour piloter la modale "Saisie manuelle" depuis n'importe
+ * où dans l'app authentifiée. Évite de plumber un onClick à travers les
+ * couches de layout, et garde une seule instance de la modale rendue à la
+ * racine (= un seul réseau de portals Radix).
+ *
+ * Usage :
+ *
+ * // ← le bouton "+ Saisir" appelle useManualInvoice()
+ *
+ */
+type Ctx = {
+ open: () => void;
+ close: () => void;
+ isOpen: boolean;
+};
+
+const ManualInvoiceContext = createContext(null);
+
+export function ManualInvoiceProvider({ children }: { children: ReactNode }) {
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+ setIsOpen(true),
+ close: () => setIsOpen(false),
+ }}
+ >
+ {children}
+
+
+ );
+}
+
+export function useManualInvoice(): Ctx {
+ const ctx = useContext(ManualInvoiceContext);
+ if (!ctx) {
+ throw new Error(
+ "useManualInvoice doit être utilisé dans un ",
+ );
+ }
+ return ctx;
+}
diff --git a/apps/web/src/mocks/handlers/invoices.ts b/apps/web/src/mocks/handlers/invoices.ts
index a9bb85e..8c05c94 100644
--- a/apps/web/src/mocks/handlers/invoices.ts
+++ b/apps/web/src/mocks/handlers/invoices.ts
@@ -115,6 +115,17 @@ const draftFieldsSchema = z.object({
planId: z.string().nullable(),
});
+const createInvoiceManualSchema = z.object({
+ clientId: z.string().optional(),
+ clientName: z.string().min(2).max(120),
+ clientEmail: z.string().email().nullable().optional(),
+ numero: z.string().min(1).max(50),
+ amountTtcCents: z.number().int().positive(),
+ issueDate: z.string().datetime(),
+ dueDate: z.string().datetime(),
+ planId: z.string().nullable().optional(),
+});
+
/**
* Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant. Très simplifié pour V1 :
@@ -295,6 +306,83 @@ export const invoiceHandlers = [
});
}),
+ // GET /api/v1/clients — autocomplete dans la saisie manuelle
+ http.get(`${apiBase}/clients`, ({ request }) => {
+ const orgId = authedOrgId(request.headers.get("authorization"));
+ if (!orgId) return unauthenticated();
+ return HttpResponse.json({ data: mockDb.listClientsForOrg(orgId) });
+ }),
+
+ // POST /api/v1/invoices — saisie manuelle (cf. wireframe 2.3)
+ http.post(`${apiBase}/invoices`, async ({ request }) => {
+ const orgId = authedOrgId(request.headers.get("authorization"));
+ if (!orgId) return unauthenticated();
+
+ const json = await request.json();
+ const parsed = createInvoiceManualSchema.safeParse(json);
+ if (!parsed.success) {
+ return HttpResponse.json(
+ {
+ errors: parsed.error.issues.map((i) => ({
+ code: "validation_failed",
+ message: i.message,
+ field: i.path.join("."),
+ })),
+ },
+ { status: 422 },
+ );
+ }
+ const fields = parsed.data;
+
+ // Résolution client : si clientId fourni → utilise. Sinon match par nom
+ // (case-insensitive). Sinon crée un nouveau client à la volée.
+ let clientId = fields.clientId;
+ let clientName = fields.clientName;
+ if (!clientId) {
+ const existing = mockDb
+ .listClientsForOrg(orgId)
+ .find(
+ (c) => c.name.toLowerCase() === fields.clientName.toLowerCase(),
+ );
+ if (existing) {
+ clientId = existing.id;
+ clientName = existing.name;
+ } else {
+ const created = mockDb.createClient(orgId, {
+ name: fields.clientName,
+ email: fields.clientEmail ?? null,
+ phone: null,
+ address: null,
+ notes: null,
+ });
+ clientId = created.id;
+ clientName = created.name;
+ }
+ } else {
+ const c = mockDb.findClientById(orgId, clientId);
+ if (c) clientName = c.name;
+ }
+
+ const plan = fields.planId ? mockDb.findPlanById(orgId, fields.planId) : null;
+
+ const invoice = mockDb.createInvoice(orgId, {
+ clientId: clientId!,
+ clientName,
+ numero: fields.numero,
+ amountTtcCents: fields.amountTtcCents,
+ issueDate: fields.issueDate,
+ dueDate: fields.dueDate,
+ status: "pending",
+ planId: plan?.id ?? null,
+ planName: plan?.name ?? null,
+ pdfStorageKey: null,
+ notes: null,
+ rubisEarned: 1, // bonus saisie
+ });
+
+ return HttpResponse.json({ data: invoice }, { status: 201 });
+ }),
+
// POST /api/v1/invoices/upload — démarre un batch OCR
http.post(`${apiBase}/invoices/upload`, async ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
diff --git a/apps/web/src/routes/_app/factures.tsx b/apps/web/src/routes/_app/factures.tsx
index bcb41ed..7b9a4c8 100644
--- a/apps/web/src/routes/_app/factures.tsx
+++ b/apps/web/src/routes/_app/factures.tsx
@@ -17,6 +17,7 @@ import {
type InvoiceListItem,
} from "@/components/factures/InvoiceTable";
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
+import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
type ImportBatchResponse = {
id: string;
@@ -88,6 +89,7 @@ function FacturesPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const upload = useUploadInvoices();
+ const manual = useManualInvoice();
// Drop-catcher global au niveau de la route : si l'utilisateur lâche un
// fichier hors de la dropzone (ailleurs sur /factures), on intercepte
@@ -138,6 +140,7 @@ function FacturesPage() {
upload.mutate(files)}
isUploading={upload.isPending}
+ onManualEntry={manual.open}
/>
);
@@ -220,9 +223,11 @@ function FacturesPage() {
function FacturesEmpty({
onFiles,
isUploading,
+ onManualEntry,
}: {
onFiles: (files: File[]) => void;
isUploading: boolean;
+ onManualEntry: () => void;
}) {
return (
@@ -235,7 +240,12 @@ function FacturesEmpty({
en 30 secondes.
-
+
);
}
diff --git a/apps/web/src/routes/_app/factures_.import.tsx b/apps/web/src/routes/_app/factures_.import.tsx
index 59208b0..a4ab672 100644
--- a/apps/web/src/routes/_app/factures_.import.tsx
+++ b/apps/web/src/routes/_app/factures_.import.tsx
@@ -7,6 +7,7 @@ import { api } from "@/lib/api";
import { Button } from "@/components/ui/Button";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Dropzone } from "@/components/factures/Dropzone";
+import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
type ImportBatchResponse = {
id: string;
@@ -19,6 +20,7 @@ export const Route = createFileRoute("/_app/factures_/import")({
function ImportLandingPage() {
const navigate = useNavigate();
+ const manual = useManualInvoice();
const upload = useMutation({
mutationFn: (files: File[]) =>
@@ -80,9 +82,8 @@ function ImportLandingPage() {
-
+
Saisir manuellement
- (bientôt)
@@ -90,6 +91,7 @@ function ImportLandingPage() {
variant="full"
onFiles={(files) => upload.mutate(files)}
isUploading={upload.isPending}
+ onManualEntry={manual.open}
/>
{/* Aide / rappel des règles d'OCR — discret en bas */}