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:
ordinarthur 2026-05-14 03:07:41 +02:00
parent 0680bb9f77
commit aa6468e9a0
4 changed files with 693 additions and 7 deletions

View File

@ -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();
},
};

View File

@ -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);
}

View File

@ -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 />

View 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;
}