Amélioration de la notion de workspace

This commit is contained in:
ocroguennec committed 2026-09-19 17:19:53 +02:00
1 parent 1d5737703b
commit c918de1e7d
11 files changed
+202 -58

No files matched your search

+29 -25
View File
@@ -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>
+3 -2
View File
@@ -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;
+5 -4
View File
@@ -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);
})(),
});
+67 -5
View File
@@ -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 />