feat(web): _app shell + dashboard + factures liste & détail

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>
This commit is contained in:
ordinarthur 2026-05-06 10:49:06 +02:00
parent 332bf0bcda
commit 14d0e982e9
31 changed files with 2918 additions and 42 deletions

View File

@ -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 <b> pour mettre en avant un nom. */
label: string;
};
const ICONS: Record<ActivityKind, LucideIcon> = {
relance_sent: Send,
invoice_paid: CheckCircle2,
invoice_imported: Inbox,
warning_drafted: AlertTriangle,
};
const TONE: Record<ActivityKind, string> = {
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 (
<Card padding="md" className={cn("min-w-0", className)}>
<Eyebrow tone="ink">Aujourd&apos;hui</Eyebrow>
{events.length === 0 ? (
<p className="mt-4 text-[14px] text-ink-3 italic">{emptyMessage}</p>
) : (
<ul className="mt-4 flex flex-col gap-3">
{events.map((event) => {
const Icon = ICONS[event.kind];
return (
<li
key={event.id}
className="flex items-start gap-3 text-[13.5px] text-ink-2"
>
<Icon
size={15}
strokeWidth={2}
className={cn("mt-0.5 shrink-0", TONE[event.kind])}
aria-hidden="true"
/>
<span
className="flex-1 min-w-0 leading-relaxed"
// Le label vient du serveur — on autorise <b> pour mettre en avant
// un nom de client ou un numéro de facture. Pas de XSS ici car les
// mocks sont locaux ; à durcir avec sanitize si jamais on accepte
// de l'input utilisateur.
dangerouslySetInnerHTML={{ __html: event.label }}
/>
<time
dateTime={event.at}
className="shrink-0 text-[12px] text-ink-3 tabular-nums"
>
{format(parseISO(event.at), "HH:mm", { locale: fr })}
</time>
</li>
);
})}
</ul>
)}
</Card>
);
}

View File

@ -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 (
<Card padding="md" className={cn("min-w-0", className)}>
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3">
{label}
</p>
<p className="mt-2 font-display text-[28px] font-bold leading-none tracking-[-0.018em] text-ink tabular-nums">
{value}
</p>
{delta && (
<p
className={cn(
"mt-2 text-[12px] leading-snug",
intent === "positive" && "text-rubis-deep",
intent === "warning" && "text-rubis-deep font-medium",
intent === "neutral" && "text-ink-3",
)}
>
{delta}
</p>
)}
</Card>
);
}

View File

@ -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 (
<Card variant="hero" padding="md" className={cn("relative overflow-hidden", className)}>
{/* Halo rubis-glow en arrière-plan, très discret */}
<div
aria-hidden="true"
className="absolute -top-20 -right-16 size-64 rounded-full pointer-events-none"
style={{
background:
"radial-gradient(circle, rgba(251,228,234,0.7), transparent 65%)",
}}
/>
<div className="relative z-10 flex flex-col gap-6 sm:flex-row sm:items-center sm:gap-8">
<div className="flex shrink-0 items-center gap-4 sm:flex-col sm:items-center sm:gap-1">
<Gem size={56} glow aria-label="Compteur de rubis" />
<p className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-ink-3 sm:mt-1">
Rubis
</p>
</div>
<div className="flex-1 min-w-0">
<p className="font-display text-[28px] font-bold leading-[1.1] tracking-[-0.022em] text-ink sm:text-[26px]">
<span className="tabular-nums">{rubisThisMonth}</span> rubis{" "}
<em className="not-italic text-rubis">gagnés</em> ce mois
</p>
<p className="mt-1.5 text-[13.5px] leading-relaxed text-ink-2">
{" "}
<span className="font-semibold text-ink tabular-nums">{hoursLabel}</span>{" "}
que vous n&apos;avez pas passées à relancer.
{percentile !== undefined && (
<>
{" "}
Vous êtes dans le <strong>top {percentile}&nbsp;%</strong> ce mois-ci.
</>
)}
</p>
</div>
<div className="shrink-0 sm:w-[160px]">
<div
className="h-1.5 w-full overflow-hidden rounded-full bg-cream-2"
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="h-full bg-rubis transition-[width] duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<p className="mt-2 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-ink-3">
Objectif mensuel · <span className="tabular-nums">{progress}&nbsp;%</span>
</p>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,61 @@
import { Link } from "@tanstack/react-router";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { cn } from "@/lib/utils";
/**
* TopLatePayers wireframe 4.1, panneau "Top retards".
*
* Note marque : "manié avec tact, jamais visible côté client" (cf. annotation
* du wireframe). On nomme "Retards récurrents" et pas "Mauvais payeurs".
*/
export type LatePayer = {
clientId: string;
name: string;
lateInvoicesCount: number;
};
type TopLatePayersProps = {
payers: LatePayer[];
className?: string;
};
export function TopLatePayers({ payers, className }: TopLatePayersProps) {
return (
<Card padding="md" className={cn("min-w-0", className)}>
<div className="flex items-center justify-between">
<Eyebrow tone="ink">Retards récurrents</Eyebrow>
<Link
to="/clients"
className="text-[12px] text-ink-3 hover:text-rubis hover:underline underline-offset-4"
>
Voir tout
</Link>
</div>
{payers.length === 0 ? (
<p className="mt-4 text-[14px] text-ink-3 italic">
Aucun retard récurrent. Vos clients sont sérieux.
</p>
) : (
<ul className="mt-4 flex flex-col divide-y divide-line">
{payers.map((payer) => (
<li
key={payer.clientId}
className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0 text-[13.5px]"
>
<span className="truncate text-ink">{payer.name}</span>
<span className="shrink-0 text-ink-2">
<strong className="font-semibold tabular-nums">{payer.lateInvoicesCount}</strong>{" "}
<span className="text-ink-3">
{payer.lateInvoicesCount > 1 ? "retards" : "retard"}
</span>
</span>
</li>
))}
</ul>
)}
</Card>
);
}

View File

@ -0,0 +1,171 @@
import { useState, useRef, useCallback } from "react";
import { UploadCloud, FilePlus, FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { ACCEPTED_INVOICE_MIME_TYPES, MAX_INVOICE_FILE_SIZE_BYTES } from "@rubis/shared";
import { cn } from "@/lib/utils";
/**
* Dropzone zone de drag & drop multi-fichiers pour les factures.
*
* Cf. wireframe 2.1 et CLAUDE.md (principe "3 clics maximum"). C'est le geste
* principal d'import le clic "parcourir" est le fallback discret.
*
* En V1, la primitive ne fait que recevoir les File[] et les passer à
* `onFiles`. L'upload, l'OCR et la création des invoices se passent en aval
* (backend Adonis ou MSW).
*/
type DropzoneProps = {
/** Variant pleine page (empty state) vs compact (dans un coin). */
variant?: "full" | "compact";
/** Max files acceptés en un drop. Default 20 (cf. wireframe). */
maxFiles?: number;
/** Callback quand des fichiers valides ont été sélectionnés. */
onFiles?: (files: File[]) => void;
className?: string;
};
const ACCEPT_ATTR = ACCEPTED_INVOICE_MIME_TYPES.join(",");
function isAcceptableFile(file: File): boolean {
return (
(ACCEPTED_INVOICE_MIME_TYPES as readonly string[]).includes(file.type) &&
file.size <= MAX_INVOICE_FILE_SIZE_BYTES
);
}
export function Dropzone({
variant = "full",
maxFiles = 20,
onFiles,
className,
}: DropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList) return;
const files = Array.from(fileList);
if (files.length > maxFiles) {
setError(`Maximum ${maxFiles} fichiers en un seul drop.`);
return;
}
const valid = files.filter(isAcceptableFile);
const rejected = files.length - valid.length;
if (rejected > 0 && valid.length === 0) {
setError("Format non supporté. PDF, PNG, JPG seulement.");
return;
}
setError(null);
onFiles?.(valid);
},
[maxFiles, onFiles],
);
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
};
const isFull = variant === "full";
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn(
"rounded-card border-2 border-dashed bg-cream-2/40 transition-colors duration-150",
"flex flex-col items-center justify-center text-center",
isFull ? "py-16 px-8 min-h-[360px]" : "py-8 px-6 min-h-[160px]",
isDragging
? "border-rubis bg-rubis-glow/30"
: "border-line hover:border-ink-3",
className,
)}
>
<div
className={cn(
"flex size-14 items-center justify-center rounded-full bg-white",
"border border-line shadow-soft",
isDragging && "border-rubis text-rubis",
)}
>
<UploadCloud
size={26}
strokeWidth={1.75}
className={isDragging ? "text-rubis" : "text-ink-2"}
aria-hidden="true"
/>
</div>
<p
className={cn(
"mt-5 font-display tracking-[-0.018em] text-ink",
isFull ? "text-[22px] font-semibold" : "text-[17px] font-semibold",
)}
>
{isDragging ? (
<>
Lâchez ici, on s&apos;<em className="text-rubis">occupe</em> du reste.
</>
) : (
<>
Glissez vos factures <em className="text-rubis">ici</em>.
</>
)}
</p>
<p className="mt-2 text-[13px] text-ink-2">
PDF, PNG, JPG · jusqu&apos;à {maxFiles} fichiers en simultané · 10 Mo par fichier
</p>
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row">
<Button
variant="secondary"
size="sm"
onClick={() => inputRef.current?.click()}
type="button"
>
<FolderOpen size={14} aria-hidden="true" /> Parcourir mes fichiers
</Button>
{isFull && (
<Button variant="ghost" size="sm" type="button">
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
</Button>
)}
</div>
{error && (
<p className="mt-4 text-[13px] font-medium text-rubis-deep" role="alert">
{error}
</p>
)}
<input
ref={inputRef}
type="file"
accept={ACCEPT_ATTR}
multiple
className="sr-only"
onChange={(e) => handleFiles(e.target.files)}
/>
{isFull && (
<p className="mt-8 text-[12px] text-ink-3 max-w-md">
L&apos;OCR fait le reste vérifiez en 30 secondes et lancez la relance.
</p>
)}
</div>
);
}

