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>
72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
import { format, formatDistanceToNowStrict, parseISO } from "date-fns";
|
||
import { fr } from "date-fns/locale";
|
||
import { MINUTES_PER_RUBIS } from "@rubis/shared";
|
||
|
||
/**
|
||
* Formateurs métier — centralisés pour cohérence d'affichage.
|
||
* Cf. /docs/tech/frontend.md §9.
|
||
*/
|
||
|
||
/** "1 240,00 €" depuis un montant en centimes. */
|
||
export function formatEuros(cents: number): string {
|
||
return new Intl.NumberFormat("fr-FR", {
|
||
style: "currency",
|
||
currency: "EUR",
|
||
minimumFractionDigits: 2,
|
||
}).format(cents / 100);
|
||
}
|
||
|
||
/**
|
||
* Convertit un nombre de rubis en libellé "X h Y" (1 rubis = 10 min).
|
||
* Ex : 124 rubis → "20 h 40".
|
||
*/
|
||
export function formatRubisToHours(rubis: number): string {
|
||
const totalMinutes = rubis * MINUTES_PER_RUBIS;
|
||
const hours = Math.floor(totalMinutes / 60);
|
||
const minutes = totalMinutes % 60;
|
||
if (hours === 0) return `${minutes} min`;
|
||
if (minutes === 0) return `${hours} h`;
|
||
return `${hours} h ${minutes.toString().padStart(2, "0")}`;
|
||
}
|
||
|
||
/** "5 mai 2026" depuis une date ISO. */
|
||
export function formatDate(iso: string): string {
|
||
return format(parseISO(iso), "d MMMM yyyy", { locale: fr });
|
||
}
|
||
|
||
/** "dans 3 jours" / "il y a 2 jours" depuis maintenant. */
|
||
export function formatRelativeDate(iso: string): string {
|
||
return formatDistanceToNowStrict(parseISO(iso), { locale: fr, addSuffix: true });
|
||
}
|
||
|
||
/** "5 mai" version courte. */
|
||
export function formatDateShort(iso: string): string {
|
||
return format(parseISO(iso), "d MMM", { locale: fr });
|
||
}
|
||
|
||
/**
|
||
* Délai par rapport à aujourd'hui pour une échéance ISO :
|
||
* - "J−3" si dépassée de 3 jours
|
||
* - "J+10" si dans 10 jours
|
||
* - "Aujourd'hui" si dans la journée
|
||
*
|
||
* Convention : le signe '−' (minus typographique, pas hyphen) marque le retard.
|
||
* Pas d'espace autour de J — c'est un token visuel compact.
|
||
*/
|
||
export function formatDueDelta(iso: string, now: Date = new Date()): string {
|
||
const due = parseISO(iso);
|
||
// Compare en jours calendaires (pas en ms exactes).
|
||
const oneDay = 24 * 60 * 60 * 1000;
|
||
const dueDay = Math.floor(due.getTime() / oneDay);
|
||
const today = Math.floor(now.getTime() / oneDay);
|
||
const diff = dueDay - today;
|
||
if (diff === 0) return "Aujourd'hui";
|
||
if (diff < 0) return `J−${Math.abs(diff)}`;
|
||
return `J+${diff}`;
|
||
}
|
||
|
||
/** True si l'échéance est passée (strict, pas le jour même). */
|
||
export function isOverdue(iso: string, now: Date = new Date()): boolean {
|
||
return parseISO(iso).getTime() < now.setHours(0, 0, 0, 0);
|
||
}
|