Mise en place des workspaces

This commit is contained in:
ocroguennec committed 2026-09-08 12:33:04 +02:00
1 parent be3c93897b
commit 6b6cad7cd6
19 files changed
+1273 -56

No files matched your search

+4
View File
@@ -7,6 +7,7 @@ import IconsSection from './admin/IconsSection.jsx';
import SmtpSection from './admin/SmtpSection.jsx';
import GeneralSection from './admin/GeneralSection.jsx';
import ExportSection from './admin/ExportSection.jsx';
import WorkspacesSection from './admin/WorkspacesSection.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
@@ -19,6 +20,7 @@ function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24"
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
function IconDownload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>; }
function IconSparkle() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>; }
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
const NAV = [
{
@@ -31,6 +33,7 @@ const NAV = [
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
{ id: 'smtp', label: 'Configuration SMTP', icon: <IconMail /> },
{ id: 'ia', label: 'Assistant IA', icon: <IconSparkle />, href: '/admin/ia' },
{ id: 'workspaces', label: 'Espaces de travail', icon: <IconLayers /> },
],
},
{
@@ -85,6 +88,7 @@ export default function Admin() {
{section === 'icons' && <IconsSection />}
{section === 'smtp' && <SmtpSection />}
{section === 'export' && <ExportSection />}
{section === 'workspaces' && <WorkspacesSection />}
</div>
</div>
);
+9
View File
@@ -9,6 +9,7 @@ import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
import ObjectifsSection from './settings/ObjectifsSection.jsx';
import DataCleanupSection from './settings/DataCleanupSection.jsx';
import ImportsSection from './settings/ImportsSection.jsx';
import WorkspacesUserSection from './settings/WorkspacesUserSection.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconFamily() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
@@ -22,6 +23,7 @@ function IconMyFiscal() { return <svg width="15" height="15" viewBox="0 0 24 24"
function IconTarget() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>; }
function IconBroom() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>; }
function IconUpload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; }
function IconSpaces() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="4" width="8" height="16" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/></svg>; }
const NAV = [
{
@@ -30,6 +32,12 @@ const NAV = [
{ id: 'apparence', label: 'Apparence', icon: <IconMonitor /> },
],
},
{
group: 'Mon espace de travail',
items: [
{ id: 'workspaces', label: 'Mes espaces de travail', icon: <IconSpaces /> },
],
},
{
group: 'Mon paramétrage',
items: [
@@ -95,6 +103,7 @@ export default function Settings() {
{section === 'secteurs-inv' && <SecteursInvSection />}
{section === 'nettoyage' && <DataCleanupSection />}
{section === 'imports' && <ImportsSection />}
{section === 'workspaces' && <WorkspacesUserSection />}
</div>
</div>
);
@@ -0,0 +1,274 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import Modal from '../../components/Modal.jsx';
/* Modale en étapes "Gérer les droits" — Administration > Utilisateurs.
* Consolide en un seul endroit ce qui était trois actions séparées du menu
* ⋮ (rôle, statut, accès IA) + le nouvel octroi de workspaces :
* 1. Information générale (rôle, statut du compte)
* 2. Espaces de travail (octroi admin — cf. adminWorkspaces.js / PATCH
* /admin/users/:id/workspaces)
* 3. Assistant IA (accès individuel, décision admin)
* Voir project_workspaces_transformation.md (mémoire projet). */
const STEPS = [
{ id: 'general', label: 'Information générale' },
{ id: 'workspaces', label: 'Espaces de travail' },
{ id: 'ia', label: 'Assistant IA' },
];
const ROLE_OPTIONS = [{ value: 'user', label: 'Utilisateur' }, { value: 'admin', label: 'Administrateur' }];
const STATUS_OPTIONS = [
{ value: 'active', label: 'Actif' },
{ value: 'deactivated', label: 'Désactivé' },
{ value: 'locked', label: 'Verrouillé' },
];
function Toggle({ checked, onClick, disabled, title }) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
style={{
width: 38, height: 21, borderRadius: 20, border: 'none', flexShrink: 0,
background: checked ? 'var(--primary)' : 'var(--border)',
position: 'relative', cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1, transition: 'background .15s',
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 19 : 2,
width: 17, height: 17, borderRadius: '50%', background: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,.3)', transition: 'left .15s',
}} />
</button>
);
}
export default function ManageRightsModal({ open, user, currentUserId, aiAvailable, aiUnavailableReason, onClose, onSaved }) {
const isSelf = !!user && user.id === currentUserId;
const [step, setStep] = useState('general');
const [form, setForm] = useState(null);
const [catalog, setCatalog] = useState([]);
const [loadingCat, setLoadingCat] = useState(false);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open || !user) return;
setStep('general');
setErr(null);
setForm({
role: user.role || 'user',
status: user.status || 'active',
workspaceIds: user.workspace_ids || [],
aiEnabled: !!user.ai_enabled,
});
setLoadingCat(true);
api.get('/admin/workspaces')
.then(setCatalog)
.catch(() => setCatalog([]))
.finally(() => setLoadingCat(false));
}, [open, user]);
const toggleWorkspace = useCallback((id) => {
setForm(f => ({
...f,
workspaceIds: f.workspaceIds.includes(id)
? f.workspaceIds.filter(w => w !== id)
: [...f.workspaceIds, id],
}));
}, []);
if (!open || !user || !form) return null;
const stepIndex = STEPS.findIndex(s => s.id === step);
async function handleSave() {
setErr(null);
setSaving(true);
try {
const calls = [];
if (!isSelf) {
if (form.role !== (user.role || 'user')) {
calls.push(api.patch(`/admin/users/${user.id}/role`, { role: form.role }));
}
if (form.status !== (user.status || 'active')) {
calls.push(api.patch(`/admin/users/${user.id}/status`, { status: form.status }));
}
}
const origWs = [...(user.workspace_ids || [])].sort().join(',');
const newWs = [...form.workspaceIds].sort().join(',');
if (origWs !== newWs) {
calls.push(api.patch(`/admin/users/${user.id}/workspaces`, { workspaceIds: form.workspaceIds }));
}
if (form.aiEnabled !== !!user.ai_enabled) {
calls.push(api.patch(`/admin/users/${user.id}/ai-access`, { aiEnabled: form.aiEnabled }));
}
await Promise.all(calls);
onSaved();
} catch (e) {
setErr(e.message || "Erreur lors de l'enregistrement");
} finally {
setSaving(false);
}
}
const identity = user.display_name || user.email;
return (
<Modal
open={open}
title={`Gérer les droits — ${identity}`}
onClose={onClose}
width={560}
footer={
<>
<button className="ghost" type="button" onClick={onClose}>Annuler</button>
{stepIndex > 0 && (
<button className="btn btn-outline" type="button" onClick={() => setStep(STEPS[stepIndex - 1].id)}>
Précédent
</button>
)}
{stepIndex < STEPS.length - 1 && (
<button className="btn btn-outline" type="button" onClick={() => setStep(STEPS[stepIndex + 1].id)}>
Suivant
</button>
)}
<button className="btn btn-primary" type="button" onClick={handleSave} disabled={saving}>
{saving ? 'Enregistrement…' : 'Enregistrer'}
</button>
</>
}
>
{/* ── Indicateur d'étape (même pattern que InvestissementFormModal) ── */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
Étape {stepIndex + 1} : {STEPS[stepIndex].label}
</div>
<div style={{ display: 'flex', gap: 6 }}>
{STEPS.map((s, i) => (
<div key={s.id} style={{
flex: 1, height: 5, borderRadius: 4,
background: i <= stepIndex ? 'var(--primary)' : 'var(--border)',
}} />
))}
</div>
</div>
{step === 'general' && (
<div>
{isSelf && (
<p style={{ fontSize: 12.5, color: 'var(--text-muted)', background: 'var(--surface-2)', padding: '8px 12px', borderRadius: 8, marginTop: 0 }}>
Vous ne pouvez pas modifier votre propre rôle ni votre propre statut depuis cet écran.
</p>
)}
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>Rôle</label>
<select
className="form-input"
value={form.role}
disabled={isSelf}
onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
>
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>Statut du compte</label>
<select
className="form-input"
value={form.status}
disabled={isSelf}
onChange={e => setForm(f => ({ ...f, status: e.target.value }))}
>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
)}
{step === 'workspaces' && (
<div>
<p style={{ margin: '0 0 12px', fontSize: 12.5, color: 'var(--text-muted)' }}>
Espaces de travail accordés à cet utilisateur. L'utilisateur pourra ensuite les activer ou
les masquer lui-même depuis ses paramètres.
</p>
{loadingCat ? (
<p style={{ color: 'var(--text-muted)' }}>Chargement…</p>
) : catalog.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}>Aucun espace de travail défini dans le catalogue.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{catalog.map(w => {
const granted = form.workspaceIds.includes(w.id);
return (
<div
key={w.id}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
borderRadius: 8, border: '1px solid var(--border)',
background: granted ? 'var(--surface-2)' : 'transparent',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14.5 }}>{w.nom}</div>
{w.description && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{w.description}</div>
)}
</div>
{!w.actif_global && (
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', border: '1px solid var(--border)', borderRadius: 10, padding: '1px 7px', flexShrink: 0 }}>
indisponible
</span>
)}
<Toggle
checked={granted}
onClick={() => toggleWorkspace(w.id)}
title={granted ? 'Retirer cet espace de travail' : 'Accorder cet espace de travail'}
/>
</div>
);
})}
</div>
)}
{form.workspaceIds.length === 0 && (
<p style={{ fontSize: 12, color: 'var(--danger, #dc2626)', marginTop: 10 }}>
Aucun espace de travail accordé : l'utilisateur n'aura accès à aucune fonctionnalité.
</p>
)}
</div>
)}
{step === 'ia' && (
<div>
<div style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
borderRadius: 8, border: '1px solid var(--border)',
background: form.aiEnabled ? 'var(--surface-2)' : 'transparent',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14.5 }}>Accès à l'Assistant IA</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>
{aiAvailable
? "Permet à l'utilisateur d'utiliser le chat de l'Assistant IA."
: aiUnavailableReason}
</div>
</div>
<Toggle
checked={form.aiEnabled}
disabled={!aiAvailable}
onClick={() => setForm(f => ({ ...f, aiEnabled: !f.aiEnabled }))}
title={!aiAvailable ? aiUnavailableReason : (form.aiEnabled ? "Désactiver l'accès à l'Assistant IA" : "Activer l'accès à l'Assistant IA")}
/>
</div>
</div>
)}
{err && <div className="error" style={{ marginTop: 14 }}>{err}</div>}
</Modal>
);
}
+18 -8
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import * as XLSX from 'xlsx';
import { api } from '../../api.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import ManageRightsModal from './ManageRightsModal.jsx';
import Modal from '../../components/Modal.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
import PasswordStrength from '../../components/PasswordStrength.jsx';
@@ -61,6 +62,7 @@ const IcoTrash = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="no
const IcoDots = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="5" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="19" r="1" fill="currentColor"/></svg>;
const IcoSearch = () => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>;
const IcoSparkle = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>;
const IcoRights = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>;
// Interrupteur compact — accès individuel à l'Assistant IA (même style visuel
// que le Toggle de AdminIA.jsx, dupliqué ici car non partagé entre les deux
@@ -426,6 +428,7 @@ export default function UsersSection({ currentUserId }) {
const [filterStatus, setFilterStatus] = useState('all');
const [filterRole, setFilterRole] = useState('all');
const [openMenu, setOpenMenu] = useState(null);
const [rightsTarget, setRightsTarget] = useState(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// null tant que non charge (voir l'effet ci-dessous) : l'interrupteur
@@ -488,12 +491,6 @@ export default function UsersSection({ currentUserId }) {
const confirm = (title, message, onConfirm, confirmLabel) =>
setConfirmAction({ title, message, onConfirm, confirmLabel });
const toggleRole = u => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
confirm('Changer le rôle',
`Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
async () => { await api.patch(`/admin/users/${u.id}/role`, { role: newRole }); load(); }, 'Confirmer');
};
const verifyEmail = u => confirm("Vérifier l'email",
`Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
async () => { await api.patch(`/admin/users/${u.id}/verify-email`, {}); load(); }, 'Confirmer');
@@ -773,8 +770,7 @@ export default function UsersSection({ currentUserId }) {
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 200,
}}>
<MenuBtn icon={<IcoEdit />} label={openMenu.user.role === 'admin' ? 'Rétrograder en utilisateur' : 'Promouvoir en admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} />
<MenuBtn icon={<IcoSparkle />} label={openMenu.user.ai_enabled ? "Désactiver l'accès IA" : "Activer l'accès IA"} onClick={() => { setOpenMenu(null); toggleAiAccess(openMenu.user); }} />
<MenuBtn icon={<IcoRights />} label="Gérer les droits" onClick={() => { setOpenMenu(null); setRightsTarget(openMenu.user); }} />
{!openMenu.user.email_verified && <>
<MenuBtn icon={<IcoMail />} label="Renvoyer l'invitation" onClick={() => { setOpenMenu(null); resendInvitation(openMenu.user); }} />
<MenuBtn icon={<IcoCheck />} label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} />
@@ -812,6 +808,20 @@ export default function UsersSection({ currentUserId }) {
onCreated={() => load()}
/>
<ManageRightsModal
open={!!rightsTarget}
user={rightsTarget}
currentUserId={currentUserId}
aiAvailable={aiAvailable}
aiUnavailableReason={aiUnavailableReason}
onClose={() => setRightsTarget(null)}
onSaved={() => {
setRightsTarget(null);
setBanner({ ok: true, msg: 'Droits mis à jour.' });
load();
}}
/>
<ConfirmModal
open={!!confirmAction}
title={confirmAction?.title}
@@ -0,0 +1,324 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import Modal from '../../components/Modal.jsx';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
/* Catalogue des espaces de travail (Administration > Espaces de travail).
* CRUD sur la table `workspaces` — indépendant de l'octroi par utilisateur,
* qui se fait via la modale "Gérer les droits" (UsersSection.jsx). Voir
* project_workspaces_transformation.md (mémoire projet). */
function IconPlus() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>;
}
function IconEdit() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
}
function IconTrash() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>;
}
function Toggle({ checked, onClick, title }) {
return (
<button
type="button"
title={title}
onClick={onClick}
style={{
width: 38, height: 21, borderRadius: 20, border: 'none', flexShrink: 0,
background: checked ? 'var(--primary)' : 'var(--border)',
position: 'relative', cursor: 'pointer', transition: 'background .15s',
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 19 : 2,
width: 17, height: 17, borderRadius: '50%', background: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,.3)', transition: 'left .15s',
}} />
</button>
);
}
const emptyForm = { slug: '', nom: '', libelleMenu: '', description: '', actifGlobal: true, ordre: 0 };
function WorkspaceFormModal({ open, onClose, onSaved, workspace }) {
const isEdit = !!workspace;
const [form, setForm] = useState(emptyForm);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setErr(null);
setForm(workspace
? {
slug: workspace.slug,
nom: workspace.nom,
libelleMenu: workspace.libelle_menu,
description: workspace.description || '',
actifGlobal: !!workspace.actif_global,
ordre: workspace.ordre ?? 0,
}
: emptyForm);
}, [open, workspace]);
async function handleSubmit(e) {
e.preventDefault();
setErr(null);
if (!form.nom.trim() || !form.libelleMenu.trim() || (!isEdit && !form.slug.trim())) {
setErr('Merci de renseigner les champs obligatoires (*)');
return;
}
setSaving(true);
try {
const payload = {
nom: form.nom.trim(),
libelleMenu: form.libelleMenu.trim(),
description: form.description.trim(),
actifGlobal: !!form.actifGlobal,
ordre: Number(form.ordre) || 0,
};
if (isEdit) {
await api.put(`/admin/workspaces/${workspace.id}`, payload);
} else {
await api.post('/admin/workspaces', { ...payload, slug: form.slug.trim() });
}
onSaved();
} catch (e2) {
setErr(e2.message || 'Erreur lors de l\'enregistrement');
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
title={isEdit ? "Modifier l'espace de travail" : 'Nouvel espace de travail'}
onClose={onClose}
width={480}
footer={
<>
<button className="ghost" type="button" onClick={onClose}>Annuler</button>
<button className="btn btn-primary" type="submit" form="workspace-form" disabled={saving}>
{saving ? 'Enregistrement…' : 'Enregistrer'}
</button>
</>
}
>
<form id="workspace-form" onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Identifiant (slug) *
</label>
<input
className="form-input"
placeholder="ex: private-equity"
value={form.slug}
disabled={isEdit}
onChange={e => setForm(f => ({ ...f, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
/>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
lettres minuscules, chiffres, tirets — non modifiable après création
</span>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Nom *
</label>
<input
className="form-input"
placeholder="ex: Private Equity"
value={form.nom}
onChange={e => setForm(f => ({ ...f, nom: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Libellé menu *
</label>
<input
className="form-input"
placeholder="ex: Private Equity"
value={form.libelleMenu}
onChange={e => setForm(f => ({ ...f, libelleMenu: e.target.value }))}
/>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Affiché dans la barre latérale lorsque cet espace est actif
</span>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
Description
</label>
<textarea
className="form-input"
rows={2}
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
<div style={{ display: 'flex', gap: 20, alignItems: 'center', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Toggle
checked={!!form.actifGlobal}
onClick={() => setForm(f => ({ ...f, actifGlobal: !f.actifGlobal }))}
title={form.actifGlobal ? 'Désactiver la disponibilité pour attribution' : 'Activer la disponibilité pour attribution'}
/>
<span style={{ fontSize: 'var(--fs-sm)' }}>Disponible pour attribution</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'var(--fs-sm)' }}>
Ordre
<input
type="number"
className="form-input"
style={{ width: 70 }}
value={form.ordre}
onChange={e => setForm(f => ({ ...f, ordre: e.target.value }))}
/>
</label>
</div>
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
</form>
</Modal>
);
}
export default function WorkspacesSection() {
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(true);
const [banner, setBanner] = useState(null);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(null);
const [toDelete, setToDelete] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try { setWorkspaces(await api.get('/admin/workspaces')); }
catch (e) { setBanner({ ok: false, msg: e.message || 'Erreur de chargement' }); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
async function handleDelete() {
if (!toDelete) return;
try {
await api.del(`/admin/workspaces/${toDelete.id}`);
setBanner({ ok: true, msg: `Espace de travail « ${toDelete.nom} » supprimé.` });
setToDelete(null);
load();
} catch (e) {
setBanner({ ok: false, msg: e.message || 'Erreur lors de la suppression' });
setToDelete(null);
}
}
if (loading) return <p className="text-muted" style={{ padding: 24 }}>Chargement…</p>;
return (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{banner && (
<ResultBanner
result={banner}
onDismiss={() => setBanner(null)}
style={{ margin: '12px 20px 0', borderRadius: 8 }}
/>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 20px 14px', gap: 16, borderBottom: '1px solid var(--border)', flexWrap: 'wrap' }}>
<div>
<h3 style={{ margin: '0 0 3px' }}>Espaces de travail</h3>
<p style={{ margin: 0, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
Catalogue des espaces de travail proposés par l'application (Crowdlending, futurs modules…).
L'attribution à un utilisateur se fait depuis sa fiche, onglet « Gérer les droits ».
</p>
</div>
<button
className="btn btn-primary btn-sm"
onClick={() => { setEditing(null); setShowForm(true); }}
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}
>
<IconPlus /> Nouvel espace de travail
</button>
</div>
{workspaces.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '40px 20px' }}>
Aucun espace de travail défini.
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ margin: 0 }}>
<thead>
<tr>
<th style={{ paddingLeft: 20 }}>Nom</th>
<th>Libellé menu</th>
<th>Slug</th>
<th style={{ textAlign: 'center' }}>Disponible</th>
<th style={{ textAlign: 'right' }}>Ordre</th>
<th style={{ textAlign: 'right' }}>Utilisateurs</th>
<th style={{ width: 90, paddingRight: 20 }}></th>
</tr>
</thead>
<tbody>
{workspaces.map(w => (
<tr key={w.id}>
<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={{ textAlign: 'center' }}>
{w.actif_global
? <span className="badge badge-success">Oui</span>
: <span className="badge">Non</span>}
</td>
<td style={{ textAlign: 'right' }}>{w.ordre}</td>
<td style={{ textAlign: 'right' }}>{w.nb_utilisateurs}</td>
<td style={{ paddingRight: 20 }}>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button
className="btn btn-sm btn-ghost"
title="Modifier"
onClick={() => { setEditing(w); setShowForm(true); }}
>
<IconEdit />
</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'}
onClick={() => setToDelete(w)}
>
<IconTrash />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<WorkspaceFormModal
open={showForm}
workspace={editing}
onClose={() => setShowForm(false)}
onSaved={() => {
setShowForm(false);
setBanner({ ok: true, msg: editing ? 'Espace de travail mis à jour.' : 'Espace de travail créé.' });
load();
}}
/>
<ConfirmModal
open={!!toDelete}
title="Supprimer l'espace de travail"
message={toDelete ? `Supprimer définitivement « ${toDelete.nom} » ? Les droits accordés aux utilisateurs sur cet espace seront retirés.` : ''}
onConfirm={handleDelete}
onCancel={() => setToDelete(null)}
/>
</div>
);
}
@@ -0,0 +1,123 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../api.js';
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
/* Settings > Mes espaces de travail — activation personnelle des espaces
* de travail accordés par l'administrateur (active_by_user). L'octroi lui-
* même (granted_by_admin) n'est modifiable que par un administrateur, voir
* ManageRightsModal.jsx (Administration > Utilisateurs). Voir
* project_workspaces_transformation.md (mémoire projet). */
function Toggle({ checked, onClick, disabled, title }) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
style={{
width: 40, height: 22, borderRadius: 20, border: 'none', flexShrink: 0,
background: checked ? 'var(--primary)' : 'var(--border)',
position: 'relative', cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1, transition: 'background .15s',
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 20 : 2,
width: 18, height: 18, borderRadius: '50%', background: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,.3)', transition: 'left .15s',
}} />
</button>
);
}
export default function WorkspacesUserSection() {
const { reload: reloadWorkspaceCtx } = useWorkspace() || {};
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(true);
const [banner, setBanner] = useState(null);
const [pending, setPending] = useState(null); // id en cours de bascule
const load = useCallback(async () => {
setLoading(true);
try { setWorkspaces(await api.get('/workspaces')); }
catch (e) { setBanner({ ok: false, msg: e.message || 'Erreur de chargement' }); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const activeCount = workspaces.filter(w => w.active_by_user).length;
async function toggle(w) {
const nextActive = !w.active_by_user;
if (!nextActive && activeCount <= 1) {
setBanner({ ok: false, msg: 'Au moins un espace de travail doit rester actif.' });
return;
}
setPending(w.id);
try {
await api.patch(`/workspaces/${w.id}/active`, { active: nextActive });
setWorkspaces(ws => ws.map(x => x.id === w.id ? { ...x, active_by_user: nextActive } : x));
reloadWorkspaceCtx?.();
} catch (e) {
setBanner({ ok: false, msg: e.message || 'Erreur lors de la mise à jour' });
} finally {
setPending(null);
}
}
if (loading) return <p className="text-muted" style={{ padding: 24 }}>Chargement…</p>;
return (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{banner && (
<ResultBanner
result={banner}
onDismiss={() => setBanner(null)}
style={{ margin: '12px 20px 0', borderRadius: 8 }}
/>
)}
<div style={{ padding: '20px 20px 14px', borderBottom: '1px solid var(--border)' }}>
<h3 style={{ margin: '0 0 3px' }}>Mes espaces de travail</h3>
<p style={{ margin: 0, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
Activez ou masquez les espaces de travail qui vous ont été accordés par un administrateur.
Un espace masqué n'apparaît plus dans le sélecteur du menu.
</p>
</div>
{workspaces.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '40px 20px' }}>
Aucun espace de travail ne vous a été accordé.
</p>
) : (
<div style={{ padding: '8px 20px 20px' }}>
{workspaces.map(w => (
<div
key={w.id}
style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 4px',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 'var(--fs-sm)' }}>{w.nom}</div>
{w.description && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{w.description}</div>
)}
</div>
<Toggle
checked={!!w.active_by_user}
disabled={pending === w.id}
onClick={() => toggle(w)}
title={w.active_by_user ? 'Masquer cet espace de travail' : 'Activer cet espace de travail'}
/>
</div>
))}
</div>
)}
</div>
);
}