feat(web): /clients liste + détail + persistance session mock

Page /clients (liste) :
- Header dynamique : 'X factures en retard chez Y clients' en rubis-deep
  s'il y en a, sinon 'Tout est calme côté clients.'
- Recherche par nom/email (param q côté serveur, debounce naturel via
  TanStack Query staleTime: 10s)
- Table desktop / cards mobile (cohérent avec /factures)
- Tri serveur : retards d'abord (actionable), puis activité récente
- Empty state distincts (recherche vide vs jamais de clients)
- Lien depuis 'Voir tout' du panel TopLatePayers du dashboard fonctionne
- '+ Nouveau client' disabled (V2)

Page /clients/$id (détail) :
- Header : eyebrow contextuel, nom h1, infos contact (mail clickable,
  phone, address) avec icônes Lucide ink-3
- 4 KPI cards en grille : Factures actives (avec sub-info 'N en retard'
  rubis-deep si pertinent), En attente, Encaissé total, Factures payées
- Liste des factures du client (cliquables vers /factures/$id) avec
  StatusBadge sans icône (compact)
- Notes internes : Textarea avec autosave on blur via PATCH /clients/:id

MSW :
- GET /clients?withStats=1&q= : enrichit avec compteurs + montants +
  lastActivityAt. Tri par retards d'abord
- GET /clients/:id : détail enrichi + invoices triées plus récentes
- PATCH /clients/:id : édition Zod
- mockDb.updateClient(orgId, id, patch) ajouté

Persistance session mock (stay logged in après reload) :
- mocks/sessionStore.ts : helpers localStorage simulant le cookie
  httpOnly côté serveur. TTL 30j (= refresh token typique). SPA n'y
  accède jamais directement, seul MSW touche cette persistance.
- POST /auth/{login,signup} : sessionStore.set après succès
- POST /auth/logout : sessionStore.clear (clean disconnect)
- POST /auth/refresh : retourne la session stockée + recharge le user
  depuis mockDb au cas où il a été modifié (signature post-onboarding etc.)
- main.tsx : bootstrapSession() avant le 1er render (silent refresh).
  Évite le flash redirect /login pour les users déjà connectés.

Architecture : le SPA n'accède jamais directement à localStorage —
il passe toujours par HTTP (/auth/refresh). Quand on branchera le vrai
backend Adonis, on supprime juste mocks/sessionStore.ts et le pattern
continue à marcher (cookie httpOnly remplace localStorage côté serveur).

queryKeys.clients.list ajouté pour le param de recherche.