View File

@ -0,0 +1,58 @@
import { Chip } from "@/components/ui/Chip";
import { cn } from "@/lib/utils";
/**
* Filtres en chips pas en dropdown. Cf. wireframe 2.4 :
* "Arthur veut 'à relancer' en 1 clic".
*/
export type FilterOption<TKey extends string = string> = {
key: TKey;
label: string;
/** Compteur affiché entre parenthèses. */
count?: number;
};
type FilterChipsProps<TKey extends string> = {
options: ReadonlyArray<FilterOption<TKey>>;
value: TKey;
onChange: (key: TKey) => void;
className?: string;
};
export function FilterChips<TKey extends string>({
options,
value,
onChange,
className,
}: FilterChipsProps<TKey>) {
return (
<div
role="tablist"
aria-label="Filtrer les factures par statut"
className={cn("flex flex-wrap gap-2", className)}
>
{options.map((opt) => (
<Chip
key={opt.key}
role="tab"
aria-selected={value === opt.key}
selected={value === opt.key}
withCheck={false}
onClick={() => onChange(opt.key)}
>
{opt.label}
{opt.count !== undefined && (
<span
className={cn(
"ml-1 tabular-nums text-[11.5px] font-medium",
value === opt.key ? "text-rubis-deep/80" : "text-ink-3",
)}
>
{opt.count}
</span>
)}
</Chip>
))}
</div>
);
}

View File

