feat(web): éditeur de factures /factures/nouvelle (Phase 4)
Page split-view qui permet de composer une facture native dans Rubis avec preview PDF en live (debounce 500 ms via POST /invoices/preview-pdf → Blob → objectURL → iframe). UI - Gauche : panneau d'édition organisé en cards (destinataire, dates + plan, lignes éditables, thème + accent, notes). - Droite : iframe sticky qui affiche le PDF rendu côté serveur. Loader discret pendant la génération, fallback "sélectionnez un client" tant qu'on n'a pas un payload minimal valide. - Lignes : ajout/suppression, quantité décimale (heures, demi-jours), taux TVA selon FRENCH_TVA_RATES, total HT recalculé live. - Totaux client-side : mêmes règles d'arrondi (Math.round par ligne) que invoice_totals.ts côté serveur — feedback instantané, le serveur recalcule à la persistance. - Footer sticky : "Enregistrer en brouillon" / "Émettre la facture", avec rappel que l'émission consomme la séquence (irréversible). API client - `useCreateNativeInvoice` : POST /invoices/native, invalide les caches invoices + counts. - `previewInvoicePdf(input, signal)` : POST /invoices/preview-pdf qui retourne un Blob (annulable via AbortSignal pour les frappes rapides). - `api.postBlob` : helper générique POST+JSON → Blob (inverse de fetchBlob). Defaults : les settings résolus de l'org (theme, accent, paymentTermsDays) sont chargés une fois au mount et appliqués comme valeurs initiales. Liste factures : remplace le bouton "Nouvelle facture" par deux actions côte-à-côte — "Importer" (secondaire, mène à /factures/import) et "Créer une facture" (primaire, mène à /factures/nouvelle). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0680bb9f77
commit
aa6468e9a0
@ -231,4 +231,45 @@ export const api = {
|
||||
const blob = await res.blob();
|
||||
return { blob, contentType: res.headers.get("content-type") ?? blob.type };
|
||||
},
|
||||
|
||||
/**
|
||||
* POST avec body JSON et réponse binaire. Pendant inverse de `fetchBlob`,
|
||||
* utilisé pour la preview PDF (POST /invoices/preview-pdf renvoie un PDF).
|
||||
*
|
||||
* Mêmes contraintes que fetchBlob : pas de silent refresh sur 401 (le
|
||||
* caller debounced retry de toute façon à chaque keystroke). Le body est
|
||||
* JSON-encoded ; pas de support FormData ici (cas spécifique aux preview).
|
||||
*/
|
||||
postBlob: async (
|
||||
path: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Blob> => {
|
||||
const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/pdf,application/octet-stream",
|
||||
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Le serveur peut renvoyer du JSON `{errors: [...]}` même sur un POST
|
||||
// qui attendait un Blob — on tente de parser pour donner un message utile.
|
||||
const text = await res.text().catch(() => null);
|
||||
let message = `HTTP ${res.status} on ${path}`;
|
||||
try {
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
if (json?.errors?.[0]?.message) message = json.errors[0].message;
|
||||
} catch {
|
||||
// text non-JSON → on garde le message générique
|
||||
}
|
||||
throw new ApiError(res.status, "blob_post_failed", message);
|
||||
}
|
||||
return res.blob();
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CreateNativeInvoiceInput, Invoice, PreviewInvoiceInput } from "@rubis/shared";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export type ImportBatchResponse = {
|
||||
@ -12,3 +15,38 @@ export function uploadInvoiceFiles(files: File[]): Promise<ImportBatchResponse>
|
||||
}
|
||||
return api.post<ImportBatchResponse>("/api/v1/invoices/upload", formData);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /invoices/native — création depuis l'éditeur natif.
|
||||
*
|
||||
* - Pas de `numero` : alloué côté serveur (séquence strict)
|
||||
* - Pas de `amountTtcCents` : recalculé depuis lines
|
||||
* - `draft: true` → la facture est créée mais ne consomme pas la séquence
|
||||
* (numero éphémère "BROUILLON-XXX")
|
||||
*/
|
||||
export function useCreateNativeInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateNativeInvoiceInput) =>
|
||||
api.post<Invoice>("/api/v1/invoices/native", input),
|
||||
onSuccess: () => {
|
||||
// Toute la liste / counts deviennent obsolètes.
|
||||
qc.invalidateQueries({ queryKey: ["invoices"] });
|
||||
qc.invalidateQueries({ queryKey: ["invoice-counts"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /invoices/preview-pdf — stream un PDF sans persister.
|
||||
*
|
||||
* Retourne un Blob à transformer en objectURL côté composant pour l'afficher
|
||||
* dans un <iframe>. À débouncer côté caller (500ms typique) pour ne pas
|
||||
* spammer le serveur pendant la saisie.
|
||||
*/
|
||||
export async function previewInvoicePdf(
|
||||
input: PreviewInvoiceInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Blob> {
|
||||
return api.postBlob("/api/v1/invoices/preview-pdf", input, signal);
|
||||
}
|
||||
|
||||
@ -188,13 +188,21 @@ function FacturesPage() {
|
||||
pour voir la timeline.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" asChild className="shrink-0">
|
||||
<Link to="/factures/import">
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Nouvelle facture</span>
|
||||
<span className="sm:hidden">Nouvelle</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link to="/factures/import">
|
||||
<span className="hidden sm:inline">Importer</span>
|
||||
<span className="sm:hidden">Import</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link to="/factures/nouvelle">
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Créer une facture</span>
|
||||
<span className="sm:hidden">Créer</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlanLimitBanner />
|
||||
|
||||
599
apps/web/src/routes/_app/factures_.nouvelle.tsx
Normal file
599
apps/web/src/routes/_app/factures_.nouvelle.tsx
Normal file
@ -0,0 +1,599 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { addDays } from "date-fns";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
CreateNativeInvoiceInput,
|
||||
InvoiceLineInput,
|
||||
InvoiceThemeSlug,
|
||||
Plan,
|
||||
} from "@rubis/shared";
|
||||
import { FRENCH_TVA_RATES } from "@rubis/shared";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useInvoiceSettings, useInvoiceThemes } from "@/lib/invoice-settings";
|
||||
import { previewInvoicePdf, useCreateNativeInvoice } from "@/lib/invoices";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Button, Card, Eyebrow } from "@rubis/ui";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Textarea } from "@/components/ui/Textarea";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { ClientCombobox } from "@/components/factures/ClientCombobox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/_app/factures_/nouvelle")({
|
||||
component: FacturesNouvellePage,
|
||||
});
|
||||
|
||||
const newLine = (): InvoiceLineInput => ({
|
||||
id: crypto.randomUUID(),
|
||||
description: "",
|
||||
quantity: 1,
|
||||
unitPriceCents: 0,
|
||||
tvaRate: 20,
|
||||
});
|
||||
|
||||
function FacturesNouvellePage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: settings } = useInvoiceSettings();
|
||||
const { data: themes } = useInvoiceThemes();
|
||||
const { data: plans } = useQuery({
|
||||
queryKey: ["plans"],
|
||||
queryFn: () => api.get<Plan[]>("/api/v1/plans"),
|
||||
});
|
||||
|
||||
// ============== Form state ==============
|
||||
const [clientName, setClientName] = useState("");
|
||||
const [clientId, setClientId] = useState<string | null>(null);
|
||||
|
||||
const todayIso = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
||||
const [issueDate, setIssueDate] = useState(todayIso);
|
||||
const [paymentTermsDays, setPaymentTermsDays] = useState(30);
|
||||
|
||||
const [planId, setPlanId] = useState<string | null>(null);
|
||||
const [themeSlug, setThemeSlug] = useState<InvoiceThemeSlug>("classique");
|
||||
const [accentColor, setAccentColor] = useState("#9F1239");
|
||||
|
||||
const [lines, setLines] = useState<InvoiceLineInput[]>([newLine()]);
|
||||
const [footerNotes, setFooterNotes] = useState("");
|
||||
|
||||
// Initialize defaults from invoice-settings (one-shot, après load).
|
||||
const settingsInitDone = useRef(false);
|
||||
useEffect(() => {
|
||||
if (settings?.resolved && !settingsInitDone.current) {
|
||||
setThemeSlug(settings.resolved.themeSlug);
|
||||
setAccentColor(settings.resolved.accentColor);
|
||||
setPaymentTermsDays(settings.resolved.paymentTermsDays);
|
||||
settingsInitDone.current = true;
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const dueDateIso = useMemo(
|
||||
() => addDays(new Date(issueDate), paymentTermsDays).toISOString().slice(0, 10),
|
||||
[issueDate, paymentTermsDays],
|
||||
);
|
||||
|
||||
// ============== Totals (côté client pour feedback instantané) ==============
|
||||
const totals = useMemo(() => computeTotals(lines), [lines]);
|
||||
|
||||
// ============== Preview PDF debounced ==============
|
||||
const { previewUrl, previewError, isRefreshing } = usePreview({
|
||||
clientId,
|
||||
issueDate,
|
||||
paymentTermsDays,
|
||||
themeSlug,
|
||||
accentColor,
|
||||
lines,
|
||||
footerNotes,
|
||||
});
|
||||
|
||||
// ============== Submit ==============
|
||||
const create = useCreateNativeInvoice();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (draft: boolean) => {
|
||||
if (!clientId) {
|
||||
setSubmitError("Sélectionnez un client.");
|
||||
return;
|
||||
}
|
||||
setSubmitError(null);
|
||||
const payload: CreateNativeInvoiceInput = {
|
||||
clientId,
|
||||
issueDate: new Date(issueDate).toISOString(),
|
||||
dueDate: new Date(dueDateIso).toISOString(),
|
||||
paymentTermsDays,
|
||||
planId: planId ?? undefined,
|
||||
themeSlug,
|
||||
accentColor,
|
||||
lines,
|
||||
footerNotes: footerNotes.trim() === "" ? null : footerNotes,
|
||||
draft,
|
||||
};
|
||||
try {
|
||||
const invoice = await create.mutateAsync(payload);
|
||||
navigate({ to: "/factures/$id", params: { id: invoice.id } });
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
!!clientId &&
|
||||
lines.length > 0 &&
|
||||
lines.every((l) => l.description.trim() !== "" && l.unitPriceCents >= 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<Button size="sm" variant="ghost" asChild className="mb-3 -ml-2">
|
||||
<Link to="/factures">
|
||||
<ArrowLeft size={14} aria-hidden="true" /> Retour aux factures
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
|
||||
Nouvelle facture
|
||||
</h1>
|
||||
<p className="mt-1 text-[13.5px] text-ink-3">
|
||||
Composez votre facture, l'aperçu se rafraîchit automatiquement.
|
||||
L'émission alloue le prochain numéro de la séquence ; le brouillon
|
||||
conserve un numéro éphémère.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
{/* ========== Édition ========== */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card padding="md" className="flex flex-col gap-4">
|
||||
<Eyebrow>Destinataire</Eyebrow>
|
||||
<Field label="Client" hint="Recherchez ou créez à la volée">
|
||||
<ClientCombobox
|
||||
value={clientName}
|
||||
selectedClientId={clientId}
|
||||
onChange={({ value, clientId }) => {
|
||||
setClientName(value);
|
||||
setClientId(clientId);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card padding="md" className="flex flex-col gap-4">
|
||||
<Eyebrow>Dates & paiement</Eyebrow>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="Émise le">
|
||||
<Input
|
||||
type="date"
|
||||
value={issueDate}
|
||||
onChange={(e) => setIssueDate(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Délai" hint="Jours">
|
||||
<Input
|
||||
type="number"
|
||||
value={paymentTermsDays}
|
||||
onChange={(e) =>
|
||||
setPaymentTermsDays(Number(e.target.value) || 0)
|
||||
}
|
||||
min={0}
|
||||
max={365}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Échéance" hint="Calculée automatiquement">
|
||||
<Input type="date" value={dueDateIso} readOnly className="bg-cream-2" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="Plan de relance"
|
||||
hint="Optionnel — si associé, Rubis programmera le check-in puis les relances à l'échéance."
|
||||
>
|
||||
<select
|
||||
value={planId ?? ""}
|
||||
onChange={(e) => setPlanId(e.target.value || null)}
|
||||
className="block w-full rounded-default border border-line bg-white px-3.5 py-3 text-base lg:text-[15px] text-ink"
|
||||
>
|
||||
<option value="">Aucun plan (relances manuelles)</option>
|
||||
{(plans ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card padding="md" className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Eyebrow>Lignes</Eyebrow>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setLines((ls) => [...ls, newLine()])}
|
||||
>
|
||||
<Plus size={14} aria-hidden="true" /> Ajouter une ligne
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<LinesEditor lines={lines} setLines={setLines} />
|
||||
|
||||
<TotalsBlock totals={totals} />
|
||||
</Card>
|
||||
|
||||
<Card padding="md" className="flex flex-col gap-4">
|
||||
<Eyebrow>Thème & accent</Eyebrow>
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{(themes ?? []).map((t) => (
|
||||
<button
|
||||
key={t.slug}
|
||||
type="button"
|
||||
onClick={() => setThemeSlug(t.slug)}
|
||||
className={cn(
|
||||
"rounded-default border bg-white px-3 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||
themeSlug === t.slug
|
||||
? "border-rubis ring-4 ring-rubis-glow text-ink"
|
||||
: "border-line text-ink-2 hover:border-rubis-light",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between">
|
||||
{t.name}
|
||||
{themeSlug === t.slug ? (
|
||||
<Check size={14} className="text-rubis" aria-hidden="true" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Field label="Couleur d'accent" hint="Hex #RRGGBB">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => setAccentColor(e.target.value)}
|
||||
className="h-11 w-16 cursor-pointer rounded-default border border-line bg-white p-1"
|
||||
aria-label="Choisir la couleur d'accent"
|
||||
/>
|
||||
<Input
|
||||
value={accentColor}
|
||||
onChange={(e) => setAccentColor(e.target.value)}
|
||||
maxLength={7}
|
||||
className="font-mono lg:max-w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<Eyebrow>Notes</Eyebrow>
|
||||
<Textarea
|
||||
value={footerNotes}
|
||||
onChange={(e) => setFooterNotes(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder="Notes affichées en pied de facture (références projet, conditions spécifiques…)"
|
||||
className="mt-2"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ========== Preview ========== */}
|
||||
<div className="lg:sticky lg:top-4 lg:self-start">
|
||||
<Card padding="sm" className="flex h-[calc(100vh-160px)] flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<Eyebrow>Aperçu</Eyebrow>
|
||||
{isRefreshing ? (
|
||||
<span className="flex items-center gap-1 text-[11px] text-ink-3">
|
||||
<Loader2 size={11} className="animate-spin" /> Génération…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{previewError ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-default border border-dashed border-line bg-cream-2 p-6 text-center text-[13px] text-ink-3">
|
||||
{previewError}
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
title="Aperçu de la facture"
|
||||
className="flex-1 w-full rounded-default border border-line bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center rounded-default border border-dashed border-line bg-cream-2 p-6 text-center text-[13px] text-ink-3">
|
||||
Sélectionnez un client et au moins une ligne pour générer l'aperçu.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== Footer actions ========== */}
|
||||
<div className="sticky bottom-0 -mx-4 mt-2 border-t border-line bg-cream/95 px-4 py-3 backdrop-blur lg:-mx-6 lg:px-6">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{submitError ? (
|
||||
<p className="mr-auto text-[13px] font-medium text-rubis-deep">
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={() => onSubmit(true)}
|
||||
disabled={!canSubmit || create.isPending}
|
||||
>
|
||||
<Save size={14} aria-hidden="true" /> Enregistrer en brouillon
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={() => onSubmit(false)}
|
||||
disabled={!canSubmit || create.isPending}
|
||||
>
|
||||
<Send size={14} aria-hidden="true" /> Émettre la facture
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-right text-[11.5px] text-ink-3">
|
||||
Émettre = alloue le prochain numéro de la séquence (irréversible).
|
||||
Brouillon = conserve un numéro éphémère, modifiable plus tard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LinesEditor — table éditable avec add/remove (drag-and-drop = V2)
|
||||
// ============================================================================
|
||||
|
||||
function LinesEditor({
|
||||
lines,
|
||||
setLines,
|
||||
}: {
|
||||
lines: InvoiceLineInput[];
|
||||
setLines: (next: InvoiceLineInput[] | ((prev: InvoiceLineInput[]) => InvoiceLineInput[])) => void;
|
||||
}) {
|
||||
const update = (id: string, patch: Partial<InvoiceLineInput>) => {
|
||||
setLines((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)));
|
||||
};
|
||||
const remove = (id: string) => {
|
||||
setLines((ls) => ls.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Header */}
|
||||
<div className="hidden grid-cols-[24px_1fr_80px_120px_80px_120px_28px] gap-2 px-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3 sm:grid">
|
||||
<span aria-hidden="true" />
|
||||
<span>Désignation</span>
|
||||
<span className="text-right">Qté</span>
|
||||
<span className="text-right">P.U. HT</span>
|
||||
<span className="text-right">TVA</span>
|
||||
<span className="text-right">Total HT</span>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
{lines.map((line) => {
|
||||
const totalHt = Math.round(line.quantity * line.unitPriceCents);
|
||||
return (
|
||||
<div
|
||||
key={line.id}
|
||||
className="grid grid-cols-1 gap-2 rounded-default border border-line bg-white p-2 sm:grid-cols-[24px_1fr_80px_120px_80px_120px_28px] sm:items-center sm:p-1.5"
|
||||
>
|
||||
<span className="hidden text-ink-3 sm:flex sm:justify-center" aria-hidden="true">
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
<Input
|
||||
value={line.description}
|
||||
onChange={(e) => update(line.id, { description: e.target.value })}
|
||||
placeholder="Désignation"
|
||||
className="text-[14px]"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.quantity}
|
||||
onChange={(e) => update(line.id, { quantity: Number(e.target.value) || 0 })}
|
||||
step="0.5"
|
||||
min={0}
|
||||
className="text-right text-[14px]"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={(line.unitPriceCents / 100).toFixed(2)}
|
||||
onChange={(e) =>
|
||||
update(line.id, {
|
||||
unitPriceCents: Math.round((Number(e.target.value) || 0) * 100),
|
||||
})
|
||||
}
|
||||
step="0.01"
|
||||
min={0}
|
||||
className="text-right text-[14px]"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
<select
|
||||
value={line.tvaRate}
|
||||
onChange={(e) => update(line.id, { tvaRate: Number(e.target.value) })}
|
||||
className="rounded-default border border-line bg-white px-2 py-2 text-[14px] text-ink"
|
||||
>
|
||||
{FRENCH_TVA_RATES.map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{Number.isInteger(rate) ? `${rate} %` : `${rate.toString().replace(".", ",")} %`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-right font-mono text-[14px] tabular-nums text-ink">
|
||||
{(totalHt / 100).toLocaleString("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(line.id)}
|
||||
disabled={lines.length === 1}
|
||||
className="text-ink-3 hover:text-rubis-deep disabled:opacity-30"
|
||||
aria-label="Supprimer cette ligne"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TotalsBlock — récapitulatif HT / TVA / TTC
|
||||
// ============================================================================
|
||||
|
||||
function TotalsBlock({
|
||||
totals,
|
||||
}: {
|
||||
totals: {
|
||||
amountHtCents: number;
|
||||
amountTvaCents: number;
|
||||
amountTtcCents: number;
|
||||
};
|
||||
}) {
|
||||
const fmt = (cents: number) =>
|
||||
(cents / 100).toLocaleString("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-1 self-end rounded-default border border-line bg-cream-2 px-4 py-3 text-[14px] sm:min-w-[280px]">
|
||||
<div className="flex justify-between text-ink-2">
|
||||
<span>Total HT</span>
|
||||
<span className="font-mono tabular-nums">{fmt(totals.amountHtCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-ink-2">
|
||||
<span>TVA</span>
|
||||
<span className="font-mono tabular-nums">{fmt(totals.amountTvaCents)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between border-t border-line pt-2 font-display font-bold text-ink">
|
||||
<span>Total TTC</span>
|
||||
<span className="font-mono tabular-nums">{fmt(totals.amountTtcCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// computeTotals — mêmes règles d'arrondi que le serveur (cf. invoice_totals.ts)
|
||||
// ============================================================================
|
||||
|
||||
function computeTotals(lines: InvoiceLineInput[]) {
|
||||
let amountHt = 0;
|
||||
let amountTva = 0;
|
||||
for (const line of lines) {
|
||||
const htCents = Math.round(line.quantity * line.unitPriceCents);
|
||||
const tvaCents = Math.round((htCents * line.tvaRate) / 100);
|
||||
amountHt += htCents;
|
||||
amountTva += tvaCents;
|
||||
}
|
||||
return {
|
||||
amountHtCents: amountHt,
|
||||
amountTvaCents: amountTva,
|
||||
amountTtcCents: amountHt + amountTva,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// usePreview — POST /invoices/preview-pdf debounced, objectURL pour iframe
|
||||
// ============================================================================
|
||||
|
||||
function usePreview(fields: {
|
||||
clientId: string | null;
|
||||
issueDate: string;
|
||||
paymentTermsDays: number;
|
||||
themeSlug: InvoiceThemeSlug;
|
||||
accentColor: string;
|
||||
lines: InvoiceLineInput[];
|
||||
footerNotes: string;
|
||||
}) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const previousUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Debounce 500ms — on attend que la frappe ralentisse avant de POST.
|
||||
const debounced = useDebouncedValue(fields, 500);
|
||||
|
||||
useEffect(() => {
|
||||
// Pré-conditions : un client sélectionné et au moins une ligne non-vide.
|
||||
const hasUsableLines = debounced.lines.some(
|
||||
(l) => l.description.trim() !== "" && l.unitPriceCents > 0,
|
||||
);
|
||||
if (!debounced.clientId || !hasUsableLines) {
|
||||
setPreviewUrl(null);
|
||||
setPreviewError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setIsRefreshing(true);
|
||||
setPreviewError(null);
|
||||
|
||||
const dueDate = addDays(
|
||||
new Date(debounced.issueDate),
|
||||
debounced.paymentTermsDays,
|
||||
).toISOString();
|
||||
|
||||
previewInvoicePdf(
|
||||
{
|
||||
clientId: debounced.clientId,
|
||||
issueDate: new Date(debounced.issueDate).toISOString(),
|
||||
dueDate,
|
||||
paymentTermsDays: debounced.paymentTermsDays,
|
||||
themeSlug: debounced.themeSlug,
|
||||
accentColor: debounced.accentColor,
|
||||
lines: debounced.lines,
|
||||
footerNotes:
|
||||
debounced.footerNotes.trim() === "" ? null : debounced.footerNotes,
|
||||
},
|
||||
ctrl.signal,
|
||||
)
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (previousUrlRef.current) URL.revokeObjectURL(previousUrlRef.current);
|
||||
previousUrlRef.current = url;
|
||||
setPreviewUrl(url);
|
||||
setIsRefreshing(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.name === "AbortError") return;
|
||||
setIsRefreshing(false);
|
||||
setPreviewError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "Aperçu indisponible — vérifiez les champs.",
|
||||
);
|
||||
});
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, [debounced]);
|
||||
|
||||
// Cleanup au démontage.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previousUrlRef.current) URL.revokeObjectURL(previousUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { previewUrl, previewError, isRefreshing };
|
||||
}
|
||||
|
||||
/** useDebouncedValue — version générique de debounce sur une valeur. */
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user