Bundle prod : 117.92 KB gzip core (stable +0.28 vs avant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 12:06:32 +02:00
parent 6de2711aa8
commit f34cc97327
11 changed files with 1014 additions and 48 deletions

View File

@ -0,0 +1,77 @@
import { Link } from "@tanstack/react-router";
import { AlertCircle } from "lucide-react";
import { formatEuros, formatRelativeDate } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ClientWithStats } from "./ClientTable";
type ClientCardListProps = {
clients: ClientWithStats[];
className?: string;
};
export function ClientCardList({ clients, className }: ClientCardListProps) {
return (
<ul className={cn("flex flex-col gap-2", className)}>
{clients.map((client) => (
<li key={client.id}>
<Link
to="/clients/$id"
params={{ id: client.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">
{client.name}
</p>
{client.email && (
<p className="mt-0.5 text-[12px] text-ink-3 truncate">
{client.email}
</p>
)}
</div>
{client.lateInvoiceCount > 0 && (
<span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-rubis-glow px-2 py-0.5 text-[11px] font-semibold text-rubis-deep">
<AlertCircle size={10} aria-hidden="true" />
{client.lateInvoiceCount}
</span>
)}
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-[12px]">
<div>
<p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Actives
</p>
<p className="mt-0.5 font-medium text-ink tabular-nums">
{client.activeInvoiceCount}
</p>
</div>
<div>
<p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Encaissé
</p>
<p className="mt-0.5 font-medium text-ink tabular-nums">
{client.paidLifetimeCents > 0
? formatEuros(client.paidLifetimeCents)
: "—"}
</p>
</div>
</div>
{client.lastActivityAt && (
<p className="mt-3 text-[11.5px] text-ink-3">
Dernière activité {formatRelativeDate(client.lastActivityAt)}
</p>
)}
</Link>
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,143 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { ChevronRight, AlertCircle } from "lucide-react";
import type { Client } from "@rubis/shared";
import { formatEuros, formatRelativeDate } from "@/lib/format";
import { cn } from "@/lib/utils";
export type ClientWithStats = Client & {
invoiceCount: number;
activeInvoiceCount: number;
lateInvoiceCount: number;
paidInvoiceCount: number;
paidLifetimeCents: number;
pendingLifetimeCents: number;
lastActivityAt: string | null;
};
type ClientTableProps = {
clients: ClientWithStats[];
className?: string;
};
/**
* Table desktop des clients.
*
* Décisions :
* - Ligne cliquable (onClick + role=link + onKeyDown), chevron Link à
* droite pour right-click "ouvrir nouvel onglet" (cohérent factures)
* - Indicateur de retard : pastille rubis si lateInvoiceCount > 0,
* pas un fond rouge agressif
* - Encaissé lifetime + actuellement en cours, séparés
* - Pas de checkbox actions-en-lot en V1
*/
export function ClientTable({ clients, className }: ClientTableProps) {
const navigate = useNavigate();
const goTo = (id: string) => {
void navigate({ to: "/clients/$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">Factures actives</th>
<th scope="col" className="px-3 py-3 font-semibold text-right">En attente</th>
<th scope="col" className="px-3 py-3 font-semibold text-right">Encaissé</th>
<th scope="col" className="px-3 py-3 font-semibold">Dernière activité</th>
<th scope="col" className="px-5 py-3 w-10" aria-label="Actions" />
</tr>
</thead>
<tbody>
{clients.map((client) => (
<tr
key={client.id}
role="link"
tabIndex={0}
aria-label={`Client ${client.name}`}
onClick={() => goTo(client.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
goTo(client.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">
<p className="font-medium text-ink truncate max-w-[260px]">
{client.name}
</p>
{client.email && (
<p className="text-[12px] text-ink-3 truncate max-w-[260px]">
{client.email}
</p>
)}
</td>
<td className="px-3 py-3.5">
<div className="flex items-center gap-2">
<span className="font-medium text-ink tabular-nums">
{client.activeInvoiceCount}
</span>
{client.lateInvoiceCount > 0 && (
<span
className="inline-flex items-center gap-1 rounded-full bg-rubis-glow px-2 py-0.5 text-[11px] font-semibold text-rubis-deep"
title={`${client.lateInvoiceCount} en retard`}
>
<AlertCircle size={10} aria-hidden="true" />
{client.lateInvoiceCount} retard
{client.lateInvoiceCount > 1 ? "s" : ""}
</span>
)}
</div>
</td>
<td className="px-3 py-3.5 text-right text-ink-2 tabular-nums">
{client.pendingLifetimeCents > 0 ? (
formatEuros(client.pendingLifetimeCents)
) : (
<span className="text-ink-3"></span>
)}
</td>
<td className="px-3 py-3.5 text-right tabular-nums">
{client.paidLifetimeCents > 0 ? (
<span className="font-medium text-ink">
{formatEuros(client.paidLifetimeCents)}
</span>
) : (
<span className="text-ink-3"></span>
)}
</td>
<td className="px-3 py-3.5 text-[12.5px] text-ink-3">
{client.lastActivityAt
? formatRelativeDate(client.lastActivityAt)
: "Jamais"}
</td>
<td className="px-5 py-3.5">
<Link
to="/clients/$id"
params={{ id: client.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 le client ${client.name}`}
>
<ChevronRight size={16} aria-hidden="true" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -17,6 +17,7 @@ export const queryKeys = {
},
clients: {
all: () => ["clients"] as const,
list: (filters: { q?: string }) => ["clients", "list", filters] as const,
detail: (id: string) => ["clients", "detail", id] as const,
},
dashboard: {

View File

@ -5,6 +5,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen";
import { env } from "./lib/env";
import { api } from "./lib/api";
import { authStore } from "./lib/auth";
import type { AuthSession } from "@rubis/shared";
import "./styles/app.css";
@ -54,6 +57,28 @@ async function enableMocking(): Promise<void> {
);
}
/**
* Tente de récupérer la session à partir du refresh token (cf. ADR-017).
* Si le serveur (ou MSW) confirme une session valide on rehydrate l'authStore
* AVANT le 1er render, ce qui évite le flash redirect /login pour les users
* déjà connectés.
*
* En mode dev avec MSW, la "session" persistée est en localStorage (cf.
* mocks/sessionStore). En prod réel, c'est le cookie httpOnly côté Adonis.
*/
async function bootstrapSession(): Promise<void> {
try {
const session = await api.post<AuthSession>(
"/api/v1/auth/refresh",
undefined,
{ anonymous: true },
);
authStore.setSession(session.accessToken, session.user);
} catch {
// Pas de session valide → on reste anonyme. _app guard redirigera vers /login.
}
}
function render(): void {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("#root introuvable dans index.html");
@ -66,4 +91,10 @@ function render(): void {
);
}
void enableMocking().then(render);
async function init(): Promise<void> {
await enableMocking();
await bootstrapSession();
render();
}
void init();

View File

@ -253,6 +253,26 @@ export const mockDb = {
save(db);
return client;
},
updateClient(
orgId: string,
id: string,
patch: Partial<Pick<Client, "name" | "email" | "phone" | "address" | "notes">>,
): Client | undefined {
const db = load();
const idx = db.clients.findIndex(
(c) => c.organizationId === orgId && c.id === id,
);
if (idx === -1) return undefined;
const existing = db.clients[idx]!;
const updated: Client = {
...existing,
...patch,
updatedAt: new Date().toISOString(),
};
db.clients[idx] = updated;
save(db);
return updated;
},
// === Plans ===
findPlanById(orgId: string, id: string): Plan | undefined {

View File

@ -1,6 +1,7 @@
import { http, HttpResponse } from "msw";
import { loginSchema, registerSchema } from "@rubis/shared";
import { mockDb } from "../db";
import { sessionStore } from "../sessionStore";
const apiBase = "*/api/v1";
@ -49,13 +50,15 @@ export const authHandlers = [
);
}
const { passwordHash: _ph, ...publicUser } = user;
return HttpResponse.json({
data: {
accessToken: fakeToken(user.id),
expiresAt: expiresInMinutes(30),
user: publicUser,
},
});
const session = {
accessToken: fakeToken(user.id),
expiresAt: expiresInMinutes(30),
user: publicUser,
};
// Persiste la session — simule le cookie httpOnly côté serveur, pour
// que le SPA puisse la récupérer au boot via /auth/refresh.
sessionStore.set(session);
return HttpResponse.json({ data: session });
}),
// POST /api/v1/auth/signup
@ -89,28 +92,48 @@ export const authHandlers = [
);
}
const user = mockDb.createUser(parsed.data);
return HttpResponse.json(
{
data: {
accessToken: fakeToken(user.id),
expiresAt: expiresInMinutes(30),
user,
},
},
{ status: 201 },
);
const session = {
accessToken: fakeToken(user.id),
expiresAt: expiresInMinutes(30),
user,
};
sessionStore.set(session);
return HttpResponse.json({ data: session }, { status: 201 });
}),
// POST /api/v1/auth/refresh
// POST /api/v1/auth/refresh — relit la session persistée par MSW.
// Renouvelle l'accessToken (TTL court) tout en gardant le user.
http.post(`${apiBase}/auth/refresh`, () => {
return HttpResponse.json(
{ errors: [{ code: "no_session", message: "Pas de session active" }] },
{ status: 401 },
);
const stored = sessionStore.get();
if (!stored) {
return HttpResponse.json(
{ errors: [{ code: "no_session", message: "Pas de session active" }] },
{ status: 401 },
);
}
// Recharge le user depuis mockDb au cas où il a été modifié (onboarding,
// signature mise à jour, etc.) — sinon le SPA verrait un user stale.
const fresh = mockDb.findUserById(stored.user.id);
if (!fresh) {
sessionStore.clear();
return HttpResponse.json(
{ errors: [{ code: "no_session", message: "Utilisateur introuvable" }] },
{ status: 401 },
);
}
const { passwordHash: _ph, ...publicUser } = fresh;
const session = {
accessToken: fakeToken(publicUser.id),
expiresAt: expiresInMinutes(30),
user: publicUser,
};
sessionStore.set(session);
return HttpResponse.json({ data: session });
}),
// POST /api/v1/account/logout
// POST /api/v1/account/logout — clean disconnect
http.post(`${apiBase}/account/logout`, () => {
sessionStore.clear();
return new HttpResponse(null, { status: 204 });
}),

View File

@ -0,0 +1,179 @@
import { http, HttpResponse } from "msw";
import { z } from "zod";
import type { Client } 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: "Client introuvable" }] },
{ status: 404 },
);
}
function authedOrgId(authHeader: string | null): string | undefined {
const userId = userIdFromAuthHeader(authHeader);
if (!userId) return undefined;
return mockDb.findUserById(userId)?.organizationId;
}
/**
* Stats agrégées pour un client : compteurs + montants + dernière activité.
* Calculées on-the-fly depuis les invoices pas de cache, V1 c'est tenable.
*/
function computeStats(invoices: StoredInvoice[], now = new Date()) {
const activeStatuses = new Set([
"pending",
"in_relance",
"awaiting_user_confirmation",
]);
let activeInvoiceCount = 0;
let lateInvoiceCount = 0;
let paidInvoiceCount = 0;
let paidLifetimeCents = 0;
let pendingLifetimeCents = 0;
let lastActivityAt: string | null = null;
for (const inv of invoices) {
if (lastActivityAt === null || inv.updatedAt > lastActivityAt) {
lastActivityAt = inv.updatedAt;
}
if (inv.status === "paid") {
paidInvoiceCount += 1;
paidLifetimeCents += inv.amountTtcCents;
} else if (activeStatuses.has(inv.status)) {
activeInvoiceCount += 1;
pendingLifetimeCents += inv.amountTtcCents;
if (new Date(inv.dueDate).getTime() < now.setHours(0, 0, 0, 0)) {
lateInvoiceCount += 1;
}
}
}
return {
invoiceCount: invoices.length,
activeInvoiceCount,
lateInvoiceCount,
paidInvoiceCount,
paidLifetimeCents,
pendingLifetimeCents,
lastActivityAt,
};
}
const updateClientSchema = z.object({
name: z.string().min(1).max(120).optional(),
email: z.string().email().nullable().optional(),
phone: z.string().max(40).nullable().optional(),
address: z.string().max(500).nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
});
export const clientHandlers = [
// GET /api/v1/clients?withStats=1&q=
// Sans `withStats`, retour à plat (utilisé par le combobox).
// Avec `withStats`, chaque client est enrichi des compteurs.
http.get(`${apiBase}/clients`, ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const url = new URL(request.url);
const withStats = url.searchParams.get("withStats") === "1";
const q = (url.searchParams.get("q") ?? "").toLowerCase().trim();
const allInvoices = mockDb.listInvoicesForOrg(orgId);
let clients: Client[] = mockDb.listClientsForOrg(orgId);
if (q.length > 0) {
clients = clients.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.email?.toLowerCase().includes(q) ?? false),
);
}
if (!withStats) {
return HttpResponse.json({ data: clients });
}
const enriched = clients.map((c) => {
const invoices = allInvoices.filter((i) => i.clientId === c.id);
return { ...c, ...computeStats(invoices) };
});
// Tri par défaut : ceux avec des retards d'abord (actionnable), puis
// par activité récente.
enriched.sort((a, b) => {
if (a.lateInvoiceCount !== b.lateInvoiceCount) {
return b.lateInvoiceCount - a.lateInvoiceCount;
}
const aLast = a.lastActivityAt ?? "";
const bLast = b.lastActivityAt ?? "";
return bLast.localeCompare(aLast);
});
return HttpResponse.json({ data: enriched });
}),
// GET /api/v1/clients/:id — détail enrichi avec invoices
http.get(`${apiBase}/clients/:id`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const id = params.id as string;
const client = mockDb.findClientById(orgId, id);
if (!client) return notFound();
const invoices = mockDb
.listInvoicesForOrg(orgId)
.filter((i) => i.clientId === id)
// Plus récent d'abord — l'utilisateur veut voir les factures fraîches
.sort(
(a, b) =>
new Date(b.dueDate).getTime() - new Date(a.dueDate).getTime(),
);
return HttpResponse.json({
data: {
...client,
...computeStats(invoices),
invoices,
},
});
}),
// PATCH /api/v1/clients/:id — édition (nom, email, phone, address, notes)
http.patch(`${apiBase}/clients/:id`, async ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const id = params.id as string;
const json = await request.json();
const parsed = updateClientSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
const updated = mockDb.updateClient(orgId, id, parsed.data);
if (!updated) return notFound();
return HttpResponse.json({ data: updated });
}),
];

View File

@ -3,6 +3,7 @@ import { onboardingHandlers } from "./onboarding";
import { dashboardHandlers } from "./dashboard";
import { invoiceHandlers } from "./invoices";
import { planHandlers } from "./plans";
import { clientHandlers } from "./clients";
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
export const handlers = [
@ -11,4 +12,5 @@ export const handlers = [
...dashboardHandlers,
...invoiceHandlers,
...planHandlers,
...clientHandlers,
];

View File

@ -0,0 +1,58 @@
/**
* Persistance "session" pour le mock MSW.
*
* Simule le pattern réel de l'ADR-017 : access token éphémère côté SPA +
* refresh token persistant côté serveur (cookie httpOnly). Comme on n'a pas
* de vrai cookie, on écrit la session dans localStorage. C'est suffisant pour
* que :
* 1. MSW puisse répondre à POST /auth/refresh (= simule le cookie côté
* "serveur") le SPA récupère sa session au boot, sans avoir à se
* reconnecter à chaque reload.
* 2. Logout efface la persistance (clean disconnect).
*
* Le SPA n'accède PAS directement à ce module seuls les handlers MSW le
* touchent. Le SPA reste fidèle au contrat HTTP.
*/
import type { User } from "@rubis/shared";
const STORAGE_KEY = "rubis.mocks.session";
export type StoredSession = {
accessToken: string;
/** ISO 8601 — au-delà, on considère la session expirée et on rejette. */
expiresAt: string;
user: User;
};
export const sessionStore = {
get(): StoredSession | null {
if (typeof localStorage === "undefined") return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as StoredSession;
// Expiration soft : on tolère 30 jours pour le mock (le vrai refresh
// token aura un TTL côté API). Au-delà, on jette pour simuler un
// cookie expiré.
const now = Date.now();
const expiresMs = new Date(parsed.expiresAt).getTime();
if (expiresMs < now - 30 * 24 * 60 * 60 * 1000) {
sessionStore.clear();
return null;
}
return parsed;
} catch {
return null;
}
},
set(session: StoredSession): void {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
},
clear(): void {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
},
};

View File

@ -1,39 +1,155 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Users } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Search, Users, Plus } from "lucide-react";
import { z } from "zod";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import {
ClientTable,
type ClientWithStats,
} from "@/components/clients/ClientTable";
import { ClientCardList } from "@/components/clients/ClientCardList";
const searchSchema = z.object({
q: z.string().optional(),
});
export const Route = createFileRoute("/_app/clients")({
validateSearch: searchSchema,
component: ClientsPage,
loader: ({ context }) => {
void context.queryClient.prefetchQuery({
queryKey: queryKeys.clients.list({}),
queryFn: () =>
api.get<ClientWithStats[]>("/api/v1/clients?withStats=1"),
});
},
});
function ClientsPage() {
// Search local state pour le debounce — on évite de re-fetch à chaque
// keystroke. Le param URL synchronisé via Route.useSearch() viendra V2 quand
// on aura besoin de partager le filtre.
const [searchInput, setSearchInput] = useState("");
const { data: clients = [], isPending } = useQuery({
queryKey: queryKeys.clients.list({ q: searchInput }),
queryFn: () => {
const params = new URLSearchParams({ withStats: "1" });
if (searchInput.trim()) params.set("q", searchInput.trim());
return api.get<ClientWithStats[]>(`/api/v1/clients?${params}`);
},
staleTime: 10_000,
});
const totalLate = clients.reduce(
(sum, c) => sum + c.lateInvoiceCount,
0,
);
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 className="flex flex-col gap-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
Clients{" "}
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
· {clients.length} fiche{clients.length > 1 ? "s" : ""}
</span>
</h1>
<p className="mt-1 text-[13.5px] text-ink-3">
{totalLate > 0 ? (
<>
<strong className="text-rubis-deep tabular-nums">
{totalLate}
</strong>{" "}
facture{totalLate > 1 ? "s" : ""} en retard chez{" "}
{clients.filter((c) => c.lateInvoiceCount > 0).length} client
{clients.filter((c) => c.lateInvoiceCount > 0).length > 1 ? "s" : ""}.
</>
) : (
"Tout est calme côté clients."
)}
</p>
</div>
<Button variant="secondary" size="sm" disabled>
<Plus size={14} aria-hidden="true" /> Nouveau client
<span className="ml-1 text-[11px] italic text-ink-3">(bientôt)</span>
</Button>
</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.
</>
}
/>
{/* Recherche */}
<div className="relative max-w-md">
<Search
size={14}
aria-hidden="true"
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-3 pointer-events-none"
/>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Rechercher un client par nom ou email…"
className="block w-full rounded-default border border-line bg-white pl-10 pr-3 py-2.5 font-sans text-[14px] text-ink placeholder:text-ink-3 transition-[border-color,box-shadow] duration-150 focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow"
/>
</div>
{isPending ? (
<SkeletonRows />
) : clients.length === 0 ? (
<EmptyState
icon={<Users size={32} strokeWidth={1.5} aria-hidden="true" />}
title={
searchInput.length > 0 ? (
<>
Aucun client ne <em className="text-rubis">correspond</em>.
</>
) : (
<>
Pas encore de <em className="text-rubis">clients</em>.
</>
)
}
description={
searchInput.length > 0
? "Essayez une autre recherche."
: "Vos clients apparaîtront ici dès qu'une facture sera importée ou saisie."
}
/>
) : (
<>
<div className="hidden lg:block">
<ClientTable clients={clients} />
</div>
<div className="lg:hidden">
<ClientCardList clients={clients} />
</div>
</>
)}
</div>
);
}
function SkeletonRows() {
return (
<div className="rounded-card border border-line bg-white">
{Array.from({ length: 5 }).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-16 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,316 @@
import { useState, useEffect } from "react";
import {
createFileRoute,
Link,
} from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Mail,
Phone,
MapPin,
AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import type { Client } from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import {
formatEuros,
formatDate,
formatRelativeDate,
} from "@/lib/format";
import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Textarea } from "@/components/ui/Textarea";
import { StatusBadge } from "@/components/ui/StatusBadge";
import type { InvoiceListItem } from "@/components/factures/InvoiceTable";
import type { ClientWithStats } from "@/components/clients/ClientTable";
type ClientDetail = ClientWithStats & {
invoices: InvoiceListItem[];
};
export const Route = createFileRoute("/_app/clients_/$id")({
component: ClientDetailPage,
loader: ({ context, params }) => {
void context.queryClient.prefetchQuery({
queryKey: queryKeys.clients.detail(params.id),
queryFn: () =>
api.get<ClientDetail>(`/api/v1/clients/${params.id}`),
});
},
});
function ClientDetailPage() {
const { id } = Route.useParams();
const queryClient = useQueryClient();
const { data: client, isPending, isError } = useQuery({
queryKey: queryKeys.clients.detail(id),
queryFn: () => api.get<ClientDetail>(`/api/v1/clients/${id}`),
});
// Notes : édition locale + sauvegarde sur blur. Garde le draft local pour
// ne pas refetch écraser ce que l'user est en train de taper.
const [notesDraft, setNotesDraft] = useState<string>("");
useEffect(() => {
if (client?.notes != null) setNotesDraft(client.notes);
else setNotesDraft("");
}, [client?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const updateNotesMutation = useMutation({
mutationFn: (notes: string) =>
api.patch<Client>(`/api/v1/clients/${id}`, { notes }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.clients.detail(id) });
void queryClient.invalidateQueries({ queryKey: queryKeys.clients.all() });
},
onError: () => {
toast.error("Notes non sauvegardées. Réessayez.");
},
});
if (isError) {
return (
<div className="text-center py-12">
<p className="font-display text-[20px] font-semibold text-ink">
Client introuvable.
</p>
<Link
to="/clients"
className="mt-3 inline-block text-[13px] text-rubis underline-offset-4 hover:underline"
>
Retour aux clients
</Link>
</div>
);
}
if (isPending || !client) {
return <ClientDetailSkeleton />;
}
const hasLate = client.lateInvoiceCount > 0;
return (
<div className="flex flex-col gap-6">
<Link
to="/clients"
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" /> Clients
</Link>
<header>
<Eyebrow>{hasLate ? "Retards récurrents" : "Client"}</Eyebrow>
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
{client.name}
</h1>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px] text-ink-2">
{client.email && (
<span className="inline-flex items-center gap-1.5">
<Mail size={13} className="text-ink-3" aria-hidden="true" />
<a
href={`mailto:${client.email}`}
className="hover:text-rubis hover:underline underline-offset-4"
>
{client.email}
</a>
</span>
)}
{client.phone && (
<span className="inline-flex items-center gap-1.5">
<Phone size={13} className="text-ink-3" aria-hidden="true" />
{client.phone}
</span>
)}
{client.address && (
<span className="inline-flex items-center gap-1.5">
<MapPin size={13} className="text-ink-3" aria-hidden="true" />
<span className="truncate max-w-[260px]">{client.address}</span>
</span>
)}
</div>
</header>
{/* Stats clés en grille — 4 colonnes desktop, 2 mobile */}
<section
aria-label="Statistiques client"
className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4"
>
<StatBlock
label="Factures actives"
value={String(client.activeInvoiceCount)}
accent={hasLate ? "warning" : "neutral"}
tail={
hasLate ? (
<span className="inline-flex items-center gap-1 text-[12px] font-medium text-rubis-deep">
<AlertCircle size={11} aria-hidden="true" />
{client.lateInvoiceCount} en retard
</span>
) : undefined
}
/>
<StatBlock
label="En attente"
value={
client.pendingLifetimeCents > 0
? formatEuros(client.pendingLifetimeCents)
: "—"
}
/>
<StatBlock
label="Encaissé total"
value={
client.paidLifetimeCents > 0
? formatEuros(client.paidLifetimeCents)
: "—"
}
accent="positive"
/>
<StatBlock
label="Factures payées"
value={`${client.paidInvoiceCount}/${client.invoiceCount}`}
tail={
client.lastActivityAt ? (
<span className="text-[12px] text-ink-3">
{formatRelativeDate(client.lastActivityAt)}
</span>
) : undefined
}
/>
</section>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.5fr_1fr]">
{/* Liste des factures du client */}
<section>
<Eyebrow tone="ink">Factures</Eyebrow>
<div className="mt-3">
{client.invoices.length === 0 ? (
<Card padding="md" variant="flat">
<p className="text-[13px] italic text-ink-3 text-center">
Aucune facture pour ce client.
</p>
</Card>
) : (
<Card padding="none" className="overflow-hidden">
<ul>
{client.invoices.map((invoice) => (
<li
key={invoice.id}
className="border-t border-line first:border-t-0"
>
<Link
to="/factures/$id"
params={{ id: invoice.id }}
className={cn(
"flex items-center justify-between gap-3 px-4 py-3",
"transition-colors hover:bg-cream-2/40",
"focus-visible:outline-none focus-visible:bg-cream",
)}
>
<div className="min-w-0">
<p className="text-[13.5px] font-medium text-ink truncate">
{invoice.numero}
</p>
<p className="text-[11.5px] text-ink-3">
Échue le {formatDate(invoice.dueDate)}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="font-display text-[15px] font-semibold tabular-nums text-ink">
{formatEuros(invoice.amountTtcCents)}
</span>
<StatusBadge status={invoice.status} withoutIcon />
</div>
</Link>
</li>
))}
</ul>
</Card>
)}
</div>
</section>
{/* Sidepanel : notes internes */}
<aside>
<Eyebrow tone="ink">Notes internes</Eyebrow>
<Card padding="md" className="mt-3">
<Textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={() => {
if (notesDraft !== (client.notes ?? "")) {
updateNotesMutation.mutate(notesDraft);
}
}}
placeholder="Préférences de paiement, contexte, anecdotes…"
rows={6}
className="bg-cream-2/40 border-0 focus:bg-white"
/>
<p className="mt-2 text-[11.5px] italic text-ink-3">
Sauvegarde automatique. Visible uniquement par vous
jamais envoyé au client.
</p>
</Card>
</aside>
</div>
</div>
);
}
type StatAccent = "neutral" | "positive" | "warning";
function StatBlock({
label,
value,
accent = "neutral",
tail,
}: {
label: string;
value: string;
accent?: StatAccent;
tail?: React.ReactNode;
}) {
return (
<Card padding="md" className="min-w-0">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3">
{label}
</p>
<p
className={cn(
"mt-2 font-display text-[24px] font-bold leading-none tracking-[-0.018em] tabular-nums",
accent === "warning" ? "text-rubis-deep" : "text-ink",
)}
>
{value}
</p>
{tail && <div className="mt-2">{tail}</div>}
{/* Note : on omet 'positive' visuellement (pas de vert succès, cf. marque) */}
{accent === "positive" && !tail && <span className="sr-only">Encaissé</span>}
</Card>
);
}
function ClientDetailSkeleton() {
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-1/2 rounded bg-cream-2" />
<div className="h-4 w-1/3 rounded bg-cream-2" />
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-card bg-cream-2" />
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.5fr_1fr]">
<div className="h-72 rounded-card bg-cream-2" />
<div className="h-72 rounded-card bg-cream-2" />
</div>
</div>
);
}