Implementation de la feature de gestion des fichiers sur les investissements

This commit is contained in:
ocroguennec committed 2026-08-29 18:49:01 +02:00
1 parent f6829dec9d
commit 8953d0bb35
21 files changed
+1729 -181

No files matched your search

+235 -83
View File
@@ -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}
+369 -7
View File
@@ -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>
+1 -1
View File
@@ -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>
+66 -15
View File
@@ -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
+14 -1
View File
@@ -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(', ')}.`;
}
+33 -20
View File
@@ -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' }}