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>
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
import * as Popover from "@radix-ui/react-popover";
|
|
import { useNavigate } from "@tanstack/react-router";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { ChevronDown, LogOut, Settings as SettingsIcon } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { api } from "@/lib/api";
|
|
import { authStore, useAuth } from "@/lib/auth";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* UserMenu — avatar initiales + popover avec logout.
|
|
* Radix Popover pour l'a11y (focus management, échap, click-outside).
|
|
*/
|
|
export function UserMenu() {
|
|
const { user } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const logoutMutation = useMutation({
|
|
mutationFn: async () => api.post<void>("/api/v1/account/logout"),
|
|
onSettled: () => {
|
|
// Quoi qu'il arrive (succès ou échec réseau), on clear la session locale.
|
|
authStore.clear();
|
|
toast.success("À très vite.");
|
|
void navigate({ to: "/login" });
|
|
},
|
|
});
|
|
|
|
const initials = user?.fullName
|
|
?.split(" ")
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
|
.join("") ?? "?";
|
|
|
|
const firstName = user?.fullName?.split(" ")[0] ?? "Utilisateur";
|
|
|
|
return (
|
|
<Popover.Root>
|
|
<Popover.Trigger
|
|
className={cn(
|
|
"inline-flex items-center gap-2 rounded-full border border-line bg-white",
|
|
"py-1 pl-1 pr-2.5 transition-colors hover:border-ink-3",
|
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
|
)}
|
|
aria-label="Ouvrir le menu utilisateur"
|
|
>
|
|
<span
|
|
className={cn(
|
|
"flex size-7 items-center justify-center rounded-full bg-rubis text-white",
|
|
"font-display text-[12.5px] font-bold leading-none",
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
{initials}
|
|
</span>
|
|
<span className="hidden sm:inline text-[13px] font-medium text-ink">{firstName}</span>
|
|
<ChevronDown size={14} className="text-ink-3" aria-hidden="true" />
|
|
</Popover.Trigger>
|
|
|
|
<Popover.Portal>
|
|
<Popover.Content
|
|
align="end"
|
|
sideOffset={8}
|
|
className={cn(
|
|
"z-50 w-[220px] rounded-card border border-line bg-white p-2 shadow-card",
|
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
)}
|
|
>
|
|
<div className="px-3 py-2">
|
|
<p className="text-[13px] font-semibold text-ink truncate">{user?.fullName}</p>
|
|
<p className="text-[12px] text-ink-3 truncate">{user?.email}</p>
|
|
</div>
|
|
<div className="my-1 h-px bg-line" />
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate({ to: "/parametres" })}
|
|
className="flex w-full items-center gap-2.5 rounded-default px-3 py-2 text-[13.5px] text-ink hover:bg-cream transition-colors"
|
|
>
|
|
<SettingsIcon size={15} className="text-ink-3" aria-hidden="true" />
|
|
Paramètres
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={logoutMutation.isPending}
|
|
onClick={() => logoutMutation.mutate()}
|
|
className="flex w-full items-center gap-2.5 rounded-default px-3 py-2 text-[13.5px] text-ink hover:bg-cream transition-colors disabled:opacity-50"
|
|
>
|
|
<LogOut size={15} className="text-ink-3" aria-hidden="true" />
|
|
Se déconnecter
|
|
</button>
|
|
</Popover.Content>
|
|
</Popover.Portal>
|
|
</Popover.Root>
|
|
);
|
|
}
|