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>
82 lines
2.5 KiB
TypeScript
82 lines
2.5 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";
|
|
|
|
/**
|
|
* 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) {
|
|
// 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">
|
|
<Plus size={14} aria-hidden="true" /> Saisir
|
|
</Button>
|
|
<Button size="sm">
|
|
<Upload size={14} aria-hidden="true" /> Importer factures
|
|
</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 />
|
|
</div>
|
|
);
|
|
}
|