From cfd3680bb4df1c675c0a124cf672b233fba0fcfc Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 11:50:46 +0200 Subject: [PATCH] feat(web): saisie manuelle de facture (modale Radix Dialog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modale 'Nouvelle facture' (cf. wireframe 2.3) accessible depuis 4 points : - Topbar '+ Saisir' (était disabled) - /factures/import bouton 'Saisir manuellement' (header) - Dropzone empty state sur /factures (variant full) - (Reachable de partout dans _app/* via le topbar) Composants ajoutés : - Dialog : wrapper Radix Dialog stylé (overlay ink/35 + blur, content bg-cream + border-line + shadow-card, close button discret, animations fade+zoom). Header / Title / Description / Footer / Close. - ClientCombobox : autocomplete maison (pas Radix Combobox qui n'existe pas, pas cmdk overkill). Input + dropdown filtré, click-outside ferme, Escape ferme, option 'Créer le client « X »' quand pas de match exact. Border rubis quand un client existant est sélectionné. - ManualInvoiceDialog : form complet (TanStack Form + validateurs Zod par champ). Client (combobox), N° + date émission (côte-à-côte), montant + échéance relative 15/30/45/60/90j (Select Radix), plan de relance. Architecture clean : - ManualInvoiceProvider au sommet d'AppLayout rend la modale une seule fois (un seul réseau de portals Radix) - Hook useManualInvoice() expose open()/close()/isOpen, accessible depuis n'importe quelle route enfant sans plumber des callbacks - État local de la modale (pas dans l'URL — propre pour V1) Logique métier MSW : - GET /api/v1/clients (autocomplete) - POST /api/v1/invoices : résolution client (clientId fourni → utilise, sinon match par nom case-insensitive, sinon création à la volée). +1 rubis bonus saisie. - Conversion relativeDueDays (15/30/45/60/90) → dueDate absolue à la soumission Bug fix montant TTC : - L'input était contrôlé avec value={(cents/100).toFixed(2)} → reformat à chaque keystroke écrasait '10000' en '1.00' (impossible de taper des gros montants) - Passé en defaultValue (uncontrolled) avec step='any' + inputMode='decimal' - Accepte virgule FR (1240,50) et point (1240.50) - DialogContent unmount à la fermeture → defaultValue ré-évalué à chaque réouverture (reset OK) Bouton '+ Saisir' du topbar plus disabled, bouton 'Saisir manuellement' de /factures/import plus disabled. Le bouton dans la dropzone (variant full) reçoit un onManualEntry prop optionnel. Bundle prod : 117.62 KB gzip core (+0.06 KB), useManualInvoiceDialog chunk 6.68 KB gzip, Select chunk 25.14 KB gzip (partagé OCR + plan editor + manual entry). Co-Authored-By: Claude Opus 4.7 --- .../components/factures/ClientCombobox.tsx | 182 ++++++++ apps/web/src/components/factures/Dropzone.tsx | 12 +- .../factures/ManualInvoiceDialog.tsx | 403 ++++++++++++++++++ apps/web/src/components/layout/AppLayout.tsx | 18 +- apps/web/src/components/ui/Dialog.tsx | 131 ++++++ apps/web/src/hooks/useManualInvoiceDialog.tsx | 48 +++ apps/web/src/mocks/handlers/invoices.ts | 88 ++++ apps/web/src/routes/_app/factures.tsx | 12 +- apps/web/src/routes/_app/factures_.import.tsx | 6 +- 9 files changed, 894 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/factures/ClientCombobox.tsx create mode 100644 apps/web/src/components/factures/ManualInvoiceDialog.tsx create mode 100644 apps/web/src/components/ui/Dialog.tsx create mode 100644 apps/web/src/hooks/useManualInvoiceDialog.tsx 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 ( +
+
+
+ + {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({ >