From 14d0e982e9684f8c036b33c39410f29d31fd3d71 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 10:49:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20=5Fapp=20shell=20+=20dashboard=20+?= =?UTF-8?q?=20factures=20liste=20&=20d=C3=A9tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/dashboard/ActivityFeed.tsx | 97 ++++ apps/web/src/components/dashboard/KpiCard.tsx | 49 ++ .../src/components/dashboard/RubisHero.tsx | 93 ++++ .../components/dashboard/TopLatePayers.tsx | 61 +++ apps/web/src/components/factures/Dropzone.tsx | 171 +++++++ .../src/components/factures/FilterChips.tsx | 58 +++ .../components/factures/InvoiceCardList.tsx | 62 +++ .../src/components/factures/InvoiceTable.tsx | 128 ++++++ apps/web/src/components/layout/AppLayout.tsx | 81 ++++ apps/web/src/components/layout/AppSidebar.tsx | 60 +++ apps/web/src/components/layout/AppTopbar.tsx | 66 +++ .../src/components/layout/MobileTabBar.tsx | 37 ++ apps/web/src/components/layout/NavLink.tsx | 60 +++ apps/web/src/components/layout/UserMenu.tsx | 96 ++++ apps/web/src/components/ui/EmptyState.tsx | 50 +++ apps/web/src/components/ui/StatusBadge.tsx | 80 ++++ apps/web/src/components/ui/Timeline.tsx | 95 ++++ apps/web/src/lib/format.ts | 26 ++ apps/web/src/mocks/db.ts | 77 +++- apps/web/src/mocks/handlers/dashboard.ts | 121 +++++ apps/web/src/mocks/handlers/index.ts | 9 +- apps/web/src/mocks/handlers/invoices.ts | 229 ++++++++++ apps/web/src/mocks/seed.ts | 421 ++++++++++++++++++ apps/web/src/routes/_app.tsx | 34 ++ apps/web/src/routes/_app/clients.tsx | 39 ++ apps/web/src/routes/_app/factures.tsx | 208 +++++++++ apps/web/src/routes/_app/factures_.$id.tsx | 211 +++++++++ apps/web/src/routes/_app/index.tsx | 142 ++++++ apps/web/src/routes/_app/parametres.tsx | 36 ++ apps/web/src/routes/_app/plans.tsx | 40 ++ apps/web/src/routes/index.tsx | 23 - 31 files changed, 2918 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/components/dashboard/ActivityFeed.tsx create mode 100644 apps/web/src/components/dashboard/KpiCard.tsx create mode 100644 apps/web/src/components/dashboard/RubisHero.tsx create mode 100644 apps/web/src/components/dashboard/TopLatePayers.tsx create mode 100644 apps/web/src/components/factures/Dropzone.tsx create mode 100644 apps/web/src/components/factures/FilterChips.tsx create mode 100644 apps/web/src/components/factures/InvoiceCardList.tsx create mode 100644 apps/web/src/components/factures/InvoiceTable.tsx create mode 100644 apps/web/src/components/layout/AppLayout.tsx create mode 100644 apps/web/src/components/layout/AppSidebar.tsx create mode 100644 apps/web/src/components/layout/AppTopbar.tsx create mode 100644 apps/web/src/components/layout/MobileTabBar.tsx create mode 100644 apps/web/src/components/layout/NavLink.tsx create mode 100644 apps/web/src/components/layout/UserMenu.tsx create mode 100644 apps/web/src/components/ui/EmptyState.tsx create mode 100644 apps/web/src/components/ui/StatusBadge.tsx create mode 100644 apps/web/src/components/ui/Timeline.tsx create mode 100644 apps/web/src/mocks/handlers/dashboard.ts create mode 100644 apps/web/src/mocks/handlers/invoices.ts create mode 100644 apps/web/src/mocks/seed.ts create mode 100644 apps/web/src/routes/_app.tsx create mode 100644 apps/web/src/routes/_app/clients.tsx create mode 100644 apps/web/src/routes/_app/factures.tsx create mode 100644 apps/web/src/routes/_app/factures_.$id.tsx create mode 100644 apps/web/src/routes/_app/index.tsx create mode 100644 apps/web/src/routes/_app/parametres.tsx create mode 100644 apps/web/src/routes/_app/plans.tsx delete mode 100644 apps/web/src/routes/index.tsx diff --git a/apps/web/src/components/dashboard/ActivityFeed.tsx b/apps/web/src/components/dashboard/ActivityFeed.tsx new file mode 100644 index 0000000..e61cb4a --- /dev/null +++ b/apps/web/src/components/dashboard/ActivityFeed.tsx @@ -0,0 +1,97 @@ +import { format, parseISO } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Send, CheckCircle2, Inbox, AlertTriangle, type LucideIcon } from "lucide-react"; + +import { Card } from "@/components/ui/Card"; +import { Eyebrow } from "@/components/ui/Eyebrow"; +import { cn } from "@/lib/utils"; + +/** + * ActivityFeed — journal d'activité. + * Pattern wireframe 4.1 : ce qui s'est passé pendant que l'user travaillait + * sur son vrai métier (intention émotionnelle forte). + */ +export type ActivityKind = + | "relance_sent" + | "invoice_paid" + | "invoice_imported" + | "warning_drafted"; + +export type ActivityEvent = { + id: string; + kind: ActivityKind; + /** ISO 8601 */ + at: string; + /** Texte HTML simple — peut contenir pour mettre en avant un nom. */ + label: string; +}; + +const ICONS: Record = { + relance_sent: Send, + invoice_paid: CheckCircle2, + invoice_imported: Inbox, + warning_drafted: AlertTriangle, +}; + +const TONE: Record = { + relance_sent: "text-ink-2", + invoice_paid: "text-rubis-deep", + invoice_imported: "text-ink-2", + warning_drafted: "text-rubis-deep", +}; + +type ActivityFeedProps = { + events: ActivityEvent[]; + /** Si la liste est vide, on affiche un empty state au lieu d'une carte vide. */ + emptyMessage?: string; + className?: string; +}; + +export function ActivityFeed({ + events, + emptyMessage = "Tout est calme. Allez vous chercher un café.", + className, +}: ActivityFeedProps) { + return ( + + Aujourd'hui + + {events.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
    + {events.map((event) => { + const Icon = ICONS[event.kind]; + return ( +
  • +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/KpiCard.tsx b/apps/web/src/components/dashboard/KpiCard.tsx new file mode 100644 index 0000000..50ec6c1 --- /dev/null +++ b/apps/web/src/components/dashboard/KpiCard.tsx @@ -0,0 +1,49 @@ +import { Card } from "@/components/ui/Card"; +import { cn } from "@/lib/utils"; + +/** + * KpiCard — un bloc statistique scannable. + * + * Personnalité : + * - Label en eyebrow caps, value en font-display gros, delta en muted small + * - Pas de couleur "verte succès" — on garde la palette rubis/neutres + * - delta neutre : ink-3, delta positif : rubis-deep, delta négatif : ink-3 (jamais rouge alerte) + * + * "Positif" est dans le sens de la métrique : + * - "Encaissé +2 800 €" : positif (intent=positive) + * - "DSO -6j" (DSO baisse → c'est bien) : positif (intent=positive) + * - On laisse l'utilisateur déclarer l'intent. + */ +type KpiCardProps = { + label: string; + value: string; + delta?: string; + /** Sens du delta affiché (sert juste à colorer subtilement). Default neutral. */ + intent?: "positive" | "neutral" | "warning"; + className?: string; +}; + +export function KpiCard({ label, value, delta, intent = "neutral", className }: KpiCardProps) { + return ( + +

+ {label} +

+

+ {value} +

+ {delta && ( +

+ {delta} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/RubisHero.tsx b/apps/web/src/components/dashboard/RubisHero.tsx new file mode 100644 index 0000000..dc15c29 --- /dev/null +++ b/apps/web/src/components/dashboard/RubisHero.tsx @@ -0,0 +1,93 @@ +import { Gem } from "@/components/brand/Gem"; +import { Card } from "@/components/ui/Card"; +import { formatRubisToHours } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +/** + * RubisHero — la pièce centrale du dashboard. + * Cf. wireframe 4.1 : ◆ géant + "X rubis gagnés ce mois" + verbalisation + * de la conversion en heures + barre de progression vers l'objectif mensuel. + * + * Layout asymétrique : + * - Mobile : centré, gem en haut + * - Desktop : gem à gauche (XL), texte au milieu, progression à droite + * + * Pas une carte plate "X stat" : c'est l'identité émotionnelle de l'app. + */ +type RubisHeroProps = { + rubisThisMonth: number; + /** Pourcentage 0-100 vers l'objectif. */ + monthlyGoalProgress: number; + /** Top X% des utilisateurs sur le mois (si disponible). */ + percentile?: number; + className?: string; +}; + +export function RubisHero({ + rubisThisMonth, + monthlyGoalProgress, + percentile, + className, +}: RubisHeroProps) { + const progress = Math.min(100, Math.max(0, monthlyGoalProgress)); + const hoursLabel = formatRubisToHours(rubisThisMonth); + + return ( + + {/* Halo rubis-glow en arrière-plan, très discret */} +