From 0680bb9f77bcf5eda60dca8f7d574a8d52ceda91 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 14 May 2026 02:32:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20page=20/parametres/facturation=20?= =?UTF-8?q?=E2=80=94=20param=C3=A9trage=20de=20la=20facturation=20(Phase?= =?UTF-8?q?=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la page de configuration de l'éditeur de factures natif côté SPA, plus les hooks React Query pour /organizations/me/invoice-settings et /invoice-themes. Sections (chacune avec son propre Save → blast radius clair) : 1. Identité émetteur (raison sociale, forme juridique, adresse structurée, SIREN/SIRET/TVA intracom, NAF, RCS, capital, contact). Snapshotée à chaque émission dans `invoices.issuer_snapshot` — modifier ces champs n'altère pas les factures déjà émises. 2. RIB (IBAN normalisé à l'enregistrement, BIC, nom de banque). 3. Numérotation avec aperçu live "FAC-2026-0042" — préfixe + prochain numéro + padding éditables. Une fois la première facture émise, le compteur s'auto-incrémente. 4. Mentions & délais — délai de paiement par défaut + textes légaux préremplis (pénalités art. L441-10, escompte art. L441-9, libre). 5. Thème par défaut + couleur d'accent — galerie 4 thèmes avec previews miniatures CSS (pas de PDF embed pour la galerie : trop lourd). Ajoute aussi le lien vers /parametres/facturation depuis /parametres (section "Facturation" placée avant "Marque"). Co-Authored-By: Claude Opus 4.7 --- apps/web/src/lib/invoice-settings.ts | 60 ++ apps/web/src/routes/_app/parametres.tsx | 30 +- .../routes/_app/parametres_.facturation.tsx | 766 ++++++++++++++++++ 3 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/invoice-settings.ts create mode 100644 apps/web/src/routes/_app/parametres_.facturation.tsx diff --git a/apps/web/src/lib/invoice-settings.ts b/apps/web/src/lib/invoice-settings.ts new file mode 100644 index 0000000..ae60e04 --- /dev/null +++ b/apps/web/src/lib/invoice-settings.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { InvoiceSettings, ResolvedInvoiceSettings, InvoiceTheme } from "@rubis/shared"; + +import { api } from "@/lib/api"; + +/** + * Réponse du GET /organizations/me/invoice-settings : + * - `settings` : raw JSONB (potentiellement {} pour une org neuve) + * - `resolved` : settings résolus avec defaults (consommables par l'éditeur + * et utilisés côté preview pour rendre les mêmes valeurs que le PDF final) + */ +export type InvoiceSettingsState = { + settings: InvoiceSettings; + resolved: ResolvedInvoiceSettings; +}; + +const invoiceSettingsKey = ["invoice-settings"] as const; +const invoiceThemesKey = ["invoice-themes"] as const; + +export function useInvoiceSettings() { + return useQuery({ + queryKey: invoiceSettingsKey, + queryFn: () => + api.get( + "/api/v1/organizations/me/invoice-settings", + ), + staleTime: 30_000, + }); +} + +/** + * PATCH partiel. Sémantique cohérente avec brand_settings : + * - clé à `null` explicite = reset au default sur ce champ précis + * - clé absente = laisse intact + * + * Les objets imbriqués (`issuer`, `rib`) suivent la même règle en deep merge. + */ +export function useUpdateInvoiceSettings() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (patch: Partial) => + api.patch( + "/api/v1/organizations/me/invoice-settings", + patch, + ), + onSuccess: (next) => { + qc.setQueryData(invoiceSettingsKey, next); + }, + }); +} + +/** GET /invoice-themes — liste statique des 4 thèmes. */ +export function useInvoiceThemes() { + return useQuery({ + queryKey: invoiceThemesKey, + queryFn: () => api.get("/api/v1/invoice-themes"), + // Les thèmes sont statiques côté serveur — pas la peine de re-fetch. + staleTime: Infinity, + }); +} diff --git a/apps/web/src/routes/_app/parametres.tsx b/apps/web/src/routes/_app/parametres.tsx index ba60c95..4b714e5 100644 --- a/apps/web/src/routes/_app/parametres.tsx +++ b/apps/web/src/routes/_app/parametres.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { ArrowRight, CreditCard, Palette } from "lucide-react"; +import { ArrowRight, CreditCard, FileText, Palette } from "lucide-react"; import { z } from "zod"; import { SettingsSection } from "@/components/settings/SettingsSection"; @@ -126,6 +126,34 @@ function ParametresPage() { + + Vos factures, votre identité + + } + description="Identité émetteur, RIB, mentions légales et thème par défaut pour les factures que vous créez dans Rubis. Mis à jour à la prochaine émission." + > + +
+

+ Éditeur de factures +

+

+ Paramétrer la facturation +

+
+ +
+
+ +
+ +

+ Facturation +

+

+ Identité émetteur, RIB, mentions légales et thème par défaut. Tout est + snapshoté à l'émission — modifier ces paramètres n'altère pas les + factures déjà émises. +

+
+ + {isPending || !data ? ( + + + Chargement… + + ) : ( +
+ + } + /> + + + + } /> + + + + + + + + + + + + + +
+ )} + + ); +} + +// ============================================================================ +// Section 1 — Identité émetteur +// ============================================================================ + +function IssuerForm({ issuer }: { issuer: Partial }) { + const update = useUpdateInvoiceSettings(); + const [draft, setDraft] = useState>(issuer); + + useEffect(() => { + setDraft(issuer); + }, [issuer]); + + const isDirty = useMemo( + () => JSON.stringify(draft) !== JSON.stringify(issuer), + [draft, issuer], + ); + + const set = (key: K, value: string) => { + setDraft((d) => ({ ...d, [key]: value.trim() === "" ? null : value })); + }; + + return ( + +
+ + set("companyName", e.target.value)} + placeholder="Cabinet Compta Martin" + /> + + + set("formeJuridique", e.target.value)} + placeholder="SARL" + /> + +
+ +
+ + set("addressLine1", e.target.value)} + placeholder="12 rue du Pain" + /> + + + set("addressLine2", e.target.value)} + placeholder="Bâtiment B" + /> + +
+ +
+ + set("addressZip", e.target.value)} + placeholder="75011" + /> + + + set("addressCity", e.target.value)} + placeholder="Paris" + /> + + + set("addressCountry", e.target.value.toUpperCase())} + placeholder="FR" + maxLength={2} + /> + +
+ +
+ + set("siren", e.target.value)} + placeholder="123456789" + inputMode="numeric" + /> + + + set("siret", e.target.value)} + placeholder="12345678900012" + inputMode="numeric" + /> + +
+ +
+ + set("tvaIntra", e.target.value.toUpperCase())} + placeholder="FR12345678901" + /> + + + set("naf", e.target.value.toUpperCase())} + placeholder="6201Z" + maxLength={5} + /> + +
+ +
+ + set("rcs", e.target.value)} + placeholder="RCS Paris 123 456 789" + /> + + + set("capital", e.target.value)} + placeholder="SARL au capital de 1 000 €" + /> + +
+ +
+ + set("contactEmail", e.target.value)} + placeholder="facturation@cabinet-martin.fr" + /> + + + set("contactPhone", e.target.value)} + placeholder="+33 1 23 45 67 89" + /> + +
+ + update.mutate({ issuer: draft })} + /> +
+ ); +} + +// ============================================================================ +// Section 2 — RIB +// ============================================================================ + +function RibForm({ rib }: { rib: Partial }) { + const update = useUpdateInvoiceSettings(); + const [draft, setDraft] = useState>(rib); + + useEffect(() => setDraft(rib), [rib]); + + const isDirty = useMemo( + () => JSON.stringify(draft) !== JSON.stringify(rib), + [draft, rib], + ); + + const set = (key: K, value: string) => { + setDraft((d) => ({ ...d, [key]: value.trim() === "" ? null : value })); + }; + + return ( + + + set("bankName", e.target.value)} + placeholder="BNP Paribas — Agence République" + /> + +
+ + set("iban", e.target.value.toUpperCase())} + placeholder="FR76 1234 5678 9012 3456 7890 123" + /> + + + set("bic", e.target.value.toUpperCase())} + placeholder="BNPAFRPPXXX" + /> + +
+ update.mutate({ rib: draft })} + /> +
+ ); +} + +// ============================================================================ +// Section 3 — Numérotation +// ============================================================================ + +function NumeroForm({ + settings, + numeroNextSeq, + numeroPadding, + numeroPrefix, +}: { + settings: InvoiceSettings; + numeroNextSeq: number; + numeroPadding: number; + numeroPrefix: string; +}) { + const update = useUpdateInvoiceSettings(); + const [prefix, setPrefix] = useState(numeroPrefix); + const [nextSeq, setNextSeq] = useState(numeroNextSeq); + const [padding, setPadding] = useState(numeroPadding); + + useEffect(() => setPrefix(numeroPrefix), [numeroPrefix]); + useEffect(() => setNextSeq(numeroNextSeq), [numeroNextSeq]); + useEffect(() => setPadding(numeroPadding), [numeroPadding]); + + const isDirty = + prefix !== numeroPrefix || + nextSeq !== numeroNextSeq || + padding !== numeroPadding; + + const preview = `${prefix}${String(nextSeq).padStart(padding, "0")}`; + + return ( + +
+ + setPrefix(e.target.value)} + placeholder="FAC-2026-" + maxLength={40} + /> + + + setNextSeq(Number(e.target.value) || 1)} + min={1} + max={9_999_999} + /> + + + setPadding(Number(e.target.value) || 1)} + min={1} + max={10} + /> + +
+ +
+ Aperçu +

