feat(web): saisie manuelle de facture (modale Radix Dialog)
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>
This commit is contained in:
parent
965a92da8f
commit
cfd3680bb4
182
apps/web/src/components/factures/ClientCombobox.tsx
Normal file
182
apps/web/src/components/factures/ClientCombobox.tsx
Normal file
@ -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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: clients = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.clients.all(),
|
||||||
|
queryFn: () => api.get<Client[]>("/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 (
|
||||||
|
<div ref={containerRef} className={cn("relative", className)}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-3 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Si l'utilisateur retape, on perd la sélection client précédente.
|
||||||
|
onChange({ value: e.target.value, clientId: null });
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"block w-full rounded-default border border-line bg-white",
|
||||||
|
"pl-10 pr-9 py-3 font-sans text-[15px] text-ink placeholder:text-ink-3",
|
||||||
|
"transition-[border-color,box-shadow] duration-150",
|
||||||
|
"focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
|
||||||
|
selectedClientId && "border-rubis bg-rubis-glow/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-ink-3 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className={cn(
|
||||||
|
"absolute z-20 mt-1 w-full max-h-[260px] overflow-y-auto",
|
||||||
|
"rounded-card border border-line bg-white shadow-card p-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{matches.length === 0 && value.trim().length === 0 && (
|
||||||
|
<p className="px-3 py-4 text-[13px] italic text-ink-3 text-center">
|
||||||
|
Aucun client encore. Tapez pour en créer un.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matches.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedClientId === c.id}
|
||||||
|
onClick={() => selectClient(c)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 rounded-default px-3 py-2",
|
||||||
|
"text-left text-[14px] text-ink hover:bg-cream-2",
|
||||||
|
selectedClientId === c.id && "bg-rubis-glow text-rubis-deep",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
{c.email && (
|
||||||
|
<span className="shrink-0 text-[11.5px] text-ink-3 truncate">
|
||||||
|
{c.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{value.trim().length >= 2 && !exactMatch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={createNew}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-default px-3 py-2",
|
||||||
|
"text-left text-[13.5px] text-rubis hover:bg-rubis-glow",
|
||||||
|
matches.length > 0 && "border-t border-line mt-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserPlus size={14} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Créer le client{" "}
|
||||||
|
<strong className="font-semibold">« {value.trim()} »</strong>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,6 +24,8 @@ type DropzoneProps = {
|
|||||||
onFiles?: (files: File[]) => void;
|
onFiles?: (files: File[]) => void;
|
||||||
/** Mode chargement : disable input + remplace le call to action par un spinner. */
|
/** Mode chargement : disable input + remplace le call to action par un spinner. */
|
||||||
isUploading?: boolean;
|
isUploading?: boolean;
|
||||||
|
/** Callback du bouton "Saisir manuellement" (variant full uniquement). */
|
||||||
|
onManualEntry?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,6 +54,7 @@ export function Dropzone({
|
|||||||
maxFiles = 20,
|
maxFiles = 20,
|
||||||
onFiles,
|
onFiles,
|
||||||
isUploading = false,
|
isUploading = false,
|
||||||
|
onManualEntry,
|
||||||
className,
|
className,
|
||||||
}: DropzoneProps) {
|
}: DropzoneProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@ -169,8 +172,13 @@ export function Dropzone({
|
|||||||
>
|
>
|
||||||
<FolderOpen size={14} aria-hidden="true" /> Parcourir mes fichiers
|
<FolderOpen size={14} aria-hidden="true" /> Parcourir mes fichiers
|
||||||
</Button>
|
</Button>
|
||||||
{isFull && !isUploading && (
|
{isFull && !isUploading && onManualEntry && (
|
||||||
<Button variant="ghost" size="sm" type="button">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={onManualEntry}
|
||||||
|
>
|
||||||
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
|
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
403
apps/web/src/components/factures/ManualInvoiceDialog.tsx
Normal file
403
apps/web/src/components/factures/ManualInvoiceDialog.tsx
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@ -8,6 +8,10 @@ import { AppSidebar } from "./AppSidebar";
|
|||||||
import { AppTopbar } from "./AppTopbar";
|
import { AppTopbar } from "./AppTopbar";
|
||||||
import { MobileTabBar } from "./MobileTabBar";
|
import { MobileTabBar } from "./MobileTabBar";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
ManualInvoiceProvider,
|
||||||
|
useManualInvoice,
|
||||||
|
} from "@/hooks/useManualInvoiceDialog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell de l'app authentifiée :
|
* Shell de l'app authentifiée :
|
||||||
@ -40,6 +44,18 @@ type DashboardKpis = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AppLayout({ children, title, subtitle, actions }: AppLayoutProps) {
|
export function AppLayout({ children, title, subtitle, actions }: AppLayoutProps) {
|
||||||
|
return (
|
||||||
|
<ManualInvoiceProvider>
|
||||||
|
<AppLayoutInner title={title} subtitle={subtitle} actions={actions}>
|
||||||
|
{children}
|
||||||
|
</AppLayoutInner>
|
||||||
|
</ManualInvoiceProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppLayoutInner({ children, title, subtitle, actions }: AppLayoutProps) {
|
||||||
|
const manual = useManualInvoice();
|
||||||
|
|
||||||
// KPIs partagés layout ↔ dashboard : on les charge ici pour que le sidebar
|
// KPIs partagés layout ↔ dashboard : on les charge ici pour que le sidebar
|
||||||
// affiche le compteur sans attendre le rendu du dashboard.
|
// affiche le compteur sans attendre le rendu du dashboard.
|
||||||
const { data: kpis } = useQuery({
|
const { data: kpis } = useQuery({
|
||||||
@ -52,7 +68,7 @@ export function AppLayout({ children, title, subtitle, actions }: AppLayoutProps
|
|||||||
// chaque route gère ses propres CTA en tête de contenu (cf. wireframe 4.3).
|
// chaque route gère ses propres CTA en tête de contenu (cf. wireframe 4.3).
|
||||||
const defaultActions = (
|
const defaultActions = (
|
||||||
<div className="hidden lg:flex items-center gap-2">
|
<div className="hidden lg:flex items-center gap-2">
|
||||||
<Button size="sm" variant="secondary" disabled>
|
<Button size="sm" variant="secondary" onClick={manual.open}>
|
||||||
<Plus size={14} aria-hidden="true" /> Saisir
|
<Plus size={14} aria-hidden="true" /> Saisir
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" asChild>
|
<Button size="sm" asChild>
|
||||||
|
|||||||
131
apps/web/src/components/ui/Dialog.tsx
Normal file
131
apps/web/src/components/ui/Dialog.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog stylé maison à partir des primitives Radix.
|
||||||
|
*
|
||||||
|
* Personnalité :
|
||||||
|
* - Backdrop : ink-2 à 35% d'opacité avec blur léger (pas un noir pur 50%)
|
||||||
|
* - Content : bg-cream (pas blanc pur), border-line, rounded-card, shadow-card
|
||||||
|
* - Anim : fade + zoom léger en open (pas de slide agressif)
|
||||||
|
* - Close button : croix discrète en haut-droite, hover bg-cream-2
|
||||||
|
*/
|
||||||
|
export const Dialog = DialogPrimitive.Root;
|
||||||
|
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
export const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
export const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
export const DialogOverlay = forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-ink/35 backdrop-blur-[2px]",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
export const DialogContent = forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
|
/** Largeur max. Default 540px (cf. wireframe 2.3). */
|
||||||
|
maxWidth?: number;
|
||||||
|
}
|
||||||
|
>(({ className, children, maxWidth = 540, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
style={{ maxWidth: `${maxWidth}px` }}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2",
|
||||||
|
"rounded-card border border-line bg-cream shadow-card",
|
||||||
|
"p-6 sm:p-7 max-h-[90vh] overflow-y-auto",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
className={cn(
|
||||||
|
"absolute right-4 top-4 inline-flex size-7 items-center justify-center",
|
||||||
|
"rounded-default text-ink-3 hover:bg-cream-2 hover:text-ink",
|
||||||
|
"transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||||
|
)}
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export function DialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1 pb-4 border-b border-line mb-5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogTitle = forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"font-display text-[19px] font-semibold tracking-[-0.018em] text-ink",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
export const DialogDescription = forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-[13px] text-ink-3 leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export function DialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 pt-5 border-t border-line mt-6",
|
||||||
|
"sm:flex-row sm:items-center sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/web/src/hooks/useManualInvoiceDialog.tsx
Normal file
48
apps/web/src/hooks/useManualInvoiceDialog.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { ManualInvoiceDialog } from "@/components/factures/ManualInvoiceDialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider + hook pour piloter la modale "Saisie manuelle" depuis n'importe
|
||||||
|
* où dans l'app authentifiée. Évite de plumber un onClick à travers les
|
||||||
|
* couches de layout, et garde une seule instance de la modale rendue à la
|
||||||
|
* racine (= un seul réseau de portals Radix).
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* <ManualInvoiceProvider>
|
||||||
|
* <App /> // ← le bouton "+ Saisir" appelle useManualInvoice()
|
||||||
|
* </ManualInvoiceProvider>
|
||||||
|
*/
|
||||||
|
type Ctx = {
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ManualInvoiceContext = createContext<Ctx | null>(null);
|
||||||
|
|
||||||
|
export function ManualInvoiceProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<ManualInvoiceContext.Provider
|
||||||
|
value={{
|
||||||
|
isOpen,
|
||||||
|
open: () => setIsOpen(true),
|
||||||
|
close: () => setIsOpen(false),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ManualInvoiceDialog open={isOpen} onOpenChange={setIsOpen} />
|
||||||
|
</ManualInvoiceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useManualInvoice(): Ctx {
|
||||||
|
const ctx = useContext(ManualInvoiceContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useManualInvoice doit être utilisé dans un <ManualInvoiceProvider>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@ -115,6 +115,17 @@ const draftFieldsSchema = z.object({
|
|||||||
planId: z.string().nullable(),
|
planId: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createInvoiceManualSchema = z.object({
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
clientName: z.string().min(2).max(120),
|
||||||
|
clientEmail: z.string().email().nullable().optional(),
|
||||||
|
numero: z.string().min(1).max(50),
|
||||||
|
amountTtcCents: z.number().int().positive(),
|
||||||
|
issueDate: z.string().datetime(),
|
||||||
|
dueDate: z.string().datetime(),
|
||||||
|
planId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit la timeline d'une facture en composant les étapes du plan
|
* Construit la timeline d'une facture en composant les étapes du plan
|
||||||
* avec l'état courant. Très simplifié pour V1 :
|
* avec l'état courant. Très simplifié pour V1 :
|
||||||
@ -295,6 +306,83 @@ export const invoiceHandlers = [
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// GET /api/v1/clients — autocomplete dans la saisie manuelle
|
||||||
|
http.get(`${apiBase}/clients`, ({ request }) => {
|
||||||
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
|
if (!orgId) return unauthenticated();
|
||||||
|
return HttpResponse.json({ data: mockDb.listClientsForOrg(orgId) });
|
||||||
|
}),
|
||||||
|
|
||||||
|
// POST /api/v1/invoices — saisie manuelle (cf. wireframe 2.3)
|
||||||
|
http.post(`${apiBase}/invoices`, async ({ request }) => {
|
||||||
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
|
if (!orgId) return unauthenticated();
|
||||||
|
|
||||||
|
const json = await request.json();
|
||||||
|
const parsed = createInvoiceManualSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
errors: parsed.error.issues.map((i) => ({
|
||||||
|
code: "validation_failed",
|
||||||
|
message: i.message,
|
||||||
|
field: i.path.join("."),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fields = parsed.data;
|
||||||
|
|
||||||
|
// Résolution client : si clientId fourni → utilise. Sinon match par nom
|
||||||
|
// (case-insensitive). Sinon crée un nouveau client à la volée.
|
||||||
|
let clientId = fields.clientId;
|
||||||
|
let clientName = fields.clientName;
|
||||||
|
if (!clientId) {
|
||||||
|
const existing = mockDb
|
||||||
|
.listClientsForOrg(orgId)
|
||||||
|
.find(
|
||||||
|
(c) => c.name.toLowerCase() === fields.clientName.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
clientId = existing.id;
|
||||||
|
clientName = existing.name;
|
||||||
|
} else {
|
||||||
|
const created = mockDb.createClient(orgId, {
|
||||||
|
name: fields.clientName,
|
||||||
|
email: fields.clientEmail ?? null,
|
||||||
|
phone: null,
|
||||||
|
address: null,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
clientId = created.id;
|
||||||
|
clientName = created.name;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const c = mockDb.findClientById(orgId, clientId);
|
||||||
|
if (c) clientName = c.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = fields.planId ? mockDb.findPlanById(orgId, fields.planId) : null;
|
||||||
|
|
||||||
|
const invoice = mockDb.createInvoice(orgId, {
|
||||||
|
clientId: clientId!,
|
||||||
|
clientName,
|
||||||
|
numero: fields.numero,
|
||||||
|
amountTtcCents: fields.amountTtcCents,
|
||||||
|
issueDate: fields.issueDate,
|
||||||
|
dueDate: fields.dueDate,
|
||||||
|
status: "pending",
|
||||||
|
planId: plan?.id ?? null,
|
||||||
|
planName: plan?.name ?? null,
|
||||||
|
pdfStorageKey: null,
|
||||||
|
notes: null,
|
||||||
|
rubisEarned: 1, // bonus saisie
|
||||||
|
});
|
||||||
|
|
||||||
|
return HttpResponse.json({ data: invoice }, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
// POST /api/v1/invoices/upload — démarre un batch OCR
|
// POST /api/v1/invoices/upload — démarre un batch OCR
|
||||||
http.post(`${apiBase}/invoices/upload`, async ({ request }) => {
|
http.post(`${apiBase}/invoices/upload`, async ({ request }) => {
|
||||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
type InvoiceListItem,
|
type InvoiceListItem,
|
||||||
} from "@/components/factures/InvoiceTable";
|
} from "@/components/factures/InvoiceTable";
|
||||||
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
|
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
|
||||||
|
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
|
||||||
|
|
||||||
type ImportBatchResponse = {
|
type ImportBatchResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -88,6 +89,7 @@ function FacturesPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const upload = useUploadInvoices();
|
const upload = useUploadInvoices();
|
||||||
|
const manual = useManualInvoice();
|
||||||
|
|
||||||
// Drop-catcher global au niveau de la route : si l'utilisateur lâche un
|
// Drop-catcher global au niveau de la route : si l'utilisateur lâche un
|
||||||
// fichier hors de la dropzone (ailleurs sur /factures), on intercepte
|
// fichier hors de la dropzone (ailleurs sur /factures), on intercepte
|
||||||
@ -138,6 +140,7 @@ function FacturesPage() {
|
|||||||
<FacturesEmpty
|
<FacturesEmpty
|
||||||
onFiles={(files) => upload.mutate(files)}
|
onFiles={(files) => upload.mutate(files)}
|
||||||
isUploading={upload.isPending}
|
isUploading={upload.isPending}
|
||||||
|
onManualEntry={manual.open}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -220,9 +223,11 @@ function FacturesPage() {
|
|||||||
function FacturesEmpty({
|
function FacturesEmpty({
|
||||||
onFiles,
|
onFiles,
|
||||||
isUploading,
|
isUploading,
|
||||||
|
onManualEntry,
|
||||||
}: {
|
}: {
|
||||||
onFiles: (files: File[]) => void;
|
onFiles: (files: File[]) => void;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
|
onManualEntry: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
@ -235,7 +240,12 @@ function FacturesEmpty({
|
|||||||
en 30 secondes.
|
en 30 secondes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dropzone variant="full" onFiles={onFiles} isUploading={isUploading} />
|
<Dropzone
|
||||||
|
variant="full"
|
||||||
|
onFiles={onFiles}
|
||||||
|
isUploading={isUploading}
|
||||||
|
onManualEntry={onManualEntry}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { api } from "@/lib/api";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
import { Dropzone } from "@/components/factures/Dropzone";
|
import { Dropzone } from "@/components/factures/Dropzone";
|
||||||
|
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
|
||||||
|
|
||||||
type ImportBatchResponse = {
|
type ImportBatchResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -19,6 +20,7 @@ export const Route = createFileRoute("/_app/factures_/import")({
|
|||||||
|
|
||||||
function ImportLandingPage() {
|
function ImportLandingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const manual = useManualInvoice();
|
||||||
|
|
||||||
const upload = useMutation({
|
const upload = useMutation({
|
||||||
mutationFn: (files: File[]) =>
|
mutationFn: (files: File[]) =>
|
||||||
@ -80,9 +82,8 @@ function ImportLandingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" disabled>
|
<Button variant="ghost" size="sm" onClick={manual.open}>
|
||||||
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
|
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
|
||||||
<span className="ml-1 text-[11px] italic text-ink-3">(bientôt)</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -90,6 +91,7 @@ function ImportLandingPage() {
|
|||||||
variant="full"
|
variant="full"
|
||||||
onFiles={(files) => upload.mutate(files)}
|
onFiles={(files) => upload.mutate(files)}
|
||||||
isUploading={upload.isPending}
|
isUploading={upload.isPending}
|
||||||
|
onManualEntry={manual.open}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Aide / rappel des règles d'OCR — discret en bas */}
|
{/* Aide / rappel des règles d'OCR — discret en bas */}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user