Implementation de la feature de gestion des fichiers sur les investissements
This commit is contained in:
1 parent
f6829dec9d
commit
8953d0bb35
21 files changed
+1729
-181
No files matched your search
@@ -9,7 +9,7 @@ import RembFormModal from '../components/RembFormModal.jsx';
|
||||
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
||||
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||
import { dayOfMonth } from '../utils/dateEcheances.js';
|
||||
import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js';
|
||||
import { fmtEUR, fmtPct, fmtDate, today, fmtOctets } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
|
||||
const emptyForm = {
|
||||
@@ -155,24 +155,121 @@ export default function InvestissementDetail() {
|
||||
const [bulkRembDone, setBulkRembDone] = useState(false);
|
||||
const [reinvTab, setReinvTab] = useState('manuel'); // 'manuel' | 'auto'
|
||||
|
||||
// Bloc "Mes documents" (cf. load() pour le chargement initial, handlers plus bas)
|
||||
const [documents, setDocuments] = useState([]);
|
||||
const [docsMenu, setDocsMenu] = useState(null);
|
||||
const [docsErr, setDocsErr] = useState(null);
|
||||
const [uploadingDoc, setUploadingDoc] = useState(false);
|
||||
const [renamingDocId, setRenamingDocId] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const docFileInputRef = useRef(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [data, p, comptes, catInv, sectInv] = await Promise.all([
|
||||
const [data, p, comptes, catInv, sectInv, docs] = await Promise.all([
|
||||
api.get(`/investissements/${id}`),
|
||||
api.get('/plateformes'),
|
||||
api.get('/investissements/comptes-courants'),
|
||||
api.get('/categories-inv'),
|
||||
api.get('/secteurs-inv'),
|
||||
api.get('/documents', { entity_type: 'investissement', entity_id: id }),
|
||||
]);
|
||||
setInv(data);
|
||||
setPlats(p);
|
||||
setComptesCourants(comptes);
|
||||
setCategoriesInv(catInv);
|
||||
setSecteursInv(sectInv);
|
||||
setDocuments(docs);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
/** Recharge uniquement la liste des documents (après upload/renommage/suppression) —
|
||||
* plus léger qu'un load() complet qui recharge aussi l'investissement, les plateformes,
|
||||
* les comptes courants et les catégories/secteurs. */
|
||||
const refreshDocuments = async () => {
|
||||
try {
|
||||
const docs = await api.get('/documents', { entity_type: 'investissement', entity_id: id });
|
||||
setDocuments(docs);
|
||||
} catch { /* la fiche reste utilisable même si le rafraîchissement échoue */ }
|
||||
};
|
||||
|
||||
const handleDocFileChange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // permet de re-sélectionner le même fichier ensuite
|
||||
if (!file) return;
|
||||
setUploadingDoc(true);
|
||||
setDocsErr(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('entity_type', 'investissement');
|
||||
fd.append('entity_id', id);
|
||||
// Nom par défaut proposé : nom du fichier sans son extension (demande Olivier),
|
||||
// modifiable ensuite via le renommage.
|
||||
fd.append('nom_affichage', file.name.replace(/\.[^./\\]+$/, ''));
|
||||
await api.postForm('/documents', fd);
|
||||
await refreshDocuments();
|
||||
} catch (err) {
|
||||
setDocsErr(err.message);
|
||||
} finally {
|
||||
setUploadingDoc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocRenameStart = (doc) => {
|
||||
setRenamingDocId(doc.id);
|
||||
setRenameValue(doc.nom_affichage);
|
||||
};
|
||||
|
||||
const handleDocRenameCommit = async (docId) => {
|
||||
const value = renameValue.trim();
|
||||
setRenamingDocId(null);
|
||||
const current = documents.find(d => d.id === docId);
|
||||
if (!value || !current || value === current.nom_affichage) return;
|
||||
try {
|
||||
await api.put(`/documents/${docId}`, { nom_affichage: value });
|
||||
await refreshDocuments();
|
||||
} catch (err) {
|
||||
setDocsErr(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocDownload = async (doc) => {
|
||||
try {
|
||||
const blob = await api.blob(`/documents/${doc.id}/download`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `${doc.nom_affichage}.${doc.extension}`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setDocsErr(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocDelete = (doc) => {
|
||||
setRowDeleteConfirm({
|
||||
message: `Supprimer définitivement le document "${doc.nom_affichage}.${doc.extension}" ?`,
|
||||
onConfirm: async () => {
|
||||
await api.del(`/documents/${doc.id}`);
|
||||
setRowDeleteConfirm(null);
|
||||
await refreshDocuments();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocDeleteAll = () => {
|
||||
setDocsMenu(null);
|
||||
setRowDeleteConfirm({
|
||||
message: `Supprimer définitivement les ${documents.length} document(s) de cet investissement ? Cette action est irréversible.`,
|
||||
onConfirm: async () => {
|
||||
await api.del(`/documents/by-entity/investissement/${id}`);
|
||||
setRowDeleteConfirm(null);
|
||||
await refreshDocuments();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { if (activeId) load(); /* eslint-disable-next-line */ }, [id, activeId]);
|
||||
useEffect(() => { api.get('/pfu').then(setPfuRates).catch(() => {}); }, []);
|
||||
|
||||
@@ -184,6 +281,7 @@ export default function InvestissementDetail() {
|
||||
setRembMenu(null);
|
||||
setReinvMenu(null);
|
||||
setSimulMenu(null);
|
||||
setDocsMenu(null);
|
||||
};
|
||||
window.addEventListener('scroll', closeAll, true);
|
||||
return () => window.removeEventListener('scroll', closeAll, true);
|
||||
@@ -851,87 +949,25 @@ export default function InvestissementDetail() {
|
||||
return [];
|
||||
};
|
||||
|
||||
// ── Export dossier JSON ────────────────────────────────────────
|
||||
const exportDossier = () => {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const name = `Dossier_Investissement_${inv.id}_${ts}.json`;
|
||||
const payload = {
|
||||
version: '1.0',
|
||||
type: 'dossier_investissement',
|
||||
exported_at: new Date().toISOString(),
|
||||
investissement: {
|
||||
nom_projet: inv.nom_projet,
|
||||
emetteur: inv.emetteur,
|
||||
date_souscription: inv.date_souscription,
|
||||
date_premiere_echeance: inv.date_premiere_echeance,
|
||||
date_cible: inv.date_cible,
|
||||
date_debut_simul: inv.date_debut_simul,
|
||||
montant_investi: inv.montant_investi,
|
||||
taux_interet: inv.taux_interet,
|
||||
duree_mois: inv.duree_mois,
|
||||
type_remb: inv.type_remb,
|
||||
freq_interets: inv.freq_interets,
|
||||
statut: inv.statut,
|
||||
reference: inv.reference,
|
||||
source: inv.source,
|
||||
notes: inv.notes,
|
||||
},
|
||||
plateforme: {
|
||||
nom: inv.plateforme_nom,
|
||||
},
|
||||
remboursements: (inv.remboursements || []).map(r => ({
|
||||
date_remb: r.date_remb,
|
||||
capital: r.capital,
|
||||
cashback: r.cashback,
|
||||
interets_bruts: r.interets_bruts,
|
||||
prelev_sociaux: r.prelev_sociaux,
|
||||
prelev_forfaitaire: r.prelev_forfaitaire,
|
||||
interets_nets: r.interets_nets,
|
||||
net_recu: r.net_recu,
|
||||
statut: r.statut,
|
||||
notes: r.notes,
|
||||
})),
|
||||
projections: (inv.simul || []).map(s => ({
|
||||
numero_echeance: s.numero_echeance,
|
||||
date_prevue: s.date_prevue,
|
||||
capital_prevu: s.capital_prevu,
|
||||
interets_prevus: s.interets_prevus,
|
||||
total_prevu: s.total_prevu,
|
||||
})),
|
||||
reinvestissements: (inv.reinvestissements || []).map(r => ({
|
||||
date_reinvestissement: r.date_reinvestissement,
|
||||
montant: r.montant,
|
||||
source: r.source,
|
||||
note: r.note,
|
||||
})),
|
||||
historique: (inv.historique || []).map(h => ({
|
||||
type_evenement: h.type_evenement,
|
||||
changements: h.changements,
|
||||
notes: h.notes,
|
||||
created_at: h.created_at,
|
||||
})),
|
||||
revisions: (inv.revisions || []).map(r => ({
|
||||
date_effet: r.date_effet,
|
||||
ancien_taux: r.ancien_taux,
|
||||
nouveau_taux: r.nouveau_taux,
|
||||
ancienne_date_cible: r.ancienne_date_cible,
|
||||
nouvelle_date_cible: r.nouvelle_date_cible,
|
||||
motif: r.motif,
|
||||
created_at: r.created_at,
|
||||
})),
|
||||
pertes: (inv.pertes || []).map(p => ({
|
||||
date_effet: p.date_effet,
|
||||
montant_perte: p.montant_perte,
|
||||
ancien_statut: p.ancien_statut,
|
||||
motif: p.motif,
|
||||
created_at: p.created_at,
|
||||
})),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
// ── Export dossier ZIP (manifest.json + documents) ──────────────
|
||||
// Rework du 29/08/26 (demande Olivier) : ce bouton produisait jusqu'ici un
|
||||
// simple .json construit ici même à partir des données déjà en mémoire.
|
||||
// Il appelle maintenant le backend (GET /investissements/:id/export), qui
|
||||
// construit un ZIP avec un manifest.json consolidant toutes les données du
|
||||
// dossier (utile pour une future réimportation) et les documents joints
|
||||
// (bloc "Mes documents" plus bas) — des données que le frontend seul
|
||||
// n'a pas (le contenu binaire des fichiers).
|
||||
const exportDossier = async () => {
|
||||
try {
|
||||
const blob = await api.blob(`/investissements/${id}/export`);
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `Dossier_Investissement_${id}_${ts}.zip`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
window.alert(`Échec de l'export du dossier : ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Déclaration de perte définitive ──────────────────────────────
|
||||
@@ -1879,6 +1915,88 @@ export default function InvestissementDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Mes documents ──────────────────────────────────────────── */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>Mes documents</h3>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setDocsMenu({ x: r.right, y: r.bottom }); }}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
title="Actions"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{docsErr && (
|
||||
<div style={{ marginBottom: 12, padding: '8px 12px', borderRadius: 6, background: 'rgba(239,68,68,0.1)', color: 'var(--danger, #ef4444)', fontSize: 'var(--fs-sm)' }}>
|
||||
{docsErr}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadingDoc && (
|
||||
<div style={{ marginBottom: 12, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Envoi du document…</div>
|
||||
)}
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>
|
||||
Aucun document pour l'instant. Utilisez le menu <strong>⋮</strong> ci-dessus pour en ajouter un.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{renamingDocId === doc.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleDocRenameCommit(doc.id);
|
||||
if (e.key === 'Escape') setRenamingDocId(null);
|
||||
}}
|
||||
onBlur={() => handleDocRenameCommit(doc.id)}
|
||||
style={{ width: '100%', fontSize: 'var(--fs-sm)' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => handleDocRenameStart(doc)}
|
||||
title="Cliquer pour renommer"
|
||||
style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
|
||||
>
|
||||
{doc.nom_affichage}.{doc.extension}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{fmtOctets(doc.taille_octets)} · {fmtDate(doc.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" title="Renommer" onClick={() => handleDocRenameStart(doc)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, padding: 0, background: 'none', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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>
|
||||
</button>
|
||||
<button type="button" title="Télécharger" onClick={() => handleDocDownload(doc)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, padding: 0, background: 'none', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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>
|
||||
</button>
|
||||
<button type="button" title="Supprimer" onClick={() => handleDocDelete(doc)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, padding: 0, background: 'none', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--danger)', flexShrink: 0 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={docFileInputRef} type="file" onChange={handleDocFileChange} style={{ display: 'none' }} />
|
||||
</div>
|
||||
|
||||
{/* ── Historique des modifications ──────────────────────────── */}
|
||||
{historique.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
@@ -2727,6 +2845,40 @@ export default function InvestissementDetail() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Menu ⋮ bloc "Mes documents" ── */}
|
||||
{docsMenu && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setDocsMenu(null)} />
|
||||
<div style={{
|
||||
position: 'fixed', left: docsMenu.x, top: docsMenu.y,
|
||||
transform: 'translateX(-100%) translateY(4px)',
|
||||
zIndex: 300,
|
||||
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,
|
||||
}}>
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => { setDocsMenu(null); docFileInputRef.current?.click(); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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>
|
||||
Ajouter un document
|
||||
</button>
|
||||
{documents.length > 0 && (
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={handleDocDeleteAll}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
Tout supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!rowDeleteConfirm}
|
||||
message={rowDeleteConfirm?.message}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { useAuth } from '../context/AuthContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import { api } from '../api.js';
|
||||
import { memberLabel } from '../utils/format.js';
|
||||
import { memberLabel, fmtDate, fmtOctets } from '../utils/format.js';
|
||||
import { withDevOverrides } from '../utils/devOverrides.js';
|
||||
|
||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||
@@ -26,6 +27,9 @@ function IconTrash() {
|
||||
function IconServer() {
|
||||
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="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01"/><path d="M6 17.5h.01"/></svg>;
|
||||
}
|
||||
function IconDocument() {
|
||||
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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>;
|
||||
}
|
||||
|
||||
/* ── Dropdown custom style Finary ────────────────────────────── */
|
||||
const LANGUES = [
|
||||
@@ -1451,6 +1455,354 @@ function McpServerSection({ goToApiKeys }) {
|
||||
}
|
||||
|
||||
/* ── Page principale ─────────────────────────────────────────── */
|
||||
/** Section "Mes documents" — gère tous les documents du compte (tous les
|
||||
* investisseurs confondus, cf. décision Olivier 29/08/26) et affiche la
|
||||
* consommation du quota. Pour l'instant, l'ajout de documents se fait
|
||||
* uniquement depuis la fiche d'un investissement (bloc "Mes documents") ;
|
||||
* cette section ne fait que gérer/consulter (renommer, télécharger,
|
||||
* supprimer) — un bouton d'ajout générique pourra être ajouté ici quand
|
||||
* d'autres catégories de documents existeront. */
|
||||
function DocumentsSection() {
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [quota, setQuota] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [renamingId, setRenamingId] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
// Filtre par catégorie ("Investissements" / "Fiscalité") — demande Olivier
|
||||
// 29/08/26. Regroupement par projet uniquement pertinent pour la catégorie
|
||||
// "investissement" (une catégorie "fiscalité" n'est pour l'instant rattachée
|
||||
// à aucune entité) ; les groupes sont repliés par défaut, dépliables via
|
||||
// l'icône chevron.
|
||||
const [filter, setFilter] = useState('investissement');
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const navigate = useNavigate();
|
||||
// Menu ⋮ du bloc (Tout effacer / Exporter en ZIP) — demande Olivier 29/08/26.
|
||||
const [menuOpen, setMenuOpen] = useState(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [clearAllConfirm, setClearAllConfirm] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, q] = await Promise.all([api.get('/documents'), api.get('/documents/quota')]);
|
||||
setDocs(d);
|
||||
setQuota(q);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const startRename = (doc) => { setRenamingId(doc.id); setRenameValue(doc.nom_affichage); };
|
||||
|
||||
const commitRename = async (docId) => {
|
||||
const value = renameValue.trim();
|
||||
setRenamingId(null);
|
||||
const current = docs.find(d => d.id === docId);
|
||||
if (!value || !current || value === current.nom_affichage) return;
|
||||
try {
|
||||
await api.put(`/documents/${docId}`, { nom_affichage: value });
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const download = async (doc) => {
|
||||
try {
|
||||
const blob = await api.blob(`/documents/${doc.id}/download`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `${doc.nom_affichage}.${doc.extension}`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const remove = async (doc) => {
|
||||
if (!window.confirm(`Supprimer définitivement le document "${doc.nom_affichage}.${doc.extension}" ?`)) return;
|
||||
setBusyId(doc.id);
|
||||
try {
|
||||
await api.del(`/documents/${doc.id}`);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setBusyId(null); }
|
||||
};
|
||||
|
||||
const toggleExpand = (key) => setExpanded(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleExportZip = async () => {
|
||||
setMenuOpen(null);
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await api.blob('/documents/export');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `documents-${new Date().toISOString().slice(0, 10)}.zip`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setExporting(false); }
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
setMenuOpen(null);
|
||||
setClearAllConfirm(true);
|
||||
};
|
||||
|
||||
const confirmClearAll = async () => {
|
||||
setClearAllConfirm(false);
|
||||
try {
|
||||
await api.del('/documents');
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const renderDocRow = (doc, withLienColonne) => (
|
||||
<tr key={doc.id}>
|
||||
<td>
|
||||
{renamingId === doc.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitRename(doc.id); if (e.key === 'Escape') setRenamingId(null); }}
|
||||
onBlur={() => commitRename(doc.id)}
|
||||
style={{ fontSize: 'var(--fs-sm)' }}
|
||||
/>
|
||||
) : (
|
||||
<span onClick={() => startRename(doc)} title="Cliquer pour renommer" style={{ cursor: 'pointer' }}>
|
||||
{doc.nom_affichage}.{doc.extension}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{withLienColonne && <td>{doc.investissement_nom || '—'}</td>}
|
||||
<td>{fmtOctets(doc.taille_octets)}</td>
|
||||
<td>{fmtDate(doc.created_at)}</td>
|
||||
<td style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button type="button" title="Télécharger" className="icon-btn" onClick={() => download(doc)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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>
|
||||
</button>
|
||||
<button type="button" title="Supprimer" className="icon-btn" style={{ color: 'var(--danger)' }} disabled={busyId === doc.id} onClick={() => remove(doc)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const CATEGORIE_LABELS = { investissement: 'Investissement', fiscalite: 'Fiscalité', autre: 'Autre' };
|
||||
const pctBytes = quota ? Math.min(100, (quota.used_bytes / quota.quota_bytes) * 100) : 0;
|
||||
const pctCount = quota ? Math.min(100, (quota.used_count / quota.quota_count) * 100) : 0;
|
||||
|
||||
const investDocs = docs.filter(d => d.categorie === 'investissement');
|
||||
const fiscDocs = docs.filter(d => d.categorie === 'fiscalite');
|
||||
const shownDocs = filter === 'investissement' ? investDocs : fiscDocs;
|
||||
|
||||
// Regroupement par investissement (entity_type + entity_id), en conservant
|
||||
// l'ordre d'apparition (les documents arrivent déjà triés du plus récent au
|
||||
// plus ancien — le premier document rencontré pour un projet donné fixe donc
|
||||
// la position de son groupe).
|
||||
const groups = [];
|
||||
if (filter === 'investissement') {
|
||||
const byKey = new Map();
|
||||
for (const doc of investDocs) {
|
||||
const key = `${doc.entity_type}:${doc.entity_id}`;
|
||||
let group = byKey.get(key);
|
||||
if (!group) {
|
||||
group = { key, label: doc.investissement_nom || `Investissement #${doc.entity_id}`, entityId: doc.entity_id, docs: [] };
|
||||
byKey.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.docs.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<h3 style={{ margin: '0 0 4px' }}>Mes documents</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); const r = e.currentTarget.getBoundingClientRect(); setMenuOpen({ x: r.right, y: r.bottom }); }}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'none', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
title="Actions"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Tous les documents rattachés à votre compte, tous investisseurs confondus. Pour l'instant, ajoutez-en depuis la fiche d'un investissement (bloc "Mes documents") — d'autres types de documents pourront être gérés ici à l'avenir.
|
||||
</p>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{quota && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-sm)', marginBottom: 4 }}>
|
||||
<span>Stockage utilisé</span>
|
||||
<span className="text-muted">{fmtOctets(quota.used_bytes)} / {fmtOctets(quota.quota_bytes)}</span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pctBytes}%`, height: '100%', background: pctBytes > 90 ? 'var(--danger, #ef4444)' : 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-sm)', marginBottom: 4 }}>
|
||||
<span>Nombre de documents</span>
|
||||
<span className="text-muted">{quota.used_count} / {quota.quota_count}</span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pctCount}%`, height: '100%', background: pctCount > 90 ? 'var(--danger, #ef4444)' : 'var(--primary)', borderRadius: 3 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="doc-cat-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`doc-cat-tab${filter === 'investissement' ? ' active' : ''}`}
|
||||
onClick={() => setFilter('investissement')}
|
||||
>
|
||||
Investissements
|
||||
<span className="doc-cat-tab-badge">{investDocs.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`doc-cat-tab${filter === 'fiscalite' ? ' active' : ''}`}
|
||||
onClick={() => setFilter('fiscalite')}
|
||||
>
|
||||
Fiscalité
|
||||
<span className="doc-cat-tab-badge">{fiscDocs.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!loading && shownDocs.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
{filter === 'investissement' ? 'Aucun document pour l\'instant.' : 'Aucun document de fiscalité pour l\'instant.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{filter === 'investissement' && groups.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{groups.map(group => {
|
||||
const totalBytes = group.docs.reduce((sum, d) => sum + d.taille_octets, 0);
|
||||
const isOpen = !!expanded[group.key];
|
||||
return (
|
||||
<div key={group.key} style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div
|
||||
onClick={() => toggleExpand(group.key)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', background: 'var(--surface-2)', cursor: 'pointer' }}
|
||||
>
|
||||
<span
|
||||
title={isOpen ? 'Masquer le détail' : 'Afficher le détail'}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, flexShrink: 0, color: 'var(--text-muted)', transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</span>
|
||||
<div
|
||||
className="doc-group-link"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/investissements/${group.entityId}`); }}
|
||||
title="Ouvrir la fiche de cet investissement"
|
||||
style={{ flex: 1, minWidth: 0, fontWeight: 600, fontSize: 'var(--fs-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--primary)', cursor: 'pointer' }}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{group.docs.length} document{group.docs.length > 1 ? 's' : ''} · {fmtOctets(totalBytes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<table style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Taille</th>
|
||||
<th>Ajouté le</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.docs.map(doc => renderDocRow(doc, false))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter !== 'investissement' && shownDocs.length > 0 && (
|
||||
<table style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Taille</th>
|
||||
<th>Ajouté le</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shownDocs.map(doc => renderDocRow(doc, false))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* ── Menu ⋮ du bloc "Mes documents" ── */}
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setMenuOpen(null)} />
|
||||
<div style={{
|
||||
position: 'fixed', left: menuOpen.x, top: menuOpen.y,
|
||||
transform: 'translateX(-100%) translateY(4px)',
|
||||
zIndex: 300,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
padding: '4px 0', minWidth: 220,
|
||||
}}>
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: (exporting || docs.length === 0) ? 'default' : 'pointer', fontSize: 'var(--fs-sm)', color: docs.length === 0 ? 'var(--text-muted)' : 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => { if (!exporting && docs.length > 0) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
disabled={exporting || docs.length === 0}
|
||||
onClick={handleExportZip}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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>
|
||||
{exporting ? 'Export en cours…' : 'Exporter en ZIP'}
|
||||
</button>
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: docs.length === 0 ? 'default' : 'pointer', fontSize: 'var(--fs-sm)', color: docs.length === 0 ? 'var(--text-muted)' : 'var(--danger)', textAlign: 'left' }}
|
||||
onMouseEnter={e => { if (docs.length > 0) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
disabled={docs.length === 0}
|
||||
onClick={handleClearAll}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
Tout effacer
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={clearAllConfirm}
|
||||
title="Tout effacer"
|
||||
confirmLabel="Tout effacer"
|
||||
message={
|
||||
<>
|
||||
Supprimer définitivement TOUS vos documents ({docs.length} document{docs.length > 1 ? 's' : ''}, {fmtOctets(docs.reduce((sum, d) => sum + d.taille_octets, 0))}), toutes catégories et tous projets confondus ?
|
||||
<br /><br />
|
||||
<strong>Cette action est irréversible : les fichiers seront perdus.</strong>
|
||||
</>
|
||||
}
|
||||
onConfirm={confirmClearAll}
|
||||
onCancel={() => setClearAllConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonCompte() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -1464,6 +1816,7 @@ export default function MonCompte() {
|
||||
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
|
||||
{ id: 'api-keys', label: 'Clés API', icon: <IconKey /> },
|
||||
{ id: 'mcp', label: 'Serveur MCP', icon: <IconServer /> },
|
||||
{ id: 'documents', label: 'Mes documents', icon: <IconDocument /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1485,12 +1838,21 @@ export default function MonCompte() {
|
||||
</aside>
|
||||
{/* ── Contenu ─────────────────────────────────────── */}
|
||||
<div className="account-content account-content-center">
|
||||
<div className="account-content-narrow">
|
||||
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
|
||||
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
||||
{section === 'api-keys' && <ApiKeysSection />}
|
||||
{section === 'mcp' && <McpServerSection goToApiKeys={() => setSection('api-keys')} />}
|
||||
</div>
|
||||
{section === 'documents' ? (
|
||||
// "Mes documents" a besoin de plus de largeur qu'un formulaire (tableau) —
|
||||
// 70% de l'espace disponible sur grand écran, 100% sur petit écran (demande
|
||||
// Olivier 29/08/26), cf. .account-content-wide dans styles.css.
|
||||
<div className="account-content-wide">
|
||||
<DocumentsSection />
|
||||
</div>
|
||||
) : (
|
||||
<div className="account-content-narrow">
|
||||
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
|
||||
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
||||
{section === 'api-keys' && <ApiKeysSection />}
|
||||
{section === 'mcp' && <McpServerSection goToApiKeys={() => setSection('api-keys')} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function ExportSection() {
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>Nouvel export</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Inclut : <code>crowdlending.db</code>, <code>logos/</code>, <code>icons/</code>, <code>manifest.json</code>
|
||||
Inclut : <code>crowdlending.db</code>, <code>logos/</code>, <code>icons/</code>, <code>documents/</code>, <code>manifest.json</code>
|
||||
</div>
|
||||
{genErr && <div className="error" style={{ marginTop: 8 }}>{genErr}</div>}
|
||||
</div>
|
||||
|
||||
@@ -33,11 +33,13 @@ function SectionHeader({ title, description }) {
|
||||
}
|
||||
|
||||
const DEFAULT = {
|
||||
appName: 'Crowdlending Tracker',
|
||||
appUrl: '',
|
||||
mcpUrl: '',
|
||||
allowRegistration: true,
|
||||
minPasswordLength: 8,
|
||||
appName: 'Crowdlending Tracker',
|
||||
appUrl: '',
|
||||
mcpUrl: '',
|
||||
allowRegistration: true,
|
||||
minPasswordLength: 8,
|
||||
documentsQuotaGo: 5,
|
||||
documentsQuotaCount: 500,
|
||||
};
|
||||
|
||||
export default function GeneralSection() {
|
||||
@@ -54,11 +56,13 @@ export default function GeneralSection() {
|
||||
// tant qu'on ne les corrige pas — voir utils/devOverrides.js.
|
||||
const dd = withDevOverrides(d);
|
||||
setForm({
|
||||
appName: dd.appName || 'Crowdlending Tracker',
|
||||
appUrl: dd.appUrl || '',
|
||||
mcpUrl: dd.mcpUrl || '',
|
||||
allowRegistration: dd.allowRegistration !== false,
|
||||
minPasswordLength: dd.minPasswordLength || 8,
|
||||
appName: dd.appName || 'Crowdlending Tracker',
|
||||
appUrl: dd.appUrl || '',
|
||||
mcpUrl: dd.mcpUrl || '',
|
||||
allowRegistration: dd.allowRegistration !== false,
|
||||
minPasswordLength: dd.minPasswordLength || 8,
|
||||
documentsQuotaGo: dd.documentsQuotaGo || 5,
|
||||
documentsQuotaCount: dd.documentsQuotaCount || 500,
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -71,11 +75,13 @@ export default function GeneralSection() {
|
||||
setBusy(true); setSaved(null);
|
||||
try {
|
||||
await api.patch('/admin/general', {
|
||||
appName: form.appName.trim(),
|
||||
appUrl: form.appUrl.trim(),
|
||||
mcpUrl: form.mcpUrl.trim(),
|
||||
allowRegistration: form.allowRegistration,
|
||||
minPasswordLength: form.minPasswordLength,
|
||||
appName: form.appName.trim(),
|
||||
appUrl: form.appUrl.trim(),
|
||||
mcpUrl: form.mcpUrl.trim(),
|
||||
allowRegistration: form.allowRegistration,
|
||||
minPasswordLength: form.minPasswordLength,
|
||||
documentsQuotaGo: form.documentsQuotaGo,
|
||||
documentsQuotaCount: form.documentsQuotaCount,
|
||||
});
|
||||
setSaved('ok');
|
||||
setTimeout(() => setSaved(null), 3000);
|
||||
@@ -202,6 +208,51 @@ export default function GeneralSection() {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{/* Documents */}
|
||||
<div className="card" style={{ marginBottom: 28 }}>
|
||||
<h3 style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Documents</h3>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Quota appliqué à chaque utilisateur pour les documents qu'il ajoute (fiches investissement...). Même valeur pour tous les comptes.
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label="Quota de stockage"
|
||||
description="Volume total de documents qu'un utilisateur peut stocker."
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={1000}
|
||||
step={0.5}
|
||||
value={form.documentsQuotaGo}
|
||||
onChange={e => set('documentsQuotaGo', Math.max(0.1, Math.min(1000, parseFloat(e.target.value) || 5)))}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Go</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Nombre de documents"
|
||||
description="Nombre maximum de documents qu'un utilisateur peut ajouter, tous types confondus."
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
value={form.documentsQuotaCount}
|
||||
onChange={e => set('documentsQuotaCount', Math.max(1, Math.min(100000, parseInt(e.target.value) || 500)))}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>documents</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{/* Bouton sauvegarde */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button
|
||||
|
||||
@@ -7,6 +7,7 @@ import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
import PasswordStrength from '../../components/PasswordStrength.jsx';
|
||||
import PasswordInput from '../../components/PasswordInput.jsx';
|
||||
import { fmt, Badge, UserStatusBadge } from './adminHelpers.jsx';
|
||||
import { fmtOctets } from '../../utils/format.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -83,6 +84,10 @@ function toRows(users) {
|
||||
Rôle: u.role === 'admin' ? 'Admin' : 'Utilisateur',
|
||||
'2FA': u.totp_enabled ? 'Oui' : 'Non',
|
||||
'Inscrit le': u.created_at ? u.created_at.replace('T', ' ').slice(0, 16) : '',
|
||||
Plateformes: u.nb_plateformes ?? 0,
|
||||
Investissements: u.nb_investissements ?? 0,
|
||||
Documents: u.nb_documents ?? 0,
|
||||
'Taille documents': fmtOctets(u.taille_documents ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -95,7 +100,7 @@ function dlBlob(content, filename, type) {
|
||||
}
|
||||
|
||||
function toCSV(users) {
|
||||
const headers = ['ID', 'Nom', 'Email', 'Statut', 'Rôle', '2FA', 'Inscrit le'];
|
||||
const headers = ['ID', 'Nom', 'Email', 'Statut', 'Rôle', '2FA', 'Inscrit le', 'Plateformes', 'Investissements', 'Documents', 'Taille documents'];
|
||||
const rows = toRows(users).map(r => Object.values(r).map(v => `"${String(v).replace(/"/g,'""')}"`).join(','));
|
||||
return '' + [headers.map(h => `"${h}"`).join(','), ...rows].join('\n');
|
||||
}
|
||||
@@ -612,6 +617,10 @@ export default function UsersSection({ currentUserId }) {
|
||||
<th>Rôle</th>
|
||||
<th>2FA</th>
|
||||
<th>Inscrit le</th>
|
||||
<th style={{ textAlign: 'right' }}>Nb de plateformes</th>
|
||||
<th style={{ textAlign: 'right' }}>Nb d'investissements</th>
|
||||
<th style={{ textAlign: 'right' }}>Nb de documents</th>
|
||||
<th style={{ textAlign: 'right' }}>Taille des documents</th>
|
||||
<th style={{ width: 48, paddingRight: 20 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -648,6 +657,10 @@ export default function UsersSection({ currentUserId }) {
|
||||
}
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
||||
<td style={{ textAlign: 'right' }}>{u.nb_plateformes ?? 0}</td>
|
||||
<td style={{ textAlign: 'right' }}>{u.nb_investissements ?? 0}</td>
|
||||
<td style={{ textAlign: 'right' }}>{u.nb_documents ?? 0}</td>
|
||||
<td style={{ textAlign: 'right', color: 'var(--text-muted)', fontSize: 12 }}>{fmtOctets(u.taille_documents ?? 0)}</td>
|
||||
<td style={{ paddingRight: 20 }}>
|
||||
<button onClick={e => openMenuFor(e, u)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '4px 6px', borderRadius: 4, display: 'flex', alignItems: 'center' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-2)'; e.currentTarget.style.color = 'var(--text)'; }}
|
||||
|
||||
@@ -6,9 +6,9 @@ function IconBroom() {
|
||||
}
|
||||
|
||||
const PURGE_SCOPES = [
|
||||
{ value: 'all', label: 'Toutes les données (dépôts/retraits + investissements + remboursements)' },
|
||||
{ value: 'all', label: 'Toutes les données (dépôts/retraits + investissements + remboursements + documents)' },
|
||||
{ value: 'depots_retraits', label: 'Dépôts / Retraits uniquement' },
|
||||
{ value: 'investissements', label: "Investissements (et leurs remboursements liés)" },
|
||||
{ value: 'investissements', label: "Investissements (et leurs remboursements et documents liés)" },
|
||||
{ value: 'remboursements', label: 'Remboursements uniquement (les investissements sont conservés)' },
|
||||
];
|
||||
|
||||
@@ -20,6 +20,9 @@ function purgeSummary(counts, scope) {
|
||||
if (scope === 'investissements' && counts.simul_remboursements > 0) {
|
||||
parts.push(`${counts.simul_remboursements} échéance(s) simulée(s)`);
|
||||
}
|
||||
if ((scope === 'investissements' || scope === 'all') && counts.documents > 0) {
|
||||
parts.push(`${counts.documents} document(s)`);
|
||||
}
|
||||
if (parts.length === 0) return 'Aucune donnée à supprimer trouvée pour cette plateforme.';
|
||||
return `Supprimé : ${parts.join(', ')}.`;
|
||||
}
|
||||
|
||||
@@ -773,31 +773,33 @@ function DossierImport({
|
||||
dossierInputRef, reloadHistory,
|
||||
}) {
|
||||
const missingInv = !activeId;
|
||||
const [dossierAnalyzing, setDossierAnalyzing] = useState(false);
|
||||
|
||||
const onFileChange = (e) => {
|
||||
const onFileChange = async (e) => {
|
||||
const f = e.target.files[0];
|
||||
setDossierFile(f || null);
|
||||
setDossierPreview(null); setDossierResult(null); setDossierErr(null);
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.target.result);
|
||||
if (parsed.type !== 'dossier_investissement') {
|
||||
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
|
||||
return;
|
||||
}
|
||||
setDossierPreview(parsed);
|
||||
} catch { setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.'); }
|
||||
};
|
||||
reader.readAsText(f);
|
||||
setDossierAnalyzing(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const r = await api.postForm('/imports/dossier/preview', fd);
|
||||
setDossierPreview(r);
|
||||
} catch (err) {
|
||||
setDossierErr(err.message);
|
||||
setDossierFile(null);
|
||||
if (dossierInputRef.current) dossierInputRef.current.value = '';
|
||||
} finally {
|
||||
setDossierAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
if (!dossierPreview) return;
|
||||
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
|
||||
try {
|
||||
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
|
||||
const r = await api.post('/imports/dossier/apply', { tempId: dossierPreview.tempId });
|
||||
setDossierResult(r);
|
||||
setDossierFile(null); setDossierPreview(null);
|
||||
if (dossierInputRef.current) dossierInputRef.current.value = '';
|
||||
@@ -806,15 +808,16 @@ function DossierImport({
|
||||
finally { setDossierBusy(false); }
|
||||
};
|
||||
|
||||
const dp = dossierPreview;
|
||||
const dp = dossierPreview?.manifest;
|
||||
const inv = dp?.investissement;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source de votre dossier d'investissement</h3>
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
|
||||
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
|
||||
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
|
||||
Restaure ou migre un dossier complet (investissement, remboursements, historique et documents) depuis un fichier
|
||||
<code style={{ margin: '0 4px' }}>.zip</code> exporté par cette application (bouton « Exporter » du bloc
|
||||
Informations du projet, sur la fiche investissement).
|
||||
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
|
||||
</p>
|
||||
|
||||
@@ -826,12 +829,15 @@ function DossierImport({
|
||||
|
||||
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Fichier dossier <code>.json</code></label>
|
||||
<input ref={dossierInputRef} type="file" accept=".json"
|
||||
disabled={missingInv} onChange={onFileChange} />
|
||||
<label>Fichier dossier <code>.zip</code></label>
|
||||
<input ref={dossierInputRef} type="file" accept=".zip"
|
||||
disabled={missingInv || dossierAnalyzing} onChange={onFileChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dossierAnalyzing && (
|
||||
<div className="text-muted" style={{ marginTop: 10, fontSize: 'var(--fs-sm)' }}>Analyse du zip…</div>
|
||||
)}
|
||||
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
|
||||
|
||||
{dp && inv && (
|
||||
@@ -848,6 +854,7 @@ function DossierImport({
|
||||
<tr><td>Réinvestissements</td><td>{dp.reinvestissements?.length ?? 0} enregistrement(s)</td></tr>
|
||||
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
|
||||
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
|
||||
<tr><td>Documents</td><td>{dp.documents?.length ?? 0} fichier(s)</td></tr>
|
||||
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -865,6 +872,12 @@ function DossierImport({
|
||||
{dossierResult && (
|
||||
<div className="success-msg" style={{ marginTop: 12 }}>
|
||||
{dossierResult.action === 'created' ? '✔ Dossier créé avec succès.' : '✔ Dossier mis à jour avec succès.'}
|
||||
{(dossierResult.docsInserted > 0 || dossierResult.docsSkipped > 0) && (
|
||||
<span className="text-muted" style={{ fontSize: 'var(--fs-xs)', marginLeft: 6 }}>
|
||||
({dossierResult.docsInserted} document{dossierResult.docsInserted > 1 ? 's' : ''} ajouté{dossierResult.docsInserted > 1 ? 's' : ''}
|
||||
{dossierResult.docsSkipped > 0 ? `, ${dossierResult.docsSkipped} déjà présent${dossierResult.docsSkipped > 1 ? 's' : ''}` : ''})
|
||||
</span>
|
||||
)}
|
||||
{' '}
|
||||
<button
|
||||
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
|
||||
|
||||
Reference in new issue
Block a user