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