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