From c918de1e7dd188b56c24866a1b42fd6a6851873a Mon Sep 17 00:00:00 2001 From: Olivier Date: Sat, 19 Sep 2026 17:19:53 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20de=20la=20notion=20de=20wor?= =?UTF-8?q?kspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/index.js | 68 +++++++++++++++--- backend/src/middleware/workspaceScope.js | 7 +- backend/src/routes/adminWorkspaces.js | 31 ++++++-- backend/src/routes/fraisOperations.js | 4 +- backend/src/routes/plateformes.js | 2 +- backend/src/routes/referentiel.js | 2 +- backend/src/routes/workspaces.js | 6 +- frontend/src/App.jsx | 54 +++++++------- frontend/src/components/Layout.jsx | 5 +- frontend/src/pages/AdminPlateformes.jsx | 9 +-- .../src/pages/admin/WorkspacesSection.jsx | 72 +++++++++++++++++-- 11 files changed, 202 insertions(+), 58 deletions(-) diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 26dd8ac..2ccf3f5 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -3050,6 +3050,7 @@ db.exec(` nom TEXT NOT NULL, libelle_menu TEXT NOT NULL, description TEXT, + type TEXT NOT NULL DEFAULT 'private_equity', actif_global INTEGER NOT NULL DEFAULT 1, ordre INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -3057,6 +3058,57 @@ db.exec(` ) `); +// ── Migration : workspaces.type (19/09/26) ─────────────────────────────── +// Jusqu'ici, TOUT le routage crowdlending/PE (frontend ET backend — plus de +// 20 endroits, cf. project_workspaces_transformation.md) reposait sur une +// comparaison littérale `slug === 'crowdlending'` : le slug — pourtant un +// simple identifiant technique choisi librement par l'admin (regex +// minuscules/chiffres/tirets, cf. adminWorkspaces.js) — portait donc +// implicitement toute la sémantique "quel modèle de page afficher ?". +// Question d'Olivier (19/09/26) : comment le programme reconnaît-il qu'il +// doit utiliser les pages Crowdlending ou Private Equity, sachant que +// l'admin choisit juste un nom ? Réponse à l'époque : aucune vraie +// typologie, seulement "crowdlending" vs "tout le reste" — décision +// d'Olivier du même jour : corriger ça maintenant, dans tout le code. +// `type` porte désormais cette sémantique explicitement ('crowdlending' ou +// 'private_equity' à ce jour — toute valeur non 'crowdlending' continue, +// pour l'instant, à être traitée comme "PE" côté UI, en attendant qu'un +// 3e modèle de page existe réellement). Choisi à la création d'un +// workspace (formulaire admin, cf. adminWorkspaces.js), immutable ensuite +// — même régime que `slug`, pour la même raison : en changer sous un +// workspace déjà peuplé de données changerait silencieusement quel modèle +// de page/API s'applique à ces données. DEFAULT 'private_equity' ci-dessus +// : filet de sécurité reproduisant le comportement historique implicite +// ("pas crowdlending" = PE) si jamais une ligne était insérée sans passer +// par l'API ; l'API elle-même exige toujours une valeur explicite. +{ + const wsCols = db.prepare('PRAGMA table_info(workspaces)').all().map(c => c.name); + if (!wsCols.includes('type')) { + db.exec("ALTER TABLE workspaces ADD COLUMN type TEXT NOT NULL DEFAULT 'private_equity'"); + } + // Backfill volontairement INCONDITIONNEL (pas seulement juste après l'ALTER ci-dessus) : + // le seul workspace qui ait jamais eu un sens "crowdlending" est celui de slug + // 'crowdlending' (seed ci-dessous, protégé, non supprimable) — tous les autres (aujourd'hui : + // 'private-equity') étaient déjà traités comme "PE" par l'ancien code, donc reçoivent le + // DEFAULT ci-dessus sans rien faire. Auto-réparateur à chaque démarrage (20/09/26) : une + // version antérieure de cette migration ne faisait ce backfill qu'au moment de l'ALTER, donc + // un redémarrage ayant appliqué l'ALTER avant la correction d'un bug dans cette ligne laissait + // le workspace crowdlending bloqué sur le DEFAULT 'private_equity' — sans nouveau redémarrage + // "à vide" pour le rattraper, ce cas ne pouvait jamais se corriger. Cette ligne s'exécute donc + // désormais à chaque démarrage, sans condition, et ne fait rien dès que le type est déjà bon. + db.exec("UPDATE workspaces SET type = 'crowdlending' WHERE slug = 'crowdlending' AND type <> 'crowdlending'"); +} +// Au plus UN workspace de type 'crowdlending' (l'app entière suppose ce +// singleton — resolveActiveWorkspaceId, grantDefaultWorkspace, etc. font +// un .get() qui prend la première ligne trouvée). Index partiel (SQLite +// supporte les index UNIQUE avec clause WHERE) : ne contraint que le type +// 'crowdlending', laisse 'private_equity' libre en plusieurs exemplaires +// si Olivier veut un jour plusieurs workspaces PE distincts. +db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_type_crowdlending_singleton + ON workspaces(type) WHERE type = 'crowdlending' +`); + db.exec(` CREATE TABLE IF NOT EXISTS user_workspaces ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -3087,8 +3139,8 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces( // Seed : workspace "crowdlending" = l'app actuelle. Idempotent (slug UNIQUE). db.prepare(` - INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre) - VALUES ('crowdlending', 'Crowdlending', 'Crowdlending', 'Suivi de portefeuille crowdlending', 1, 0) + INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre) + VALUES ('crowdlending', 'Crowdlending', 'Crowdlending', 'Suivi de portefeuille crowdlending', 'crowdlending', 1, 0) `).run(); /** Accorde + active le workspace par défaut (crowdlending) à un utilisateur. @@ -3097,7 +3149,7 @@ db.prepare(` * auto du profil investisseur principal à ces 3 mêmes endroits, pour * qu'aucun nouveau compte ne se retrouve sans accès à l'app actuelle. */ export function grantDefaultWorkspace(userId) { - const ws = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const ws = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (!ws) return; db.prepare(` INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at) @@ -3110,7 +3162,7 @@ export function grantDefaultWorkspace(userId) { // rien casser du fonctionnement actuel. Idempotent (INSERT OR IGNORE sur la // PK composite user_id/workspace_id). { - const wsBackfill = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const wsBackfill = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (wsBackfill) { db.prepare(` INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at) @@ -3141,7 +3193,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_ref_workspaces_ws ON referentiel_workspa // (comptes créés avant cette migration, ou entrées jamais touchées depuis) // est rattachée au workspace "crowdlending" — seul workspace réel à ce jour. { - const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (wsCrowdlending) { db.prepare(` INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id) @@ -3174,7 +3226,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_plat_workspaces_ws ON plateforme_workspa // Backfill idempotent : toute plateforme utilisateur sans aucune affectation // est rattachée au workspace "crowdlending" — seul workspace réel à ce jour. { - const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (wsCrowdlending) { db.prepare(` INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id) @@ -3204,7 +3256,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_depret_workspace ON depots_retraits(work // Backfill idempotent : tout dépôt/retrait sans workspace (créé avant cette // migration) est rattaché à "crowdlending" — seul workspace réel à ce jour. { - const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (wsCrowdlending2) { db.prepare('UPDATE depots_retraits SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending2.id); } @@ -3226,7 +3278,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_remb_workspace ON remboursements(workspa // Backfill idempotent : tout remboursement sans workspace (créé avant cette // migration) est rattaché à "crowdlending" — seul workspace réel à ce jour. { - const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (wsCrowdlending3) { db.prepare('UPDATE remboursements SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending3.id); } diff --git a/backend/src/middleware/workspaceScope.js b/backend/src/middleware/workspaceScope.js index 933cfad..6cb586f 100644 --- a/backend/src/middleware/workspaceScope.js +++ b/backend/src/middleware/workspaceScope.js @@ -22,8 +22,9 @@ export function getUserActiveWorkspaceIds(userId) { * Lu depuis le header X-Workspace-Id, envoyé automatiquement par le frontend * (frontend/src/api.js, miroir de X-Investisseur-Id) et validé contre les workspaces actifs * de l'utilisateur. Si le header est absent, invalide, ou non autorisé (ex. appel API v1/MCP - * qui ne l'envoie pas encore), retombe silencieusement sur le workspace "crowdlending" — - * jamais d'échec bloquant, pour rester transparent tant qu'un seul workspace existe réellement. + * qui ne l'envoie pas encore), retombe silencieusement sur le workspace de type "crowdlending" + * (le singleton — cf. workspaces.type, 19/09/26) — jamais d'échec bloquant, pour rester + * transparent quel que soit le nombre de workspaces existants. */ export function resolveActiveWorkspaceId(req) { const raw = req.header('X-Workspace-Id'); @@ -32,6 +33,6 @@ export function resolveActiveWorkspaceId(req) { const allowed = new Set(getUserActiveWorkspaceIds(req.user.id)); if (allowed.has(id)) return id; } - const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const cl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); return cl ? cl.id : null; } diff --git a/backend/src/routes/adminWorkspaces.js b/backend/src/routes/adminWorkspaces.js index 93f2eca..1dcbb95 100644 --- a/backend/src/routes/adminWorkspaces.js +++ b/backend/src/routes/adminWorkspaces.js @@ -14,6 +14,14 @@ import { audit } from '../utils/audit.js'; const router = Router(); +// Types de workspace reconnus par l'app (19/09/26 — cf. db/index.js pour le contexte complet +// de cette migration). 'crowdlending' est le singleton historique (un seul exemplaire, imposé +// par l'index unique partiel côté DB et re-vérifié ici pour un message d'erreur clair) ; +// 'private_equity' peut avoir plusieurs exemplaires (ex. si Olivier veut un jour séparer deux +// portefeuilles PE distincts). Un 3e type nécessiterait d'abord une page/route dédiée avant +// d'avoir un sens ici — l'ajouter à cette liste seul ne suffit pas. +const WORKSPACE_TYPES = ['crowdlending', 'private_equity']; + router.get('/', (_req, res) => { const rows = db.prepare(` SELECT w.*, @@ -31,6 +39,11 @@ const WorkspaceCreateSchema = z.object({ nom: z.string().min(1), libelleMenu: z.string().min(1), description: z.string().optional(), + // Choisi une fois à la création, jamais modifiable ensuite (même régime que slug, cf. + // WorkspaceUpdateSchema ci-dessous qui n'inclut ni l'un ni l'autre) — changer le type d'un + // workspace déjà peuplé de données changerait silencieusement le modèle de page/API qui + // s'applique à ces données. + type: z.enum(WORKSPACE_TYPES, { errorMap: () => ({ message: `Type invalide (valeurs acceptées : ${WORKSPACE_TYPES.join(', ')})` }) }), actifGlobal: z.boolean().default(true), ordre: z.number().int().default(0), }); @@ -40,14 +53,20 @@ router.post('/', (req, res, next) => { const body = WorkspaceCreateSchema.parse(req.body); const exists = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(body.slug); if (exists) throw new HttpError(409, 'Ce slug est déjà utilisé'); + // Cf. l'index unique partiel côté DB (idx_workspaces_type_crowdlending_singleton) : cette + // vérification applicative donne un message clair avant même d'atteindre la contrainte SQL. + if (body.type === 'crowdlending') { + const alreadyCl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); + if (alreadyCl) throw new HttpError(409, 'Un workspace de type Crowdlending existe déjà'); + } const r = db.prepare(` - INSERT INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre) - VALUES (?,?,?,?,?,?) - `).run(body.slug, body.nom, body.libelleMenu, body.description || null, body.actifGlobal ? 1 : 0, body.ordre); + INSERT INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre) + VALUES (?,?,?,?,?,?,?) + `).run(body.slug, body.nom, body.libelleMenu, body.description || null, body.type, body.actifGlobal ? 1 : 0, body.ordre); - audit(req, { action: 'workspace_created', category: 'workspaces', actorId: req.user.id, details: { slug: body.slug } }); - res.status(201).json({ id: r.lastInsertRowid, slug: body.slug, nom: body.nom, libelle_menu: body.libelleMenu }); + audit(req, { action: 'workspace_created', category: 'workspaces', actorId: req.user.id, details: { slug: body.slug, type: body.type } }); + res.status(201).json({ id: r.lastInsertRowid, slug: body.slug, nom: body.nom, libelle_menu: body.libelleMenu, type: body.type }); } catch (e) { next(e); } }); @@ -82,7 +101,7 @@ router.delete('/:id', (req, res, next) => { const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id); if (!ws) throw new HttpError(404, 'Espace de travail introuvable'); // Le workspace socle ne peut pas être supprimé — toute l'app actuelle en dépend. - if (ws.slug === 'crowdlending') throw new HttpError(400, 'Le workspace Crowdlending ne peut pas être supprimé'); + if (ws.type === 'crowdlending') throw new HttpError(400, 'Le workspace Crowdlending ne peut pas être supprimé'); db.prepare('DELETE FROM workspaces WHERE id = ?').run(id); audit(req, { action: 'workspace_deleted', category: 'workspaces', actorId: req.user.id, details: { slug: ws.slug } }); diff --git a/backend/src/routes/fraisOperations.js b/backend/src/routes/fraisOperations.js index 4b984c9..9fa9f16 100644 --- a/backend/src/routes/fraisOperations.js +++ b/backend/src/routes/fraisOperations.js @@ -137,8 +137,8 @@ router.get('/', (req, res, next) => { `).all(Number(investissement_pe_id)); } else { const workspaceId = resolveActiveWorkspaceId(req); - const ws = db.prepare('SELECT slug FROM workspaces WHERE id = ?').get(workspaceId); - if (!ws || ws.slug === 'crowdlending') { + const ws = db.prepare('SELECT type FROM workspaces WHERE id = ?').get(workspaceId); + if (!ws || ws.type === 'crowdlending') { rows = db.prepare(` SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom FROM frais_operations f diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index 603fb1c..6f831a6 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -626,7 +626,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { if (row && userAllowedWs.has(row.id)) ids.push(row.id); } if (ids.length === 0) { - const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const cl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (cl && userAllowedWs.has(cl.id)) ids.push(cl.id); } return ids; diff --git a/backend/src/routes/referentiel.js b/backend/src/routes/referentiel.js index 2540c7e..c37e49f 100644 --- a/backend/src/routes/referentiel.js +++ b/backend/src/routes/referentiel.js @@ -551,7 +551,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { if (row) ids.push(row.id); } if (ids.length === 0) { - const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get(); + const cl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get(); if (cl) ids.push(cl.id); } return ids; diff --git a/backend/src/routes/workspaces.js b/backend/src/routes/workspaces.js index 4c6a2ef..9cbcdbb 100644 --- a/backend/src/routes/workspaces.js +++ b/backend/src/routes/workspaces.js @@ -14,8 +14,12 @@ import { COLOR_THEME_VALUES } from './preferences.js'; const router = Router(); router.get('/', (req, res) => { + // w.type (19/09/26) : c'est ce champ, PAS w.slug, que le frontend doit utiliser pour choisir + // entre les pages "Crowdlending" et "Private Equity" (App.jsx, Layout.jsx) — cf. db/index.js + // pour le contexte. slug reste exposé (identifiant technique stable, ex. exports/imports ZIP) + // mais ne porte plus la sémantique de routage. const rows = db.prepare(` - SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user, uw.color_theme + SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, w.type, uw.active_by_user, uw.color_theme FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND w.actif_global = 1 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8921de0..3e57318 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -71,15 +71,16 @@ function FraisPeOnly({ children }) { } // ── Gating de navigation par workspace (Phase 4, étape 0 — 08/09/26) ──── -// cf. project_workspaces_transformation.md (mémoire projet). N'agit -// aujourd'hui sur rien de visible tant qu'un seul workspace (crowdlending) -// existe réellement : `active` vaut alors toujours 'crowdlending' pour tout -// utilisateur. Volontairement "fail-open" — si `active` n'est pas encore -// chargé (null), on laisse passer plutôt que de bloquer, pour ne jamais -// afficher un flash de contenu erroné le temps du premier chargement. -function RequireWorkspace({ slug, children }) { +// cf. project_workspaces_transformation.md (mémoire projet). Prend un +// `type` (workspaces.type, ex. "crowdlending") plutôt qu'un slug (19/09/26 +// — le slug est un identifiant technique choisi librement par l'admin, il +// ne doit plus porter de sémantique de routage, cf. db/index.js). Fail-open +// : si `active` n'est pas encore chargé (null), on laisse passer plutôt que +// de bloquer, pour ne jamais afficher un flash de contenu erroné le temps +// du premier chargement. +function RequireWorkspace({ type, children }) { const { active } = useWorkspace() || {}; - if (active && active.slug !== slug) return ; + if (active && active.type !== type) return ; return children; } @@ -90,7 +91,7 @@ function RequireWorkspace({ slug, children }) { // portefeuille), à enrichir ensuite. function WorkspaceHome() { const { active } = useWorkspace() || {}; - if (active && active.slug !== 'crowdlending') return ; + if (active && active.type !== 'crowdlending') return ; return ; } @@ -98,13 +99,15 @@ function WorkspaceHome() { // demande explicite d'Olivier de réutiliser le code de la page crowdlending // plutôt que d'en construire une séparée à chaque nouveau workspace). Bascule // sur la version simplifiée (pages/DepotsRetraitsSimple.jsx) pour tout -// workspace autre que crowdlending — jamais lié en dur à un slug précis -// (ex. "private_equity") pour rester valable si un 3e workspace apparaît un -// jour. Fail-open vers la page crowdlending tant que `active` n'est pas -// encore chargé, comme RequireWorkspace ci-dessus. +// workspace dont le type n'est pas "crowdlending" (19/09/26 : autrefois testé +// sur le slug, désormais sur workspaces.type — cf. db/index.js) — jamais lié +// en dur à une valeur de type précise (ex. "private_equity"), pour rester +// valable si un 3e type de workspace apparaît un jour. Fail-open vers la +// page crowdlending tant que `active` n'est pas encore chargé, comme +// RequireWorkspace ci-dessus. function DepotsRetraitsRouter() { const { active } = useWorkspace() || {}; - if (active && active.slug !== 'crowdlending') return ; + if (active && active.type !== 'crowdlending') return ; return ; } @@ -112,11 +115,12 @@ function DepotsRetraitsRouter() { // Remboursements simplifiée (RemboursementsSimple.jsx, limitée aux types // bonus/intérêts plateforme — pas de type "normal" lié à un investissement, // investissements n'ayant pas encore de workspace_id) sert tout workspace -// autre que crowdlending, générique par slug pour rester valable si un 3e -// workspace apparaît un jour. +// dont le type n'est pas "crowdlending", générique par type (pas par slug, +// cf. commentaire de DepotsRetraitsRouter ci-dessus) pour rester valable si +// un 3e type de workspace apparaît un jour. function RemboursementsRouter() { const { active } = useWorkspace() || {}; - if (active && active.slug !== 'crowdlending') return ; + if (active && active.type !== 'crowdlending') return ; return ; } @@ -127,7 +131,7 @@ function RemboursementsRouter() { // cf. project_workspaces_transformation.md. function InvestissementsRouter() { const { active } = useWorkspace() || {}; - if (active && active.slug !== 'crowdlending') return ; + if (active && active.type !== 'crowdlending') return ; return ; } @@ -136,7 +140,7 @@ function InvestissementsRouter() { // frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx). function InvestissementDetailRouter() { const { active } = useWorkspace() || {}; - if (active && active.slug !== 'crowdlending') return ; + if (active && active.type !== 'crowdlending') return ; return ; } @@ -145,7 +149,7 @@ function InvestissementDetailRouter() { // son propre indicateur "au moins un frais déclaré" (FraisOnly / FraisPeOnly ci-dessus). function FraisRouter() { const { active } = useWorkspace() || {}; - if (active && active.slug !== 'crowdlending') return ; + if (active && active.type !== 'crowdlending') return ; return ; } @@ -193,15 +197,15 @@ export default function App() { } /> }> } /> - } /> + } /> } /> } /> } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> @@ -214,7 +218,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 5f2008c..4fabefe 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -264,8 +264,9 @@ export default function Layout() { setWorkspaceColorOverride(hasMultipleWorkspaces ? (activeWorkspace?.color_theme || null) : null); }, [activeWorkspace, hasMultipleWorkspaces, setWorkspaceColorOverride]); // Gating de nav par workspace (Phase 4, étape 0) : fail-open tant que - // activeWorkspace n'est pas chargé, cf. App.jsx (RequireWorkspace). - const isCrowdlendingActive = !activeWorkspace || activeWorkspace.slug === 'crowdlending'; + // activeWorkspace n'est pas chargé, cf. App.jsx (RequireWorkspace). Testé sur + // workspaces.type (19/09/26), pas sur slug — cf. db/index.js. + const isCrowdlendingActive = !activeWorkspace || activeWorkspace.type === 'crowdlending'; // Lien "Frais" (chantier "frais PE", 17/09/26) : visible dans les deux workspaces, chacun // avec son propre indicateur "au moins un frais déclaré" (hasFrais / hasFraisPe). const showFrais = isCrowdlendingActive ? hasFrais : hasFraisPe; diff --git a/frontend/src/pages/AdminPlateformes.jsx b/frontend/src/pages/AdminPlateformes.jsx index 2dd7287..3829bb3 100644 --- a/frontend/src/pages/AdminPlateformes.jsx +++ b/frontend/src/pages/AdminPlateformes.jsx @@ -83,14 +83,15 @@ function RefModal({ row, onClose, onSaved }) { setCatsInv(cats); setSecteursInv(sects); setWorkspaces(ws); - // À la création, présélectionne le workspace "crowdlending" (seul - // workspace réel aujourd'hui) — l'utilisateur reste libre d'ajuster ; - // au moins une affectation reste exigée à l'enregistrement. + // À la création, présélectionne le workspace de type "crowdlending" + // (19/09/26 : testé sur le type, plus sur le slug — cf. db/index.js) — + // l'utilisateur reste libre d'ajuster ; au moins une affectation reste + // exigée à l'enregistrement. if (isNew) { setForm(f => (f.workspace_ids && f.workspace_ids.length > 0) ? f : { ...f, workspace_ids: (() => { - const cl = ws.find(w => w.slug === 'crowdlending'); + const cl = ws.find(w => w.type === 'crowdlending'); return cl ? [cl.id] : ws.map(w => w.id); })(), }); diff --git a/frontend/src/pages/admin/WorkspacesSection.jsx b/frontend/src/pages/admin/WorkspacesSection.jsx index 8786b09..5f5b215 100644 --- a/frontend/src/pages/admin/WorkspacesSection.jsx +++ b/frontend/src/pages/admin/WorkspacesSection.jsx @@ -40,7 +40,29 @@ function Toggle({ checked, onClick, title }) { ); } -const emptyForm = { slug: '', nom: '', libelleMenu: '', description: '', actifGlobal: true, ordre: 0 }; +// Types reconnus par l'app (19/09/26 — cf. db/index.js pour le contexte complet de cette +// migration : c'est ce champ, et non plus le slug, qui détermine quel modèle de pages +// crowdlending/PE l'app affiche pour ce workspace). Choisi une seule fois à la création, comme +// le slug — jamais modifiable ensuite (changer le type d'un workspace déjà peuplé de données +// changerait silencieusement le modèle de page/API qui s'applique à ces données). +const WORKSPACE_TYPE_LABELS = { crowdlending: 'Crowdlending', private_equity: 'Private Equity' }; + +// Valeurs pré-remplies suggérées selon le type choisi (retour d'Olivier, 20/09/26) — appliquées +// uniquement à la création et uniquement sur les champs encore vides (ne jamais écraser ce que +// l'admin a déjà saisi), pour lui faire gagner du temps sans lui imposer les valeurs : il reste +// libre de tout changer avant d'enregistrer. +const WORKSPACE_TYPE_SUGGESTIONS = { + crowdlending: { + slug: 'crowdlending', nom: 'Crowdlending', libelleMenu: 'Crowdlending', + description: 'Suivi de portefeuille crowdlending', + }, + private_equity: { + slug: 'private-equity', nom: 'Private Equity', libelleMenu: 'Private Equity', + description: 'Suivi des investissements de type Private Equity', + }, +}; + +const emptyForm = { slug: '', nom: '', libelleMenu: '', description: '', type: '', actifGlobal: true, ordre: 0 }; function WorkspaceFormModal({ open, onClose, onSaved, workspace }) { const isEdit = !!workspace; @@ -57,6 +79,7 @@ function WorkspaceFormModal({ open, onClose, onSaved, workspace }) { nom: workspace.nom, libelleMenu: workspace.libelle_menu, description: workspace.description || '', + type: workspace.type || '', actifGlobal: !!workspace.actif_global, ordre: workspace.ordre ?? 0, } @@ -66,7 +89,7 @@ function WorkspaceFormModal({ open, onClose, onSaved, workspace }) { async function handleSubmit(e) { e.preventDefault(); setErr(null); - if (!form.nom.trim() || !form.libelleMenu.trim() || (!isEdit && !form.slug.trim())) { + if (!form.nom.trim() || !form.libelleMenu.trim() || (!isEdit && !form.slug.trim()) || (!isEdit && !form.type)) { setErr('Merci de renseigner les champs obligatoires (*)'); return; } @@ -82,7 +105,7 @@ function WorkspaceFormModal({ open, onClose, onSaved, workspace }) { if (isEdit) { await api.put(`/admin/workspaces/${workspace.id}`, payload); } else { - await api.post('/admin/workspaces', { ...payload, slug: form.slug.trim() }); + await api.post('/admin/workspaces', { ...payload, slug: form.slug.trim(), type: form.type }); } onSaved(); } catch (e2) { @@ -92,6 +115,24 @@ function WorkspaceFormModal({ open, onClose, onSaved, workspace }) { } } + // N'a d'effet qu'à la création (le type n'est de toute façon pas modifiable à l'édition, + // cf. rendu du champ Type ci-dessous) : pré-remplit les champs encore vides avec les valeurs + // suggérées pour le type choisi, sans jamais écraser une saisie déjà faite par l'admin. + function handleTypeChange(newType) { + setForm(f => { + const suggestion = WORKSPACE_TYPE_SUGGESTIONS[newType]; + if (!suggestion) return { ...f, type: newType }; + return { + ...f, + type: newType, + slug: f.slug.trim() ? f.slug : suggestion.slug, + nom: f.nom.trim() ? f.nom : suggestion.nom, + libelleMenu: f.libelleMenu.trim() ? f.libelleMenu : suggestion.libelleMenu, + description: f.description.trim() ? f.description : suggestion.description, + }; + }); + } + return (
+
+ + + + Détermine les pages affichées pour cet espace (Crowdlending ou Private Equity) — non + modifiable après création. Un seul espace de type Crowdlending peut exister. + +