rubis/apps/web/src/components/factures/ManualInvoiceDialog.tsx
ordinarthur 06dcf38fee
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 20s
feat(ui): DatePicker brandé Rubis (remplace input type=date)
Le natif <input type="date"> affiche le calendrier moche du navigateur
(différent par client OS, pas d'alignement palette). On le remplace par
un composant Radix Popover + grille mensuelle Tailwind aux couleurs
Rubis.

Composant : apps/web/src/components/ui/DatePicker.tsx
- Trigger : bouton style Input (border line, focus rubis-glow,
  data-[state=open]:border-rubis pour hint visuel)
- Popover Radix : focus trap, Escape, click outside, animation
- Grille 7×6 (semaine commence lundi, locale FR via date-fns)
- Sélection : bg-rubis text-white + ombre rubis
- Today : ring-inset rubis-glow
- Hover : bg-cream
- Raccourci "Aujourd'hui" en footer

API alignée avec l'usage existant :
- value: string ISO | Date | null
- onChange(date: Date) — l'appelant fait .toISOString() comme avant

Usages migrés :
- ManualInvoiceDialog.tsx : Date d'émission
- factures_.import_.$batchId.tsx : Date d'échéance (avec préservation
  du className aria-invalid pour les low-confidence OCR)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:01:31 +02:00

401 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 { 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<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}>
<DatePicker
id={field.name}
value={field.state.value}
onChange={(d) => 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();
}