diff --git a/apps/web/src/components/clients/ClientCardList.tsx b/apps/web/src/components/clients/ClientCardList.tsx new file mode 100644 index 0000000..05bc5a6 --- /dev/null +++ b/apps/web/src/components/clients/ClientCardList.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/clients/ClientTable.tsx b/apps/web/src/components/clients/ClientTable.tsx new file mode 100644 index 0000000..00b44f6 --- /dev/null +++ b/apps/web/src/components/clients/ClientTable.tsx @@ -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 ( +
+ + + + + + + + + + + + {clients.map((client) => ( + 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", + )} + > + + + + + + + + ))} + +
ClientFactures activesEn attenteEncaisséDernière activité +
+

+ {client.name} +

+ {client.email && ( +

+ {client.email} +

+ )} +
+
+ + {client.activeInvoiceCount} + + {client.lateInvoiceCount > 0 && ( + + + )} +
+
+ {client.pendingLifetimeCents > 0 ? ( + formatEuros(client.pendingLifetimeCents) + ) : ( + + )} + + {client.paidLifetimeCents > 0 ? ( + + {formatEuros(client.paidLifetimeCents)} + + ) : ( + + )} + + {client.lastActivityAt + ? formatRelativeDate(client.lastActivityAt) + : "Jamais"} + + 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}`} + > +
+
+ ); +} diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 55d8c41..6456ec2 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -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: { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 3c817a8..441c00c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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 { ); } +/** + * 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 { + try { + const session = await api.post( + "/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 { + await enableMocking(); + await bootstrapSession(); + render(); +} + +void init(); diff --git a/apps/web/src/mocks/db.ts b/apps/web/src/mocks/db.ts index 58217a3..4d4c5de 100644 --- a/apps/web/src/mocks/db.ts +++ b/apps/web/src/mocks/db.ts @@ -253,6 +253,26 @@ export const mockDb = { save(db); return client; }, + updateClient( + orgId: string, + id: string, + patch: Partial>, + ): 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 { diff --git a/apps/web/src/mocks/handlers/auth.ts b/apps/web/src/mocks/handlers/auth.ts index d70ee69..a0ce129 100644 --- a/apps/web/src/mocks/handlers/auth.ts +++ b/apps/web/src/mocks/handlers/auth.ts @@ -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 }); }), diff --git a/apps/web/src/mocks/handlers/clients.ts b/apps/web/src/mocks/handlers/clients.ts new file mode 100644 index 0000000..a3622e1 --- /dev/null +++ b/apps/web/src/mocks/handlers/clients.ts @@ -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 }); + }), +]; diff --git a/apps/web/src/mocks/handlers/index.ts b/apps/web/src/mocks/handlers/index.ts index 0f027ba..8992c8e 100644 --- a/apps/web/src/mocks/handlers/index.ts +++ b/apps/web/src/mocks/handlers/index.ts @@ -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, ]; diff --git a/apps/web/src/mocks/sessionStore.ts b/apps/web/src/mocks/sessionStore.ts new file mode 100644 index 0000000..0fdbb9a --- /dev/null +++ b/apps/web/src/mocks/sessionStore.ts @@ -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); + } + }, +}; diff --git a/apps/web/src/routes/_app/clients.tsx b/apps/web/src/routes/_app/clients.tsx index 1106a1a..c1bb0d8 100644 --- a/apps/web/src/routes/_app/clients.tsx +++ b/apps/web/src/routes/_app/clients.tsx @@ -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("/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(`/api/v1/clients?${params}`); + }, + staleTime: 10_000, + }); + + const totalLate = clients.reduce( + (sum, c) => sum + c.lateInvoiceCount, + 0, + ); + return ( -
-
-

- Clients -

-

- Vos clients facturés, leurs coordonnées, leur historique. -

+
+
+
+

+ Clients{" "} + + · {clients.length} fiche{clients.length > 1 ? "s" : ""} + +

+

+ {totalLate > 0 ? ( + <> + + {totalLate} + {" "} + 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." + )} +

+
+ +
-
+ ); +} + +function SkeletonRows() { + return ( +
+ {Array.from({ length: 5 }).map((_, idx) => ( +
+
+
+
+
+
+ ))}
); } diff --git a/apps/web/src/routes/_app/clients_.$id.tsx b/apps/web/src/routes/_app/clients_.$id.tsx new file mode 100644 index 0000000..3077dfc --- /dev/null +++ b/apps/web/src/routes/_app/clients_.$id.tsx @@ -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(`/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(`/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(""); + 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(`/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 ( +
+

+ Client introuvable. +

+ + ← Retour aux clients + +
+ ); + } + + if (isPending || !client) { + return ; + } + + const hasLate = client.lateInvoiceCount > 0; + + return ( +
+ +