From aa6468e9a067973da5b18b3fa3d0a8394649079b Mon Sep 17 00:00:00 2001
From: ordinarthur <@arthurbarre.js@gmail.com>
Date: Thu, 14 May 2026 03:07:41 +0200
Subject: [PATCH] =?UTF-8?q?feat(web):=20=C3=A9diteur=20de=20factures=20/fa?=
=?UTF-8?q?ctures/nouvelle=20(Phase=204)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
apps/web/src/lib/api.ts | 41 ++
apps/web/src/lib/invoices.ts | 38 ++
apps/web/src/routes/_app/factures.tsx | 22 +-
.../src/routes/_app/factures_.nouvelle.tsx | 599 ++++++++++++++++++
4 files changed, 693 insertions(+), 7 deletions(-)
create mode 100644 apps/web/src/routes/_app/factures_.nouvelle.tsx
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
+ 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.
+