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>
415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
/**
|
|
* Petite base in-memory pour les mocks MSW.
|
|
* Persiste dans sessionStorage pour survivre aux reload pendant le dev,
|
|
* mais reste isolée par onglet (pas d'interférence entre devs).
|
|
*/
|
|
import type { Client, Invoice, Organization, Plan, User } from "@rubis/shared";
|
|
import { SEED_CLIENTS, SEED_INVOICES, SEED_PLANS } from "./seed";
|
|
|
|
const STORAGE_KEY = "rubis.mocks.db.v2";
|
|
|
|
type StoredUser = User & { passwordHash: string };
|
|
|
|
/** Forme enrichie des invoices stockée localement (avec dénormalisations
|
|
* pratiques pour les listes : clientName, planName, statusLabel). */
|
|
export type StoredInvoice = Invoice & {
|
|
clientName: string;
|
|
planName: string | null;
|
|
statusLabel?: string;
|
|
};
|
|
|
|
/** Champs extraits par l'OCR (et leur version éditée par l'utilisateur). */
|
|
export type DraftFields = {
|
|
/** Si l'OCR matche un client existant (case-insensitive sur le nom),
|
|
* on pré-remplit clientId pour que l'utilisateur n'ait pas à re-sélectionner.
|
|
* Sinon null = nouveau client à créer à la validation. */
|
|
clientId: string | null;
|
|
clientName: string;
|
|
clientEmail: string | null;
|
|
numero: string;
|
|
amountTtcCents: number;
|
|
issueDate: string;
|
|
dueDate: string;
|
|
planId: string | null;
|
|
};
|
|
|
|
/** Brouillon d'une facture en cours de review. */
|
|
export type StoredImportDraft = {
|
|
id: string;
|
|
filename: string;
|
|
/** Champs initiaux extraits par "l'OCR" (notre mock). */
|
|
extracted: DraftFields;
|
|
/** Champs édités par l'utilisateur. Initialement = extracted. */
|
|
edited: DraftFields;
|
|
/** Confiance OCR par champ (0-1). Sert à signaler les champs douteux. */
|
|
confidence: Partial<Record<keyof DraftFields, number>>;
|
|
status: "pending" | "validated" | "skipped";
|
|
/** Si validé, l'id de l'invoice créée. */
|
|
invoiceId?: string;
|
|
};
|
|
|
|
export type StoredImportBatch = {
|
|
id: string;
|
|
organizationId: string;
|
|
drafts: StoredImportDraft[];
|
|
createdAt: string;
|
|
};
|
|
|
|
type Db = {
|
|
users: StoredUser[];
|
|
organizations: Organization[];
|
|
clients: Client[];
|
|
plans: Plan[];
|
|
invoices: StoredInvoice[];
|
|
importBatches: StoredImportBatch[];
|
|
};
|
|
|
|
const seedDb = (): Db => ({
|
|
users: [
|
|
{
|
|
id: "usr_demo",
|
|
email: "demo@rubis.fr",
|
|
fullName: "Arthur Démo",
|
|
organizationId: "org_demo",
|
|
signature: "Cordialement,\nArthur — Rubis Démo",
|
|
createdAt: new Date("2026-01-01").toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
passwordHash: "demo1234",
|
|
},
|
|
],
|
|
organizations: [
|
|
{
|
|
id: "org_demo",
|
|
name: "Rubis Démo",
|
|
siret: null,
|
|
monthlyVolumeBucket: "10-50",
|
|
rubisCount: 124,
|
|
createdAt: new Date("2026-01-01").toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
],
|
|
clients: SEED_CLIENTS,
|
|
plans: SEED_PLANS,
|
|
invoices: SEED_INVOICES,
|
|
importBatches: [],
|
|
});
|
|
|
|
/**
|
|
* Migration douce : si un champ top-level a été ajouté au schema entre
|
|
* deux runs (ex. importBatches arrivé tardivement), on patche le snapshot
|
|
* stocké avec le défaut du seed plutôt que de tout perdre. Évite les
|
|
* `TypeError: Cannot read properties of undefined` pendant le dev.
|
|
*/
|
|
function migrate(stored: Partial<Db>): Db {
|
|
const fresh = seedDb();
|
|
// Les batches d'import seedés avant l'ajout de clientId sur DraftFields
|
|
// sont incompatibles avec le combobox (no clientId). Plus simple de les
|
|
// jeter à la migration : ils sont éphémères de toute façon (drafts
|
|
// pending qu'on ne validera plus).
|
|
const importBatches = (stored.importBatches ?? []).map((b) => ({
|
|
...b,
|
|
drafts: b.drafts.map((d) => ({
|
|
...d,
|
|
// Spread des champs stockés AVANT clientId, pour qu'on n'écrase pas
|
|
// un clientId déjà présent dans un nouveau snapshot. La défaut `null`
|
|
// ne s'applique que pour les vieux snapshots qui n'avaient pas le champ.
|
|
extracted: { ...d.extracted, clientId: d.extracted.clientId ?? null },
|
|
edited: { ...d.edited, clientId: d.edited.clientId ?? null },
|
|
})),
|
|
}));
|
|
return {
|
|
users: stored.users ?? fresh.users,
|
|
organizations: stored.organizations ?? fresh.organizations,
|
|
clients: stored.clients ?? fresh.clients,
|
|
plans: stored.plans ?? fresh.plans,
|
|
invoices: stored.invoices ?? fresh.invoices,
|
|
importBatches,
|
|
};
|
|
}
|
|
|
|
function load(): Db {
|
|
if (typeof sessionStorage === "undefined") return seedDb();
|
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
if (!raw) {
|
|
const fresh = seedDb();
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
|
|
return fresh;
|
|
}
|
|
try {
|
|
const stored = JSON.parse(raw) as Partial<Db>;
|
|
const migrated = migrate(stored);
|
|
// Persiste la version migrée pour ne pas refaire le merge à chaque load.
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(migrated));
|
|
return migrated;
|
|
} catch {
|
|
const fresh = seedDb();
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
|
|
return fresh;
|
|
}
|
|
}
|
|
|
|
function save(db: Db): void {
|
|
if (typeof sessionStorage !== "undefined") {
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(db));
|
|
}
|
|
}
|
|
|
|
function stripHash(user: StoredUser): User {
|
|
const { passwordHash: _ph, ...publicUser } = user;
|
|
return publicUser;
|
|
}
|
|
|
|
export const mockDb = {
|
|
// === Users ===
|
|
findUserByEmail(email: string): StoredUser | undefined {
|
|
return load().users.find((u) => u.email.toLowerCase() === email.toLowerCase());
|
|
},
|
|
findUserById(id: string): StoredUser | undefined {
|
|
return load().users.find((u) => u.id === id);
|
|
},
|
|
createUser(input: { email: string; password: string; fullName: string }): User {
|
|
const db = load();
|
|
const now = new Date().toISOString();
|
|
const orgId = `org_${crypto.randomUUID()}`;
|
|
const userId = `usr_${crypto.randomUUID()}`;
|
|
|
|
const org: Organization = {
|
|
id: orgId,
|
|
name: "",
|
|
siret: null,
|
|
monthlyVolumeBucket: null,
|
|
rubisCount: 0,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
const user: StoredUser = {
|
|
id: userId,
|
|
email: input.email,
|
|
fullName: input.fullName,
|
|
organizationId: orgId,
|
|
signature: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
passwordHash: input.password,
|
|
};
|
|
db.users.push(user);
|
|
db.organizations.push(org);
|
|
save(db);
|
|
return stripHash(user);
|
|
},
|
|
updateUser(
|
|
id: string,
|
|
patch: Partial<Pick<User, "fullName" | "email" | "signature">>,
|
|
): User | undefined {
|
|
const db = load();
|
|
const idx = db.users.findIndex((u) => u.id === id);
|
|
if (idx === -1) return undefined;
|
|
const existing = db.users[idx]!;
|
|
const updated: StoredUser = { ...existing, ...patch, updatedAt: new Date().toISOString() };
|
|
db.users[idx] = updated;
|
|
save(db);
|
|
return stripHash(updated);
|
|
},
|
|
|
|
// === Organizations ===
|
|
findOrgById(id: string): Organization | undefined {
|
|
return load().organizations.find((o) => o.id === id);
|
|
},
|
|
updateOrg(
|
|
id: string,
|
|
patch: Partial<Pick<Organization, "name" | "siret" | "monthlyVolumeBucket">>,
|
|
): Organization | undefined {
|
|
const db = load();
|
|
const idx = db.organizations.findIndex((o) => o.id === id);
|
|
if (idx === -1) return undefined;
|
|
const existing = db.organizations[idx]!;
|
|
const updated: Organization = { ...existing, ...patch, updatedAt: new Date().toISOString() };
|
|
db.organizations[idx] = updated;
|
|
save(db);
|
|
return updated;
|
|
},
|
|
|
|
// === Clients ===
|
|
findClientById(orgId: string, id: string): Client | undefined {
|
|
return load().clients.find((c) => c.organizationId === orgId && c.id === id);
|
|
},
|
|
listClientsForOrg(orgId: string): Client[] {
|
|
return load().clients.filter((c) => c.organizationId === orgId);
|
|
},
|
|
createClient(
|
|
orgId: string,
|
|
input: Omit<Client, "id" | "organizationId" | "createdAt" | "updatedAt">,
|
|
): Client {
|
|
const db = load();
|
|
const now = new Date().toISOString();
|
|
const client: Client = {
|
|
...input,
|
|
id: `cli_${crypto.randomUUID()}`,
|
|
organizationId: orgId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
db.clients.push(client);
|
|
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 {
|
|
return load().plans.find((p) => p.organizationId === orgId && p.id === id);
|
|
},
|
|
findPlanBySlug(orgId: string, slug: string): Plan | undefined {
|
|
return load().plans.find(
|
|
(p) => p.organizationId === orgId && p.slug === slug,
|
|
);
|
|
},
|
|
listPlansForOrg(orgId: string): Plan[] {
|
|
return load().plans.filter((p) => p.organizationId === orgId);
|
|
},
|
|
updatePlan(
|
|
orgId: string,
|
|
id: string,
|
|
patch: Partial<Pick<Plan, "name" | "description" | "steps">>,
|
|
): Plan | undefined {
|
|
const db = load();
|
|
const idx = db.plans.findIndex(
|
|
(p) => p.organizationId === orgId && p.id === id,
|
|
);
|
|
if (idx === -1) return undefined;
|
|
const existing = db.plans[idx]!;
|
|
const updated: Plan = {
|
|
...existing,
|
|
...patch,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
db.plans[idx] = updated;
|
|
save(db);
|
|
return updated;
|
|
},
|
|
|
|
// === Invoices ===
|
|
listInvoicesForOrg(orgId: string): StoredInvoice[] {
|
|
return load().invoices.filter((i) => i.organizationId === orgId);
|
|
},
|
|
createInvoice(orgId: string, input: Omit<StoredInvoice, "id" | "organizationId" | "createdAt" | "updatedAt">): StoredInvoice {
|
|
const db = load();
|
|
const now = new Date().toISOString();
|
|
const invoice: StoredInvoice = {
|
|
...input,
|
|
id: `inv_${crypto.randomUUID()}`,
|
|
organizationId: orgId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
db.invoices.push(invoice);
|
|
save(db);
|
|
return invoice;
|
|
},
|
|
findInvoiceById(orgId: string, id: string): StoredInvoice | undefined {
|
|
return load().invoices.find((i) => i.organizationId === orgId && i.id === id);
|
|
},
|
|
updateInvoice(
|
|
orgId: string,
|
|
id: string,
|
|
patch: Partial<StoredInvoice>,
|
|
): StoredInvoice | undefined {
|
|
const db = load();
|
|
const idx = db.invoices.findIndex(
|
|
(i) => i.organizationId === orgId && i.id === id,
|
|
);
|
|
if (idx === -1) return undefined;
|
|
const existing = db.invoices[idx]!;
|
|
const updated: StoredInvoice = {
|
|
...existing,
|
|
...patch,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
db.invoices[idx] = updated;
|
|
save(db);
|
|
return updated;
|
|
},
|
|
|
|
// === Import batches (OCR review flow) ===
|
|
createImportBatch(
|
|
orgId: string,
|
|
drafts: Omit<StoredImportDraft, "id" | "status" | "edited">[],
|
|
): StoredImportBatch {
|
|
const db = load();
|
|
const batch: StoredImportBatch = {
|
|
id: `batch_${crypto.randomUUID()}`,
|
|
organizationId: orgId,
|
|
createdAt: new Date().toISOString(),
|
|
drafts: drafts.map((d) => ({
|
|
...d,
|
|
id: `draft_${crypto.randomUUID()}`,
|
|
edited: { ...d.extracted },
|
|
status: "pending" as const,
|
|
})),
|
|
};
|
|
db.importBatches.push(batch);
|
|
save(db);
|
|
return batch;
|
|
},
|
|
findImportBatch(orgId: string, id: string): StoredImportBatch | undefined {
|
|
return load().importBatches.find(
|
|
(b) => b.organizationId === orgId && b.id === id,
|
|
);
|
|
},
|
|
updateImportDraft(
|
|
orgId: string,
|
|
batchId: string,
|
|
draftId: string,
|
|
patch: Partial<StoredImportDraft>,
|
|
): StoredImportDraft | undefined {
|
|
const db = load();
|
|
const batch = db.importBatches.find(
|
|
(b) => b.organizationId === orgId && b.id === batchId,
|
|
);
|
|
if (!batch) return undefined;
|
|
const idx = batch.drafts.findIndex((d) => d.id === draftId);
|
|
if (idx === -1) return undefined;
|
|
const updated = { ...batch.drafts[idx]!, ...patch };
|
|
batch.drafts[idx] = updated;
|
|
save(db);
|
|
return updated;
|
|
},
|
|
deleteImportBatch(orgId: string, id: string): boolean {
|
|
const db = load();
|
|
const before = db.importBatches.length;
|
|
db.importBatches = db.importBatches.filter(
|
|
(b) => !(b.organizationId === orgId && b.id === id),
|
|
);
|
|
if (db.importBatches.length < before) {
|
|
save(db);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
reset(): void {
|
|
if (typeof sessionStorage !== "undefined") {
|
|
sessionStorage.removeItem(STORAGE_KEY);
|
|
}
|
|
},
|
|
};
|