Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import { memberInitials, memberDisplayName } from '../utils/format.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
/* ── Avatar ─────────────────────────────────────────────────── */
function MemberAvatar({ membre, size = 40 }) {
const initials = memberInitials(membre);
const bg = membre.type === 'entreprise'
? 'linear-gradient(135deg, #4f46e5 0%, #3730a3 100%)'
: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)';
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: bg,
color: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.35),
flexShrink: 0, letterSpacing: '.03em', userSelect: 'none',
}}>
{initials}
</div>
);
}
/* ── Menu "···" par membre ──────────────────────────────────── */
function MemberMenu({ onEdit, onDelete, isPrincipal, isOnly }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button className="member-dots-btn" onClick={() => setOpen(o => !o)}
aria-label="Actions" aria-haspopup="menu">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
</svg>
</button>
{open && (
<div className="member-dots-menu" role="menu">
<button className="member-dots-menu-item" role="menuitem" onClick={() => { setOpen(false); onEdit(); }}>
Modifier
</button>
{!isPrincipal && (
<button className="member-dots-menu-item danger-item" role="menuitem"
disabled={isOnly}
title={isOnly ? 'Impossible de supprimer le dernier profil' : ''}
onClick={() => { setOpen(false); onDelete(); }}>
Supprimer
</button>
)}
</div>
)}
</div>
);
}
/* ── Ligne "Ajouter" ─────────────────────────────────────────── */
function AddRow({ label, onClick }) {
return (
<div className="member-add-row" onClick={onClick} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && onClick()}>
<div className="member-add-circle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</div>
<span>{label}</span>
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function FamilleEntreprises() {
const { reload: reloadCtx } = useInvestisseur();
const [membres, setMembres] = useState([]);
const [tab, setTab] = useState('famille');
const [err, setErr] = useState(null);
/* Modals */
const [modalFamille, setModalFamille] = useState(false);
const [modalEntreprise, setModalEntreprise] = useState(false);
const [editTarget, setEditTarget] = useState(null); // membre à éditer
/* Formulaires */
const emptyFam = { prenom: '', nom_famille: '' };
const emptyEnt = { nom: '', type_fiscal: 'PM' };
const [famForm, setFamForm] = useState(emptyFam);
const [entForm, setEntForm] = useState(emptyEnt);
const [saving, setSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const load = async () => {
const list = await api.get('/investisseurs');
setMembres(list);
};
useEffect(() => { load(); }, []);
const famille = membres.filter(m => m.type === 'famille');
const entreprises = membres.filter(m => m.type === 'entreprise');
const totalCount = membres.length;
/* ── Ouvrir modal édition ─────────────────────────────────── */
const openEdit = (m) => {
setEditTarget(m);
if (m.type === 'famille') {
const restNom = m.prenom ? m.nom.replace(m.prenom, '').trim() : m.nom;
setFamForm({ prenom: m.prenom || '', nom_famille: restNom });
setModalFamille(true);
} else {
setEntForm({ nom: m.nom, type_fiscal: m.type_fiscal || 'PM' });
setModalEntreprise(true);
}
};
const closeModals = () => {
setModalFamille(false); setModalEntreprise(false);
setEditTarget(null);
setFamForm(emptyFam); setEntForm(emptyEnt);
setErr(null);
};
/* ── Sauvegarde famille ────────────────────────────────────── */
const saveFamille = async (e) => {
e.preventDefault(); setErr(null); setSaving(true);
try {
const fullName = [famForm.prenom.trim(), famForm.nom_famille.trim()].filter(Boolean).join(' ');
if (!fullName) throw new Error('Veuillez renseigner au moins un prénom ou un nom.');
const payload = {
nom: fullName,
prenom: famForm.prenom.trim() || null,
type: 'famille',
type_fiscal: 'PP',
};
if (editTarget) {
await api.put(`/investisseurs/${editTarget.id}`, payload);
} else {
await api.post('/investisseurs', payload);
}
await load(); await reloadCtx();
closeModals();
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
/* ── Sauvegarde entreprise ─────────────────────────────────── */
const saveEntreprise = async (e) => {
e.preventDefault(); setErr(null); setSaving(true);
try {
const payload = {
nom: entForm.nom.trim(),
prenom: null,
type: 'entreprise',
type_fiscal: entForm.type_fiscal,
};
if (editTarget) {
await api.put(`/investisseurs/${editTarget.id}`, payload);
} else {
await api.post('/investisseurs', payload);
}
await load(); await reloadCtx();
closeModals();
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
/* ── Suppression ──────────────────────────────────────────── */
const deleteMembre = (m) => {
setDeleteConfirm({
message: `Supprimer "${memberDisplayName(m)}" ? Tous les investissements associés seront effacés.`,
onConfirm: async () => {
try {
await api.del(`/investisseurs/${m.id}`);
await load(); await reloadCtx();
} catch (e) { setErr(e.message); }
finally { setDeleteConfirm(null); }
},
});
};
/* ── Render ───────────────────────────────────────────────── */
const currentList = tab === 'famille' ? famille : entreprises;
return (
<div className="famille-wrap">
{/* Tabs */}
<div className="famille-tabs">
<button
className={`famille-tab${tab === 'famille' ? ' active' : ''}`}
onClick={() => setTab('famille')}
>
Famille
<span className="famille-tab-count">{famille.length}</span>
</button>
<button
className={`famille-tab${tab === 'entreprise' ? ' active' : ''}`}
onClick={() => setTab('entreprise')}
>
Entreprises
<span className="famille-tab-count">{entreprises.length}</span>
</button>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{/* Liste */}
<div className="membre-list">
{currentList.map(m => (
<div key={m.id} className="membre-row">
<MemberAvatar membre={m} size={42} />
<div className="membre-info">
<span className="membre-name">{memberDisplayName(m)}</span>
{m.type === 'famille' && (
<span className="membre-role">
{m.is_principal ? '(compte principal)' : '(Membre de la famille)'}
</span>
)}
</div>
{m.type_fiscal && m.type === 'entreprise' && (
<span className="membre-badge">{m.type_fiscal}</span>
)}
<MemberMenu
onEdit={() => openEdit(m)}
onDelete={() => deleteMembre(m)}
isPrincipal={!!m.is_principal}
isOnly={totalCount <= 1}
/>
</div>
))}
{/* Ligne d'ajout */}
{tab === 'famille' && (
<AddRow label="Ajouter une personne"
onClick={() => { setEditTarget(null); setFamForm(emptyFam); setModalFamille(true); }} />
)}
{tab === 'entreprise' && (
<AddRow label="Ajouter une entreprise"
onClick={() => { setEditTarget(null); setEntForm(emptyEnt); setModalEntreprise(true); }} />
)}
</div>
{/* ── Modal famille ──────────────────────────────────────── */}
<Modal
open={modalFamille}
title={editTarget ? 'Modifier le membre' : 'Ajouter une personne'}
onClose={closeModals}
footer={
<>
<button className="ghost" type="button" onClick={closeModals}>Annuler</button>
<button className="primary" form="form-famille" type="submit" disabled={saving}>
{saving ? '…' : 'Enregistrer'}
</button>
</>
}
>
<form id="form-famille" onSubmit={saveFamille}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && <div className="error">{err}</div>}
<div className="modal-field">
<label>Prénom</label>
<input autoFocus value={famForm.prenom}
onChange={e => setFamForm({ ...famForm, prenom: e.target.value })}
placeholder="Olivier" />
</div>
<div className="modal-field">
<label>Nom de famille <span className="text-muted" style={{ fontWeight: 400 }}>(optionnel)</span></label>
<input value={famForm.nom_famille}
onChange={e => setFamForm({ ...famForm, nom_famille: e.target.value })}
placeholder="CROGUENNEC" />
</div>
</form>
</Modal>
{/* ── Modal entreprise ───────────────────────────────────── */}
<Modal
open={modalEntreprise}
title={editTarget ? "Modifier l'entreprise" : 'Ajouter une entreprise'}
onClose={closeModals}
footer={
<>
<button className="ghost" type="button" onClick={closeModals}>Annuler</button>
<button className="primary" form="form-entreprise" type="submit" disabled={saving}>
{saving ? '…' : 'Enregistrer'}
</button>
</>
}
>
<form id="form-entreprise" onSubmit={saveEntreprise}
style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && <div className="error">{err}</div>}
<div className="modal-field">
<label>Nom de l'entreprise *</label>
<input autoFocus required value={entForm.nom}
onChange={e => setEntForm({ ...entForm, nom: e.target.value })}
placeholder="SCI Famille Croguennec" />
</div>
<div className="modal-field">
<label>Forme juridique</label>
<select value={entForm.type_fiscal}
onChange={e => setEntForm({ ...entForm, type_fiscal: e.target.value })}>
<option value="PM">Personne morale</option>
<option value="SCI">SCI</option>
<option value="SCPI">SCPI</option>
<option value="SARL">SARL</option>
<option value="SAS">SAS</option>
<option value="SA">SA</option>
</select>
</div>
</form>
</Modal>
<ConfirmModal
open={!!deleteConfirm}
message={deleteConfirm?.message}
onConfirm={deleteConfirm?.onConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
}