Amélioration de la notion de workspace
This commit is contained in:
1 parent
1d5737703b
commit
c918de1e7d
11 files changed
+202
-58
No files matched your search
+60
-8
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-25
@@ -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 <Navigate to="/" replace />;
|
||||
if (active && active.type !== type) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ function RequireWorkspace({ slug, children }) {
|
||||
// portefeuille), à enrichir ensuite.
|
||||
function WorkspaceHome() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') return <DashboardPe />;
|
||||
if (active && active.type !== 'crowdlending') return <DashboardPe />;
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
@@ -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 <DepotsRetraitsSimple />;
|
||||
if (active && active.type !== 'crowdlending') return <DepotsRetraitsSimple />;
|
||||
return <DepotsRetraits />;
|
||||
}
|
||||
|
||||
@@ -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 <RemboursementsSimple />;
|
||||
if (active && active.type !== 'crowdlending') return <RemboursementsSimple />;
|
||||
return <Remboursements />;
|
||||
}
|
||||
|
||||
@@ -127,7 +131,7 @@ function RemboursementsRouter() {
|
||||
// cf. project_workspaces_transformation.md.
|
||||
function InvestissementsRouter() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') return <InvestissementsPe />;
|
||||
if (active && active.type !== 'crowdlending') return <InvestissementsPe />;
|
||||
return <Investissements />;
|
||||
}
|
||||
|
||||
@@ -136,7 +140,7 @@ function InvestissementsRouter() {
|
||||
// frais, documents), à enrichir ensuite (cf. InvestissementPeDetail.jsx).
|
||||
function InvestissementDetailRouter() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') return <InvestissementPeDetail />;
|
||||
if (active && active.type !== 'crowdlending') return <InvestissementPeDetail />;
|
||||
return <InvestissementDetail />;
|
||||
}
|
||||
|
||||
@@ -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 <FraisPeOnly><FraisPe /></FraisPeOnly>;
|
||||
if (active && active.type !== 'crowdlending') return <FraisPeOnly><FraisPe /></FraisPeOnly>;
|
||||
return <FraisOnly><Frais /></FraisOnly>;
|
||||
}
|
||||
|
||||
@@ -193,15 +197,15 @@ export default function App() {
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route element={<Protected><Layout /></Protected>}>
|
||||
<Route index element={<WorkspaceHome />} />
|
||||
<Route path="plateformes" element={<RequireWorkspace slug="crowdlending"><Plateformes /></RequireWorkspace>} />
|
||||
<Route path="plateformes" element={<RequireWorkspace type="crowdlending"><Plateformes /></RequireWorkspace>} />
|
||||
<Route path="depots-retraits" element={<DepotsRetraitsRouter />} />
|
||||
<Route path="investissements" element={<InvestissementsRouter />} />
|
||||
<Route path="investissements/:id" element={<InvestissementDetailRouter />} />
|
||||
<Route path="remboursements" element={<RemboursementsRouter />} />
|
||||
<Route path="frais" element={<FraisRouter />} />
|
||||
<Route path="simul" element={<RequireWorkspace slug="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
|
||||
<Route path="taxreport" element={<RequireWorkspace slug="crowdlending"><TaxReport /></RequireWorkspace>} />
|
||||
<Route path="assistant-ia" element={<RequireWorkspace slug="crowdlending"><AssistantIA /></RequireWorkspace>} />
|
||||
<Route path="simul" element={<RequireWorkspace type="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
|
||||
<Route path="taxreport" element={<RequireWorkspace type="crowdlending"><TaxReport /></RequireWorkspace>} />
|
||||
<Route path="assistant-ia" element={<RequireWorkspace type="crowdlending"><AssistantIA /></RequireWorkspace>} />
|
||||
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
|
||||
<Route path="imports" element={<Navigate to="/settings?section=imports" replace />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
@@ -214,7 +218,7 @@ export default function App() {
|
||||
<Route path="aide" element={<Aide />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="communication" element={<Communication />} />
|
||||
<Route path="referentiel/:id" element={<RequireWorkspace slug="crowdlending"><PlatformeProfile /></RequireWorkspace>} />
|
||||
<Route path="referentiel/:id" element={<RequireWorkspace type="crowdlending"><PlatformeProfile /></RequireWorkspace>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
})(),
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -108,6 +149,25 @@ function WorkspaceFormModal({ open, onClose, onSaved, workspace }) {
|
||||
}
|
||||
>
|
||||
<form id="workspace-form" onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Type *
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={form.type}
|
||||
disabled={isEdit}
|
||||
onChange={e => handleTypeChange(e.target.value)}
|
||||
>
|
||||
<option value="">— Choisir —</option>
|
||||
<option value="crowdlending">Crowdlending</option>
|
||||
<option value="private_equity">Private Equity</option>
|
||||
</select>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Identifiant (slug) *
|
||||
@@ -256,6 +316,7 @@ export default function WorkspacesSection() {
|
||||
<th style={{ paddingLeft: 20 }}>Nom</th>
|
||||
<th>Libellé menu</th>
|
||||
<th>Slug</th>
|
||||
<th>Type</th>
|
||||
<th style={{ textAlign: 'center' }}>Disponible</th>
|
||||
<th style={{ textAlign: 'right' }}>Ordre</th>
|
||||
<th style={{ textAlign: 'right' }}>Utilisateurs</th>
|
||||
@@ -268,6 +329,7 @@ export default function WorkspacesSection() {
|
||||
<td style={{ paddingLeft: 20, fontWeight: 600 }}>{w.nom}</td>
|
||||
<td>{w.libelle_menu}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontFamily: 'monospace', fontSize: 12 }}>{w.slug}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{WORKSPACE_TYPE_LABELS[w.type] || w.type}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{w.actif_global
|
||||
? <span className="badge badge-success">Oui</span>
|
||||
@@ -286,8 +348,8 @@ export default function WorkspacesSection() {
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
title={w.slug === 'crowdlending' ? 'Le workspace Crowdlending ne peut pas être supprimé' : 'Supprimer'}
|
||||
disabled={w.slug === 'crowdlending'}
|
||||
title={w.type === 'crowdlending' ? 'Le workspace Crowdlending ne peut pas être supprimé' : 'Supprimer'}
|
||||
disabled={w.type === 'crowdlending'}
|
||||
onClick={() => setToDelete(w)}
|
||||
>
|
||||
<IconTrash />
|
||||
|
||||
Reference in new issue
Block a user