diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index d064ecf..779ffd9 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -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 => {
+ 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();
+ },
};
diff --git a/apps/web/src/lib/invoices.ts b/apps/web/src/lib/invoices.ts
index d26ddf8..c530de9 100644
--- a/apps/web/src/lib/invoices.ts
+++ b/apps/web/src/lib/invoices.ts
@@ -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
}
return api.post("/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("/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
-
-
-
- Nouvelle facture
- Nouvelle
-
-
+
+
+
+ Importer
+ Import
+
+
+
+
+
+ Créer une facture
+ Créer
+
+
+
diff --git a/apps/web/src/routes/_app/factures_.nouvelle.tsx b/apps/web/src/routes/_app/factures_.nouvelle.tsx
new file mode 100644
index 0000000..a78e50c
--- /dev/null
+++ b/apps/web/src/routes/_app/factures_.nouvelle.tsx
@@ -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("/api/v1/plans"),
+ });
+
+ // ============== Form state ==============
+ const [clientName, setClientName] = useState("");
+ const [clientId, setClientId] = useState(null);
+
+ const todayIso = useMemo(() => new Date().toISOString().slice(0, 10), []);
+ const [issueDate, setIssueDate] = useState(todayIso);
+ const [paymentTermsDays, setPaymentTermsDays] = useState(30);
+
+ const [planId, setPlanId] = useState(null);
+ const [themeSlug, setThemeSlug] = useState("classique");
+ const [accentColor, setAccentColor] = useState("#9F1239");
+
+ const [lines, setLines] = useState([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(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 (
+
+
+
+
+ {/* ========== Édition ========== */}
+
+
+ {/* ========== Preview ========== */}
+
+
+
+ Aperçu
+ {isRefreshing ? (
+
+ Génération…
+
+ ) : null}
+
+ {previewError ? (
+
+ {previewError}
+
+ ) : previewUrl ? (
+
+ ) : (
+
+ Sélectionnez un client et au moins une ligne pour générer l'aperçu.
+
+ )}
+
+
+
+
+ {/* ========== Footer actions ========== */}
+
+
+ {submitError ? (
+
+ {submitError}
+
+ ) : null}
+
onSubmit(true)}
+ disabled={!canSubmit || create.isPending}
+ >
+ Enregistrer en brouillon
+
+
onSubmit(false)}
+ disabled={!canSubmit || create.isPending}
+ >
+ Émettre la facture
+
+
+
+ Émettre = alloue le prochain numéro de la séquence (irréversible).
+ Brouillon = conserve un numéro éphémère, modifiable plus tard.
+
+
+
+ );
+}
+
+// ============================================================================
+// 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) => {
+ setLines((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)));
+ };
+ const remove = (id: string) => {
+ setLines((ls) => ls.filter((l) => l.id !== id));
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ Désignation
+ Qté
+ P.U. HT
+ TVA
+ Total HT
+
+
+ {lines.map((line) => {
+ const totalHt = Math.round(line.quantity * line.unitPriceCents);
+ return (
+
+
+
+
+
update(line.id, { description: e.target.value })}
+ placeholder="Désignation"
+ className="text-[14px]"
+ />
+
update(line.id, { quantity: Number(e.target.value) || 0 })}
+ step="0.5"
+ min={0}
+ className="text-right text-[14px]"
+ />
+
+ 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"
+ />
+
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) => (
+
+ {Number.isInteger(rate) ? `${rate} %` : `${rate.toString().replace(".", ",")} %`}
+
+ ))}
+
+
+ {(totalHt / 100).toLocaleString("fr-FR", {
+ style: "currency",
+ currency: "EUR",
+ })}
+
+
remove(line.id)}
+ disabled={lines.length === 1}
+ className="text-ink-3 hover:text-rubis-deep disabled:opacity-30"
+ aria-label="Supprimer cette ligne"
+ >
+
+
+
+ );
+ })}
+
+ );
+}
+
+// ============================================================================
+// 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 (
+
+
+ Total HT
+ {fmt(totals.amountHtCents)}
+
+
+ TVA
+ {fmt(totals.amountTvaCents)}
+
+
+ Total TTC
+ {fmt(totals.amountTtcCents)}
+
+
+ );
+}
+
+// ============================================================================
+// 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(null);
+ const [previewError, setPreviewError] = useState(null);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const previousUrlRef = useRef(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(value: T, delayMs: number): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const id = setTimeout(() => setDebounced(value), delayMs);
+ return () => clearTimeout(id);
+ }, [value, delayMs]);
+ return debounced;
+}