import { useEffect } from "react"; import { useForm } from "@tanstack/react-form"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Paperclip } from "lucide-react"; import { toast } from "sonner"; import { z } from "zod"; import type { Plan } from "@rubis/shared"; import { api } from "@/lib/api"; import { queryKeys } from "@/lib/queryKeys"; import { Button } from "@/components/ui/Button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/Dialog"; import { DatePicker } from "@/components/ui/DatePicker"; import { Field } from "@/components/ui/Field"; import { Input } from "@/components/ui/Input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/Select"; import { ClientCombobox } from "./ClientCombobox"; /** * Saisie manuelle d'une facture (cf. wireframe 2.3). * Modal Radix Dialog avec form local TanStack Form + validateurs Zod par * champ (plus pratique que le validator global vu les contraintes de typing * imposées par TanStack Form sur le mapping schema↔defaultValues). * * Échéance gérée en RELATIF (15/30/45/60/90 jours après émission) car * c'est plus rapide que calculer une date — cf. annotation wireframe. * À la soumission, on convertit en absolu. */ const RELATIVE_DUE_OPTIONS = ["15", "30", "45", "60", "90"] as const; type RelativeDueDays = (typeof RELATIVE_DUE_OPTIONS)[number]; const RELATIVE_DUE_LABELS: Record = { "15": "15 jours après émission", "30": "30 jours après émission (standard)", "45": "45 jours après émission", "60": "60 jours après émission", "90": "90 jours après émission", }; type FormValues = { clientName: string; clientId: string | null; numero: string; amountTtcCents: number; issueDate: string; relativeDueDays: RelativeDueDays; planId: string; }; // Validateurs par champ (TanStack Form supporte le validator par champ via // validators.onChange, et le typing est plus clean qu'avec un schema global). const validators = { clientName: z .string() .min(2, "Au moins 2 caractères") .max(120, "120 caractères max"), numero: z .string() .min(1, "Numéro requis") .max(50, "50 caractères max"), amountTtcCents: z .number({ invalid_type_error: "Montant invalide" }) .int() .positive("Le montant doit être positif"), planId: z.string().min(1, "Choisissez un plan"), }; type ManualInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; }; export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogProps) { const queryClient = useQueryClient(); const { data: plans = [] } = useQuery({ queryKey: queryKeys.plans.all(), queryFn: () => api.get("/api/v1/plans"), staleTime: 60_000, enabled: open, }); const createMutation = useMutation({ mutationFn: (input: FormValues) => { const issueDate = new Date(input.issueDate); const dueDate = new Date(issueDate); dueDate.setDate(dueDate.getDate() + Number(input.relativeDueDays)); return api.post(`/api/v1/invoices`, { clientId: input.clientId ?? undefined, clientName: input.clientName, clientEmail: null, numero: input.numero, amountTtcCents: input.amountTtcCents, issueDate: issueDate.toISOString(), dueDate: dueDate.toISOString(), planId: input.planId, }); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() }); void queryClient.invalidateQueries({ queryKey: ["invoices", "counts"] }); void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.kpis() }); void queryClient.invalidateQueries({ queryKey: queryKeys.clients.all() }); toast.success("Facture créée. + 1 rubis."); onOpenChange(false); }, onError: () => { toast.error("Création impossible. Vérifiez les champs."); }, }); const today = todayISO(); const defaultPlanId = plans.find((p) => p.slug === "standard-30j")?.id ?? plans[0]?.id ?? ""; const initialValues: FormValues = { clientName: "", clientId: null, numero: "", amountTtcCents: 0, issueDate: today, relativeDueDays: "30", planId: defaultPlanId, }; const form = useForm({ defaultValues: initialValues, onSubmit: async ({ value }) => { // Validation finale sur tous les champs avant submit. Si un champ est // invalide, on toast et on n'appelle pas l'API. const checks: Array<[keyof FormValues, z.ZodTypeAny]> = [ ["clientName", validators.clientName], ["numero", validators.numero], ["amountTtcCents", validators.amountTtcCents], ["planId", validators.planId], ]; for (const [key, schema] of checks) { const r = schema.safeParse(value[key]); if (!r.success) { toast.error(r.error.issues[0]?.message ?? "Champ invalide"); return; } } await createMutation.mutateAsync(value); }, }); // Reset à chaque ouverture (et synchronise defaultPlanId une fois les // plans chargés — premier render le tableau peut être vide). useEffect(() => { if (open) form.reset(initialValues); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, defaultPlanId]); return ( Nouvelle facture Saisie manuelle — pour les factures que vous avez sous la main mais sans PDF à OCRiser.
{ e.preventDefault(); void form.handleSubmit(); }} className="flex flex-col gap-5" > {/* === Client === */} {(field) => ( {(idField) => ( { field.handleChange(value); idField.handleChange(clientId); }} /> )} )} {/* === N° + Date d'émission === */}
{(field) => ( field.handleChange(e.target.value)} /> )} {(field) => ( field.handleChange(d.toISOString())} /> )}
{/* === 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) => ( )}
); } /** * 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(); }