{preview}

+

+ Le numéro suivant sera{" "} + + {prefix} + {String(nextSeq + 1).padStart(padding, "0")} + + . +

+
+ + + update.mutate({ + numeroPrefix: prefix, + numeroNextSeq: nextSeq, + numeroPadding: padding, + }) + } + /> +
+ ); +} + +// ============================================================================ +// Section 4 — Mentions & délais +// ============================================================================ + +function MentionsForm({ + resolvedPaymentTermsDays, + resolvedPenalty, + resolvedEscompte, + resolvedFooter, +}: { + resolvedPaymentTermsDays: number; + resolvedPenalty: string; + resolvedEscompte: string; + resolvedFooter: string; +}) { + const update = useUpdateInvoiceSettings(); + const [days, setDays] = useState(resolvedPaymentTermsDays); + const [penalty, setPenalty] = useState(resolvedPenalty); + const [escompte, setEscompte] = useState(resolvedEscompte); + const [footer, setFooter] = useState(resolvedFooter); + + useEffect(() => setDays(resolvedPaymentTermsDays), [resolvedPaymentTermsDays]); + useEffect(() => setPenalty(resolvedPenalty), [resolvedPenalty]); + useEffect(() => setEscompte(resolvedEscompte), [resolvedEscompte]); + useEffect(() => setFooter(resolvedFooter), [resolvedFooter]); + + const isDirty = + days !== resolvedPaymentTermsDays || + penalty !== resolvedPenalty || + escompte !== resolvedEscompte || + footer !== resolvedFooter; + + return ( + + + setDays(Number(e.target.value) || 0)} + min={0} + max={365} + className="lg:max-w-[200px]" + /> + + + +