rubis/apps/web/src/components/layout/AppLayout.tsx
ordinarthur 933c6496b1 feat(demo): mode démo live — horloge virtuelle + emails capturés
Permet de faire vivre Rubis en accéléré pour démontrer le produit à des
prospects, SANS impacter la prod. Les vrais users ont demoMode=false par
défaut → toute la logique démo est court-circuitée.

Architecture (priorité : zéro impact prod, codebase propre)

Phase 1 — Abstraction Clock
- Migration : organizations.demo_mode + virtual_now + demo_speed_factor
  (défaut false/null/1, zéro effet sur les orgs existantes)
- services/clock.ts : now(orgId?) → DateTime.utc() en prod, virtualNow
  en démo. Cache mémoire 250ms pour pas spammer la DB. Helpers
  setVirtualNow / setDemoMode pour les transitions.
- Refacto 7 fichiers : relance_scheduler, checkin_scheduler, dashboard,
  send_relance_job, send_checkin_job, mail_dispatcher (buildRelanceVars
  daysLate), activity_recorder, checkin_controller, invoices_controller
  (buildTimeline + markPaid). DateTime.now() → clock.now(orgId).
- Tests existants (51) passent identique → preuve que la prod est intacte.

Phase 2 — Capture emails + dispatch
- Migration : demo_captured_emails (kind, to, from, subject, body, sent_at,
  meta) — index sur (org, sent_at desc) pour l'inbox.
- services/demo/capture.ts : captureEmailIfDemo() — UNIQUE point de fork
  dans la prod (deux lignes dans mail_dispatcher : if captured return).
  Hors démo, fonction retourne false → flux Resend inchangé.
- services/demo/dispatch.ts : tickAndDispatch(orgId, target) → bump
  virtual_now, trouve les tasks dues (relance + checkin), invoke les
  handlers existants synchronement (skip BullMQ, propre). Retourne les
  events fired pour l'UI.
- POST /api/v1/demo/{start,end,tick} + GET /demo/{state,inbox}, toutes
  protégées par requireDemoOrg() (403 si demoMode=false).

Phase 3 — UI horloge "vivante"
- lib/demo.ts : useDemoState, useDemoTick (boucle rAF locale qui avance
  virtualNow à `speed * elapsed` jours/sec, sync backend toutes les
  250ms, auto-pause sur fired events). Pas de boutons +1j/+3j —
  l'horloge tourne vraiment.
- DemoClock (top-right, fixed) : date pleine en font display, rail
  rubis-glow avec pastille ◆ qui glisse vers le prochain event,
  play/pause + sélecteur 1x/2x/5x. Auto-cachée si demoMode=false.
- DemoEmailSlide : slide-over droite quand event fires — affiche
  l'email capturé (de/à/sujet/body) façon vrai client mail. Pause
  forcée tant que tous les events ne sont pas acquittés ("comme si
  le temps était vraiment passé").
- DemoToggle dans /parametres : démarrer/quitter le mode démo, avec
  copy explicite ("emails capturés, pas envoyés à de vrais clients").

Le code démo vit isolé dans services/demo/, controllers/demo_controller.ts,
components/demo/, lib/demo.ts. La prod ne référence ces fichiers QUE
via captureEmailIfDemo dans mail_dispatcher.

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

105 lines
3.1 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { Plus, Upload } from "lucide-react";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/Button";
import { AppSidebar } from "./AppSidebar";
import { AppTopbar } from "./AppTopbar";
import { MobileTabBar } from "./MobileTabBar";
import { DemoClock } from "@/components/demo/DemoClock";
import { Link } from "@tanstack/react-router";
import {
ManualInvoiceProvider,
useManualInvoice,
} from "@/hooks/useManualInvoiceDialog";
/**
* Shell de l'app authentifiée :
* - Desktop : sidebar fixe à gauche + main + topbar sticky en haut
* - Mobile : pas de sidebar, topbar avec brand, tab bar fixe en bas
*
* Le bottom-padding mobile (pb-20) évite que le tab bar masque le contenu.
*/
type AppLayoutProps = {
children: React.ReactNode;
/** Titre du topbar (sinon greeting + date). */
title?: string;
subtitle?: string;
/** Actions à droite du topbar. */
actions?: React.ReactNode;
};
type DashboardKpis = {
rubisCount: number;
rubisThisMonth: number;
hoursLiberatedThisMonth: number;
encaisseCents: number;
encaisseDeltaCents: number;
dsoDays: number;
dsoDeltaDays: number;
factureToRelance: number;
factureInRelance: number;
factureNewToday: number;
miseEnDemeurePending: number;
};
export function AppLayout({ children, title, subtitle, actions }: AppLayoutProps) {
return (
<ManualInvoiceProvider>
<AppLayoutInner title={title} subtitle={subtitle} actions={actions}>
{children}
</AppLayoutInner>
</ManualInvoiceProvider>
);
}
function AppLayoutInner({ children, title, subtitle, actions }: AppLayoutProps) {
const manual = useManualInvoice();
// KPIs partagés layout ↔ dashboard : on les charge ici pour que le sidebar
// affiche le compteur sans attendre le rendu du dashboard.
const { data: kpis } = useQuery({
queryKey: queryKeys.dashboard.kpis(),
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
staleTime: 30_000,
});
// Actions globales par défaut (visibles desktop seulement). Sur mobile,
// chaque route gère ses propres CTA en tête de contenu (cf. wireframe 4.3).
const defaultActions = (
<div className="hidden lg:flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={manual.open}>
<Plus size={14} aria-hidden="true" /> Saisir
</Button>
<Button size="sm" asChild>
<Link to="/factures/import">
<Upload size={14} aria-hidden="true" /> Importer factures
</Link>
</Button>
</div>
);
return (
<div className="min-h-screen flex bg-cream">
<AppSidebar rubisThisMonth={kpis?.rubisThisMonth ?? 0} />
<div className="flex flex-1 min-w-0 flex-col">
<AppTopbar
title={title}
subtitle={subtitle}
actions={actions ?? defaultActions}
/>
<main className="flex-1 px-5 py-6 pb-24 lg:px-8 lg:py-8 lg:pb-12">
{children}
</main>
</div>
<MobileTabBar />
{/* Horloge démo — auto-cachée si org.demoMode = false */}
<DemoClock />
</div>
);
}