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:
parent
6de2711aa8
commit
f34cc97327
77
apps/web/src/components/clients/ClientCardList.tsx
Normal file
77
apps/web/src/components/clients/ClientCardList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
apps/web/src/components/clients/ClientTable.tsx
Normal file
143
apps/web/src/components/clients/ClientTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 });
|
||||
}),
|
||||
|
||||
|
||||
179
apps/web/src/mocks/handlers/clients.ts
Normal file
179
apps/web/src/mocks/handlers/clients.ts
Normal 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 });
|
||||
}),
|
||||
];
|
||||
@ -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,
|
||||
];
|
||||
|
||||
58
apps/web/src/mocks/sessionStore.ts
Normal file
58
apps/web/src/mocks/sessionStore.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
316
apps/web/src/routes/_app/clients_.$id.tsx
Normal file
316
apps/web/src/routes/_app/clients_.$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user