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>
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>
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>
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>