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({ >