rubis/apps/web/src/lib/format.ts
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

72 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 :
* - "J3" 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);
}