25 Commits

Author SHA1 Message Date
ordinarthur
dd93249362 refactor(deploy): split monolithique en 2 services (rubis-web nginx + rubis-api Node)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m16s
Build & Deploy API / build-and-deploy (push) Successful in 1m32s
Avant : une seule image (Dockerfile.app) qui bundle AdonisJS + SPA static.
Après : deux images, deux deployments, deux workflows CI avec path filters
indépendants.

Architecture
- rubis-web (NodePort 30110, exposé via Traefik)
  · nginx-alpine + SPA Vite dist + nginx.conf
  · sert /assets/* (cache 1y immutable), / (try_files index.html SPA fallback)
  · reverse-proxy /api/* → rubis-api.rubis.svc.cluster.local:3333
- rubis-api (ClusterIP, accessible uniquement depuis le cluster)
  · AdonisJS V7 + workers BullMQ dans le même process
  · init-container migrate (idempotent, depuis build/)
  · /api/v1/health pour les probes K3s + healthcheck Docker
- rubis-redis (ClusterIP, inchangé)

Bénéfices
- Build/deploy indépendants : changement front ne reconstruit pas l'API,
  changement API ne reconstruit pas le SPA
- nginx en frontal donne du gzip + cache long sur les assets fingerprintés
- API n'expose plus de surface publique (defense in depth)
- Routes plus simples : on retire le wildcard SPA fallback dans
  start/routes.ts (nginx s'en charge), on retire @adonisjs/static aurait
  été cohérent mais on le garde pour minimiser les diffs

Files
- Dockerfile.api (replaces Dockerfile.app, Node-only)
- Dockerfile.web (new, nginx)
- apps/web/nginx.conf (new)
- k3s/app/api.yml (replaces deployment.yml + service.yml, ClusterIP)
- k3s/app/web.yml (new, NodePort 30110)
- .gitea/workflows/deploy-{api,web}.yml (replaces deploy-app.yml)
- /api/v1/health route ajoutée

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 02:58:25 +02:00
ordinarthur
461ab9bcd9 feat(deploy): app.rubis.arthurbarre.fr — image, manifests K3s, route Traefik
Some checks failed
Build & Deploy Landing / build-and-deploy (push) Successful in 31s
Build & Deploy App / build-and-deploy (push) Failing after 46s
Premier déploiement de l'app SaaS (apps/api + apps/web) — distinct de la
landing déjà sur rubis.arthurbarre.fr. Architecture :
- Image unique (Dockerfile.app, multi-stage) : AdonisJS sert l'API ET le
  SPA static via @adonisjs/static + wildcard fallback pour TanStack Router
- Workers BullMQ tournent dans le même process Node (cf. start/queue.ts)
- Redis 7 dans le namespace rubis (PVC local-path 1Gi)
- Migrations en init-container avant le serveur (idempotent)

Infra :
- K3s namespace rubis (déjà existant) — ajout deploy/svc rubis-app + redis
- NodePort 30110 → Traefik → app.rubis.arthurbarre.fr (TLS Let's Encrypt)
- Postgres : base rubis_prod + user rubis créés sur 10.10.10.3
- MinIO : bucket rubis-prod-invoices créé via mc
- Secrets K3s posés via kubectl create secret (APP_KEY généré, DB pwd
  généré, MinIO root creds réutilisées, Resend/Mistral keys)
- DNS OVH A record app.rubis créé (id 5413305619)
- CI Gitea : .gitea/workflows/deploy-app.yml séparé du workflow landing,
  filtres sur paths apps/**, packages/**, Dockerfile.app, k3s/app/**

Code app :
- Static middleware @adonisjs/static configuré
- Wildcard route SPA fallback en fin de routes.ts
- Fix erreurs strict TS qui bloquaient le build vite (unused vars,
  Client missing contactFirstName/LastName dans MSW)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 02:01:39 +02:00
ordinarthur
ca95dde9b3 style(web): cursor-pointer global sur les éléments interactifs
Tailwind v4 ne pose plus cursor:pointer sur <button> par défaut, ce qui
rendait l'app un peu morte au survol. Plutôt que d'ajouter cursor-pointer
sur chaque composant, on le pose une fois pour toutes en CSS de base sur :
- button, role="button", a[href], summary, label[for], select
- inputs cliquables (submit, button, reset, checkbox, radio)

Les éléments désactivés (disabled, aria-disabled) basculent en
cursor:not-allowed pour signaler clairement l'état.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 00:42:13 +02:00
ordinarthur
5c7dbc2eba fix(plans/ai): contexte plan + interdiction Mustache sections
Bugs remontés sur les générations IA :
- Le modèle utilisait `{{#var}}...{{/var}}` (sections Mustache) pour
  gérer les fallbacks de prénom — notre interpréteur ne fait que de
  la substitution simple, donc le charabia s'affichait dans l'email.
- La signature était dupliquée : l'IA écrivait le nom à la main puis
  ajoutait `{{signature}}`.
- Le contexte du plan (nom + description) n'était pas transmis, donc
  les générations étaient déconnectées du sens du plan parent.

Corrections du SYSTEM_PROMPT :
- Section "Syntaxe des variables" explicite : substitution simple
  uniquement, INTERDICTION des `{{#...}}` / `{{^...}}` / conditionnels
- Section "Tu n'es PAS obligé d'utiliser toutes les variables"
  → l'IA pioche celles qui rendent le message naturel
- Règle : terminer toujours par {{signature}} sur sa propre ligne,
  ne JAMAIS réécrire le nom de l'expéditeur après (la variable
  contient déjà nom + entreprise + formule de politesse)

Backend
- ai_relance_generator : type GenerateRelanceInput accepte planName
  + planDescription (à la place de l'ancien planContext fourre-tout)
- user message structuré en sections # Plan parent / # Cette relance
  / # Brief de l'utilisateur, plus lisible pour le modèle
- ai_controller validator : accepte planName + planDescription

Frontend
- AiGenerateModal accepte planName + planDescription en props et
  les passe à l'API
- Affiche le nom du plan dans la description de la modale
- Bloc dépliable "Variables que l'IA peut insérer (sans obligation)"
  pour montrer à l'utilisateur ce qui est dispo
- StepMessages passe draft.name + draft.description au modal
- MSW handler aligné sur le nouveau contrat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:48:57 +02:00
ordinarthur
0a3b8523ef feat(plans/wizard): éditeur avec icônes de tonalité + toggle de sélection
- Champ Décalage retiré : on change le timing en cliquant une autre
  case du calendrier (delete + click ailleurs), c'est plus aligné
  avec la métaphore calendrier
- Tonalité passée d'un select à un groupe de 4 boutons icônes :
  · Doux → Smile (sourire chaleureux)
  · Standard → MessageCircle (bulle de conversation polie)
  · Ferme → AlertTriangle (alerte mesurée)
  · Strict → Gavel (marteau de juge)
  Chaque bouton actif prend la couleur de fond de sa tonalité, plus
  visuel et compact qu'un dropdown
- Header de l'éditeur : la pastille colorée devient une pastille avec
  l'icône de tonalité dedans → on lit la tonalité d'un coup d'œil
- Toggle : re-cliquer la case déjà sélectionnée la désélectionne
  (retour à l'état "vue d'ensemble" avec le hint), au lieu d'avoir
  une sélection permanente

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:34:33 +02:00
ordinarthur
07712da774 fix(plans/wizard): calendrier vraiment pratique (3 problèmes UX)
Issues remontées :
- Cellules étirées en horizontal sur desktop (rectangles plats)
- Échéance et "Doux" indiscernables (tous deux en rubis-glow)
- Pas de feedback au clic, "Étape 1" déjà affichée par défaut sans
  qu'on l'ait sélectionnée
- "Étape 1 · 20 juin · J+5" pas parlant
- Cliquer sur une case vide ne faisait rien

Refonte :
- Calendrier max-w-md mx-auto + cells aspect-square → carrés équilibrés
- Échéance = bg-rubis solide (pas glow) + ◆ blanc + ombre rubis →
  visuellement distincte de toutes les tonalités
- Cellule étape = couleur tonalité + badge "J+X" en coin haut-droit
- Sélection forte : ring-4 + scale-1.08 + shadow-rubis-hover sur la
  case sélectionnée → impossible de la rater
- Default selectedIdx = -1 (pas de présélection) → hint clair :
  "Touchez une case colorée pour modifier, ou un jour vide pour ajouter"
- **Click sur case vide → crée une étape à cet offset**, triée par
  ordre temporel (insertion smart, pas en bout). Plus l'usage le plus
  naturel de l'outil : "je veux relancer le 5 juin" → clic.
- Click sur échéance → toast explicatif (pas une no-op silencieuse)
- Header de l'éditeur : "Relance du **5 juin** · J-10" (pas "Étape 1")
- Hover sur jour vide : "+" rubis apparaît → affordance d'ajout claire
- Hors plage [-30, +180] ou >= 8 étapes : cellule disabled, toast info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:29:39 +02:00
ordinarthur
149f60dbb0 refactor(plans/wizard): calendrier compact en outil de navigation
Le calendrier précédent prenait toute la page (1-2 mois empilés en
pleine taille). Refonte en mini-calendrier de navigation :

- Un seul mois affiché à la fois, navigation prev/next via chevrons
- Auto-jump au mois de l'étape sélectionnée pour ne jamais perdre
  la cellule de vue
- Cellules h-9 fixe (plus de aspect-square qui gonflait sur écran large)
- Header compact : juste mois + chevrons (pas de gros titre)
- Légende inline une ligne ("◆ Échéance le X · couleur = tonalité")
- Éditeur compact en dessous : 1 ligne d'en-tête (◆ tonalité · étape N
  · 18 mai · J+3) + 1 ligne 2-cols (input offset + select tonalité +
  bouton supprimer en icône). Plus de Field / hint volumineux.
- Footer : bouton Ajouter en pleine largeur (sauf compteur 3/8 à droite)

Hauteur totale ~400px en pratique vs 700px+ avant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:22:13 +02:00
ordinarthur
a136c54501 feat(plans/wizard): cadence sur calendrier mensuel avec tonalités
Remplace la liste verticale par une vraie visu calendrier qui ancre
chaque étape sur une date concrète, ce qui donne du sens au timing.

- Date d'échéance fictive : le 15 du mois prochain (stable, prévisible,
  laisse de la marge avant/après pour offsets négatifs comme positifs)
- Cellule échéance = ◆ rubis plein sur fond rubis-glow + shadow rubis,
  jour mis en exergue
- Cellule étape = couleur de fond pleine selon la tonalité (Doux =
  rubis-glow, Standard = cream-2, Ferme = ink, Strict = rubis-deep)
  avec affichage J+X / numéro du jour
- Cellule jour normal = numéro muted, today souligné en rubis-glow
- Click sur cellule étape → sélection, l'éditeur (offset, ton,
  supprimer) apparaît directement sous le calendrier
- Légende des tonalités juste sous l'en-tête
- Affiche tous les mois entre la 1re et la dernière étape (échéance
  incluse) — typiquement 1 à 2 mois en pratique
- Mêmes raccourcis qu'avant : OffsetInput string-controlled qui accepte
  les états intermédiaires "" et "-"

Suppression de CadenceTimeline.tsx (la liste verticale précédente).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:18:27 +02:00
ordinarthur
05ad3fa5cf refactor(plans/wizard): refonte cadence en liste verticale lisible (mobile + desktop)
Le précédent layout avec ◆ rotatés en timeline causait des collisions
visuelles sur mobile (les coins du diamant débordaient sur les labels et
la ligne de connexion). Inutilisable.

Nouvelle approche, inspirée des éditeurs de séquences éprouvés
(Mailchimp, Klaviyo) : liste verticale de cards de step, identique
sur mobile et desktop. Plus prévisible, plus lisible, mêmes tap targets.

- Chaque step = card cliquable avec : numéro d'ordre, ◆ accent (petit,
  coloré par tonalité, signature de marque sans gêner la lecture),
  J+X, label de tonalité, bouton retirer aligné dans le flux
- La card sélectionnée (rubis border + shadow) révèle l'éditeur inline
  (Décalage + Tonalité) directement sous l'en-tête → pas de panneau
  séparé, pas de saut de focus, l'utilisateur édite ce qu'il vient
  de taper
- Bouton "Ajouter une étape" en pleine largeur en pied de liste
- L'avertissement mise-en-demeure (validation manuelle) s'affiche dans
  la card sélectionnée
- OffsetInput déplacé dans CadenceTimeline avec le reste de l'éditeur ;
  duplication supprimée du fichier route

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:10:32 +02:00
ordinarthur
24cbf35902 fix(plans/wizard): variables dans le sujet + UX mobile resserrée
Variables
- Le clic sur un chip de variable insère désormais au curseur du dernier
  champ focus (sujet OU corps), pas seulement dans le corps. On capture
  la position via onSelect/onClick/onKeyUp/onBlur et on utilise mousedown
  + preventDefault sur les chips pour que le focus ne quitte pas le champ
  ciblé avant l'insertion. Le label sous les chips indique en live
  quel champ est ciblé.
- OffsetInput (étape Cadence) : composant string-controlled qui accepte
  les états intermédiaires "" et "-" pour ne plus avoir le 0 fantôme
  quand on efface pour ressaisir un offset négatif.

Mobile
- Bottom nav (Annuler/Continuer) sticky en bas sur mobile, en flux normal
  sur desktop. Safe-area inset respectée.
- Header du wizard : back button compact (icône seule sous sm), compteur
  d'étape toujours visible, stepper centré.
- Card padding adaptatif (p-5 sm:p-7 lg:p-9).
- Step 3 — sélecteur d'étape : scroll horizontal sur mobile (au lieu de
  wrap), évite l'effet escalier avec 5 étapes.
- Step 3 — body textarea : min-h adaptatif (180px mobile, 260px sm+).
- CadenceTimeline : rail horizontal masqué sous lg ; en mobile, ligne
  verticale fine entre les nœuds (cohérent identité ◆) ; bouton retirer
  visible en permanence sur mobile (les hover-only ne marchent pas tactile).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:05:34 +02:00
ordinarthur
9e531e32a9 feat(plans): wizard de création de plan custom + génération IA Mistral
Backend
- migration : champs contact_first_name / contact_last_name (nullable)
  sur clients pour personnaliser les variables de relance
- POST /api/v1/plans : création de plan custom avec slug auto-généré
  (suffixé en cas de collision, "nouveau"/"new"/"create" réservés)
- POST /api/v1/ai/generate-relance : génération de subject+body via
  mistral-small-latest, avec brief utilisateur et tonalité ciblée
- mail_dispatcher : nouvelles variables {{daysLate}}, {{issueDate}},
  {{user.fullName}}, {{user.companyName}}, {{client.contactFirstName}},
  {{client.contactLastName}} (helper buildRelanceVars exposé pour preview)
- send_relance_job preload désormais l'organization pour exposer son name

Frontend
- /plans/nouveau : wizard 4 étapes (Identité → Cadence → Messages → Récap)
  - Stepper en haut, navigation guidée, validation par étape
  - Étape 1 : nom + tonalité globale (4 cards Doux/Standard/Ferme/Strict)
    avec aperçu de la cadence par défaut associée
  - Étape 2 : timeline horizontale (rail rubis-glow + nœuds ◆ teintés
    selon la tonalité), édition décalage/ton de l'étape sélectionnée
  - Étape 3 : édition par étape avec preview live à droite, chips de
    variables cliquables, bouton "Générer avec l'IA" qui ouvre une modale
    Mistral (brief + résultat + régénérer)
  - Étape 4 : récap avec preview de chaque email rendu sur un client fictif
- Détection des variables sensibles → warning si X clients existants n'ont
  pas le champ contactFirstName/contactLastName rempli (UX informative,
  fallback vide à l'envoi)
- "Dupliquer" sur chaque card de plan → /plans/nouveau?from=<slug>
  pour pré-remplir le wizard à partir d'un plan existant
- ClientCreateDialog : ajout des champs prénom/nom du contact dédié
- TEMPLATE_VARIABLES étendu, helper renderTemplate côté front en miroir
  exact de l'implémentation API
- MSW handlers ai/plans/clients alignés sur le nouveau contrat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:55:00 +02:00
ordinarthur
b8dec6d494 update la relance par mail 2026-05-06 19:02:39 +02:00
ordinarthur
5e41e2a9fa add ocr + add factures 2026-05-06 18:47:35 +02:00
ordinarthur
57e1d0d0be update frontend ( tarpin bo ) 2026-05-06 15:15:07 +02:00
ordinarthur
8cec9d2f33 feat(web): page /parametres complète (compte, entreprise, signature, danger)
Remplace le placeholder par 4 sections fonctionnelles, chacune avec son
form indépendant et son Save (blast radius clair : modifier sa signature
ne sauvegarde pas l'org).

Layout : sections verticales avec gap large, pas de tabs ni sidebar
interne en V1 (mono-utilisateur, peu de surface). Pattern type Linear /
Stripe : eyebrow + titre + description à gauche (280px), Card form à
droite (1fr). Empilé sur mobile.

Sections :

1. Compte — AccountForm : fullName + email. Synchronise authStore
   après save → topbar greeting / sidebar avatar se mettent à jour
   live. Save désactivé si form.state.isDirty=false.

2. Entreprise — OrganizationForm : nom + SIRET (14 chiffres) + chips
   volume mensuel (réutilise le pattern de l'onboarding step 2).
   Fetch GET /organizations/me, PATCH au save, setQueryData pour
   éviter un refetch.

3. Signature — SignatureForm : Textarea + aperçu live dans Card flat
   avec eyebrow + Sparkles (cohérent onboarding step 3). PATCH
   /account/profile avec field signature.

4. Zone danger — DangerZone, variant 'danger' sur SettingsSection
   (border rubis-deep/30 dashed + bg rubis-glow/20 — sobre, pas
   alarmiste). Logout fonctionnel (duplique UserMenu, c'est OK et
   attendu dans les paramètres). Suppression compte disabled
   (bientôt) avec mention 'RGPD article 17'.

Composants nouveaux :
- SettingsSection : pattern visuel commun, prop tone='default'|'danger'
- AccountForm, OrganizationForm, SignatureForm, DangerZone

MSW : ajout GET /api/v1/organizations/me (on n'avait que le PATCH).

Bundle prod : 116.21 KB gzip core (-1.76 KB grâce au tree-shaking
mutualisé des deps form).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:29:31 +02:00
ordinarthur
16120ed3e0 feat(web): création client (modale) + email required + SIRET optionnel
Réflexion produit : email required vs optionnel.

Le coeur de Rubis = relances email automatiques. Sans email client →
aucune relance ne peut partir → la fiche client est inutilisable pour
le coeur du produit. Décision : email REQUIRED partout, plutôt que
laisser créer des fiches mortes.

Type Client (packages/shared) :
- email: string (était string | null)
- siret: string | null ajouté (optionnel mais recommandé pour mises
  en demeure formelles + intégrations comptables V2 type Pennylane)

ClientCreateDialog (modale "+ Nouveau client" sur /clients) :
- Email required avec validator Zod min(1).email()
- SIRET ajouté côte-à-côte avec Téléphone (validator 14 chiffres
  ou vide, inputMode='numeric', espaces tolérés à la frappe)
- Adresse postale déplacée full-width (lisibilité)
- Hints éducatifs : 'Préférez compta@/facturation@ à une nominative',
  'Recommandé pour les mises en demeure', 'Requise pour les mises en
  demeure formelles'

Field component aligned :
- Label/hint en haut, input en bas (mt-auto sur le wrapper input)
- Quand 2 Fields sont côte-à-côte avec hints de longueur différente,
  les inputs restent alignés au bas — le hint plus long étire le haut
- Erreur reste collée sous l'input (pas en bas de la cellule)

MSW :
- POST /clients schema strict : email required, siret 14 chiffres si fourni
- Détection doublon par nom (409) conservée
- Handlers création de client implicites (saisie facture, OCR review)
  refusent maintenant la création quand email manquant : 422 ciblé
  'Email du client requis — Rubis en a besoin pour envoyer les relances.'
  Si l'user pick un client existant via le combobox → email déjà en
  DB, pas demandé.

Migration mockDb :
- Anciens clients sans siret → null
- Anciens clients avec email null (cas test) → placeholder dérivé du
  slug du nom (contact@boulangerie-martin.fr) — éditable, juste évite
  un crash au load. slugifyClientName() supprime SARL/SAS/EURL et accents.

Détail /clients/$id :
- SIRET ajouté dans la barre meta du header (Hash icon Lucide +
  tabular-nums) — affiché seulement si rempli
- Email plus conditionnel (toujours présent maintenant)

Seeds :
- Boulangerie Martin SARL : SIRET 82345678900012
- Cabinet Rousseau : SIRET 53412987600028
- Atelier Durand, Garage Lemoine, Studio Lefèvre : siret null
  (pour tester les deux cas dans la liste)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:25:37 +02:00
ordinarthur
f34cc97327 feat(web): /clients liste + détail + persistance session mock
Page /clients (liste) :
- Header dynamique : 'X factures en retard chez Y clients' en rubis-deep
  s'il y en a, sinon 'Tout est calme côté clients.'
- Recherche par nom/email (param q côté serveur, debounce naturel via
  TanStack Query staleTime: 10s)
- Table desktop / cards mobile (cohérent avec /factures)
- Tri serveur : retards d'abord (actionable), puis activité récente
- Empty state distincts (recherche vide vs jamais de clients)
- Lien depuis 'Voir tout' du panel TopLatePayers du dashboard fonctionne
- '+ Nouveau client' disabled (V2)

Page /clients/$id (détail) :
- Header : eyebrow contextuel, nom h1, infos contact (mail clickable,
  phone, address) avec icônes Lucide ink-3
- 4 KPI cards en grille : Factures actives (avec sub-info 'N en retard'
  rubis-deep si pertinent), En attente, Encaissé total, Factures payées
- Liste des factures du client (cliquables vers /factures/$id) avec
  StatusBadge sans icône (compact)
- Notes internes : Textarea avec autosave on blur via PATCH /clients/:id

MSW :
- GET /clients?withStats=1&q= : enrichit avec compteurs + montants +
  lastActivityAt. Tri par retards d'abord
- GET /clients/:id : détail enrichi + invoices triées plus récentes
- PATCH /clients/:id : édition Zod
- mockDb.updateClient(orgId, id, patch) ajouté

Persistance session mock (stay logged in après reload) :
- mocks/sessionStore.ts : helpers localStorage simulant le cookie
  httpOnly côté serveur. TTL 30j (= refresh token typique). SPA n'y
  accède jamais directement, seul MSW touche cette persistance.
- POST /auth/{login,signup} : sessionStore.set après succès
- POST /auth/logout : sessionStore.clear (clean disconnect)
- POST /auth/refresh : retourne la session stockée + recharge le user
  depuis mockDb au cas où il a été modifié (signature post-onboarding etc.)
- main.tsx : bootstrapSession() avant le 1er render (silent refresh).
  Évite le flash redirect /login pour les users déjà connectés.

Architecture : le SPA n'accède jamais directement à localStorage —
il passe toujours par HTTP (/auth/refresh). Quand on branchera le vrai
backend Adonis, on supprime juste mocks/sessionStore.ts et le pattern
continue à marcher (cookie httpOnly remplace localStorage côté serveur).

queryKeys.clients.list ajouté pour le param de recherche.

Bundle prod : 117.92 KB gzip core (stable +0.28 vs avant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:06:32 +02:00
ordinarthur
6de2711aa8 feat(web): OCR review utilise ClientCombobox au lieu d'un Input libre
L'écran de review OCR avait un Input texte libre pour le nom du client,
ce qui faisait qu'on créait un nouveau client à chaque validation même
quand le nom matchait un client existant — doublons assurés.

Maintenant l'OCR fait le matching en amont :
- L'extraction côté MSW (fakeOcrExtract) cherche un client existant par
  nom case-insensitive et pré-remplit clientId dans extracted/edited.
  Confidence clientName = 1 quand match (vs 0.95 sinon).
- DraftFields type ajoute clientId: string | null
- draftFieldsSchema (validation) ajoute clientId nullable

Côté UI :
- L'Input clientName devient un ClientCombobox (le même que pour la
  saisie manuelle — chunk mutualisé 26 KB gzip)
- Border rubis quand un client existant est sélectionné
- Hint contextuel sur le Field :
  · clientId set → "Lié à un client existant ✓"
  · clientId null + nom ≥ 2 chars → "Nouveau client — sera créé à la validation."
  · Sinon → "Tapez pour rechercher ou créer un client."

Validate handler MSW (résolution client en cascade) :
1. clientId explicite (combobox) → utilise direct, zéro lookup
2. Match par nom case-insensitive sur les clients existants → utilise si match
3. Création à la volée si rien ne matche
Fallback création si clientId fourni mais introuvable.

Migration mockDb : les batches d'import seedés avant l'ajout du champ
sont patchés à load() avec clientId ?? null (spread des données stockées
d'abord pour ne pas écraser les snapshots récents).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:55:12 +02:00
ordinarthur
cfd3680bb4 feat(web): saisie manuelle de facture (modale Radix Dialog)
Modale 'Nouvelle facture' (cf. wireframe 2.3) accessible depuis 4 points :
- Topbar '+ Saisir' (était disabled)
- /factures/import bouton 'Saisir manuellement' (header)
- Dropzone empty state sur /factures (variant full)
- (Reachable de partout dans _app/* via le topbar)

Composants ajoutés :
- Dialog : wrapper Radix Dialog stylé (overlay ink/35 + blur, content
  bg-cream + border-line + shadow-card, close button discret, animations
  fade+zoom). Header / Title / Description / Footer / Close.
- ClientCombobox : autocomplete maison (pas Radix Combobox qui n'existe
  pas, pas cmdk overkill). Input + dropdown filtré, click-outside ferme,
  Escape ferme, option 'Créer le client « X »' quand pas de match exact.
  Border rubis quand un client existant est sélectionné.
- ManualInvoiceDialog : form complet (TanStack Form + validateurs Zod
  par champ). Client (combobox), N° + date émission (côte-à-côte), montant
  + échéance relative 15/30/45/60/90j (Select Radix), plan de relance.

Architecture clean :
- ManualInvoiceProvider au sommet d'AppLayout rend la modale une seule
  fois (un seul réseau de portals Radix)
- Hook useManualInvoice() expose open()/close()/isOpen, accessible
  depuis n'importe quelle route enfant sans plumber des callbacks
- État local de la modale (pas dans l'URL — propre pour V1)

Logique métier MSW :
- GET /api/v1/clients (autocomplete)
- POST /api/v1/invoices : résolution client (clientId fourni → utilise,
  sinon match par nom case-insensitive, sinon création à la volée).
  +1 rubis bonus saisie.
- Conversion relativeDueDays (15/30/45/60/90) → dueDate absolue à la
  soumission

Bug fix montant TTC :
- L'input était contrôlé avec value={(cents/100).toFixed(2)} → reformat
  à chaque keystroke écrasait '10000' en '1.00' (impossible de taper
  des gros montants)
- Passé en defaultValue (uncontrolled) avec step='any' + inputMode='decimal'
- Accepte virgule FR (1240,50) et point (1240.50)
- DialogContent unmount à la fermeture → defaultValue ré-évalué à
  chaque réouverture (reset OK)

Bouton '+ Saisir' du topbar plus disabled, bouton 'Saisir manuellement'
de /factures/import plus disabled. Le bouton dans la dropzone (variant
full) reçoit un onManualEntry prop optionnel.

Bundle prod : 117.62 KB gzip core (+0.06 KB), useManualInvoiceDialog
chunk 6.68 KB gzip, Select chunk 25.14 KB gzip (partagé OCR + plan
editor + manual entry).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:50:46 +02:00
ordinarthur
965a92da8f feat(web): /factures/import — page focused d'import via bouton topbar
Le bouton '+ Importer factures' du topbar avait un Button inerte. Il
ouvre maintenant une vraie page focused dédiée :

- Route /factures/import (factures_.import.tsx) avec breadcrumb,
  eyebrow, H1 'Importer *plusieurs* factures.', lede explicatif,
  dropzone full-page avec mutation upload câblée
- Drop-catcher de page comme sur /factures (drop n'importe où marche)
- 3 hints discrets en bas (Formats / Confidentiel / Reprenable) pour
  rassurer le user au moment décisif de l'upload

Routing nesting fix :
- Renommé factures_.import.\$batchId.tsx → factures_.import_.\$batchId.tsx
- Trailing underscore sur 'import_' escape la nouvelle landing parent
- Les 2 routes sont maintenant siblings sous _app :
  · /factures/import → factures_.import.tsx
  · /factures/import/\$batchId → factures_.import_.\$batchId.tsx

Topbar AppLayout :
- '+ Importer factures' = Button asChild + Link to /factures/import
  (middle-click / cmd-click / right-click ouvrent un nouvel onglet)
- '+ Saisir' reste disabled (placeholder modale 2.3, prochaine étape)

Bundle prod : 117.56 KB gzip core (stable, +0.06 vs avant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:35:59 +02:00
ordinarthur
86dae64eb4 feat(web): import OCR — drop fichier → review batch → factures créées
Boucle import complète (cf. wireframe 2.2) :
1. Drop PDF/PNG/JPG sur /factures (dropzone full-page si vide, compact en
   bas si populée, OU drop n'importe où sur la page grâce au drop-catcher
   de route — évite que le browser ouvre le fichier dans un onglet)
2. POST /invoices/upload → MSW génère un batch avec drafts pré-remplis
   (OCR simulé : nom client aléatoire depuis 7 entreprises plausibles,
   montant random, dates calibrées, confidences variables) + délai 800ms
3. Toast "X factures extraites. Vérifions ensemble." + navigate vers
   /factures/import/$batchId
4. Page review step-by-step : PDF preview à gauche + form à droite,
   champs douteux (confidence < 0.7) surlignés border-rubis + hint
   inline, bandeau warning rubis-glow si plusieurs champs incertains
5. Valider & suivante → POST validate → crée la facture en mockDb
   (nouveau client si nom inconnu) + 1 rubis bonus → la suivante
   apparaît automatiquement
6. Skip ou Annuler le batch entier disponibles à tout moment
7. Fin de batch → toast bilan ("X validées · Y ignorées") → /factures

Composants ajoutés :
- PdfPreview : placeholder anti-générique (pas un viewer gris) — header
  mono filename + "page A4" simulée avec barres bg-ink/15 et bg-rubis-glow
- Select : wrapper Radix Select stylé (Trigger / Content / Item) cohérent
  avec Input (1px line, focus rubis-glow ring, item sélectionné rubis + ✓)

Dropzone amélioré :
- Filtre fichier plus tolérant : MIME OU extension (Finder/Explorer
  envoient parfois type === ""), erreur dédiée taille vs format
- Mode isUploading : titre devient "On lit vos factures…", spinner
  sur le bouton Parcourir

MSW handlers (invoices.ts) :
- POST /invoices/upload : crée batch + drafts avec OCR simulé
- GET /invoices/import-batch/:id
- POST /invoices/import-batch/:id/drafts/:draftId/validate
- POST /invoices/import-batch/:id/drafts/:draftId/skip
- DELETE /invoices/import-batch/:id

mockDb étendu :
- importBatches store + StoredImportDraft type
- createImportBatch / findImportBatch / updateImportDraft / deleteImportBatch
- createInvoice / createClient / listClientsForOrg

Bug fix migration :
- Le sessionStorage stockait des snapshots d'avant l'ajout du champ
  importBatches → db.importBatches undefined → push() crashait. Ajout
  d'une migration douce dans load() qui patche les champs manquants
  avec les défauts du seed (pas de perte de données existantes).

Bundle : 117.50 KB gzip core. Route factures_.import._batchId 10.26 KB
gzip — la plus grosse à cause de Radix Select + state form complexe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:26:31 +02:00
ordinarthur
b5b67056aa feat(web): plans bibliothèque + éditeur
Bibliothèque /plans (cf. wireframe 3.1) :
- Grid responsive 1/2/3 cols avec PlanCard + CreatePlanCard placeholder
- PlanCard : titre, chip meta (un seul à la fois), aperçu 3 étapes avec
  ◆ rotated comme bullet, footer usage + lien "Modifier →"
- Le plan le plus utilisé reçoit le badge "✦ Le plus utilisé" (rubis-glow
  + Sparkles), les autres gardent leur label de tonalité (Doux / Standard
  / Ferme / Strict). Pas de "PLAN PAR DÉFAUT" partout — info tautologique
  vu que les 4 plans seedés sont des défauts.
- Chips de tonalité adoucis (bg-cream-2 ou rubis-glow, plus de fills lourds)
- Skeleton pulsé pendant le fetch

Éditeur /plans/$slug (cf. wireframe 3.2, route _app/plans_.$slug pour
escape la layout parent) :
- Header : eyebrow humeur + nom + compteur d'usage + boutons Dupliquer
  (V2) / Enregistrer (fonctionnel, désactivé tant que pas de changements)
- Layout 2-col responsive (1fr / 1.4fr) :
  · Gauche : cadence — list de StepCard cliquables, état sélectionné avec
    border-rubis + shadow-rubis, "+ Ajouter une étape" disabled (V2)
  · Droite : éditeur d'email — Card avec subject (Input), body (Textarea
    mono 10 rows), grille de variables-chips
- Variable insertion fonctionnelle : clic = insertion au curseur via
  selectionStart/End du textarea, label FR + token mono ({{numero}})
- Bandeau warning rubis-glow quand l'étape est requiresManualValidation :
  "Validation manuelle obligatoire. L'email est généré en brouillon"
- Save fonctionnel : isDirty calculé via JSON.stringify, mutation PATCH
  /plans/:slug, invalidate cache plans.all + setQueryData detail, toast
- Sync state local ↔ serveur via useEffect sur plan.id+updatedAt

MSW :
- handlers/plans.ts : GET /plans (avec usageCount), GET /plans/:slug,
  PATCH /plans/:slug (validation Zod, recompose ids manquants)
- mockDb : findPlanBySlug, listPlansForOrg, updatePlan
- Calcul usageCount : factures du plan en statut != paid && != cancelled

Lib /plans.ts :
- TONE_LABELS : Amical / Standard / Ferme / Mise en demeure (FR)
- planMoodLabel + planOverallTone (humeur globale = ton de la dernière étape)
- TEMPLATE_VARIABLES : 5 variables avec token + label FR + preview

Bundle prod : 117.31 KB gzip core (stable). plans 2.06 KB gzip,
plans_._slug 3.28 KB gzip — la plus grosse route chunk vu sa complexité
(form + variables + state local).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:05:36 +02:00
ordinarthur
14d0e982e9 feat(web): _app shell + dashboard + factures liste & détail
Layout shell pour l'app authentifiée :
- routes/_app.tsx : pathless layout avec auth-guard + onboarding-guard
  (signature null → redirect onboarding/compte)
- AppLayout : grid sidebar + topbar + main + tab bar mobile
- AppSidebar (lg+) : nav verticale + mini compteur rubis en bas
- MobileTabBar : 4 onglets fixed bottom (Accueil, Factures, Plans, Réglages)
- AppTopbar : sticky bg-cream/85 + backdrop-blur, greeting + date sur desktop,
  brand sur mobile
- UserMenu : Radix Popover, avatar initiales rubis, logout mutation

Dashboard / (cf. wireframe 4.1) :
- RubisHero : ◆ 56px + drop-shadow rubis-tinted, "X rubis gagnés" en italic
  rubis sur "gagnés", verbalisation conversion en heures, progression mensuelle
- 4 KpiCard scannables : À relancer, En cours, Encaissé, DSO
  (delta en rubis-deep si intent positif, jamais de vert succès)
- ActivityFeed : journal du jour avec icônes Lucide tonalisées
- TopLatePayers : "Retards récurrents" (pas "mauvais payeurs", cf. marque)
- Quick actions mobile (+ Photo de facture / + Saisir)

Factures liste /factures (wireframe 2.4 + 2.1) :
- 3 états : 0 facture → dropzone full-page · filtre vide → mini-empty
  · populated → filter chips + table desktop / cards mobile
- FilterChips : sync URL (validateSearch zod), counts entre parenthèses
- InvoiceTable : ligne entière cliquable (onClick + role=link + onKeyDown),
  chevron Link séparé pour right-click "ouvrir nouvel onglet"
- InvoiceCardList : version mobile aérée
- StatusBadge : 6 statuts mappés palette marque (rubis solide pour "À valider",
  ink pour "En relance", crème+✓ pour "Encaissée")
- Skeleton pulsé pendant le fetch

Détail facture /factures/$id (wireframe 4.2) :
- Header : eyebrow client + numéro + montant + échéance + délai (J−4 rouge)
  + StatusBadge inline
- Actions : Marquer encaissée (mutation + bonus rubis + invalidate)
- Layout 2-col : Timeline (1.4fr) + sidepanel client/notes (1fr)
- Timeline primitive : pastilles passé/présent/futur (rubis-glow ✓ /
  rubis solide + Clock + ring glow / cercle vide)

Bug fix routing :
- factures.$id.tsx était nesté sous factures.tsx (flat naming TanStack Router)
  → la liste s'affichait à la place de la détail. Renommé factures_.$id.tsx
  pour escape le layout parent. URL inchangée (/factures/$id).

Placeholders soignés : /plans, /clients, /parametres avec EmptyState draft
(bordure pointillée + message qui assume "ça arrive").

MSW étendu :
- mocks/seed.ts : 5 clients, 4 plans avec étapes complètes (Standard B2B,
  Rapide, Patient, Ferme), 10 invoices avec statuses variés calibrés
  sur le wireframe
- handlers/dashboard.ts : GET /dashboard/{kpis,activity,top-late}
- handlers/invoices.ts : GET /invoices (filtres + tri par priorité statut),
  GET /invoices/counts, GET /invoices/:id (timeline calculée depuis le plan),
  POST /invoices/:id/mark-paid (passe en paid + bonus rubis)

Lib étendue :
- format : formatDueDelta (J+10, J−4 avec − typographique), isOverdue
- routes/index.tsx supprimé (remplacé par _app/index.tsx)

Bundle prod : 117 KB gzip core, chaque route en chunk dédié (dashboard
inline dans _app, factures 3.69 KB gzip, factures._id 2.22 KB gzip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:49:06 +02:00
ordinarthur
332bf0bcda feat(web): /signup + 3-step onboarding flow
Nouvelles routes :
- /signup : inscription (fullName + email + password) → /onboarding/compte
- /onboarding : layout avec brand + stepper, auth-guard
- /onboarding/compte : étape 1 (nom + email, prefilled depuis la session)
- /onboarding/entreprise : étape 2 (nom, SIRET optionnel, chips volume)
- /onboarding/signature : étape 3 (signature email + aperçu live)

Nouvelles primitives UI :
- <Card variant="default|flat|hero" padding="sm|md|lg">
- <Stepper> wizard horizontal (current rubis, done rubis-glow + ✓, todo line)
- <Chip selected> : pastille pill, glow + deep quand sélectionnée (le rubis
  plein reste réservé aux CTA, cf. règle "le rubis est rare")
- <Textarea> : mêmes règles a11y/focus que <Input>

MSW handlers étendus :
- PATCH /api/v1/account/profile (fullName, email, signature)
- PATCH /api/v1/organizations/me (name, siret, monthlyVolumeBucket)
- mockDb : ajout des organizations, méthodes updateUser/updateOrg

Wiring :
- /login → "Créer un compte" pointe vers /signup (avant : loop)
- /login succès → /  (au lieu de /login)
- /  → /onboarding/compte si auth, /login sinon (placeholder dashboard)
- /onboarding/signature succès → /

Bundle prod : 113.87 KB gzip core (-2 KB grâce à MSW exclu en prod via
import.meta.env.DEV). Chaque route en chunk dédié (1-2 KB gzip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:22:53 +02:00
ordinarthur
8d3bab6a89 feat: scaffold frontend monorepo + first /login screen
Monorepo Turborepo (pnpm workspaces) avec 3 packages :

- apps/web : SPA React 19 + Vite 8 + Tailwind v4 (CSS-first)
  • TanStack Router (file-based, auto code-splitting), Query, Form
  • Radix primitives bruts + CVA + clsx + tailwind-merge
  • MSW pour mocker l'API tant qu'Adonis n'est pas branché
  • Polices Bricolage Grotesque + Inter self-hostées via fontsource
  • Tokens marque (rubis, cream, ink) exposés via @theme
  • Primitives maison : Gem, Brand, Eyebrow, Button, Input, Field
  • Route /login full flow : TanStack Form + Zod + mutation Query

- apps/api : Adonis 7 (kit api, scaffold via create-adonisjs)
  • Auth access tokens (Bearer) — cf. ADR-017
  • Tuyau core déjà câblé pour la génération de types
  • Routes /api/v1/auth/{signup,login} + /api/v1/account/{profile,logout}
  • Minimal — uniquement le pont front ↔ back

- packages/shared : types TS + schemas Zod + constantes
  • Source unique de vérité partagée api ↔ web
  • Domaines : User, Org, Auth, Client, Invoice, Plan

Tooling racine : Turbo, ESLint v9 flat, Prettier, husky, lint-staged.

CLAUDE.md et docs/decisions.md mis à jour avec ADR-014 à ADR-018
(stack, monorepo, PG existant, Bearer tokens, MinIO existant)
et le pointeur vers docs/tech/architecture.md.

Logo Rubis déplacé de landing/assets/ vers /assets/ (source unique
réutilisée par la landing et l'app).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:10:48 +02:00