rubis/apps/web/src/components/factures/InvoiceTable.tsx
ordinarthur 14d0e982e9 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>
2026-05-06 10:49:06 +02:00

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>
);
}