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>
105 lines
3.1 KiB
TypeScript
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>
|
|
);
|
|
}
|