325 lines
13 KiB
React
325 lines
13 KiB
React
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>
|
|
);
|
|
}
|