feat(web): page /parametres/facturation — paramétrage de la facturation (Phase 3)

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 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-14 02:32:07 +02:00
parent ab07cd4a3b
commit 0680bb9f77
3 changed files with 855 additions and 1 deletions

View File

@ -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<InvoiceSettingsState>(
"/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<InvoiceSettings>) =>
api.patch<InvoiceSettingsState>(
"/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<InvoiceTheme[]>("/api/v1/invoice-themes"),
// Les thèmes sont statiques côté serveur — pas la peine de re-fetch.
staleTime: Infinity,
});
}

View File

@ -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() {
</Card>
</SettingsSection>
<SettingsSection
eyebrow="Facturation"
title={
<>
Vos factures, <em className="text-rubis">votre identité</em>
</>
}
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."
>
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Éditeur de factures
</p>
<p className="mt-1 font-display text-[18px] font-bold text-ink">
Paramétrer la facturation
</p>
</div>
<Button size="sm" variant="secondary" asChild>
<Link to="/parametres/facturation">
<FileText size={14} aria-hidden="true" />
Configurer
<ArrowRight size={13} aria-hidden="true" />
</Link>
</Button>
</Card>
</SettingsSection>
<SettingsSection
eyebrow="Marque"
title={

View File

@ -0,0 +1,766 @@
import { useEffect, useMemo, useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, Check, Loader2 } from "lucide-react";
import type {
InvoiceIssuer,
InvoiceRib,
InvoiceSettings,
InvoiceThemeSlug,
} from "@rubis/shared";
import {
useInvoiceSettings,
useUpdateInvoiceSettings,
useInvoiceThemes,
} from "@/lib/invoice-settings";
import { SettingsSection } from "@/components/settings/SettingsSection";
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 { cn } from "@/lib/utils";
export const Route = createFileRoute("/_app/parametres_/facturation")({
component: ParametresFacturationPage,
});
/**
* /parametres/facturation paramétrage de l'éditeur de factures natif.
*
* Cinq sections autonomes (chacune son save) :
* 1. Identité émetteur (figée dans chaque facture émise)
* 2. RIB (pied de page paiement)
* 3. Numérotation (préfixe + compteur + padding, prévisualisation live)
* 4. Mentions & délais (texte légal + jours de paiement par défaut)
* 5. Thème par défaut + couleur d'accent
*
* Convention d'enregistrement : chaque section sauve indépendamment via
* un PATCH partiel. Cohérent avec l'idée "modifier ses mentions ne
* sauvegarde pas son RIB" (clarté du blast radius).
*/
function ParametresFacturationPage() {
const { data, isPending } = useInvoiceSettings();
const { data: themes } = useInvoiceThemes();
return (
<div className="flex flex-col gap-2">
<header className="mb-4">
<Button size="sm" variant="ghost" asChild className="mb-3 -ml-2">
<Link to="/parametres">
<ArrowLeft size={14} aria-hidden="true" /> Retour aux paramètres
</Link>
</Button>
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Facturation
</h1>
<p className="mt-1.5 text-[14px] text-ink-3">
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.
</p>
</header>
{isPending || !data ? (
<Card padding="md" className="text-center text-ink-3">
<Loader2 className="mx-auto mb-2 animate-spin" size={20} />
Chargement
</Card>
) : (
<div className="flex flex-col gap-10 lg:gap-12">
<SettingsSection
eyebrow="Identité"
title="Vos informations légales"
description="Nom commercial, adresse, SIREN/SIRET, TVA intracommunautaire et autres mentions obligatoires. Apparaissent dans l'entête de chaque facture émise."
>
<IssuerForm
issuer={(data.settings.issuer ?? {}) as Partial<InvoiceIssuer>}
/>
</SettingsSection>
<SettingsSection
eyebrow="RIB"
title="Coordonnées de paiement"
description="IBAN, BIC et nom de banque affichés en pied de facture pour faciliter le virement entrant."
>
<RibForm rib={(data.settings.rib ?? {}) as Partial<InvoiceRib>} />
</SettingsSection>
<SettingsSection
eyebrow="Numérotation"
title="Préfixe et séquence"
description="Chronologie strictement séquentielle par organisation (art. 242 nonies A du CGI). Le compteur s'incrémente automatiquement à chaque facture émise."
>
<NumeroForm
settings={data.settings}
numeroNextSeq={data.resolved.numeroNextSeq}
numeroPadding={data.resolved.numeroPadding}
numeroPrefix={data.resolved.numeroPrefix}
/>
</SettingsSection>
<SettingsSection
eyebrow="Mentions"
title="Délais & textes légaux"
description="Délai de paiement par défaut, pénalités de retard et escompte (mentions obligatoires Code de commerce art. L441-9 et L441-10)."
>
<MentionsForm
resolvedPaymentTermsDays={data.resolved.paymentTermsDays}
resolvedPenalty={data.resolved.penaltyRateText}
resolvedEscompte={data.resolved.escompteText}
resolvedFooter={data.resolved.footerLegalText}
/>
</SettingsSection>
<SettingsSection
eyebrow="Thème"
title="Apparence par défaut"
description="Choisissez le thème et la couleur d'accent qui s'appliquent par défaut aux nouvelles factures. Modifiable par facture dans l'éditeur."
>
<ThemeForm
themes={themes ?? []}
currentThemeSlug={data.resolved.themeSlug}
currentAccentColor={data.resolved.accentColor}
/>
</SettingsSection>
</div>
)}
</div>
);
}
// ============================================================================
// Section 1 — Identité émetteur
// ============================================================================
function IssuerForm({ issuer }: { issuer: Partial<InvoiceIssuer> }) {
const update = useUpdateInvoiceSettings();
const [draft, setDraft] = useState<Partial<InvoiceIssuer>>(issuer);
useEffect(() => {
setDraft(issuer);
}, [issuer]);
const isDirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(issuer),
[draft, issuer],
);
const set = <K extends keyof InvoiceIssuer>(key: K, value: string) => {
setDraft((d) => ({ ...d, [key]: value.trim() === "" ? null : value }));
};
return (
<Card padding="md" className="flex flex-col gap-5">
<div className="grid gap-4 lg:grid-cols-2">
<Field label="Raison sociale">
<Input
value={draft.companyName ?? ""}
onChange={(e) => set("companyName", e.target.value)}
placeholder="Cabinet Compta Martin"
/>
</Field>
<Field label="Forme juridique" hint="SARL, SAS, EI, EURL…">
<Input
value={draft.formeJuridique ?? ""}
onChange={(e) => set("formeJuridique", e.target.value)}
placeholder="SARL"
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="Adresse" hint="Numéro et rue">
<Input
value={draft.addressLine1 ?? ""}
onChange={(e) => set("addressLine1", e.target.value)}
placeholder="12 rue du Pain"
/>
</Field>
<Field label="Complément" hint="Bâtiment, étage (optionnel)">
<Input
value={draft.addressLine2 ?? ""}
onChange={(e) => set("addressLine2", e.target.value)}
placeholder="Bâtiment B"
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Field label="Code postal">
<Input
value={draft.addressZip ?? ""}
onChange={(e) => set("addressZip", e.target.value)}
placeholder="75011"
/>
</Field>
<Field label="Ville">
<Input
value={draft.addressCity ?? ""}
onChange={(e) => set("addressCity", e.target.value)}
placeholder="Paris"
/>
</Field>
<Field label="Pays" hint="ISO 2 lettres (FR par défaut)">
<Input
value={draft.addressCountry ?? ""}
onChange={(e) => set("addressCountry", e.target.value.toUpperCase())}
placeholder="FR"
maxLength={2}
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="SIREN" hint="9 chiffres">
<Input
value={draft.siren ?? ""}
onChange={(e) => set("siren", e.target.value)}
placeholder="123456789"
inputMode="numeric"
/>
</Field>
<Field label="SIRET" hint="14 chiffres">
<Input
value={draft.siret ?? ""}
onChange={(e) => set("siret", e.target.value)}
placeholder="12345678900012"
inputMode="numeric"
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="TVA intracom" hint="Ex. FR12345678901">
<Input
value={draft.tvaIntra ?? ""}
onChange={(e) => set("tvaIntra", e.target.value.toUpperCase())}
placeholder="FR12345678901"
/>
</Field>
<Field label="Code NAF/APE" hint="Ex. 6201Z">
<Input
value={draft.naf ?? ""}
onChange={(e) => set("naf", e.target.value.toUpperCase())}
placeholder="6201Z"
maxLength={5}
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field
label="RCS"
hint="Mention obligatoire si commerçant : ville d'immatriculation"
>
<Input
value={draft.rcs ?? ""}
onChange={(e) => set("rcs", e.target.value)}
placeholder="RCS Paris 123 456 789"
/>
</Field>
<Field
label="Capital"
hint="Mention obligatoire pour les sociétés à capital"
>
<Input
value={draft.capital ?? ""}
onChange={(e) => set("capital", e.target.value)}
placeholder="SARL au capital de 1 000 €"
/>
</Field>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="Email de contact">
<Input
type="email"
value={draft.contactEmail ?? ""}
onChange={(e) => set("contactEmail", e.target.value)}
placeholder="facturation@cabinet-martin.fr"
/>
</Field>
<Field label="Téléphone de contact">
<Input
value={draft.contactPhone ?? ""}
onChange={(e) => set("contactPhone", e.target.value)}
placeholder="+33 1 23 45 67 89"
/>
</Field>
</div>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() => update.mutate({ issuer: draft })}
/>
</Card>
);
}
// ============================================================================
// Section 2 — RIB
// ============================================================================
function RibForm({ rib }: { rib: Partial<InvoiceRib> }) {
const update = useUpdateInvoiceSettings();
const [draft, setDraft] = useState<Partial<InvoiceRib>>(rib);
useEffect(() => setDraft(rib), [rib]);
const isDirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(rib),
[draft, rib],
);
const set = <K extends keyof InvoiceRib>(key: K, value: string) => {
setDraft((d) => ({ ...d, [key]: value.trim() === "" ? null : value }));
};
return (
<Card padding="md" className="flex flex-col gap-5">
<Field label="Nom de la banque">
<Input
value={draft.bankName ?? ""}
onChange={(e) => set("bankName", e.target.value)}
placeholder="BNP Paribas — Agence République"
/>
</Field>
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
<Field label="IBAN" hint="Espaces tolérés, normalisés à l'enregistrement">
<Input
value={draft.iban ?? ""}
onChange={(e) => set("iban", e.target.value.toUpperCase())}
placeholder="FR76 1234 5678 9012 3456 7890 123"
/>
</Field>
<Field label="BIC / SWIFT" hint="8 ou 11 caractères">
<Input
value={draft.bic ?? ""}
onChange={(e) => set("bic", e.target.value.toUpperCase())}
placeholder="BNPAFRPPXXX"
/>
</Field>
</div>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() => update.mutate({ rib: draft })}
/>
</Card>
);
}
// ============================================================================
// 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 (
<Card padding="md" className="flex flex-col gap-5">
<div className="grid gap-4 lg:grid-cols-3">
<Field label="Préfixe" hint="Ex. FAC-2026-, INV-, F-, …">
<Input
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
placeholder="FAC-2026-"
maxLength={40}
/>
</Field>
<Field
label="Prochain numéro"
hint={
settings.numeroNextSeq === undefined || settings.numeroNextSeq === null
? "Modifiable une fois pour reprendre une séquence existante"
: "Auto-incrémenté à chaque facture émise"
}
>
<Input
type="number"
value={nextSeq}
onChange={(e) => setNextSeq(Number(e.target.value) || 1)}
min={1}
max={9_999_999}
/>
</Field>
<Field label="Padding" hint="Zéros de tête (0042 = 4)">
<Input
type="number"
value={padding}
onChange={(e) => setPadding(Number(e.target.value) || 1)}
min={1}
max={10}
/>
</Field>
</div>
<div className="rounded-default border border-line bg-cream-2 px-4 py-3">
<Eyebrow>Aperçu</Eyebrow>
<p className="mt-1 font-mono text-[18px] font-bold text-ink">{preview}</p>
<p className="mt-1 text-[12.5px] text-ink-3">
Le numéro suivant sera{" "}
<span className="font-mono">
{prefix}
{String(nextSeq + 1).padStart(padding, "0")}
</span>
.
</p>
</div>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() =>
update.mutate({
numeroPrefix: prefix,
numeroNextSeq: nextSeq,
numeroPadding: padding,
})
}
/>
</Card>
);
}
// ============================================================================
// 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 (
<Card padding="md" className="flex flex-col gap-5">
<Field
label="Délai de paiement (jours)"
hint="Loi LME : plafond à 60 jours ou 45 jours fin de mois entre professionnels."
>
<Input
type="number"
value={days}
onChange={(e) => setDays(Number(e.target.value) || 0)}
min={0}
max={365}
className="lg:max-w-[200px]"
/>
</Field>
<Field
label="Pénalités de retard"
hint="Obligatoire (art. L441-10 du Code de commerce)"
>
<Textarea
value={penalty}
onChange={(e) => setPenalty(e.target.value)}
rows={3}
maxLength={1000}
/>
</Field>
<Field
label="Escompte pour paiement anticipé"
hint="Obligatoire (art. L441-9 du Code de commerce)"
>
<Textarea
value={escompte}
onChange={(e) => setEscompte(e.target.value)}
rows={2}
maxLength={500}
/>
</Field>
<Field
label="Mention libre additionnelle"
hint="Texte affiché en pied de page (CGV, mentions spécifiques métier…)"
>
<Textarea
value={footer}
onChange={(e) => setFooter(e.target.value)}
rows={3}
maxLength={1000}
placeholder="Vos CGV sont disponibles sur demande à contact@…"
/>
</Field>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() =>
update.mutate({
paymentTermsDays: days,
// Null si l'utilisateur a vidé le champ → reset au default Rubis.
penaltyRateText: penalty.trim() === "" ? null : penalty,
escompteText: escompte.trim() === "" ? null : escompte,
footerLegalText: footer.trim() === "" ? null : footer,
})
}
/>
</Card>
);
}
// ============================================================================
// Section 5 — Thème & accent
// ============================================================================
function ThemeForm({
themes,
currentThemeSlug,
currentAccentColor,
}: {
themes: { slug: InvoiceThemeSlug; name: string; description: string }[];
currentThemeSlug: InvoiceThemeSlug;
currentAccentColor: string;
}) {
const update = useUpdateInvoiceSettings();
const [slug, setSlug] = useState<InvoiceThemeSlug>(currentThemeSlug);
const [accent, setAccent] = useState(currentAccentColor);
useEffect(() => setSlug(currentThemeSlug), [currentThemeSlug]);
useEffect(() => setAccent(currentAccentColor), [currentAccentColor]);
const isDirty = slug !== currentThemeSlug || accent !== currentAccentColor;
return (
<Card padding="md" className="flex flex-col gap-5">
<div>
<Eyebrow>Galerie</Eyebrow>
<div className="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{themes.map((t) => (
<button
key={t.slug}
type="button"
onClick={() => setSlug(t.slug)}
className={cn(
"flex flex-col gap-2 rounded-default border bg-white p-3 text-left transition-colors",
slug === t.slug
? "border-rubis ring-4 ring-rubis-glow"
: "border-line hover:border-rubis-light",
)}
>
<ThemePreview themeSlug={t.slug} accentColor={accent} />
<div className="flex items-center justify-between">
<span className="font-display text-[14px] font-bold text-ink">
{t.name}
</span>
{slug === t.slug ? (
<Check size={16} className="text-rubis" aria-hidden="true" />
) : null}
</div>
<p className="text-[12px] leading-snug text-ink-3">{t.description}</p>
</button>
))}
</div>
</div>
<Field
label="Couleur d'accent"
hint="Hex #RRGGBB — utilisée sur le numéro, les filets et le total TTC selon le thème."
>
<div className="flex items-center gap-3">
<input
type="color"
value={accent}
onChange={(e) => setAccent(e.target.value)}
className="h-11 w-16 cursor-pointer rounded-default border border-line bg-white p-1"
aria-label="Choisir la couleur d'accent"
/>
<Input
value={accent}
onChange={(e) => setAccent(e.target.value)}
placeholder="#9F1239"
maxLength={7}
className="font-mono lg:max-w-[160px]"
/>
</div>
</Field>
<SaveBar
isDirty={isDirty}
isPending={update.isPending}
error={update.error}
onSave={() =>
update.mutate({
themeSlug: slug,
accentColor: accent,
})
}
/>
</Card>
);
}
/**
* Preview miniature d'un thème wireframe simplifié rendu en pur CSS
* (pas de PDF embed dans la galerie : trop lourd, trop lent à scroll).
* Reflète l'esprit du template (bandeau, hairline, etc.).
*/
function ThemePreview({
themeSlug,
accentColor,
}: {
themeSlug: InvoiceThemeSlug;
accentColor: string;
}) {
const accent = { backgroundColor: accentColor };
const accentBorder = { borderColor: accentColor };
if (themeSlug === "classique") {
return (
<div className="aspect-[1/1.4] rounded-sm border border-line bg-paper p-2.5 text-[6px]">
<div className="space-y-0.5 text-center">
<div className="mx-auto h-1 w-6 rounded-sm bg-ink-3" />
<div className="text-[6px] font-bold text-ink">Société</div>
<div className="text-ink-3">12 rue · 75001 Paris</div>
</div>
<div className="my-1.5 border-b" style={accentBorder} />
<div className="font-bold text-ink-2" style={{ color: accentColor }}>
FACTURE
</div>
<div className="mt-1 h-0.5 w-full bg-line" />
<div className="mt-0.5 h-0.5 w-3/4 bg-line" />
<div className="mt-0.5 h-0.5 w-2/3 bg-line" />
</div>
);
}
if (themeSlug === "moderne") {
return (
<div className="aspect-[1/1.4] overflow-hidden rounded-sm border border-line bg-paper text-[6px]">
<div className="px-2 py-1.5 text-white" style={accent}>
<div className="text-[8px] font-bold">FACTURE</div>
<div className="opacity-80">N° FAC-2026-0042</div>
</div>
<div className="p-2 space-y-0.5">
<div className="h-0.5 w-3/4 bg-line" />
<div className="h-0.5 w-2/3 bg-line" />
<div className="h-0.5 w-1/2 bg-line" />
</div>
</div>
);
}
if (themeSlug === "minimal") {
return (
<div className="aspect-[1/1.4] rounded-sm border border-line bg-paper p-3 text-[6px]">
<div className="flex justify-between">
<div className="font-bold text-ink">Société</div>
<div className="font-bold" style={{ color: accentColor }}>
FAC-2026
</div>
</div>
<div className="mt-2 space-y-0.5">
<div className="h-0.5 w-3/4 bg-line" />
<div className="h-0.5 w-2/3 bg-line" />
</div>
<div className="mt-3 text-right text-[7px] font-bold text-ink"></div>
</div>
);
}
// elegant
return (
<div className="aspect-[1/1.4] rounded-sm border border-line bg-paper p-2 text-[6px]">
<div className="border-b" style={accentBorder} />
<div className="mt-1 text-center">
<div className="text-[5px] uppercase tracking-widest" style={{ color: accentColor }}>
Facture
</div>
<div className="text-[7px] font-bold italic text-ink">N° FAC-0042</div>
</div>
<div className="mt-1 border-b" style={accentBorder} />
<div className="mt-1.5 space-y-0.5">
<div className="h-0.5 w-full bg-line" />
<div className="h-0.5 w-3/4 bg-line" />
<div className="h-0.5 w-2/3 bg-line" />
</div>
</div>
);
}
// ============================================================================
// SaveBar — footer commun à toutes les sections
// ============================================================================
function SaveBar({
isDirty,
isPending,
error,
onSave,
}: {
isDirty: boolean;
isPending: boolean;
error: Error | null;
onSave: () => void;
}) {
return (
<div className="flex items-center justify-end gap-3 border-t border-line pt-4">
{error ? (
<p className="mr-auto text-[13px] font-medium text-rubis-deep">
{error.message}
</p>
) : null}
<Button onClick={onSave} disabled={!isDirty || isPending} size="sm">
{isPending ? (
<>
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
Enregistrement
</>
) : (
"Enregistrer"
)}
</Button>
</div>
);
}