@ -0,0 +1,62 @@
import { Link } from "@tanstack/react-router";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { formatEuros, formatDateShort, formatDueDelta, isOverdue } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { InvoiceListItem } from "./InvoiceTable";
/**
* Vue mobile des factures : cards empilées, plus aérées qu'une mini-table.
* Chaque card est un Link vers le détail.
*/
type InvoiceCardListProps = {
invoices: InvoiceListItem[];
className?: string;
};
export function InvoiceCardList({ invoices, className }: InvoiceCardListProps) {
return (
<ul className={cn("flex flex-col gap-2", className)}>
{invoices.map((invoice) => {
const overdue = isOverdue(invoice.dueDate);
return (
<li key={invoice.id}>
<Link
to="/factures/$id"
params={{ id: invoice.id }}
className={cn(
"block rounded-card border border-line bg-white p-4",
"transition-colors hover:border-ink-3",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[14.5px] font-semibold text-ink truncate">
{invoice.clientName}
</p>
<p className="mt-0.5 text-[12px] text-ink-3 tabular-nums">
{invoice.numero}
</p>
</div>
<p className="font-display text-[17px] font-semibold tabular-nums text-ink whitespace-nowrap">
{formatEuros(invoice.amountTtcCents)}
</p>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<StatusBadge status={invoice.status} label={invoice.statusLabel} />
<p className="text-[12px] text-ink-3 tabular-nums">
<span className={cn(overdue && "text-rubis-deep font-semibold")}>
{formatDueDelta(invoice.dueDate)}
</span>{" "}
· {formatDateShort(invoice.dueDate)}
</p>
</div>
</Link>
</li>
);
})}
</ul>
);
}

View File

@ -0,0 +1,128 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import type { Invoice } from "@rubis/shared";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format";
import { cn } from "@/lib/utils";
/** Forme enrichie utilisée par la liste : invoice + nom client + nom plan. */
export type InvoiceListItem = Invoice & {
clientName: string;
planName: string | null;
/** Label de statut serveur, ex: "Relance J+3 envoyée". Override visuel. */
statusLabel?: string;
};
type InvoiceTableProps = {
invoices: InvoiceListItem[];
className?: string;
};
/**
* Table desktop des factures.
*
* Décisions visuelles :
* - Échéance dépassée en bold (cf. wireframe 2.4)
* - **Ligne entière cliquable** via onClick + role="link" + onKeyDown
* (le chevron de droite reste un vrai <Link> pour le right-click "ouvrir
* dans un nouvel onglet" + middle-click)
* - Statut dans une chip sémantique (StatusBadge)
* - Pas de checkbox actions-en-lot en V1 (à venir avec le backend)
*/
export function InvoiceTable({ invoices, className }: InvoiceTableProps) {
const navigate = useNavigate();
const goToInvoice = (id: string): void => {
void navigate({ to: "/factures/$id", params: { id } });
};
return (
<div
className={cn(
"overflow-hidden rounded-card border border-line bg-white",
className,
)}
>
<table className="w-full text-left text-[13.5px]">
<thead>
<tr className="border-b border-line bg-cream-2/50 text-[11px] font-semibold uppercase tracking-[0.1em] text-ink-3">
<th scope="col" className="px-5 py-3 font-semibold">Client</th>
<th scope="col" className="px-3 py-3 font-semibold">N° facture</th>
<th scope="col" className="px-3 py-3 font-semibold text-right">Montant</th>
<th scope="col" className="px-3 py-3 font-semibold">Échéance</th>
<th scope="col" className="px-3 py-3 font-semibold">Statut</th>
<th scope="col" className="px-3 py-3 font-semibold">Plan</th>
<th scope="col" className="px-5 py-3 w-10" aria-label="Actions" />
</tr>
</thead>
<tbody>
{invoices.map((invoice) => {
const overdue = isOverdue(invoice.dueDate);
return (
<tr
key={invoice.id}
role="link"
tabIndex={0}
aria-label={`Facture ${invoice.numero}${invoice.clientName}`}
onClick={() => goToInvoice(invoice.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
goToInvoice(invoice.id);
}
}}
className={cn(
"border-t border-line first:border-t-0 cursor-pointer",
"transition-colors hover:bg-cream-2/40",
"focus-visible:outline-none focus-visible:bg-cream focus-visible:ring-2 focus-visible:ring-rubis-glow focus-visible:ring-inset",
)}
>
<td className="px-5 py-3.5 text-ink font-medium truncate max-w-[200px]">
{invoice.clientName}
</td>
<td className="px-3 py-3.5 text-ink-2 tabular-nums">{invoice.numero}</td>
<td className="px-3 py-3.5 text-ink tabular-nums text-right font-medium">
{formatEuros(invoice.amountTtcCents)}
</td>
<td className="px-3 py-3.5">
<span className={cn("text-ink-2", overdue && "text-ink font-semibold")}>
{formatDate(invoice.dueDate)}
</span>
<span
className={cn(
"ml-2 text-[11.5px] tabular-nums",
overdue ? "text-rubis-deep font-semibold" : "text-ink-3",
)}
>
{formatDueDelta(invoice.dueDate)}
</span>
</td>
<td className="px-3 py-3.5">
<StatusBadge status={invoice.status} label={invoice.statusLabel} />
</td>
<td className="px-3 py-3.5 text-ink-3">
{invoice.planName ?? <span className="italic"> aucun</span>}
</td>
<td className="px-5 py-3.5">
{/* Chevron explicite — vrai <Link> pour le right-click /
middle-click "ouvrir dans nouvel onglet". On stoppe la
propagation pour ne pas double-naviguer via le row click. */}
<Link
to="/factures/$id"
params={{ id: invoice.id }}
onClick={(e) => e.stopPropagation()}
className="inline-flex size-7 items-center justify-center rounded-default text-ink-3 hover:bg-cream hover:text-rubis"
aria-label={`Voir la facture ${invoice.numero}`}
>
<ChevronRight size={16} aria-hidden="true" />
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,81 @@
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>
);
}

View File

@ -0,0 +1,60 @@
import { Link } from "@tanstack/react-router";
import {
LayoutDashboard,
FileText,
ListChecks,
Users,
Settings,
} from "lucide-react";
import { Brand } from "@/components/brand/Brand";
import { Gem } from "@/components/brand/Gem";
import { useAuth } from "@/lib/auth";
import { formatRubisToHours } from "@/lib/format";
import { NavLink } from "./NavLink";
/**
* Sidebar desktop 240px wide, sticky.
* - Brand en haut
* - Nav verticale au centre
* - Compteur rubis en bas (gratification permanente, cf. wireframe 4.1)
*
* Le compteur rubis dans le sidebar n'est pas négociable : c'est ce qui
* fait dire au user "putain j'ai gagné 24h ce mois".
*/
export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) {
const { user: _user } = useAuth();
return (
<aside className="hidden lg:flex h-screen sticky top-0 w-[240px] shrink-0 flex-col border-r border-line bg-cream-2/60 px-4 py-6">
<Link to="/" className="mb-10 px-2">
<Brand />
</Link>
<nav className="flex flex-col gap-1">
<NavLink to="/" icon={<LayoutDashboard size={17} />} label="Dashboard" />
<NavLink to="/factures" icon={<FileText size={17} />} label="Factures" />
<NavLink to="/plans" icon={<ListChecks size={17} />} label="Plans de relance" />
<NavLink to="/clients" icon={<Users size={17} />} label="Clients" />
<NavLink to="/parametres" icon={<Settings size={17} />} label="Paramètres" />
</nav>
<div className="mt-auto">
<div className="rounded-soft border border-line bg-white px-3.5 py-3">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Rubis ce mois
</p>
<div className="mt-1.5 flex items-end gap-2">
<Gem size={18} />
<span className="font-display text-[22px] font-bold leading-none tabular-nums">
{rubisThisMonth}
</span>
</div>
<p className="mt-1 text-[11px] text-ink-3">
{formatRubisToHours(rubisThisMonth)} libérées
</p>
</div>
</div>
</aside>
);
}

View File

@ -0,0 +1,66 @@
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Brand } from "@/components/brand/Brand";
import { useAuth } from "@/lib/auth";
import { cn } from "@/lib/utils";
import { UserMenu } from "./UserMenu";
/**
* Topbar greeting (desktop) ou brand (mobile) à gauche, actions slot et UserMenu à droite.
* Sticky, bg cream/85 + backdrop-blur (cohérent landing).
*/
type AppTopbarProps = {
/** Titre de la page courante (au lieu du greeting). Ex: "Factures". */
title?: string;
/** Sous-titre (date par défaut, ou contexte custom). */
subtitle?: string;
/** Slot d'actions à droite avant l'avatar (ex: boutons + Importer / + Saisir). */
actions?: React.ReactNode;
};
const GREETING_BY_HOUR = (h: number): string => {
if (h < 6) return "Bonsoir";
if (h < 18) return "Bonjour";
return "Bonsoir";
};
export function AppTopbar({ title, subtitle, actions }: AppTopbarProps) {
const { user } = useAuth();
const firstName = user?.fullName?.split(" ")[0];
const now = new Date();
const greeting = title ?? `${GREETING_BY_HOUR(now.getHours())} ${firstName ?? ""}`.trim();
const dateLabel = subtitle ?? format(now, "EEEE d MMMM yyyy", { locale: fr });
return (
<header
className={cn(
"sticky top-0 z-30 border-b border-line",
"bg-cream/85 backdrop-blur-md backdrop-saturate-150",
)}
>
<div className="flex items-center justify-between gap-4 px-5 py-4 lg:px-8 lg:py-5">
{/* Mobile : brand à gauche (pas de sidebar). Desktop : greeting. */}
<div className="lg:hidden">
<Brand />
</div>
<div className="hidden lg:block min-w-0">
<h1 className="font-display text-[19px] font-semibold tracking-[-0.018em] text-ink leading-tight">
{greeting}
{!title && firstName && (
<span aria-hidden="true" className="ml-1.5">
👋
</span>
)}
</h1>
<p className="mt-0.5 text-[12.5px] text-ink-3 first-letter:uppercase">{dateLabel}</p>
</div>
<div className="flex items-center gap-2 sm:gap-3">
{actions}
<UserMenu />
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,37 @@
import { Home, FileText, ListChecks, Settings } from "lucide-react";
import { NavLink } from "./NavLink";
/**
* Tab bar mobile fixed bottom, 4 entrées max.
* Pas de "Clients" : on y accède depuis une facture (cf. wireframe 4.3).
*/
export function MobileTabBar() {
return (
<nav
aria-label="Navigation principale"
className="lg:hidden fixed bottom-0 inset-x-0 z-40 border-t border-line bg-cream-2/95 backdrop-blur-md pb-[env(safe-area-inset-bottom)]"
>
<div className="flex">
<NavLink to="/" variant="tab-bar" icon={<Home size={19} />} label="Accueil" />
<NavLink
to="/factures"
variant="tab-bar"
icon={<FileText size={19} />}
label="Factures"
/>
<NavLink
to="/plans"
variant="tab-bar"
icon={<ListChecks size={19} />}
label="Plans"
/>
<NavLink
to="/parametres"
variant="tab-bar"
icon={<Settings size={19} />}
label="Réglages"
/>
</div>
</nav>
);
}

View File

@ -0,0 +1,60 @@
import { Link, type LinkProps } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
/**
* NavLink item de nav du sidebar / tab bar.
* État actif : bord gauche rubis (desktop) ou label rubis (tab bar).
* Pas de bg plein quand inactif, le sidebar reste calme.
*/
type Variant = "sidebar" | "tab-bar";
type NavLinkProps = Omit<LinkProps, "children"> & {
icon: React.ReactNode;
label: string;
variant?: Variant;
className?: string;
};
export function NavLink({ icon, label, variant = "sidebar", className, ...linkProps }: NavLinkProps) {
const isSidebar = variant === "sidebar";
return (
<Link
{...linkProps}
activeOptions={{ exact: linkProps.to === "/" }}
className={cn(
"group relative flex items-center transition-colors duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
isSidebar
? "gap-3 rounded-default px-3 py-2.5 text-[14px] font-medium text-ink-2 hover:text-ink hover:bg-cream"
: "flex-1 flex-col gap-0.5 py-2 text-[10.5px] font-semibold text-ink-3 hover:text-ink",
className,
)}
activeProps={{
className: isSidebar
? "text-rubis bg-cream shadow-soft"
: "text-rubis",
}}
>
{/* Marqueur rubis vertical sur le sidebar quand actif */}
{isSidebar && (
<span
aria-hidden="true"
className={cn(
"absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-rubis",
"opacity-0 transition-opacity duration-150",
"group-aria-[current=page]:opacity-100",
)}
/>
)}
<span
className={cn(
"shrink-0",
isSidebar ? "text-ink-3 group-aria-[current=page]:text-rubis" : "text-current",
)}
>
{icon}
</span>
<span className={cn(isSidebar ? "" : "leading-none")}>{label}</span>
</Link>
);
}

View File

@ -0,0 +1,96 @@
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>
);
}

View File

@ -0,0 +1,50 @@
import { Card } from "./Card";
import { cn } from "@/lib/utils";
/**
* EmptyState message centré pour pages vides ou écrans en attente.
* - icon optionnel en SVG/Lucide
* - title font-display, body Inter
* - cta optionnel (slot React)
*/
type EmptyStateProps = {
icon?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
cta?: React.ReactNode;
className?: string;
/** Tone "draft" pour les écrans pas encore implémentés. */
draft?: boolean;
};
export function EmptyState({
icon,
title,
description,
cta,
className,
draft = false,
}: EmptyStateProps) {
return (
<Card
variant="flat"
padding="lg"
className={cn(
"flex flex-col items-center text-center min-h-[280px] justify-center",
draft && "border border-dashed border-line bg-cream",
className,
)}
>
{icon && <div className="text-ink-3 mb-3">{icon}</div>}
<h2 className="font-display text-[22px] font-semibold tracking-[-0.018em] text-ink">
{title}
</h2>
{description && (
<p className="mt-2 max-w-md text-[14.5px] leading-relaxed text-ink-2">
{description}
</p>
)}
{cta && <div className="mt-6">{cta}</div>}
</Card>
);
}

View File

@ -0,0 +1,80 @@
import { Check, AlertTriangle, Send, Clock, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { InvoiceStatus } from "@rubis/shared";
/**
* StatusBadge pastille de statut de facture.
*
* Mapping marque-respectueux (cf. /docs/marque.md §3, "le rubis est rare") :
* - pending : cream-2 + ink-2 calme, attente
* - in_relance : ink + cream actif, sombre
* - awaiting_user_confirmation : rubis solide + white "à vous de jouer"
* - paid : cream + ink + accomplissement calme (pas de vert)
* - litigation : rubis-deep + white sérieux
* - cancelled : line + ink-3 muted
*/
type StatusVisual = {
label: string;
icon?: React.ComponentType<{ size?: number; "aria-hidden"?: boolean | "true" | "false" }>;
className: string;
};
const STATUS_MAP: Record<InvoiceStatus, StatusVisual> = {
pending: {
label: "À relancer",
icon: Clock,
className: "bg-cream-2 text-ink-2 border-line",
},
in_relance: {
label: "En relance",
icon: Send,
className: "bg-ink text-cream border-ink",
},
awaiting_user_confirmation: {
label: "À valider",
icon: AlertTriangle,
className: "bg-rubis text-white border-rubis",
},
paid: {
label: "Encaissée",
icon: Check,
className: "bg-white text-ink border-line",
},
litigation: {
label: "Litige",
icon: AlertTriangle,
className: "bg-rubis-deep text-white border-rubis-deep",
},
cancelled: {
label: "Annulée",
icon: X,
className: "bg-cream-2 text-ink-3 border-line",
},
};
type StatusBadgeProps = {
status: InvoiceStatus;
/** Override le label (ex: "Relance J+3 envoyée"). */
label?: string;
/** Si true, masque l'icône — utile dans une cellule dense. */
withoutIcon?: boolean;
className?: string;
};
export function StatusBadge({ status, label, withoutIcon = false, className }: StatusBadgeProps) {
const visual = STATUS_MAP[status];
const Icon = visual.icon;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1",
"font-sans text-[12px] font-medium leading-tight whitespace-nowrap",
visual.className,
className,
)}
>
{!withoutIcon && Icon && <Icon size={12} aria-hidden="true" />}
{label ?? visual.label}
</span>
);
}

View File

@ -0,0 +1,95 @@
import { Check, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Timeline frise verticale d'événements passés / présents / futurs.
* Cf. wireframe 4.2 (détail facture) : "une seule colonne qui mélange passé
* (plein) et futur (estompé) l'utilisateur voit tout l'arc de la relance".
*
* Visuel :
* - past : pastille rubis-glow + rubis-deep, texte ink
* - current : pastille rubis pleine + Clock blanc, texte ink, bordure rubis sur la pastille
* - future : pastille blanche + bord line, texte ink-3 (estompé)
* - ligne verticale ink-3 entre les pastilles
*/
export type TimelineState = "past" | "current" | "future";
export type TimelineEvent = {
id: string;
state: TimelineState;
/** Métadonnée temporelle ("02/04/2026 · facture émise" ou "J+10 — Étape 2"). */
when: string;
/** Action / titre de l'événement. Peut contenir du markup léger via React. */
what: React.ReactNode;
/** Détail secondaire optionnel (ex: "ouvert 2x"). */
detail?: React.ReactNode;
};
type TimelineProps = {
events: TimelineEvent[];
className?: string;
};
export function Timeline({ events, className }: TimelineProps) {
return (
<ol className={cn("relative flex flex-col", className)}>
{events.map((event, idx) => {
const isLast = idx === events.length - 1;
return (
<li key={event.id} className="relative flex gap-4 pb-7 last:pb-0">
{/* Ligne verticale qui descend de la pastille jusqu'au prochain item */}
{!isLast && (
<span
aria-hidden="true"
className={cn(
"absolute left-[14px] top-7 -bottom-1 w-px",
event.state === "past" ? "bg-rubis-glow" : "bg-line",
)}
/>
)}
<Pastille state={event.state} />
<div className="flex-1 min-w-0 pt-0.5">
<p
className={cn(
"text-[11px] font-semibold uppercase tracking-[0.1em]",
event.state === "future" ? "text-ink-3" : "text-rubis",
)}
>
{event.when}
</p>
<div
className={cn(
"mt-1 text-[14px] leading-relaxed",
event.state === "future" ? "text-ink-3" : "text-ink",
)}
>
{event.what}
</div>
{event.detail && (
<p className="mt-0.5 text-[12.5px] text-ink-3">{event.detail}</p>
)}
</div>
</li>
);
})}
</ol>
);
}
function Pastille({ state }: { state: TimelineState }) {
return (
<span
aria-hidden="true"
className={cn(
"relative z-10 flex size-7 shrink-0 items-center justify-center rounded-full",
state === "past" && "bg-rubis-glow text-rubis-deep",
state === "current" && "bg-rubis text-white shadow-rubis ring-4 ring-rubis-glow",
state === "future" && "bg-white text-ink-3 border border-line",
)}
>
{state === "past" && <Check size={13} strokeWidth={2.5} aria-hidden="true" />}
{state === "current" && <Clock size={13} strokeWidth={2.5} aria-hidden="true" />}
{/* future : pas d'icône, juste un cercle vide. */}
</span>
);
}

View File

@ -43,3 +43,29 @@ export function formatRelativeDate(iso: string): string {
export function formatDateShort(iso: string): string {
return format(parseISO(iso), "d MMM", { locale: fr });
}
/**
* Délai par rapport à aujourd'hui pour une échéance ISO :
* - "J3" si dépassée de 3 jours
* - "J+10" si dans 10 jours
* - "Aujourd'hui" si dans la journée
*
* Convention : le signe '' (minus typographique, pas hyphen) marque le retard.
* Pas d'espace autour de J — c'est un token visuel compact.
*/
export function formatDueDelta(iso: string, now: Date = new Date()): string {
const due = parseISO(iso);
// Compare en jours calendaires (pas en ms exactes).
const oneDay = 24 * 60 * 60 * 1000;
const dueDay = Math.floor(due.getTime() / oneDay);
const today = Math.floor(now.getTime() / oneDay);
const diff = dueDay - today;
if (diff === 0) return "Aujourd'hui";
if (diff < 0) return `J${Math.abs(diff)}`;
return `J+${diff}`;
}
/** True si l'échéance est passée (strict, pas le jour même). */
export function isOverdue(iso: string, now: Date = new Date()): boolean {
return parseISO(iso).getTime() < now.setHours(0, 0, 0, 0);
}

View File

@ -3,15 +3,27 @@
* Persiste dans sessionStorage pour survivre aux reload pendant le dev,
* mais reste isolée par onglet (pas d'interférence entre devs).
*/
import type { Organization, User } from "@rubis/shared";
import type { Client, Invoice, Organization, Plan, User } from "@rubis/shared";
import { SEED_CLIENTS, SEED_INVOICES, SEED_PLANS } from "./seed";
const STORAGE_KEY = "rubis.mocks.db";
const STORAGE_KEY = "rubis.mocks.db.v2";
type StoredUser = User & { passwordHash: string };
/** Forme enrichie des invoices stockée localement (avec dénormalisations
* pratiques pour les listes : clientName, planName, statusLabel). */
export type StoredInvoice = Invoice & {
clientName: string;
planName: string | null;
statusLabel?: string;
};
type Db = {
users: StoredUser[];
organizations: Organization[];
clients: Client[];
plans: Plan[];
invoices: StoredInvoice[];
};
const seedDb = (): Db => ({
@ -38,6 +50,9 @@ const seedDb = (): Db => ({
updatedAt: new Date().toISOString(),
},
],
clients: SEED_CLIENTS,
plans: SEED_PLANS,
invoices: SEED_INVOICES,
});
function load(): Db {
@ -69,18 +84,13 @@ function stripHash(user: StoredUser): User {
}
export const mockDb = {
// === Users ===
findUserByEmail(email: string): StoredUser | undefined {
return load().users.find((u) => u.email.toLowerCase() === email.toLowerCase());
},
findUserById(id: string): StoredUser | undefined {
return load().users.find((u) => u.id === id);
},
findOrgById(id: string): Organization | undefined {
return load().organizations.find((o) => o.id === id);
},
createUser(input: { email: string; password: string; fullName: string }): User {
const db = load();
const now = new Date().toISOString();
@ -96,7 +106,6 @@ export const mockDb = {
createdAt: now,
updatedAt: now,
};
const user: StoredUser = {
id: userId,
email: input.email,
@ -107,13 +116,11 @@ export const mockDb = {
updatedAt: now,
passwordHash: input.password,
};
db.users.push(user);
db.organizations.push(org);
save(db);
return stripHash(user);
},
updateUser(
id: string,
patch: Partial<Pick<User, "fullName" | "email" | "signature">>,
@ -122,16 +129,16 @@ export const mockDb = {
const idx = db.users.findIndex((u) => u.id === id);
if (idx === -1) return undefined;
const existing = db.users[idx]!;
const updated: StoredUser = {
...existing,
...patch,
updatedAt: new Date().toISOString(),
};
const updated: StoredUser = { ...existing, ...patch, updatedAt: new Date().toISOString() };
db.users[idx] = updated;
save(db);
return stripHash(updated);
},
// === Organizations ===
findOrgById(id: string): Organization | undefined {
return load().organizations.find((o) => o.id === id);
},
updateOrg(
id: string,
patch: Partial<Pick<Organization, "name" | "siret" | "monthlyVolumeBucket">>,
@ -140,12 +147,46 @@ export const mockDb = {
const idx = db.organizations.findIndex((o) => o.id === id);
if (idx === -1) return undefined;
const existing = db.organizations[idx]!;
const updated: Organization = {
const updated: Organization = { ...existing, ...patch, updatedAt: new Date().toISOString() };
db.organizations[idx] = updated;
save(db);
return updated;
},
// === Clients ===
findClientById(orgId: string, id: string): Client | undefined {
return load().clients.find((c) => c.organizationId === orgId && c.id === id);
},
// === Plans ===
findPlanById(orgId: string, id: string): Plan | undefined {
return load().plans.find((p) => p.organizationId === orgId && p.id === id);
},
// === Invoices ===
listInvoicesForOrg(orgId: string): StoredInvoice[] {
return load().invoices.filter((i) => i.organizationId === orgId);
},
findInvoiceById(orgId: string, id: string): StoredInvoice | undefined {
return load().invoices.find((i) => i.organizationId === orgId && i.id === id);
},
updateInvoice(
orgId: string,
id: string,
patch: Partial<StoredInvoice>,
): StoredInvoice | undefined {
const db = load();
const idx = db.invoices.findIndex(
(i) => i.organizationId === orgId && i.id === id,
);
if (idx === -1) return undefined;
const existing = db.invoices[idx]!;
const updated: StoredInvoice = {
...existing,
...patch,
updatedAt: new Date().toISOString(),
};
db.organizations[idx] = updated;
db.invoices[idx] = updated;
save(db);
return updated;
},

View File

@ -0,0 +1,121 @@
import { http, HttpResponse } from "msw";
import { mockDb } from "../db";
import { userIdFromAuthHeader } from "./auth";
const apiBase = "*/api/v1";
function unauthenticated() {
return HttpResponse.json(
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
{ status: 401 },
);
}
/** Données dashboard simulées — calibrées sur le wireframe 4.1. */
const SAMPLE_KPIS = {
rubisCount: 184,
rubisThisMonth: 124,
hoursLiberatedThisMonth: 1240, // minutes
encaisseCents: 1_432_000,
encaisseDeltaCents: 280_000,
dsoDays: 38,
dsoDeltaDays: -6,
factureToRelance: 8,
factureInRelance: 11,
factureNewToday: 3,
miseEnDemeurePending: 2,
monthlyGoalProgress: 80,
percentile: 15,
};
const SAMPLE_ACTIVITY = [
{
id: "evt_1",
kind: "relance_sent",
at: todayAt(11, 14),
label: "Relance J+3 envoyée à <b>Atelier Durand</b>",
},
{
id: "evt_2",
kind: "invoice_paid",
at: todayAt(10, 2),
label: "Facture <b>F-2026-0035</b> marquée encaissée",
},
{
id: "evt_3",
kind: "invoice_imported",
at: todayAt(9, 48),
label: "<b>3 factures</b> importées et OCRisées",
},
{
id: "evt_4",
kind: "warning_drafted",
at: todayAt(9, 30),
label: "Brouillon mise en demeure prêt — <b>Cabinet Rousseau</b>",
},
];
const SAMPLE_LATE_PAYERS = [
{ clientId: "cli_rousseau", name: "Cabinet Rousseau", lateInvoicesCount: 2 },
{ clientId: "cli_durand", name: "Atelier Durand", lateInvoicesCount: 2 },
{ clientId: "cli_lemoine", name: "Garage Lemoine", lateInvoicesCount: 1 },
];
function todayAt(hours: number, minutes: number): string {
const d = new Date();
d.setHours(hours, minutes, 0, 0);
return d.toISOString();
}
export const dashboardHandlers = [
// GET /api/v1/dashboard/kpis
http.get(`${apiBase}/dashboard/kpis`, ({ request }) => {
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
if (!userId) return unauthenticated();
// L'utilisateur démo a accès aux KPIs simulés. Les autres (signups frais)
// démarrent à zéro pour donner un sentiment de "monde vierge".
const user = mockDb.findUserById(userId);
if (user?.email === "demo@rubis.fr") {
return HttpResponse.json({ data: SAMPLE_KPIS });
}
return HttpResponse.json({
data: {
rubisCount: 0,
rubisThisMonth: 0,
hoursLiberatedThisMonth: 0,
encaisseCents: 0,
encaisseDeltaCents: 0,
dsoDays: 0,
dsoDeltaDays: 0,
factureToRelance: 0,
factureInRelance: 0,
factureNewToday: 0,
miseEnDemeurePending: 0,
monthlyGoalProgress: 0,
percentile: undefined,
},
});
}),
// GET /api/v1/dashboard/activity
http.get(`${apiBase}/dashboard/activity`, ({ request }) => {
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
if (!userId) return unauthenticated();
const user = mockDb.findUserById(userId);
return HttpResponse.json({
data: user?.email === "demo@rubis.fr" ? SAMPLE_ACTIVITY : [],
});
}),
// GET /api/v1/dashboard/top-late
http.get(`${apiBase}/dashboard/top-late`, ({ request }) => {
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
if (!userId) return unauthenticated();
const user = mockDb.findUserById(userId);
return HttpResponse.json({
data: user?.email === "demo@rubis.fr" ? SAMPLE_LATE_PAYERS : [],
});
}),
];

View File

@ -1,5 +1,12 @@
import { authHandlers } from "./auth";
import { onboardingHandlers } from "./onboarding";
import { dashboardHandlers } from "./dashboard";
import { invoiceHandlers } from "./invoices";
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
export const handlers = [...authHandlers, ...onboardingHandlers];
export const handlers = [
...authHandlers,
...onboardingHandlers,
...dashboardHandlers,
...invoiceHandlers,
];

View File

@ -0,0 +1,229 @@
import { http, HttpResponse } from "msw";
import { invoiceListFiltersSchema } from "@rubis/shared";
import type { Client, InvoiceStatus, Plan, PlanStep } from "@rubis/shared";
import { mockDb, type StoredInvoice } from "../db";
import { userIdFromAuthHeader } from "./auth";
const apiBase = "*/api/v1";
function unauthenticated() {
return HttpResponse.json(
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
{ status: 401 },
);
}
function notFound() {
return HttpResponse.json(
{ errors: [{ code: "not_found", message: "Facture introuvable" }] },
{ status: 404 },
);
}
function authedOrgId(authHeader: string | null): string | undefined {
const userId = userIdFromAuthHeader(authHeader);
if (!userId) return undefined;
return mockDb.findUserById(userId)?.organizationId;
}
/**
* Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant. Très simplifié pour V1 :
* - étapes dont sendDay <= aujourd'hui : "past" (envoyées)
* - étape actuelle (la prochaine future) : "current"
* - étapes futures : "future"
*
* On ajoute un événement initial "facture émise".
*/
function buildTimeline(
invoice: StoredInvoice,
plan: Plan | null,
): Array<{
id: string;
state: "past" | "current" | "future";
when: string;
what: string;
detail?: string;
}> {
const events: ReturnType<typeof buildTimeline> = [
{
id: `${invoice.id}__issued`,
state: "past",
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
what: "Importée · OCR validée",
},
];
if (plan && invoice.status !== "paid" && invoice.status !== "cancelled") {
const dueMs = new Date(invoice.dueDate).getTime();
const nowMs = Date.now();
let currentSet = false;
for (const step of plan.steps) {
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000;
const sendDate = new Date(sendMs);
const labelStep = `J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays} — Étape ${step.order + 1}`;
let state: "past" | "current" | "future";
if (sendMs < nowMs) {
state = "past";
} else if (!currentSet) {
state = "current";
currentSet = true;
} else {
state = "future";
}
events.push({
id: `${invoice.id}__step_${step.order}`,
state,
when: `${formatShortDate(sendDate.toISOString())} · ${labelStep}`,
what:
state === "past"
? `Email envoyé · "${step.subject.replace("{{numero}}", invoice.numero)}"`
: state === "current"
? `Email programmé · "${step.subject.replace("{{numero}}", invoice.numero)}"`
: `Email programmé · "${step.subject.replace("{{numero}}", invoice.numero)}"`,
detail: state === "past" ? "Ouvert 1×" : undefined,
});
}
}
if (invoice.status === "paid") {
events.push({
id: `${invoice.id}__paid`,
state: "past",
when: `${formatShortDate(invoice.updatedAt)} · facture encaissée`,
what: "Marquée encaissée — relances stoppées",
});
}
return events;
}
function formatShortDate(iso: string): string {
const d = new Date(iso);
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1)
.toString()
.padStart(2, "0")}/${d.getFullYear()}`;
}
export const invoiceHandlers = [
// GET /api/v1/invoices?status=&q=&clientId=&page=
http.get(`${apiBase}/invoices`, ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const parsed = invoiceListFiltersSchema.safeParse({
...params,
page: params.page ? Number(params.page) : undefined,
});
let invoices = mockDb.listInvoicesForOrg(orgId);
const filters = parsed.success ? parsed.data : { page: 1 };
if (filters.status && filters.status !== "all") {
invoices = invoices.filter((i) => i.status === (filters.status as InvoiceStatus));
}
if (filters.clientId) {
invoices = invoices.filter((i) => i.clientId === filters.clientId);
}
if (filters.q) {
const q = filters.q.toLowerCase();
invoices = invoices.filter(
(i) =>
i.numero.toLowerCase().includes(q) ||
i.clientName.toLowerCase().includes(q),
);
}
// Tri : à relancer / en relance d'abord (les plus actionnables), puis par échéance
const STATUS_PRIO: Record<InvoiceStatus, number> = {
awaiting_user_confirmation: 0,
in_relance: 1,
pending: 2,
litigation: 3,
paid: 4,
cancelled: 5,
};
invoices = [...invoices].sort((a, b) => {
const dp = STATUS_PRIO[a.status] - STATUS_PRIO[b.status];
if (dp !== 0) return dp;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
});
return HttpResponse.json({
data: invoices,
meta: {
total: invoices.length,
page: filters.page ?? 1,
},
});
}),
// GET /api/v1/invoices/counts — compteurs par statut pour les chips
http.get(`${apiBase}/invoices/counts`, ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const invoices = mockDb.listInvoicesForOrg(orgId);
const counts = {
all: invoices.length,
pending: invoices.filter((i) => i.status === "pending").length,
in_relance: invoices.filter((i) => i.status === "in_relance").length,
awaiting_user_confirmation: invoices.filter(
(i) => i.status === "awaiting_user_confirmation",
).length,
paid: invoices.filter((i) => i.status === "paid").length,
litigation: invoices.filter((i) => i.status === "litigation").length,
cancelled: invoices.filter((i) => i.status === "cancelled").length,
};
return HttpResponse.json({ data: counts });
}),
// GET /api/v1/invoices/:id — détail enrichi (client + plan + timeline)
http.get(`${apiBase}/invoices/:id`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const id = params.id as string;
const invoice = mockDb.findInvoiceById(orgId, id);
if (!invoice) return notFound();
const client = mockDb.findClientById(orgId, invoice.clientId) as Client;
const plan = invoice.planId ? mockDb.findPlanById(orgId, invoice.planId) ?? null : null;
const timeline = buildTimeline(invoice, plan);
return HttpResponse.json({
data: {
...invoice,
client,
plan,
timeline,
},
});
}),
// POST /api/v1/invoices/:id/mark-paid
http.post(`${apiBase}/invoices/:id/mark-paid`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const id = params.id as string;
const invoice = mockDb.findInvoiceById(orgId, id);
if (!invoice) return notFound();
const updated = mockDb.updateInvoice(orgId, id, {
status: "paid",
// +1 rubis bonus quand on encaisse — gamification
rubisEarned: invoice.rubisEarned + 1,
});
return HttpResponse.json({ data: updated });
}),
];
// Suppress unused warnings for the imported types we re-export indirectly.
export type { PlanStep };

421
apps/web/src/mocks/seed.ts Normal file
View File

@ -0,0 +1,421 @@
/**
* Seed des données pour l'utilisateur démo.
*
* On factorise le seed dans son propre fichier pour ne pas alourdir db.ts
* avec 200 lignes de données. Calibré sur les wireframes pour donner un
* dashboard / liste réalistes au premier login.
*/
import type { Client, Invoice, Plan } from "@rubis/shared";
const NOW = new Date("2026-05-06T08:30:00.000Z");
function isoFromOffset(daysOffset: number, hour = 9): string {
const d = new Date(NOW);
d.setDate(d.getDate() + daysOffset);
d.setHours(hour, 0, 0, 0);
return d.toISOString();
}
const ORG = "org_demo";
export const SEED_CLIENTS: Client[] = [
{
id: "cli_martin",
organizationId: ORG,
name: "Boulangerie Martin SARL",
email: "compta@boulangerie-martin.fr",
phone: "+33 1 23 45 67 89",
address: "12 rue du Pain, 75011 Paris",
notes: null,
createdAt: isoFromOffset(-90),
updatedAt: isoFromOffset(-2),
},
{
id: "cli_durand",
organizationId: ORG,
name: "Atelier Durand",
email: "contact@atelier-durand.fr",
phone: null,
address: null,
notes: "Le client a confirmé la réception le 14/04 par téléphone — relance ferme inutile.",
createdAt: isoFromOffset(-120),
updatedAt: isoFromOffset(-3),
},
{
id: "cli_rousseau",
organizationId: ORG,
name: "Cabinet Rousseau",
email: "facturation@cabinet-rousseau.fr",
phone: "+33 4 56 78 90 12",
address: "8 place de la République, 69002 Lyon",
notes: null,
createdAt: isoFromOffset(-200),
updatedAt: isoFromOffset(-1),
},
{
id: "cli_lemoine",
organizationId: ORG,
name: "Garage Lemoine",
email: "admin@garage-lemoine.fr",
phone: null,
address: null,
notes: null,
createdAt: isoFromOffset(-60),
updatedAt: isoFromOffset(-5),
},
{
id: "cli_lefevre",
organizationId: ORG,
name: "Studio Lefèvre",
email: "hello@studio-lefevre.com",
phone: null,
address: null,
notes: null,
createdAt: isoFromOffset(-30),
updatedAt: isoFromOffset(-1),
},
];
export const SEED_PLANS: Plan[] = [
{
id: "plan_standard",
organizationId: ORG,
slug: "standard-30j",
name: "Standard B2B",
description: "Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.",
isDefault: true,
steps: [
{
id: "step_std_1",
order: 0,
offsetDays: 3,
tone: "amical",
subject: "Petit rappel — facture {{numero}}",
body: "Bonjour {{client.name}},\n\nNous espérons que tout va bien. Un petit rappel concernant la facture {{numero}} d'un montant de {{amount}}, échue le {{dueDate}}.\n\nMerci d'avance,\n{{signature}}",
requiresManualValidation: false,
},
{
id: "step_std_2",
order: 1,
offsetDays: 10,
tone: "courtois",
subject: "Relance — facture {{numero}} en retard",
body: "Bonjour {{client.name}},\n\nSauf erreur de notre part, la facture {{numero}} d'un montant de {{amount}} reste impayée.\n\nMerci de procéder au règlement dans les meilleurs délais.\n\n{{signature}}",
requiresManualValidation: false,
},
{
id: "step_std_3",
order: 2,
offsetDays: 25,
tone: "ferme",
subject: "Mise en demeure — facture {{numero}}",
body: "Bonjour {{client.name}},\n\nMalgré nos relances, la facture {{numero}} d'un montant de {{amount}} reste impayée. Nous vous mettons en demeure de régler sous 8 jours.\n\n{{signature}}",
requiresManualValidation: true,
},
],
createdAt: isoFromOffset(-365),
updatedAt: isoFromOffset(-30),
},
{
id: "plan_rapide",
organizationId: ORG,
slug: "rapide-15j",
name: "Rapide",
description: "Cadence resserrée pour les factures récurrentes ou les délais courts.",
isDefault: true,
steps: [
{
id: "step_rap_1",
order: 0,
offsetDays: 1,
tone: "amical",
subject: "Facture {{numero}} échue",
body: "Bonjour, petit rappel pour la facture {{numero}}.\n\n{{signature}}",
requiresManualValidation: false,
},
{
id: "step_rap_2",
order: 1,
offsetDays: 7,
tone: "courtois",
subject: "Relance facture {{numero}}",
body: "La facture {{numero}} reste impayée à ce jour. Merci de régulariser.\n\n{{signature}}",
requiresManualValidation: false,
},
{
id: "step_rap_3",
order: 2,
offsetDays: 15,
tone: "ferme",
subject: "Mise en demeure {{numero}}",
body: "Mise en demeure formelle de payer sous 8 jours.\n\n{{signature}}",
requiresManualValidation: true,
},
],
createdAt: isoFromOffset(-365),
updatedAt: isoFromOffset(-365),
},
{
id: "plan_patient",
organizationId: ORG,
slug: "patient-60j",
name: "Patient",
description: "Pour les clients de longue date. On laisse respirer avant de relancer.",
isDefault: true,
steps: [
{
id: "step_pat_1",
order: 0,
offsetDays: 15,
tone: "amical",
subject: "Facture {{numero}}",
body: "Bonjour, simple rappel.\n\n{{signature}}",
requiresManualValidation: false,
},
{
id: "step_pat_2",
order: 1,
offsetDays: 30,
tone: "courtois",
subject: "Relance facture {{numero}}",
body: "Merci de régulariser dans les meilleurs délais.\n\n{{signature}}",
requiresManualValidation: false,
},
],
createdAt: isoFromOffset(-365),
updatedAt: isoFromOffset(-365),
},
{
id: "plan_ferme",
organizationId: ORG,
slug: "ferme-7j",
name: "Ferme",
description: "Cadence stricte pour les clients à risque ou les retards récurrents.",
isDefault: true,
steps: [
{
id: "step_fer_1",
order: 0,
offsetDays: 1,
tone: "courtois",
subject: "Facture {{numero}}",
body: "Premier rappel.\n\n{{signature}}",
requiresManualValidation: false,
},
{
id: "step_fer_2",
order: 1,
offsetDays: 5,
tone: "ferme",
subject: "Relance ferme {{numero}}",
body: "Le règlement est attendu sous 48h.\n\n{{signature}}",
requiresManualValidation: false,
},
{
id: "step_fer_3",
order: 2,
offsetDays: 10,
tone: "mise_en_demeure",
subject: "Mise en demeure {{numero}}",
body: "Mise en demeure formelle.\n\n{{signature}}",
requiresManualValidation: true,
},
],
createdAt: isoFromOffset(-365),
updatedAt: isoFromOffset(-365),
},
];
type SeedInvoice = Invoice & { clientName: string; planName: string | null; statusLabel?: string };
export const SEED_INVOICES: SeedInvoice[] = [
// À relancer (échéance future)
{
id: "inv_001",
organizationId: ORG,
clientId: "cli_martin",
clientName: "Boulangerie Martin SARL",
numero: "F-2026-0042",
amountTtcCents: 124_000,
issueDate: isoFromOffset(-15),
dueDate: isoFromOffset(10),
status: "pending",
planId: "plan_standard",
planName: "Standard B2B",
pdfStorageKey: null,
notes: null,
rubisEarned: 0,
createdAt: isoFromOffset(-15),
updatedAt: isoFromOffset(-15),
},
{
id: "inv_002",
organizationId: ORG,
clientId: "cli_lefevre",
clientName: "Studio Lefèvre",
numero: "F-2026-0044",
amountTtcCents: 245_000,
issueDate: isoFromOffset(-10),
dueDate: isoFromOffset(20),
status: "pending",
planId: "plan_standard",
planName: "Standard B2B",
pdfStorageKey: null,
notes: null,
rubisEarned: 0,
createdAt: isoFromOffset(-10),
updatedAt: isoFromOffset(-10),
},
{
id: "inv_003",
organizationId: ORG,
clientId: "cli_lemoine",
clientName: "Garage Lemoine",
numero: "F-2026-0045",
amountTtcCents: 89_000,
issueDate: isoFromOffset(-5),
dueDate: isoFromOffset(25),
status: "pending",
planId: "plan_patient",
planName: "Patient",
pdfStorageKey: null,
notes: null,
rubisEarned: 0,
createdAt: isoFromOffset(-5),
updatedAt: isoFromOffset(-5),
},
// En relance (échéance passée, étape envoyée)
{
id: "inv_004",
organizationId: ORG,
clientId: "cli_durand",
clientName: "Atelier Durand",
numero: "F-2026-0039",
amountTtcCents: 360_000,
issueDate: isoFromOffset(-34),
dueDate: isoFromOffset(-4),
status: "in_relance",
planId: "plan_standard",
planName: "Standard B2B",
pdfStorageKey: null,
notes: "Client a confirmé la réception le 14/04 par téléphone.",
rubisEarned: 1,
statusLabel: "Relance J+3 envoyée",
createdAt: isoFromOffset(-34),
updatedAt: isoFromOffset(-1),
},
{
id: "inv_005",
organizationId: ORG,
clientId: "cli_durand",
clientName: "Atelier Durand",
numero: "F-2026-0036",
amountTtcCents: 180_000,
issueDate: isoFromOffset(-50),
dueDate: isoFromOffset(-20),
status: "in_relance",
planId: "plan_standard",
planName: "Standard B2B",
pdfStorageKey: null,
notes: null,
rubisEarned: 2,
statusLabel: "Relance J+10 envoyée",
createdAt: isoFromOffset(-50),
updatedAt: isoFromOffset(-10),
},
{
id: "inv_006",
organizationId: ORG,
clientId: "cli_martin",
clientName: "Boulangerie Martin SARL",
numero: "F-2026-0033",
amountTtcCents: 95_500,
issueDate: isoFromOffset(-40),
dueDate: isoFromOffset(-10),
status: "in_relance",
planId: "plan_standard",
planName: "Standard B2B",
pdfStorageKey: null,
notes: null,
rubisEarned: 1,
statusLabel: "Relance J+10 demain",
createdAt: isoFromOffset(-40),
updatedAt: isoFromOffset(-3),
},
// À valider (mise en demeure)
{
id: "inv_007",
organizationId: ORG,
clientId: "cli_rousseau",
clientName: "Cabinet Rousseau",
numero: "F-2026-0028",
amountTtcCents: 85_000,
issueDate: isoFromOffset(-65),
dueDate: isoFromOffset(-35),
status: "awaiting_user_confirmation",
planId: "plan_standard",
planName: "Standard B2B",
pdfStorageKey: null,
notes: null,
rubisEarned: 2,
statusLabel: "Mise en demeure prête",
createdAt: isoFromOffset(-65),
updatedAt: isoFromOffset(0),
},
// Encaissées
{
id: "inv_008",
organizationId: ORG,
clientId: "cli_lemoine",
clientName: "Garage Lemoine",
numero: "F-2026-0035",
amountTtcCents: 420_000,
issueDate: isoFromOffset(-45),
dueDate: isoFromOffset(-15),
status: "paid",
planId: "plan_standard",
planName: "Standard B2B",
pdfStorageKey: null,
notes: null,
rubisEarned: 2,
createdAt: isoFromOffset(-45),
updatedAt: isoFromOffset(0),
},
{
id: "inv_009",
organizationId: ORG,
clientId: "cli_lefevre",
clientName: "Studio Lefèvre",
numero: "F-2026-0030",
amountTtcCents: 156_000,
issueDate: isoFromOffset(-60),
dueDate: isoFromOffset(-30),
status: "paid",
planId: "plan_standard",
planName: "Standard B2B",
pdfStorageKey: null,
notes: null,
rubisEarned: 1,
createdAt: isoFromOffset(-60),
updatedAt: isoFromOffset(-25),
},
// Litige
{
id: "inv_010",
organizationId: ORG,
clientId: "cli_rousseau",
clientName: "Cabinet Rousseau",
numero: "F-2026-0019",
amountTtcCents: 1_200_000,
issueDate: isoFromOffset(-100),
dueDate: isoFromOffset(-70),
status: "litigation",
planId: null,
planName: null,
pdfStorageKey: null,
notes: "Litige en cours — passé en contentieux.",
rubisEarned: 3,
createdAt: isoFromOffset(-100),
updatedAt: isoFromOffset(-15),
},
];

View File

@ -0,0 +1,34 @@
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { authStore } from "@/lib/auth";
import { AppLayout } from "@/components/layout/AppLayout";
/**
* `_app` layout pathless pour l'app authentifiée.
* URL des enfants : /, /factures, /plans, /clients, /parametres
*
* Garde d'auth + onboarding :
* - Pas authentifié /login (avec redirect)
* - Authentifié mais signature null onboarding pas terminé /onboarding/compte
*/
export const Route = createFileRoute("/_app")({
beforeLoad: ({ location }) => {
if (!authStore.isAuthenticated()) {
throw redirect({ to: "/login", search: { redirect: location.href } });
}
// Onboarding incomplet : on a un user mais pas de signature → step 3 pas faite.
// Côté API "vrai", on s'appuiera plutôt sur `organization.onboardingCompletedAt`.
if (authStore.user && authStore.user.signature === null) {
throw redirect({ to: "/onboarding/compte" });
}
},
component: AppRouteComponent,
});
function AppRouteComponent() {
return (
<AppLayout>
<Outlet />
</AppLayout>
);
}

View File

@ -0,0 +1,39 @@
import { createFileRoute } from "@tanstack/react-router";
import { Users } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
export const Route = createFileRoute("/_app/clients")({
component: ClientsPage,
});
function ClientsPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
Clients
</h1>
<p className="mt-1 text-[14px] text-ink-3">
Vos clients facturés, leurs coordonnées, leur historique.
</p>
</div>
<EmptyState
draft
icon={<Users size={36} strokeWidth={1.5} aria-hidden="true" />}
title={
<>
Pas encore de <em className="text-rubis">clients</em> à afficher.
</>
}
description={
<>
Le carnet client se remplit automatiquement à chaque facture
importée. La vue dédiée arrive dans une prochaine itération.
</>
}
/>
</div>
);
}

View File

@ -0,0 +1,208 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import {
invoiceListFiltersSchema,
type InvoiceStatus,
} from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { Dropzone } from "@/components/factures/Dropzone";
import { FilterChips, type FilterOption } from "@/components/factures/FilterChips";
import {
InvoiceTable,
type InvoiceListItem,
} from "@/components/factures/InvoiceTable";
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
/** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */
const FILTER_KEYS = [
"all",
"pending",
"in_relance",
"awaiting_user_confirmation",
"paid",
"litigation",
] as const;
type FilterKey = (typeof FILTER_KEYS)[number];
/** Sous-ensemble du schéma shared, adapté aux clés UI. */
const searchSchema = invoiceListFiltersSchema.extend({
status: z.enum(FILTER_KEYS).optional().default("all"),
});
type StatusCounts = {
all: number;
pending: number;
in_relance: number;
awaiting_user_confirmation: number;
paid: number;
litigation: number;
cancelled: number;
};
export const Route = createFileRoute("/_app/factures")({
validateSearch: searchSchema,
component: FacturesPage,
loader: ({ context }) => {
void context.queryClient.prefetchQuery({
queryKey: queryKeys.invoices.list({}),
queryFn: () => api.get<InvoiceListItem[]>("/api/v1/invoices"),
});
},
});
function FacturesPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const { data: invoices = [], isPending } = useQuery({
queryKey: queryKeys.invoices.list({
status: search.status as InvoiceStatus | "all" | undefined,
q: search.q,
clientId: search.clientId,
page: search.page,
}),
queryFn: () => {
const params = new URLSearchParams();
if (search.status && search.status !== "all") params.set("status", search.status);
if (search.q) params.set("q", search.q);
if (search.clientId) params.set("clientId", search.clientId);
const qs = params.toString();
return api.get<InvoiceListItem[]>(
`/api/v1/invoices${qs ? `?${qs}` : ""}`,
);
},
});
const { data: counts } = useQuery({
queryKey: ["invoices", "counts"] as const,
queryFn: () => api.get<StatusCounts>("/api/v1/invoices/counts"),
});
// Empty state global = aucune facture du tout (pas juste filtre vide).
// Pour l'instant on l'estime via counts.all === 0.
const totalInvoices = counts?.all ?? null;
const isFilteredEmpty = invoices.length === 0 && totalInvoices !== 0;
if (totalInvoices === 0) {
return <FacturesEmpty />;
}
const filterOptions: ReadonlyArray<FilterOption<FilterKey>> = [
{ key: "all", label: "Toutes", count: counts?.all },
{ key: "pending", label: "À relancer", count: counts?.pending },
{ key: "in_relance", label: "En relance", count: counts?.in_relance },
{
key: "awaiting_user_confirmation",
label: "À valider",
count: counts?.awaiting_user_confirmation,
},
{ key: "paid", label: "Encaissées", count: counts?.paid },
{ key: "litigation", label: "Litige", count: counts?.litigation },
];
const handleFilter = (key: FilterKey): void => {
void navigate({
to: "/factures",
search: (prev) => ({ ...prev, status: key, page: 1 }),
});
};
return (
<div className="flex flex-col gap-5">
<div>
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
Factures{" "}
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
· {totalInvoices ?? 0} active{(totalInvoices ?? 0) > 1 ? "s" : ""}
</span>
</h1>
<p className="mt-1 text-[13.5px] text-ink-3">
Vos factures à relancer, en relance et encaissées. Cliquez une ligne
pour voir la timeline.
</p>
</div>
<FilterChips
options={filterOptions}
value={(search.status as FilterKey | undefined) ?? "all"}
onChange={handleFilter}
/>
{isFilteredEmpty ? (
<FilteredEmpty onReset={() => handleFilter("all")} />
) : isPending ? (
<SkeletonRows />
) : (
<>
<div className="hidden lg:block">
<InvoiceTable invoices={invoices} />
</div>
<div className="lg:hidden">
<InvoiceCardList invoices={invoices} />
</div>
</>
)}
</div>
);
}
/** Empty state global : pas une seule facture. La dropzone EST l'écran. */
function FacturesEmpty() {
return (
<div className="flex flex-col gap-5">
<div>
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
<em className="text-rubis">Première</em> facture ?
</h1>
<p className="mt-1 text-[14px] text-ink-3">
Glissez vos PDF ici, l&apos;OCR se charge du reste. Vous validez
en 30 secondes.
</p>
</div>
<Dropzone variant="full" />
</div>
);
}
/** Empty state filtré : on a des factures mais pas dans ce filtre. */
function FilteredEmpty({ onReset }: { onReset: () => void }) {
return (
<div className="rounded-card border border-dashed border-line bg-cream-2/30 px-6 py-12 text-center">
<p className="font-display text-[18px] font-semibold text-ink">
Rien dans <em className="text-rubis">ce filtre</em>.
</p>
<p className="mt-2 text-[13.5px] text-ink-2">
Aucune facture ne correspond. C&apos;est plutôt bon signe.
</p>
<button
type="button"
onClick={onReset}
className="mt-4 text-[13px] font-semibold text-rubis underline-offset-4 hover:underline"
>
Voir toutes les factures
</button>
</div>
);
}
function SkeletonRows() {
return (
<div className="rounded-card border border-line bg-white">
{Array.from({ length: 6 }).map((_, idx) => (
<div
key={idx}
className="flex items-center gap-4 px-5 py-4 border-t border-line first:border-t-0 animate-pulse"
>
<div className="h-3 flex-1 rounded bg-cream-2" />
<div className="h-3 w-20 rounded bg-cream-2" />
<div className="h-3 w-24 rounded bg-cream-2" />
<div className="h-3 w-28 rounded bg-cream-2" />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,211 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, Send } from "lucide-react";
import { toast } from "sonner";
import type { Client, Invoice, Plan } from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import {
formatEuros,
formatDate,
formatDueDelta,
isOverdue,
} from "@/lib/format";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { Timeline, type TimelineEvent } from "@/components/ui/Timeline";
import { Textarea } from "@/components/ui/Textarea";
type InvoiceDetail = Invoice & {
client: Client;
plan: Plan | null;
timeline: TimelineEvent[];
/** Override du label statut (ex: "Relance J+3 envoyée"). */
statusLabel?: string;
};
export const Route = createFileRoute("/_app/factures_/$id")({
component: InvoiceDetailPage,
loader: ({ context, params }) => {
void context.queryClient.prefetchQuery({
queryKey: queryKeys.invoices.detail(params.id),
queryFn: () => api.get<InvoiceDetail>(`/api/v1/invoices/${params.id}`),
});
},
});
function InvoiceDetailPage() {
const { id } = Route.useParams();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: invoice, isPending, isError } = useQuery({
queryKey: queryKeys.invoices.detail(id),
queryFn: () => api.get<InvoiceDetail>(`/api/v1/invoices/${id}`),
});
const markPaidMutation = useMutation({
mutationFn: () => api.post<InvoiceDetail>(`/api/v1/invoices/${id}/mark-paid`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() });
void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.kpis() });
toast.success("Encaissée. + 1 rubis bien mérité.");
void navigate({ to: "/factures" });
},
onError: () => {
toast.error("On n'a pas pu marquer la facture. Réessayez.");
},
});
if (isError) {
return (
<div className="text-center py-12">
<p className="font-display text-[20px] font-semibold text-ink">
Facture introuvable.
</p>
<Link
to="/factures"
className="mt-3 inline-block text-[13px] text-rubis underline-offset-4 hover:underline"
>
Retour aux factures
</Link>
</div>
);
}
if (isPending || !invoice) {
return <InvoiceDetailSkeleton />;
}
const overdue = isOverdue(invoice.dueDate);
const isPaid = invoice.status === "paid";
return (
<div className="flex flex-col gap-6">
<Link
to="/factures"
className="inline-flex items-center gap-1.5 self-start text-[12.5px] text-ink-3 hover:text-rubis"
>
<ArrowLeft size={13} aria-hidden="true" /> Factures
</Link>
<header className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<Eyebrow>{invoice.client.name}</Eyebrow>
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Facture {invoice.numero}
</h1>
<p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[14px] text-ink-2">
<span className="font-display text-[18px] font-semibold text-ink tabular-nums">
{formatEuros(invoice.amountTtcCents)}
</span>
<span className="text-ink-3">·</span>
<span>
échue le{" "}
<span className={overdue ? "font-semibold text-ink" : ""}>
{formatDate(invoice.dueDate)}
</span>{" "}
<span
className={`tabular-nums ${
overdue ? "font-semibold text-rubis-deep" : "text-ink-3"
}`}
>
({formatDueDelta(invoice.dueDate)})
</span>
</span>
<StatusBadge
status={invoice.status}
label={invoice.statusLabel}
className="ml-1"
/>
</p>
</div>
{!isPaid && invoice.status !== "litigation" && (
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
loading={markPaidMutation.isPending}
onClick={() => markPaidMutation.mutate()}
>
<Check size={14} aria-hidden="true" /> Marquer encaissée
</Button>
<Button size="sm" disabled>
<Send size={14} aria-hidden="true" /> Relancer maintenant
</Button>
</div>
)}
</header>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.4fr_1fr]">
{/* Timeline */}
<Card padding="md">
<Eyebrow tone="ink">
Timeline
{invoice.plan && (
<span className="text-ink-3"> · plan {invoice.plan.name}</span>
)}
</Eyebrow>
<Timeline events={invoice.timeline} className="mt-5" />
</Card>
{/* Sidepanel : client + notes */}
<div className="flex flex-col gap-4">
<Card padding="md">
<Eyebrow tone="ink">Client</Eyebrow>
<p className="mt-3 font-display text-[16px] font-semibold text-ink">
{invoice.client.name}
</p>
{invoice.client.email && (
<p className="mt-1 text-[13.5px] text-ink-2 truncate">
{invoice.client.email}
</p>
)}
{invoice.client.phone && (
<p className="mt-0.5 text-[13.5px] text-ink-2">
{invoice.client.phone}
</p>
)}
{invoice.client.address && (
<p className="mt-2 text-[12.5px] leading-relaxed text-ink-3">
{invoice.client.address}
</p>
)}
</Card>
<Card padding="md">
<Eyebrow tone="ink">Notes internes</Eyebrow>
<Textarea
defaultValue={invoice.notes ?? invoice.client.notes ?? ""}
placeholder="Notes privées sur ce client ou cette facture…"
rows={5}
className="mt-3 bg-cream-2/40 border-0 focus:bg-white"
/>
<p className="mt-2 text-[11.5px] text-ink-3 italic">
Visibles uniquement par vous. Pas envoyées au client.
</p>
</Card>
</div>
</div>
</div>
);
}
function InvoiceDetailSkeleton() {
return (
<div className="flex flex-col gap-6 animate-pulse">
<div className="h-3 w-20 rounded bg-cream-2" />
<div className="h-8 w-2/3 rounded bg-cream-2" />
<div className="h-4 w-1/2 rounded bg-cream-2" />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.4fr_1fr]">
<div className="h-72 rounded-card bg-cream-2" />
<div className="h-72 rounded-card bg-cream-2" />
</div>
</div>
);
}

View File

@ -0,0 +1,142 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Camera, Plus, ArrowDownRight } from "lucide-react";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { formatEuros } from "@/lib/format";
import { Button } from "@/components/ui/Button";
import { RubisHero } from "@/components/dashboard/RubisHero";
import { KpiCard } from "@/components/dashboard/KpiCard";
import {
ActivityFeed,
type ActivityEvent,
} from "@/components/dashboard/ActivityFeed";
import {
TopLatePayers,
type LatePayer,
} from "@/components/dashboard/TopLatePayers";
type DashboardKpis = {
rubisCount: number;
rubisThisMonth: number;
hoursLiberatedThisMonth: number;
encaisseCents: number;
encaisseDeltaCents: number;
dsoDays: number;
dsoDeltaDays: number;
factureToRelance: number;
factureInRelance: number;
factureNewToday: number;
miseEnDemeurePending: number;
monthlyGoalProgress: number;
percentile?: number;
};
export const Route = createFileRoute("/_app/")({
component: DashboardPage,
loader: ({ context }) => {
// Préchauffe le cache pour éviter le flash KPIs vides au mount.
void context.queryClient.prefetchQuery({
queryKey: queryKeys.dashboard.kpis(),
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
});
},
});
function DashboardPage() {
const { data: kpis } = useQuery({
queryKey: queryKeys.dashboard.kpis(),
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
});
const { data: activity = [] } = useQuery({
queryKey: queryKeys.dashboard.activity(),
queryFn: () => api.get<ActivityEvent[]>("/api/v1/dashboard/activity"),
});
const { data: latePayers = [] } = useQuery({
queryKey: ["dashboard", "top-late"] as const,
queryFn: () => api.get<LatePayer[]>("/api/v1/dashboard/top-late"),
});
return (
<div className="flex flex-col gap-6 lg:gap-7">
{/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */}
<div className="flex gap-2 lg:hidden">
<Button size="sm" className="flex-1">
<Camera size={15} aria-hidden="true" /> Photo de facture
</Button>
<Button size="sm" variant="secondary" className="flex-1">
<Plus size={15} aria-hidden="true" /> Saisir
</Button>
</div>
<RubisHero
rubisThisMonth={kpis?.rubisThisMonth ?? 0}
monthlyGoalProgress={kpis?.monthlyGoalProgress ?? 0}
percentile={kpis?.percentile}
/>
<section
aria-label="Indicateurs clés"
className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4"
>
<KpiCard
label="À relancer"
value={String(kpis?.factureToRelance ?? 0)}
delta={
kpis?.factureNewToday
? `${kpis.factureNewToday} nouvelle${kpis.factureNewToday > 1 ? "s" : ""} aujourd'hui`
: undefined
}
intent="neutral"
/>
<KpiCard
label="En cours de relance"
value={String(kpis?.factureInRelance ?? 0)}
delta={
kpis?.miseEnDemeurePending
? `${kpis.miseEnDemeurePending} mise${kpis.miseEnDemeurePending > 1 ? "s" : ""} en demeure à valider`
: undefined
}
intent="warning"
/>
<KpiCard
label="Encaissé ce mois"
value={formatEuros(kpis?.encaisseCents ?? 0)}
delta={
kpis?.encaisseDeltaCents
? `+ ${formatEuros(kpis.encaisseDeltaCents)} vs avril`
: undefined
}
intent="positive"
/>
<KpiCard
label="DSO moyen"
value={`${kpis?.dsoDays ?? 0} j`}
delta={
kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0
? `${
kpis.dsoDeltaDays < 0 ? "↘" : "↗"
} ${Math.abs(kpis.dsoDeltaDays)} j depuis Rubis`
: undefined
}
intent={kpis && kpis.dsoDeltaDays < 0 ? "positive" : "neutral"}
/>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_1fr] lg:gap-5">
<ActivityFeed events={activity} />
<TopLatePayers payers={latePayers} />
</section>
{/* Petite signature visuelle en bas — discret, juste pour aérer. */}
<p className="mt-2 hidden lg:flex items-center gap-1.5 text-[11px] text-ink-3">
<ArrowDownRight size={12} aria-hidden="true" />
Vos factures se relancent en silence pendant que vous travaillez.
</p>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { createFileRoute } from "@tanstack/react-router";
import { Settings } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
export const Route = createFileRoute("/_app/parametres")({
component: ParametresPage,
});
function ParametresPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
Paramètres
</h1>
<p className="mt-1 text-[14px] text-ink-3">
Compte, entreprise, signature, facturation.
</p>
</div>
<EmptyState
draft
icon={<Settings size={36} strokeWidth={1.5} aria-hidden="true" />}
title="Bientôt ici."
description={
<>
On va y reposer ce que vous avez rempli à l&apos;onboarding,
avec en plus la facturation Rubis et la gestion des invitations
(V2). Vous pourrez tout modifier à tout moment.
</>
}
/>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { createFileRoute } from "@tanstack/react-router";
import { ListChecks } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
export const Route = createFileRoute("/_app/plans")({
component: PlansPage,
});
function PlansPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
Plans de relance
</h1>
<p className="mt-1 text-[14px] text-ink-3">
La cadence avec laquelle Rubis relance pour vous.
</p>
</div>
<EmptyState
draft
icon={<ListChecks size={36} strokeWidth={1.5} aria-hidden="true" />}
title={
<>
Quatre plans <em className="text-rubis">prêts à l&apos;emploi</em>.
</>
}
description={
<>
Bibliothèque + éditeur de plan : cadences (J+3, J+10, J+20),
templates email avec variables, ton qui monte avec le retard.
Étape suivante.
</>
}
/>
</div>
);
}

View File

@ -1,23 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { authStore } from "@/lib/auth";
/**
* "/" pas d'UI propre, juste un router.
*
* Décision actuelle :
* - non authentifié /login
* - authentifié /onboarding/compte (placeholder tant que le layout
* `_app` et le dashboard n'existent pas)
*
* Quand le layout `_app` arrivera, on enverra plutôt vers / côté `_app`,
* qui lui-même décidera entre dashboard et onboarding selon
* `organization.onboardingCompletedAt`.
*/
export const Route = createFileRoute("/")({
beforeLoad: () => {
if (!authStore.isAuthenticated()) {
throw redirect({ to: "/login" });
}
throw redirect({ to: "/onboarding/compte" });
},
});