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>
129 lines
5.3 KiB
TypeScript
129 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|