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 <noreply@anthropic.com>
404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
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 { 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<RelativeDueDays, string> = {
|
|
"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<Plan[]>("/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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent maxWidth={560}>
|
|
<DialogHeader>
|
|
<DialogTitle>Nouvelle facture</DialogTitle>
|
|
<DialogDescription>
|
|
Saisie manuelle — pour les factures que vous avez sous la main mais
|
|
sans PDF à OCRiser.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form
|
|
noValidate
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void form.handleSubmit();
|
|
}}
|
|
className="flex flex-col gap-5"
|
|
>
|
|
{/* === Client === */}
|
|
<form.Field
|
|
name="clientName"
|
|
validators={{ onChange: validators.clientName }}
|
|
>
|
|
{(field) => (
|
|
<form.Field name="clientId">
|
|
{(idField) => (
|
|
<Field
|
|
label="Client"
|
|
htmlFor={field.name}
|
|
hint="Tapez pour rechercher un client existant ou en créer un."
|
|
error={firstError(field.state.meta.errors)}
|
|
>
|
|
<ClientCombobox
|
|
id={field.name}
|
|
value={field.state.value}
|
|
selectedClientId={idField.state.value}
|
|
onChange={({ value, clientId }) => {
|
|
field.handleChange(value);
|
|
idField.handleChange(clientId);
|
|
}}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</form.Field>
|
|
)}
|
|
</form.Field>
|
|
|
|
{/* === N° + Date d'émission === */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<form.Field
|
|
name="numero"
|
|
validators={{ onChange: validators.numero }}
|
|
>
|
|
{(field) => (
|
|
<Field
|
|
label="N° de facture"
|
|
htmlFor={field.name}
|
|
error={firstError(field.state.meta.errors)}
|
|
>
|
|
<Input
|
|
id={field.name}
|
|
placeholder="F-2026-0043"
|
|
value={field.state.value}
|
|
onChange={(e) => field.handleChange(e.target.value)}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</form.Field>
|
|
|
|
<form.Field name="issueDate">
|
|
{(field) => (
|
|
<Field label="Date d'émission" htmlFor={field.name}>
|
|
<Input
|
|
id={field.name}
|
|
type="date"
|
|
value={field.state.value.slice(0, 10)}
|
|
onChange={(e) => {
|
|
const d = new Date(e.target.value);
|
|
field.handleChange(d.toISOString());
|
|
}}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</form.Field>
|
|
</div>
|
|
|
|
{/* === Montant + Échéance relative === */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<form.Field
|
|
name="amountTtcCents"
|
|
validators={{ onChange: validators.amountTtcCents }}
|
|
>
|
|
{(field) => (
|
|
<Field
|
|
label="Montant TTC"
|
|
htmlFor={field.name}
|
|
hint="Montant en euros (ex: 1240 ou 1240.50)."
|
|
error={firstError(field.state.meta.errors)}
|
|
>
|
|
{/* 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. */}
|
|
<Input
|
|
id={field.name}
|
|
type="number"
|
|
inputMode="decimal"
|
|
step="any"
|
|
min={0}
|
|
placeholder="0,00"
|
|
defaultValue={
|
|
field.state.value === 0
|
|
? ""
|
|
: (field.state.value / 100).toString()
|
|
}
|
|
onChange={(e) => {
|
|
const raw = e.target.value.replace(",", ".");
|
|
const val = parseFloat(raw);
|
|
field.handleChange(
|
|
Number.isNaN(val) ? 0 : Math.round(val * 100),
|
|
);
|
|
}}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</form.Field>
|
|
|
|
<form.Field name="relativeDueDays">
|
|
{(field) => (
|
|
<Field label="Échéance" htmlFor={field.name}>
|
|
<Select
|
|
value={field.state.value}
|
|
onValueChange={(v) =>
|
|
field.handleChange(v as RelativeDueDays)
|
|
}
|
|
>
|
|
<SelectTrigger id={field.name}>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{RELATIVE_DUE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt} value={opt}>
|
|
{RELATIVE_DUE_LABELS[opt]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
)}
|
|
</form.Field>
|
|
</div>
|
|
|
|
{/* === Plan === */}
|
|
<form.Field
|
|
name="planId"
|
|
validators={{ onChange: validators.planId }}
|
|
>
|
|
{(field) => (
|
|
<Field
|
|
label="Plan de relance"
|
|
htmlFor={field.name}
|
|
error={firstError(field.state.meta.errors)}
|
|
>
|
|
<Select
|
|
value={field.state.value}
|
|
onValueChange={(v) => field.handleChange(v)}
|
|
>
|
|
<SelectTrigger id={field.name}>
|
|
<SelectValue placeholder="Choisir un plan…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{plans.map((plan) => (
|
|
<SelectItem key={plan.id} value={plan.id}>
|
|
{plan.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
)}
|
|
</form.Field>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
type="button"
|
|
disabled
|
|
className="sm:mr-auto"
|
|
>
|
|
<Paperclip size={14} aria-hidden="true" /> Joindre le PDF
|
|
<span className="ml-1 text-[11px] italic text-ink-3">(bientôt)</span>
|
|
</Button>
|
|
<DialogClose asChild>
|
|
<Button variant="secondary" size="sm" type="button">
|
|
Annuler
|
|
</Button>
|
|
</DialogClose>
|
|
<Button
|
|
size="sm"
|
|
type="submit"
|
|
loading={createMutation.isPending}
|
|
>
|
|
Créer la facture
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|