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: {
|
clients: {
|
||||||
all: () => ["clients"] as const,
|
all: () => ["clients"] as const,
|
||||||
|
list: (filters: { q?: string }) => ["clients", "list", filters] as const,
|
||||||
detail: (id: string) => ["clients", "detail", id] as const,
|
detail: (id: string) => ["clients", "detail", id] as const,
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { env } from "./lib/env";
|
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";
|
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 {
|
function render(): void {
|
||||||
const rootEl = document.getElementById("root");
|
const rootEl = document.getElementById("root");
|
||||||
if (!rootEl) throw new Error("#root introuvable dans index.html");
|
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);
|
save(db);
|
||||||
return client;
|
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 ===
|
// === Plans ===
|
||||||
findPlanById(orgId: string, id: string): Plan | undefined {
|
findPlanById(orgId: string, id: string): Plan | undefined {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { loginSchema, registerSchema } from "@rubis/shared";
|
import { loginSchema, registerSchema } from "@rubis/shared";
|
||||||
import { mockDb } from "../db";
|
import { mockDb } from "../db";
|
||||||
|
import { sessionStore } from "../sessionStore";
|
||||||
|
|
||||||
const apiBase = "*/api/v1";
|
const apiBase = "*/api/v1";
|
||||||
|
|
||||||
@ -49,13 +50,15 @@ export const authHandlers = [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { passwordHash: _ph, ...publicUser } = user;
|
const { passwordHash: _ph, ...publicUser } = user;
|
||||||
return HttpResponse.json({
|
const session = {
|
||||||
data: {
|
accessToken: fakeToken(user.id),
|
||||||
accessToken: fakeToken(user.id),
|
expiresAt: expiresInMinutes(30),
|
||||||
expiresAt: expiresInMinutes(30),
|
user: publicUser,
|
||||||
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
|
// POST /api/v1/auth/signup
|
||||||
@ -89,28 +92,48 @@ export const authHandlers = [
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const user = mockDb.createUser(parsed.data);
|
const user = mockDb.createUser(parsed.data);
|
||||||
return HttpResponse.json(
|
const session = {
|
||||||
{
|
accessToken: fakeToken(user.id),
|
||||||
data: {
|
expiresAt: expiresInMinutes(30),
|
||||||
accessToken: fakeToken(user.id),
|
user,
|
||||||
expiresAt: expiresInMinutes(30),
|
};
|
||||||
user,
|
sessionStore.set(session);
|
||||||
},
|
return HttpResponse.json({ data: session }, { status: 201 });
|
||||||
},
|
|
||||||
{ 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`, () => {
|
http.post(`${apiBase}/auth/refresh`, () => {
|
||||||
return HttpResponse.json(
|
const stored = sessionStore.get();
|
||||||
{ errors: [{ code: "no_session", message: "Pas de session active" }] },
|
if (!stored) {
|
||||||
{ status: 401 },
|
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`, () => {
|
http.post(`${apiBase}/account/logout`, () => {
|
||||||
|
sessionStore.clear();
|
||||||
return new HttpResponse(null, { status: 204 });
|
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 { dashboardHandlers } from "./dashboard";
|
||||||
import { invoiceHandlers } from "./invoices";
|
import { invoiceHandlers } from "./invoices";
|
||||||
import { planHandlers } from "./plans";
|
import { planHandlers } from "./plans";
|
||||||
|
import { clientHandlers } from "./clients";
|
||||||
|
|
||||||
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
@ -11,4 +12,5 @@ export const handlers = [
|
|||||||
...dashboardHandlers,
|
...dashboardHandlers,
|
||||||
...invoiceHandlers,
|
...invoiceHandlers,
|
||||||
...planHandlers,
|
...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 { 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 { 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")({
|
export const Route = createFileRoute("/_app/clients")({
|
||||||
|
validateSearch: searchSchema,
|
||||||
component: ClientsPage,
|
component: ClientsPage,
|
||||||
|
loader: ({ context }) => {
|
||||||
|
void context.queryClient.prefetchQuery({
|
||||||
|
queryKey: queryKeys.clients.list({}),
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<ClientWithStats[]>("/api/v1/clients?withStats=1"),
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function ClientsPage() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-5">
|
||||||
<div>
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
|
<div>
|
||||||
Clients
|
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
|
||||||
</h1>
|
Clients{" "}
|
||||||
<p className="mt-1 text-[14px] text-ink-3">
|
<span className="font-sans font-normal text-[14px] text-ink-3 align-middle">
|
||||||
Vos clients facturés, leurs coordonnées, leur historique.
|
· {clients.length} fiche{clients.length > 1 ? "s" : ""}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<EmptyState
|
{/* Recherche */}
|
||||||
draft
|
<div className="relative max-w-md">
|
||||||
icon={<Users size={36} strokeWidth={1.5} aria-hidden="true" />}
|
<Search
|
||||||
title={
|
size={14}
|
||||||
<>
|
aria-hidden="true"
|
||||||
Pas encore de <em className="text-rubis">clients</em> à afficher.
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-3 pointer-events-none"
|
||||||
</>
|
/>
|
||||||
}
|
<input
|
||||||
description={
|
type="text"
|
||||||
<>
|
value={searchInput}
|
||||||
Le carnet client se remplit automatiquement à chaque facture
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
importée. La vue dédiée arrive dans une prochaine itération.
|
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>
|
</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