Implementation de la feature de gestion des fichiers sur les investissements
This commit is contained in:
1 parent
f6829dec9d
commit
8953d0bb35
21 files changed
+1683
-135
No files matched your search
@@ -2532,4 +2532,48 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_pertes_inv ON investissement_pertes(inve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Migration : table documents ───────────────────────────────────────────
|
||||||
|
// Pièces jointes de l'utilisateur (compte de login) — pour l'instant
|
||||||
|
// uniquement des documents rattachés à un investissement (entity_type
|
||||||
|
// ='investissement', entity_id=investissements.id), mais entity_type/
|
||||||
|
// entity_id/categorie restent génériques pour accueillir d'autres types de
|
||||||
|
// documents à l'avenir (demande Olivier 29/08/26). Quota de stockage et de
|
||||||
|
// nombre de documents par utilisateur — valeur fixe globale pour l'instant,
|
||||||
|
// cf. QUOTA_BYTES/QUOTA_COUNT dans backend/src/routes/documents.js.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
categorie TEXT NOT NULL DEFAULT 'investissement',
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id INTEGER,
|
||||||
|
nom_affichage TEXT NOT NULL,
|
||||||
|
nom_original TEXT NOT NULL,
|
||||||
|
extension TEXT NOT NULL,
|
||||||
|
mime_type TEXT,
|
||||||
|
taille_octets INTEGER NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_documents_entity ON documents(entity_type, entity_id)');
|
||||||
|
|
||||||
|
// ── Migration : quotas documentaires configurables (Admin > Général) ──────
|
||||||
|
// Demande Olivier 29/08/26 : plutôt qu'une valeur fixe codée en dur dans
|
||||||
|
// backend/src/routes/documents.js, le quota (par utilisateur — même valeur
|
||||||
|
// pour tous les comptes, pas de personnalisation individuelle pour l'instant)
|
||||||
|
// est stocké sur la ligne unique de smtp_config, comme le reste des
|
||||||
|
// paramètres généraux (app_name, app_url...), et lu à la volée par
|
||||||
|
// documents.js à chaque requête. Défauts alignés sur les anciennes
|
||||||
|
// constantes : 5 Go / 500 documents.
|
||||||
|
const smtpCols = db.prepare("PRAGMA table_info(smtp_config)").all().map(c => c.name);
|
||||||
|
if (!smtpCols.includes('documents_quota_bytes')) {
|
||||||
|
db.exec('ALTER TABLE smtp_config ADD COLUMN documents_quota_bytes INTEGER NOT NULL DEFAULT 5368709120'); // 5 Go (Gio)
|
||||||
|
}
|
||||||
|
if (!smtpCols.includes('documents_quota_count')) {
|
||||||
|
db.exec('ALTER TABLE smtp_config ADD COLUMN documents_quota_count INTEGER NOT NULL DEFAULT 500');
|
||||||
|
}
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
@@ -10,6 +10,7 @@ import os from 'node:os';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import db from '../db/index.js';
|
import db from '../db/index.js';
|
||||||
import { createZip } from '../utils/zip.js';
|
import { createZip } from '../utils/zip.js';
|
||||||
|
import { buildAllDocumentsZipEntries } from '../routes/documents.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const dataDir = process.env.DATA_DIR
|
const dataDir = process.env.DATA_DIR
|
||||||
@@ -85,13 +86,20 @@ export async function runAutoExport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fichiers documents (tous utilisateurs) — la base SQLite ci-dessus contient
|
||||||
|
// déjà les lignes `documents`, mais pas les fichiers physiques qu'elles
|
||||||
|
// référencent ; cf. demande Olivier 29/08/26. Même helper que la route
|
||||||
|
// manuelle GET /api/admin/export-full, pour ne pas dupliquer cette logique.
|
||||||
|
const documentEntries = buildAllDocumentsZipEntries();
|
||||||
|
for (const entry of documentEntries) entries.push(entry);
|
||||||
|
|
||||||
const zipBuf = createZip(entries);
|
const zipBuf = createZip(entries);
|
||||||
fs.mkdirSync(exportsDir, { recursive: true });
|
fs.mkdirSync(exportsDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(exportsDir, filename), zipBuf);
|
fs.writeFileSync(path.join(exportsDir, filename), zipBuf);
|
||||||
purgeOldExports();
|
purgeOldExports();
|
||||||
|
|
||||||
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||||
const details = `Fichier : ${filename} | Taille : ${(zipBuf.length / 1024).toFixed(0)} Ko | Assets : ${assetCount} | Durée : ${elapsed}s`;
|
const details = `Fichier : ${filename} | Taille : ${(zipBuf.length / 1024).toFixed(0)} Ko | Assets : ${assetCount} | Documents : ${documentEntries.length} | Durée : ${elapsed}s`;
|
||||||
writeLog({ status: 'ok', nbChanges: 1, details });
|
writeLog({ status: 'ok', nbChanges: 1, details });
|
||||||
console.log(`[autoExport] Export terminé en ${elapsed}s → ${filename}`);
|
console.log(`[autoExport] Export terminé en ${elapsed}s → ${filename}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { runAutoExport } from '../jobs/autoExport.js';
|
|||||||
import { audit } from '../utils/audit.js';
|
import { audit } from '../utils/audit.js';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { createZip, readZip } from '../utils/zip.js';
|
import { createZip, readZip } from '../utils/zip.js';
|
||||||
|
import { deleteAllDocumentFilesForUser, buildAllDocumentsZipEntries } from './documents.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const dataDir = process.env.DATA_DIR
|
const dataDir = process.env.DATA_DIR
|
||||||
@@ -64,10 +65,21 @@ const router = Router();
|
|||||||
|
|
||||||
/* ── Utilisateurs ─────────────────────────────────────────────────────── */
|
/* ── Utilisateurs ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Liste tous les utilisateurs */
|
/** Liste tous les utilisateurs, avec quelques compteurs d'usage par compte
|
||||||
|
* (plateformes, investissements, documents) — demande Olivier 29/08/26,
|
||||||
|
* colonnes affichées dans Administration > Utilisateurs. Sous-requêtes
|
||||||
|
* corrélées plutôt que JOIN + GROUP BY : chaque compte reste une ligne
|
||||||
|
* distincte sans risquer de doublons liés aux jointures multiples. */
|
||||||
router.get('/users', (req, res) => {
|
router.get('/users', (req, res) => {
|
||||||
const users = db.prepare(`
|
const users = db.prepare(`
|
||||||
SELECT id, email, display_name, role, email_verified, totp_enabled, status, created_at
|
SELECT
|
||||||
|
id, email, display_name, role, email_verified, totp_enabled, status, created_at,
|
||||||
|
(SELECT COUNT(*) FROM plateformes p WHERE p.user_id = users.id) AS nb_plateformes,
|
||||||
|
(SELECT COUNT(*) FROM investissements i
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
WHERE inv.user_id = users.id) AS nb_investissements,
|
||||||
|
(SELECT COUNT(*) FROM documents d WHERE d.user_id = users.id) AS nb_documents,
|
||||||
|
(SELECT COALESCE(SUM(d.taille_octets), 0) FROM documents d WHERE d.user_id = users.id) AS taille_documents
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`).all();
|
`).all();
|
||||||
@@ -176,6 +188,14 @@ router.delete('/users/:id', (req, res, next) => {
|
|||||||
throw new HttpError(400, 'Vous ne pouvez pas supprimer votre propre compte');
|
throw new HttpError(400, 'Vous ne pouvez pas supprimer votre propre compte');
|
||||||
}
|
}
|
||||||
const targetUser = db.prepare('SELECT email, display_name FROM users WHERE id = ?').get(targetId);
|
const targetUser = db.prepare('SELECT email, display_name FROM users WHERE id = ?').get(targetId);
|
||||||
|
if (!targetUser) throw new HttpError(404, 'Utilisateur introuvable');
|
||||||
|
|
||||||
|
// Fichiers documents de l'utilisateur : à effacer AVANT le DELETE FROM users
|
||||||
|
// (les lignes `documents` seront supprimées en cascade par la contrainte
|
||||||
|
// ON DELETE CASCADE, mais une cascade SQLite n'efface jamais les fichiers
|
||||||
|
// du disque — il faut les lister pendant que le user existe encore).
|
||||||
|
deleteAllDocumentFilesForUser(targetId);
|
||||||
|
|
||||||
const r = db.prepare('DELETE FROM users WHERE id = ?').run(targetId);
|
const r = db.prepare('DELETE FROM users WHERE id = ?').run(targetId);
|
||||||
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
|
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
|
||||||
audit(req, { action: 'user_deleted', category: 'account', actorId: req.user.id, details: { email: targetUser?.email, display_name: targetUser?.display_name } });
|
audit(req, { action: 'user_deleted', category: 'account', actorId: req.user.id, details: { email: targetUser?.email, display_name: targetUser?.display_name } });
|
||||||
@@ -497,6 +517,11 @@ router.get('/export-full', (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fichiers documents (tous utilisateurs) — la base SQLite ci-dessus contient
|
||||||
|
// déjà les lignes `documents`, mais pas les fichiers physiques qu'elles
|
||||||
|
// référencent ; cf. demande Olivier 29/08/26.
|
||||||
|
entries.push(...buildAllDocumentsZipEntries());
|
||||||
|
|
||||||
const zipBuf = createZip(entries);
|
const zipBuf = createZip(entries);
|
||||||
|
|
||||||
// Sauvegarde sur disque + purge
|
// Sauvegarde sur disque + purge
|
||||||
@@ -597,7 +622,7 @@ router.delete('/exports/:filename', (req, res, next) => {
|
|||||||
* POST /api/admin/exports/:filename/restore
|
* POST /api/admin/exports/:filename/restore
|
||||||
* Restaure l'environnement depuis un export stocké :
|
* Restaure l'environnement depuis un export stocké :
|
||||||
* 1. Sauvegarde l'état courant dans exports/ (filet de sécurité)
|
* 1. Sauvegarde l'état courant dans exports/ (filet de sécurité)
|
||||||
* 2. Copie les assets (logos, icons) immédiatement
|
* 2. Copie les assets (logos, icons, documents) immédiatement
|
||||||
* 3. Écrit la nouvelle DB dans {DB_PATH}.pending-restore
|
* 3. Écrit la nouvelle DB dans {DB_PATH}.pending-restore
|
||||||
* 4. Répond au client, puis redémarre le processus (process.exit)
|
* 4. Répond au client, puis redémarre le processus (process.exit)
|
||||||
* → En prod (DATA_DIR défini), le restart policy Docker relance le conteneur
|
* → En prod (DATA_DIR défini), le restart policy Docker relance le conteneur
|
||||||
@@ -642,7 +667,10 @@ router.post('/exports/:filename/restore', async (req, res, next) => {
|
|||||||
}, null, 2),
|
}, null, 2),
|
||||||
});
|
});
|
||||||
backupEntries.push({ name: 'crowdlending.db', data: fs.readFileSync(tmpDb) });
|
backupEntries.push({ name: 'crowdlending.db', data: fs.readFileSync(tmpDb) });
|
||||||
for (const subdir of ['logos', 'icons']) {
|
// 'documents' inclus au même titre que logos/icons : c'est un filet de
|
||||||
|
// sécurité, on sauvegarde tout ce qui est actuellement sur disque avant
|
||||||
|
// de l'écraser à l'étape 2, indépendamment de ce que référence la base.
|
||||||
|
for (const subdir of ['logos', 'icons', 'documents']) {
|
||||||
const dir = path.join(dataDir, subdir);
|
const dir = path.join(dataDir, subdir);
|
||||||
if (!fs.existsSync(dir)) continue;
|
if (!fs.existsSync(dir)) continue;
|
||||||
for (const f of fs.readdirSync(dir)) {
|
for (const f of fs.readdirSync(dir)) {
|
||||||
@@ -657,8 +685,16 @@ router.post('/exports/:filename/restore', async (req, res, next) => {
|
|||||||
fs.writeFileSync(path.join(exportsDir, `pre-restore-backup-${ts}.zip`), createZip(backupEntries));
|
fs.writeFileSync(path.join(exportsDir, `pre-restore-backup-${ts}.zip`), createZip(backupEntries));
|
||||||
purgeOldExports();
|
purgeOldExports();
|
||||||
|
|
||||||
// 2. Remplacement des assets (logos + icons) — safe à faire en live
|
// 2. Remplacement des assets (logos + icons + documents) — safe à faire en live.
|
||||||
for (const subdir of ['logos', 'icons']) {
|
// 'documents' suit exactement le même traitement que logos/icons (demande
|
||||||
|
// Olivier 29/08/26) : le dossier est vidé puis repeuplé depuis le zip.
|
||||||
|
// Même remarque que pour logos/icons : entre ce remplacement et le
|
||||||
|
// redémarrage effectif du process qui applique le pending-restore de la
|
||||||
|
// base (étape 3/4), la base encore active référence des `filename` qui
|
||||||
|
// viennent d'être remplacés par ceux de l'export importé — fenêtre de
|
||||||
|
// cohérence transitoire déjà acceptée pour logos/icons, désormais partagée
|
||||||
|
// par documents.
|
||||||
|
for (const subdir of ['logos', 'icons', 'documents']) {
|
||||||
const dir = path.join(dataDir, subdir);
|
const dir = path.join(dataDir, subdir);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
// Vidage du dossier existant (fichiers et sous-dossiers)
|
// Vidage du dossier existant (fichiers et sous-dossiers)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const _require = createRequire(import.meta.url);
|
|||||||
const QRCode = _require('qrcode');
|
const QRCode = _require('qrcode');
|
||||||
import { generateSecret as totpGenerateSecret, generateURI as totpGenerateURI, verifySync as totpVerifySync } from 'otplib';
|
import { generateSecret as totpGenerateSecret, generateURI as totpGenerateURI, verifySync as totpVerifySync } from 'otplib';
|
||||||
import { audit } from '../utils/audit.js';
|
import { audit } from '../utils/audit.js';
|
||||||
|
import { deleteAllDocumentFilesForUser } from './documents.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -313,6 +314,12 @@ router.delete('/me', requireAuth, (req, res, next) => {
|
|||||||
notifyTx(otherAdmins);
|
notifyTx(otherAdmins);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fichiers documents de l'utilisateur : à effacer AVANT le DELETE FROM users
|
||||||
|
// (les lignes `documents` seront supprimées en cascade par la contrainte
|
||||||
|
// ON DELETE CASCADE, mais une cascade SQLite n'efface jamais les fichiers
|
||||||
|
// du disque — il faut les lister pendant que le user existe encore).
|
||||||
|
deleteAllDocumentFilesForUser(user.id);
|
||||||
|
|
||||||
// Suppression définitive — cascade en base sur toutes les données liées
|
// Suppression définitive — cascade en base sur toutes les données liées
|
||||||
// (investisseurs, plateformes, investissements, remboursements, comptes,
|
// (investisseurs, plateformes, investissements, remboursements, comptes,
|
||||||
// préférences, notifications, tickets, appareils de confiance, etc.)
|
// préférences, notifications, tickets, appareils de confiance, etc.)
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import db from '../db/index.js';
|
||||||
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
|
import { createZip, sanitizeZipPart } from '../utils/zip.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// ── Stockage fichiers ──────────────────────────────────────────────────────
|
||||||
|
// Même convention que backend/src/routes/tickets.js (pièces jointes support) :
|
||||||
|
// répertoire plat sous data/, nom de fichier sur disque randomisé, nom
|
||||||
|
// d'origine + métadonnées conservés en base pour l'affichage/téléchargement.
|
||||||
|
const dataDir = process.env.DATA_DIR
|
||||||
|
? path.resolve(process.env.DATA_DIR)
|
||||||
|
: path.resolve(__dirname, '../../../data');
|
||||||
|
// Exporté : réutilisé par investissements.js pour lire les fichiers lors de
|
||||||
|
// l'export ZIP d'un dossier d'investissement (manifest.json + documents).
|
||||||
|
export const docsDir = path.join(dataDir, 'documents');
|
||||||
|
fs.mkdirSync(docsDir, { recursive: true });
|
||||||
|
|
||||||
|
// ── Quotas (par utilisateur/compte — même valeur pour tous les comptes,
|
||||||
|
// pas de personnalisation individuelle pour l'instant). Configurables depuis
|
||||||
|
// Admin > Général (colonnes documents_quota_bytes/documents_quota_count sur
|
||||||
|
// smtp_config, cf. migration dans db/index.js) — getQuotaConfig() relit la
|
||||||
|
// valeur courante à chaque requête, DEFAULT ci-dessous seulement si la ligne
|
||||||
|
// smtp_config n'existe pas encore (Général jamais ouvert). Le plafond par
|
||||||
|
// fichier reste une constante — non demandé configurable. ─────────────────
|
||||||
|
const DEFAULT_QUOTA_BYTES = 5 * 1024 * 1024 * 1024; // 5 Go
|
||||||
|
const DEFAULT_QUOTA_COUNT = 500;
|
||||||
|
export const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 Mo par fichier — exporté : réutilisé par imports.js pour vérifier chaque document d'un dossier importé
|
||||||
|
|
||||||
|
export function getQuotaConfig() {
|
||||||
|
const row = db.prepare('SELECT documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get();
|
||||||
|
return {
|
||||||
|
quotaBytes: row?.documents_quota_bytes || DEFAULT_QUOTA_BYTES,
|
||||||
|
quotaCount: row?.documents_quota_count || DEFAULT_QUOTA_COUNT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_, __, cb) => cb(null, docsDir),
|
||||||
|
filename: (_, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
const base = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
cb(null, base + ext);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES } });
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/** Usage courant (nombre de documents + octets) pour un utilisateur.
|
||||||
|
* Exporté : réutilisé par imports.js (import de dossier d'investissement)
|
||||||
|
* pour vérifier le quota avant d'écrire les documents du zip importé. */
|
||||||
|
export function getUsage(userId) {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT COUNT(*) AS used_count, COALESCE(SUM(taille_octets),0) AS used_bytes FROM documents WHERE user_id = ?'
|
||||||
|
).get(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/documents ──────────────────────────────────────────────────
|
||||||
|
Sans filtre : tous les documents du compte (utilisé par "Mon compte" —
|
||||||
|
décision Olivier : la vue d'ensemble couvre tous les investisseurs du
|
||||||
|
compte, pas seulement l'investisseur actif), avec le contexte investissement
|
||||||
|
/investisseur en plus pour s'y retrouver.
|
||||||
|
Avec ?entity_type=&entity_id= : documents d'une entité précise (utilisé par
|
||||||
|
le bloc "Mes documents" de la fiche investissement). ─────────────────── */
|
||||||
|
router.get('/', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { entity_type, entity_id } = req.query;
|
||||||
|
let rows;
|
||||||
|
if (entity_type && entity_id) {
|
||||||
|
rows = db.prepare(
|
||||||
|
'SELECT * FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ? ORDER BY created_at DESC'
|
||||||
|
).all(req.user.id, entity_type, Number(entity_id));
|
||||||
|
} else {
|
||||||
|
rows = db.prepare(`
|
||||||
|
SELECT d.*, i.nom_projet AS investissement_nom, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN investissements i ON d.entity_type = 'investissement' AND i.id = d.entity_id
|
||||||
|
LEFT JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||||
|
WHERE d.user_id = ?
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
`).all(req.user.id);
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── GET /api/documents/quota ────────────────────────────────────────────
|
||||||
|
Consommation courante + limites, pour l'affichage dans "Mon compte". ── */
|
||||||
|
router.get('/quota', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const usage = getUsage(req.user.id);
|
||||||
|
const { quotaBytes, quotaCount } = getQuotaConfig();
|
||||||
|
res.json({
|
||||||
|
used_bytes: usage.used_bytes,
|
||||||
|
used_count: usage.used_count,
|
||||||
|
quota_bytes: quotaBytes,
|
||||||
|
quota_count: quotaCount,
|
||||||
|
max_file_bytes: MAX_FILE_BYTES,
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── GET /api/documents/export ───────────────────────────────────────────
|
||||||
|
Export ZIP de tous les documents du compte, organisés en dossiers selon
|
||||||
|
la même logique de regroupement que "Mon compte" > "Mes documents" côté
|
||||||
|
frontend : <Catégorie>/<Projet>/<fichier> pour les documents liés à un
|
||||||
|
investissement, <Catégorie>/<fichier> sinon — demande Olivier 29/08/26.
|
||||||
|
Utilise le builder ZIP maison (backend/src/utils/zip.js, déjà utilisé par
|
||||||
|
les exports plateformes/référentiel), pas de dépendance externe. ─────── */
|
||||||
|
const CATEGORIE_FOLDER_LABELS = { investissement: 'Investissements', fiscalite: 'Fiscalite', autre: 'Autres' };
|
||||||
|
|
||||||
|
router.get('/export', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT d.*, i.nom_projet AS investissement_nom
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN investissements i ON d.entity_type = 'investissement' AND i.id = d.entity_id
|
||||||
|
WHERE d.user_id = ?
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
if (rows.length === 0) throw new HttpError(400, 'Aucun document à exporter');
|
||||||
|
|
||||||
|
const usedNamesByFolder = new Map(); // dossier -> Set des noms déjà pris (gestion des doublons)
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
for (const doc of rows) {
|
||||||
|
const catFolder = sanitizeZipPart(CATEGORIE_FOLDER_LABELS[doc.categorie] || doc.categorie || 'Autres');
|
||||||
|
const folder = (doc.entity_type === 'investissement' && doc.entity_id)
|
||||||
|
? `${catFolder}/${sanitizeZipPart(doc.investissement_nom || `Investissement #${doc.entity_id}`)}`
|
||||||
|
: catFolder;
|
||||||
|
|
||||||
|
const baseLabel = sanitizeZipPart(doc.nom_affichage);
|
||||||
|
const used = usedNamesByFolder.get(folder) || new Set();
|
||||||
|
let finalName = `${baseLabel}.${doc.extension}`;
|
||||||
|
let n = 2;
|
||||||
|
while (used.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; }
|
||||||
|
used.add(finalName);
|
||||||
|
usedNamesByFolder.set(folder, used);
|
||||||
|
|
||||||
|
const filePath = path.join(docsDir, doc.filename);
|
||||||
|
if (!fs.existsSync(filePath)) continue; // fichier disparu du disque — ignoré plutôt que d'échouer tout l'export
|
||||||
|
entries.push({ name: `${folder}/${finalName}`, data: fs.readFileSync(filePath) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBuf = createZip(entries);
|
||||||
|
const filename = `documents-${new Date().toISOString().slice(0, 10)}.zip`;
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(zipBuf);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── POST /api/documents ─────────────────────────────────────────────────
|
||||||
|
multipart/form-data : file (obligatoire), entity_type, entity_id,
|
||||||
|
categorie (optionnelle), nom_affichage (optionnel — par défaut le nom du
|
||||||
|
fichier sans son extension, cf. demande Olivier). ────────────────────── */
|
||||||
|
router.post('/', (req, res, next) => {
|
||||||
|
upload.single('file')(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return next(new HttpError(400, `Le fichier dépasse la taille maximale autorisée (${Math.round(MAX_FILE_BYTES / 1024 / 1024)} Mo).`));
|
||||||
|
}
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
handleUpload(req, res, next);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleUpload(req, res, next) {
|
||||||
|
const cleanup = () => { if (req.file) { try { fs.unlinkSync(req.file.path); } catch { /* déjà absent */ } } };
|
||||||
|
try {
|
||||||
|
if (!req.file) throw new HttpError(400, 'Aucun fichier reçu');
|
||||||
|
|
||||||
|
const entityType = req.body.entity_type || null;
|
||||||
|
const entityId = req.body.entity_id ? Number(req.body.entity_id) : null;
|
||||||
|
const categorie = req.body.categorie?.trim() || (entityType === 'investissement' ? 'investissement' : 'autre');
|
||||||
|
|
||||||
|
// Un document lié à un investissement doit appartenir à l'utilisateur
|
||||||
|
// (même vérification de propriété que le reste de l'API — jointure via
|
||||||
|
// investisseurs.user_id, cf. backend/src/routes/investissements.js).
|
||||||
|
if (entityType === 'investissement') {
|
||||||
|
if (!entityId) throw new HttpError(400, 'entity_id est requis pour la catégorie investissement');
|
||||||
|
const owned = db.prepare(`
|
||||||
|
SELECT i.id FROM investissements i
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
|
||||||
|
WHERE i.id = ?
|
||||||
|
`).get(req.user.id, entityId);
|
||||||
|
if (!owned) throw new HttpError(404, 'Investissement introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = getUsage(req.user.id);
|
||||||
|
const { quotaBytes, quotaCount } = getQuotaConfig();
|
||||||
|
if (usage.used_count + 1 > quotaCount) {
|
||||||
|
throw new HttpError(400, `Quota de documents atteint (${quotaCount} maximum) — supprimez des documents existants avant d'en ajouter de nouveaux.`);
|
||||||
|
}
|
||||||
|
if (usage.used_bytes + req.file.size > quotaBytes) {
|
||||||
|
throw new HttpError(400, `Quota de stockage dépassé (${(quotaBytes / 1024 / 1024 / 1024).toFixed(0)} Go maximum) — supprimez des documents existants avant d'en ajouter de nouveaux.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = (path.extname(req.file.originalname).replace(/^\./, '') || 'bin').toLowerCase();
|
||||||
|
const nomOriginalSansExt = req.file.originalname.replace(/\.[^./\\]+$/, '');
|
||||||
|
const nomAffichage = req.body.nom_affichage?.trim() || nomOriginalSansExt || req.file.originalname;
|
||||||
|
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO documents (user_id, categorie, entity_type, entity_id, nom_affichage, nom_original, extension, mime_type, taille_octets, filename)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
`).run(req.user.id, categorie, entityType, entityId, nomAffichage, req.file.originalname, ext, req.file.mimetype || null, req.file.size, req.file.filename);
|
||||||
|
|
||||||
|
res.status(201).json(db.prepare('SELECT * FROM documents WHERE id = ?').get(r.lastInsertRowid));
|
||||||
|
} catch (e) {
|
||||||
|
cleanup();
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PUT /api/documents/:id ──────────────────────────────────────────────
|
||||||
|
Renommage (nom_affichage uniquement — le fichier stocké et son extension
|
||||||
|
ne changent pas). ──────────────────────────────────────────────────── */
|
||||||
|
router.put('/:id', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT id FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||||
|
if (!row) throw new HttpError(404, 'Document introuvable');
|
||||||
|
const nomAffichage = req.body?.nom_affichage?.trim();
|
||||||
|
if (!nomAffichage) throw new HttpError(400, 'nom_affichage est requis');
|
||||||
|
db.prepare("UPDATE documents SET nom_affichage = ?, updated_at = datetime('now') WHERE id = ?").run(nomAffichage, req.params.id);
|
||||||
|
res.json(db.prepare('SELECT * FROM documents WHERE id = ?').get(req.params.id));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── DELETE /api/documents ────────────────────────────────────────────────
|
||||||
|
"Tout effacer" — supprime TOUS les documents du compte, toutes catégories
|
||||||
|
et tous projets confondus (menu ⋮ du bloc "Mes documents" dans Mon compte)
|
||||||
|
— demande Olivier 29/08/26. L'avertissement d'irréversibilité est affiché
|
||||||
|
côté frontend avant l'appel (confirmation). ───────────────────────────── */
|
||||||
|
router.delete('/', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = db.prepare('SELECT * FROM documents WHERE user_id = ?').all(req.user.id);
|
||||||
|
db.prepare('DELETE FROM documents WHERE user_id = ?').run(req.user.id);
|
||||||
|
for (const doc of rows) {
|
||||||
|
try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ }
|
||||||
|
}
|
||||||
|
res.json({ deleted: rows.length });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime les documents d'une entité (lignes `documents` + fichiers sur
|
||||||
|
* disque) — factorisé pour être réutilisé hors de ce routeur : suppression
|
||||||
|
* d'un investissement (investissements.js) et purge des données d'une
|
||||||
|
* plateforme (plateformes.js), qui n'ont sinon aucun moyen de nettoyer les
|
||||||
|
* documents liés (entity_id est un lien polymorphe, sans contrainte de clé
|
||||||
|
* étrangère — rien ne les supprime automatiquement). Retourne le nombre de
|
||||||
|
* documents supprimés.
|
||||||
|
*/
|
||||||
|
export function deleteDocumentsForEntity(userId, entityType, entityId) {
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT * FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ?'
|
||||||
|
).all(userId, entityType, entityId);
|
||||||
|
db.prepare(
|
||||||
|
'DELETE FROM documents WHERE user_id = ? AND entity_type = ? AND entity_id = ?'
|
||||||
|
).run(userId, entityType, entityId);
|
||||||
|
for (const doc of rows) {
|
||||||
|
try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ }
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Efface du disque les fichiers de TOUS les documents d'un utilisateur, sans
|
||||||
|
* toucher aux lignes en base — à appeler juste AVANT un `DELETE FROM users`
|
||||||
|
* (suppression de compte, admin ou self-service) : les lignes `documents`
|
||||||
|
* sont supprimées automatiquement par la contrainte `ON DELETE CASCADE` sur
|
||||||
|
* `documents.user_id`, mais une cascade SQLite n'efface jamais les fichiers
|
||||||
|
* du disque — il faut donc les lister nous-mêmes pendant que le user (et ses
|
||||||
|
* documents) existent encore, puis les effacer. Retourne le nombre de
|
||||||
|
* fichiers effacés.
|
||||||
|
*/
|
||||||
|
export function deleteAllDocumentFilesForUser(userId) {
|
||||||
|
const rows = db.prepare('SELECT filename FROM documents WHERE user_id = ?').all(userId);
|
||||||
|
for (const doc of rows) {
|
||||||
|
try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ }
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit les entrées zip pour TOUS les documents de TOUS les utilisateurs
|
||||||
|
* (pas de filtre user_id) — utilisé par l'export complet admin (route manuelle
|
||||||
|
* ET job planifié quotidien, cf. admin.js /export-full et jobs/autoExport.js) :
|
||||||
|
* ces exports embarquent déjà la base SQLite entière via VACUUM INTO (donc les
|
||||||
|
* lignes `documents`), mais jusqu'ici pas les fichiers physiques qu'elles
|
||||||
|
* référencent. Chaque fichier existant sur disque devient une entrée
|
||||||
|
* `documents/<filename>`, même convention que les dossiers `logos/`/`icons/`
|
||||||
|
* déjà présents dans cet export. Un fichier disparu du disque (référencé en
|
||||||
|
* base mais absent) est ignoré plutôt que de faire échouer tout l'export.
|
||||||
|
*/
|
||||||
|
export function buildAllDocumentsZipEntries() {
|
||||||
|
const rows = db.prepare('SELECT filename FROM documents').all();
|
||||||
|
const entries = [];
|
||||||
|
for (const { filename } of rows) {
|
||||||
|
const filePath = path.join(docsDir, filename);
|
||||||
|
if (fs.existsSync(filePath)) entries.push({ name: `documents/${filename}`, data: fs.readFileSync(filePath) });
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DELETE /api/documents/by-entity/:entityType/:entityId ───────────────
|
||||||
|
"Tout supprimer" scopé à une entité (ex. tous les documents d'un
|
||||||
|
investissement) — déclaré avant /:id pour éviter toute ambiguïté de route
|
||||||
|
bien que les deux formes ne se chevauchent pas (nombre de segments
|
||||||
|
différent). ─────────────────────────────────────────────────────────── */
|
||||||
|
router.delete('/by-entity/:entityType/:entityId', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { entityType, entityId } = req.params;
|
||||||
|
if (entityType === 'investissement') {
|
||||||
|
const owned = db.prepare(`
|
||||||
|
SELECT i.id FROM investissements i
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
|
||||||
|
WHERE i.id = ?
|
||||||
|
`).get(req.user.id, entityId);
|
||||||
|
if (!owned) throw new HttpError(404, 'Investissement introuvable');
|
||||||
|
}
|
||||||
|
const deleted = deleteDocumentsForEntity(req.user.id, entityType, Number(entityId));
|
||||||
|
res.json({ deleted });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── GET /api/documents/:id/download ─────────────────────────────────── */
|
||||||
|
router.get('/:id/download', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const doc = db.prepare('SELECT * FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||||
|
if (!doc) throw new HttpError(404, 'Document introuvable');
|
||||||
|
const filePath = path.join(docsDir, doc.filename);
|
||||||
|
const safeName = `${doc.nom_affichage}.${doc.extension}`.replace(/["\r\n]/g, '');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||||
|
if (doc.mime_type) res.setHeader('Content-Type', doc.mime_type);
|
||||||
|
res.sendFile(filePath, (err) => { if (err && !res.headersSent) next(err); });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── DELETE /api/documents/:id ────────────────────────────────────────── */
|
||||||
|
router.delete('/:id', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const doc = db.prepare('SELECT * FROM documents WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||||
|
if (!doc) throw new HttpError(404, 'Document introuvable');
|
||||||
|
db.prepare('DELETE FROM documents WHERE id = ?').run(req.params.id);
|
||||||
|
try { fs.unlinkSync(path.join(docsDir, doc.filename)); } catch { /* fichier déjà absent — pas bloquant */ }
|
||||||
|
res.json({ deleted: true });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -23,19 +23,27 @@ function ensureRow() {
|
|||||||
allow_unauth, app_name, app_url, allow_registration, min_password_length)
|
allow_unauth, app_name, app_url, allow_registration, min_password_length)
|
||||||
VALUES (1, 0, '', 587, 0, '', '', '', 0, 'Crowdlending Tracker', '', 1, 8)
|
VALUES (1, 0, '', 587, 0, '', '', '', 0, 'Crowdlending Tracker', '', 1, 8)
|
||||||
`).run();
|
`).run();
|
||||||
|
// documents_quota_bytes/documents_quota_count non listées ci-dessus : la
|
||||||
|
// colonne applique automatiquement son DEFAULT (5 Go / 500 documents,
|
||||||
|
// cf. migration dans db/index.js) quand elle est omise d'un INSERT.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1 Go = 1024^3 octets (convention déjà utilisée par fmtOctets côté frontend).
|
||||||
|
const GO = 1024 ** 3;
|
||||||
|
|
||||||
router.get('/', (_req, res, next) => {
|
router.get('/', (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
ensureRow();
|
ensureRow();
|
||||||
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
|
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length, documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get();
|
||||||
res.json({
|
res.json({
|
||||||
appName: row.app_name || 'Crowdlending Tracker',
|
appName: row.app_name || 'Crowdlending Tracker',
|
||||||
appUrl: row.app_url || '',
|
appUrl: row.app_url || '',
|
||||||
mcpUrl: row.mcp_url || '',
|
mcpUrl: row.mcp_url || '',
|
||||||
allowRegistration: row.allow_registration !== 0,
|
allowRegistration: row.allow_registration !== 0,
|
||||||
minPasswordLength: row.min_password_length || 8,
|
minPasswordLength: row.min_password_length || 8,
|
||||||
|
documentsQuotaGo: Math.round(((row.documents_quota_bytes || 5 * GO) / GO) * 100) / 100,
|
||||||
|
documentsQuotaCount: row.documents_quota_count || 500,
|
||||||
});
|
});
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
@@ -46,13 +54,15 @@ const PatchSchema = z.object({
|
|||||||
mcpUrl: z.string().max(500).optional(),
|
mcpUrl: z.string().max(500).optional(),
|
||||||
allowRegistration: z.boolean().optional(),
|
allowRegistration: z.boolean().optional(),
|
||||||
minPasswordLength: z.number().int().min(6).max(64).optional(),
|
minPasswordLength: z.number().int().min(6).max(64).optional(),
|
||||||
|
documentsQuotaGo: z.number().positive().max(1000).optional(),
|
||||||
|
documentsQuotaCount: z.number().int().positive().max(100000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/', (req, res, next) => {
|
router.patch('/', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
ensureRow();
|
ensureRow();
|
||||||
const body = PatchSchema.parse(req.body);
|
const body = PatchSchema.parse(req.body);
|
||||||
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
|
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length, documents_quota_bytes, documents_quota_count FROM smtp_config WHERE id = 1').get();
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE smtp_config SET
|
UPDATE smtp_config SET
|
||||||
@@ -60,7 +70,9 @@ router.patch('/', (req, res, next) => {
|
|||||||
app_url = ?,
|
app_url = ?,
|
||||||
mcp_url = ?,
|
mcp_url = ?,
|
||||||
allow_registration = ?,
|
allow_registration = ?,
|
||||||
min_password_length = ?
|
min_password_length = ?,
|
||||||
|
documents_quota_bytes = ?,
|
||||||
|
documents_quota_count = ?
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`).run(
|
`).run(
|
||||||
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
|
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
|
||||||
@@ -68,6 +80,8 @@ router.patch('/', (req, res, next) => {
|
|||||||
body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''),
|
body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''),
|
||||||
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
|
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
|
||||||
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
|
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
|
||||||
|
body.documentsQuotaGo !== undefined ? Math.round(body.documentsQuotaGo * GO) : (row.documents_quota_bytes || 5 * GO),
|
||||||
|
body.documentsQuotaCount !== undefined ? body.documentsQuotaCount : (row.documents_quota_count || 500),
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|||||||
+226
-18
@@ -13,6 +13,8 @@ import { generateSimul, generateSimulWithReinvestissements, adjustSimulForActual
|
|||||||
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
|
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
|
||||||
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
|
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
|
||||||
import { syncInvestissementStatut } from './remboursements.js';
|
import { syncInvestissementStatut } from './remboursements.js';
|
||||||
|
import { readZip } from '../utils/zip.js';
|
||||||
|
import { docsDir, getUsage, getQuotaConfig, MAX_FILE_BYTES } from './documents.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -24,6 +26,15 @@ const upload = multer({
|
|||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Instance dédiée à l'import de dossier d'investissement (zip manifest.json +
|
||||||
|
// documents, cf. GET /api/investissements/:id/export) : limite plus haute que
|
||||||
|
// les imports xlsx/csv classiques car le zip embarque aussi les fichiers du
|
||||||
|
// dossier, pas seulement des lignes tabulaires.
|
||||||
|
const dossierUpload = multer({
|
||||||
|
dest: UPLOAD_DIR,
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 1: POST /api/imports/preview
|
* Step 1: POST /api/imports/preview
|
||||||
* multipart/form-data with `file` (xlsx/csv)
|
* multipart/form-data with `file` (xlsx/csv)
|
||||||
@@ -626,26 +637,90 @@ router.post('/template', (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/imports/dossier
|
* Lit et valide un zip de dossier d'investissement (manifest.json + documents/,
|
||||||
* Importe un dossier investissement complet (format d'export natif).
|
* cf. GET /api/investissements/:id/export). Partagé par /dossier/preview et
|
||||||
* Scénario CREATE : le dossier n'existe pas → création complète.
|
* /dossier/apply pour ne parser le zip qu'à un seul endroit.
|
||||||
* Scénario UPDATE : le dossier existe déjà → mise à jour des champs + remboursements manquants.
|
|
||||||
* Identification : (investisseur_id, nom_projet, date_souscription) — clé naturelle portable.
|
|
||||||
*/
|
*/
|
||||||
router.post('/dossier', (req, res, next) => {
|
function parseDossierZip(zipPath) {
|
||||||
|
const buffer = fs.readFileSync(zipPath);
|
||||||
|
let zipEntries;
|
||||||
|
try {
|
||||||
|
zipEntries = readZip(buffer);
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'Fichier zip invalide ou corrompu');
|
||||||
|
}
|
||||||
|
const manifestEntry = zipEntries.find(e => e.name === 'manifest.json');
|
||||||
|
if (!manifestEntry) throw new HttpError(400, 'Zip invalide — manifest.json introuvable');
|
||||||
|
|
||||||
|
let manifest;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(manifestEntry.data.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'manifest.json illisible (JSON invalide)');
|
||||||
|
}
|
||||||
|
if (!manifest || manifest.type !== 'dossier_investissement') {
|
||||||
|
throw new HttpError(400, 'Format invalide — attendu un zip dont le manifest.json est de type "dossier_investissement"');
|
||||||
|
}
|
||||||
|
return { manifest, zipEntries };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/imports/dossier/preview
|
||||||
|
* Étape 1 : dépose le zip exporté depuis la fiche investissement (bouton
|
||||||
|
* « Exporter » du bloc Informations du projet), le valide et renvoie son
|
||||||
|
* contenu pour aperçu avant confirmation. Le fichier reste sur disque
|
||||||
|
* (tempId) jusqu'à l'appel de /dossier/apply — même principe que le couple
|
||||||
|
* /preview + /apply utilisé pour les imports xlsx/csv plus haut dans ce fichier.
|
||||||
|
*/
|
||||||
|
router.post('/dossier/preview', dossierUpload.single('file'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) throw new HttpError(400, 'Fichier zip requis');
|
||||||
|
const { manifest } = parseDossierZip(req.file.path);
|
||||||
|
res.json({ tempId: path.basename(req.file.path), manifest });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/imports/dossier/apply
|
||||||
|
* Étape 2 : importe pour de bon le dossier déposé à l'étape /preview (tempId).
|
||||||
|
* Scénario CREATE : le dossier n'existe pas → création complète.
|
||||||
|
* Scénario UPDATE : le dossier existe déjà → mise à jour des champs + ajout des
|
||||||
|
* remboursements / réinvestissements / catégories / secteurs / révisions /
|
||||||
|
* pertes / documents manquants — la déduplication à chaque étape garantit
|
||||||
|
* qu'un réimport répété du même zip ne crée jamais de doublons.
|
||||||
|
* Identification du dossier : (investisseur_id, nom_projet, date_souscription)
|
||||||
|
* — clé naturelle portable. L'investisseur est celui actuellement sélectionné
|
||||||
|
* dans l'application (X-Investisseur-Id), pas celui décrit dans le manifeste
|
||||||
|
* (cf. décision Olivier : le rattachement suit le contexte courant de
|
||||||
|
* l'utilisateur, pas les données du fichier importé).
|
||||||
|
* Les projections (simulation) du manifeste ne sont pas réimportées : la
|
||||||
|
* simulation est régénérée après coup à partir des données réelles, comme
|
||||||
|
* pour toute création/modification d'investissement.
|
||||||
|
*/
|
||||||
|
router.post('/dossier/apply', (req, res, next) => {
|
||||||
|
const writtenFiles = []; // fichiers documents déjà écrits sur disque pendant cette requête — nettoyés si erreur ensuite
|
||||||
try {
|
try {
|
||||||
requireInvestisseur(req, res, () => {});
|
requireInvestisseur(req, res, () => {});
|
||||||
|
|
||||||
const { dossier } = req.body || {};
|
const { tempId } = req.body || {};
|
||||||
if (!dossier || dossier.type !== 'dossier_investissement') {
|
if (!tempId) throw new HttpError(400, 'tempId est requis');
|
||||||
throw new HttpError(400, 'Format invalide — attendu { dossier: { type: "dossier_investissement", ... } }');
|
const tempPath = path.join(UPLOAD_DIR, tempId);
|
||||||
}
|
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Fichier déposé expiré — redéposez le zip');
|
||||||
const { investissement: inv, plateforme: platInfo, remboursements = [], reinvestissements = [], historique = [] } = dossier;
|
|
||||||
|
const { manifest: dossier, zipEntries } = parseDossierZip(tempPath);
|
||||||
|
|
||||||
|
const {
|
||||||
|
investissement: inv, plateforme: platInfo,
|
||||||
|
remboursements = [], reinvestissements = [], historique = [],
|
||||||
|
categories_inv: categoriesNoms = [], secteurs_inv: secteursNoms = [],
|
||||||
|
revisions = [], pertes = [], documents: documentsMeta = [],
|
||||||
|
} = dossier;
|
||||||
if (!inv?.nom_projet || !inv?.date_souscription) {
|
if (!inv?.nom_projet || !inv?.date_souscription) {
|
||||||
throw new HttpError(400, 'Champs obligatoires manquants : nom_projet, date_souscription');
|
throw new HttpError(400, 'Champs obligatoires manquants : nom_projet, date_souscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
let action, investissementId;
|
let action, investissementId;
|
||||||
|
let docsInserted = 0, docsSkipped = 0;
|
||||||
|
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
/* ── 1. Résoudre / créer la plateforme ─────────────────── */
|
/* ── 1. Résoudre / créer la plateforme ─────────────────── */
|
||||||
@@ -672,8 +747,9 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
INSERT INTO investissements
|
INSERT INTO investissements
|
||||||
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
|
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
|
||||||
date_premiere_echeance, date_cible, date_debut_simul, montant_investi,
|
date_premiere_echeance, date_cible, date_debut_simul, montant_investi,
|
||||||
taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes)
|
taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?)
|
pays_exposition, methode_remboursement, echeance_fin_de_mois, nom_compte_courant)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?, ?,?,?,?)
|
||||||
`).run(
|
`).run(
|
||||||
req.investisseur.id, plateformeId,
|
req.investisseur.id, plateformeId,
|
||||||
inv.nom_projet, inv.emetteur || null,
|
inv.nom_projet, inv.emetteur || null,
|
||||||
@@ -682,6 +758,8 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
|
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
|
||||||
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
|
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
|
||||||
inv.statut || 'en_cours', inv.reference || null, inv.notes || null,
|
inv.statut || 'en_cours', inv.reference || null, inv.notes || null,
|
||||||
|
inv.pays_exposition || 'FR', inv.methode_remboursement || null,
|
||||||
|
inv.echeance_fin_de_mois ? 1 : 0, inv.nom_compte_courant || null,
|
||||||
);
|
);
|
||||||
investissementId = Number(r.lastInsertRowid);
|
investissementId = Number(r.lastInsertRowid);
|
||||||
|
|
||||||
@@ -763,7 +841,8 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
date_premiere_echeance = ?, date_cible = ?, date_debut_simul = ?,
|
date_premiere_echeance = ?, date_cible = ?, date_debut_simul = ?,
|
||||||
montant_investi = ?, taux_interet = ?, duree_mois = ?,
|
montant_investi = ?, taux_interet = ?, duree_mois = ?,
|
||||||
type_remb = ?, freq_interets = ?, statut = ?,
|
type_remb = ?, freq_interets = ?, statut = ?,
|
||||||
reference = ?, notes = ?
|
reference = ?, notes = ?,
|
||||||
|
pays_exposition = ?, methode_remboursement = ?, echeance_fin_de_mois = ?, nom_compte_courant = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
nouveau.plateforme_id, inv.emetteur || null,
|
nouveau.plateforme_id, inv.emetteur || null,
|
||||||
@@ -771,6 +850,8 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
|
nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
|
||||||
nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
|
nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
|
||||||
inv.reference || null, inv.notes || null,
|
inv.reference || null, inv.notes || null,
|
||||||
|
inv.pays_exposition || 'FR', inv.methode_remboursement || null,
|
||||||
|
inv.echeance_fin_de_mois ? 1 : 0, inv.nom_compte_courant || null,
|
||||||
investissementId,
|
investissementId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -848,11 +929,130 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
|
|
||||||
action = 'updated';
|
action = 'updated';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Catégories / secteurs d'investissement (résolution par nom,
|
||||||
|
trouve-ou-crée ; association idempotente via INSERT OR IGNORE sur
|
||||||
|
la table de jonction — aucun doublon possible même en cas de
|
||||||
|
réimport répété du même zip) ─────────────────────────────── */
|
||||||
|
const findOrCreateCategorieInv = (nom) => {
|
||||||
|
let row = db.prepare('SELECT id FROM categories_inv WHERE nom = ?').get(nom);
|
||||||
|
if (!row) {
|
||||||
|
const r = db.prepare('INSERT INTO categories_inv (nom) VALUES (?)').run(nom);
|
||||||
|
row = { id: r.lastInsertRowid };
|
||||||
|
}
|
||||||
|
return row.id;
|
||||||
|
};
|
||||||
|
const findOrCreateSecteurInv = (nom) => {
|
||||||
|
let row = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ?').get(nom);
|
||||||
|
if (!row) {
|
||||||
|
const r = db.prepare('INSERT INTO secteurs_inv (nom) VALUES (?)').run(nom);
|
||||||
|
row = { id: r.lastInsertRowid };
|
||||||
|
}
|
||||||
|
return row.id;
|
||||||
|
};
|
||||||
|
const insCatInv = db.prepare('INSERT OR IGNORE INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)');
|
||||||
|
const insSectInv = db.prepare('INSERT OR IGNORE INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)');
|
||||||
|
for (const nom of categoriesNoms) if (nom) insCatInv.run(investissementId, findOrCreateCategorieInv(nom));
|
||||||
|
for (const nom of secteursNoms) if (nom) insSectInv.run(investissementId, findOrCreateSecteurInv(nom));
|
||||||
|
|
||||||
|
/* ── Révisions : ajouter les manquantes (clé = date_effet + motif) ── */
|
||||||
|
for (const rv of revisions) {
|
||||||
|
if (!rv.date_effet || !rv.motif) continue;
|
||||||
|
const exists = db.prepare(
|
||||||
|
'SELECT id FROM investissement_revisions WHERE investissement_id = ? AND date_effet = ? AND motif = ?'
|
||||||
|
).get(investissementId, rv.date_effet, rv.motif);
|
||||||
|
if (!exists) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO investissement_revisions
|
||||||
|
(investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible, motif)
|
||||||
|
VALUES (?,?,?,?,?,?,?)
|
||||||
|
`).run(
|
||||||
|
investissementId, rv.date_effet,
|
||||||
|
rv.ancien_taux ?? null, rv.nouveau_taux ?? null,
|
||||||
|
rv.ancienne_date_cible ?? null, rv.nouvelle_date_cible ?? null,
|
||||||
|
rv.motif,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pertes : ajouter les manquantes (clé = date_effet + motif) ──── */
|
||||||
|
for (const p of pertes) {
|
||||||
|
if (!p.date_effet || !p.motif || p.montant_perte == null) continue;
|
||||||
|
const exists = db.prepare(
|
||||||
|
'SELECT id FROM investissement_pertes WHERE investissement_id = ? AND date_effet = ? AND motif = ?'
|
||||||
|
).get(investissementId, p.date_effet, p.motif);
|
||||||
|
if (!exists) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO investissement_pertes (investissement_id, date_effet, montant_perte, ancien_statut, motif)
|
||||||
|
VALUES (?,?,?,?,?)
|
||||||
|
`).run(investissementId, p.date_effet, p.montant_perte, p.ancien_statut ?? null, p.motif);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Documents : recrée les fichiers absents. Déduplication par
|
||||||
|
(entity, nom_original, taille_octets) : un réimport répété du
|
||||||
|
même zip ne recrée jamais un document déjà présent. ────────── */
|
||||||
|
const docsToInsert = [];
|
||||||
|
for (const docMeta of documentsMeta) {
|
||||||
|
const already = db.prepare(`
|
||||||
|
SELECT id FROM documents
|
||||||
|
WHERE user_id = ? AND entity_type = 'investissement' AND entity_id = ?
|
||||||
|
AND nom_original = ? AND taille_octets = ?
|
||||||
|
`).get(req.user.id, investissementId, docMeta.nom_original || null, docMeta.taille_octets ?? null);
|
||||||
|
if (already) { docsSkipped++; continue; }
|
||||||
|
|
||||||
|
const zipEntry = zipEntries.find(e => e.name === docMeta.zip_path);
|
||||||
|
if (!zipEntry) continue; // référencé dans le manifeste mais absent du zip — ignoré silencieusement
|
||||||
|
|
||||||
|
// Même plafond par fichier que l'upload normal d'un document — un
|
||||||
|
// zip de dossier ne doit pas permettre de contourner cette limite.
|
||||||
|
if (zipEntry.data.length > MAX_FILE_BYTES) {
|
||||||
|
throw new HttpError(400, `Le document « ${docMeta.nom_original || docMeta.zip_path} » dépasse la taille maximale autorisée par fichier (${Math.round(MAX_FILE_BYTES / 1024 / 1024)} Mo).`);
|
||||||
|
}
|
||||||
|
docsToInsert.push({ docMeta, zipEntry });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérification du quota AVANT d'écrire le moindre fichier sur disque —
|
||||||
|
// même garde-fou que handleUpload() dans documents.js pour l'upload
|
||||||
|
// normal d'un document, jusqu'ici absent de l'import de dossier
|
||||||
|
// (un dossier importé pouvait donc dépasser le quota configuré).
|
||||||
|
// Vérifié en une seule fois pour tout le lot de documents à insérer.
|
||||||
|
if (docsToInsert.length > 0) {
|
||||||
|
const usage = getUsage(req.user.id);
|
||||||
|
const { quotaBytes, quotaCount } = getQuotaConfig();
|
||||||
|
const addedBytes = docsToInsert.reduce((sum, { zipEntry }) => sum + zipEntry.data.length, 0);
|
||||||
|
if (usage.used_count + docsToInsert.length > quotaCount) {
|
||||||
|
throw new HttpError(400, `Quota de documents atteint (${quotaCount} maximum) — ce dossier ajouterait ${docsToInsert.length} document(s), supprimez des documents existants avant de réessayer.`);
|
||||||
|
}
|
||||||
|
if (usage.used_bytes + addedBytes > quotaBytes) {
|
||||||
|
throw new HttpError(400, `Quota de stockage dépassé (${(quotaBytes / 1024 / 1024 / 1024).toFixed(0)} Go maximum) — ce dossier ajouterait ${(addedBytes / 1024 / 1024).toFixed(1)} Mo, supprimez des documents existants avant de réessayer.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { docMeta, zipEntry } of docsToInsert) {
|
||||||
|
const ext = (docMeta.extension || '').toLowerCase();
|
||||||
|
const diskName = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext ? '.' + ext : ''}`;
|
||||||
|
const diskPath = path.join(docsDir, diskName);
|
||||||
|
fs.writeFileSync(diskPath, zipEntry.data);
|
||||||
|
writtenFiles.push(diskPath);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO documents (user_id, categorie, entity_type, entity_id, nom_affichage, nom_original, extension, mime_type, taille_octets, filename)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
`).run(
|
||||||
|
req.user.id, docMeta.categorie || 'investissement', 'investissement', investissementId,
|
||||||
|
docMeta.nom_affichage || docMeta.nom_original || diskName,
|
||||||
|
docMeta.nom_original || docMeta.nom_affichage || diskName,
|
||||||
|
ext, docMeta.mime_type || null,
|
||||||
|
docMeta.taille_octets ?? zipEntry.data.length,
|
||||||
|
diskName,
|
||||||
|
);
|
||||||
|
docsInserted++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
tx();
|
tx();
|
||||||
|
|
||||||
/* ── 3. Régénérer la simulation ─────────────────────────── */
|
/* ── 3. Régénérer la simulation ─────────────────────────── */
|
||||||
const fresh = db.prepare('SELECT * FROM investissements WHERE id = ?').get(investissementId);
|
|
||||||
generateSimulWithReinvestissements(db, investissementId);
|
generateSimulWithReinvestissements(db, investissementId);
|
||||||
|
|
||||||
/* ── 4. Log import ──────────────────────────────────────── */
|
/* ── 4. Log import ──────────────────────────────────────── */
|
||||||
@@ -864,11 +1064,19 @@ router.post('/dossier', (req, res, next) => {
|
|||||||
'dossier_investissement',
|
'dossier_investissement',
|
||||||
`Dossier_${inv.nom_projet}`,
|
`Dossier_${inv.nom_projet}`,
|
||||||
1, 1, 0,
|
1, 1, 0,
|
||||||
JSON.stringify({ action, investissementId }),
|
JSON.stringify({ action, investissementId, docsInserted, docsSkipped }),
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ action, investissementId });
|
try { fs.unlinkSync(tempPath); } catch { /* déjà absent */ }
|
||||||
} catch (e) { next(e); }
|
|
||||||
|
res.json({ action, investissementId, docsInserted, docsSkipped });
|
||||||
|
} catch (e) {
|
||||||
|
// Une erreur après écriture de documents sur disque n'annule pas ces
|
||||||
|
// écritures (seule la transaction DB est annulée automatiquement par
|
||||||
|
// better-sqlite3) : on nettoie pour ne pas laisser de fichiers orphelins.
|
||||||
|
for (const f of writtenFiles) { try { fs.unlinkSync(f); } catch { /* déjà absent */ } }
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/history', (req, res) => {
|
router.get('/history', (req, res) => {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
import db from '../db/index.js';
|
import db from '../db/index.js';
|
||||||
import { HttpError } from '../middleware/errorHandler.js';
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||||
import { generateSimul, generateSimulWithReinvestissements, monthsDiff, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js';
|
import { generateSimul, generateSimulWithReinvestissements, monthsDiff, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js';
|
||||||
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
||||||
|
import { createZip, sanitizeZipPart } from '../utils/zip.js';
|
||||||
|
import { docsDir, deleteDocumentsForEntity } from './documents.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -488,6 +492,173 @@ router.get('/:id', (req, res, next) => {
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── GET /api/investissements/:id/export ─────────────────────────────────
|
||||||
|
Export ZIP complet d'un "dossier" d'investissement — demande Olivier
|
||||||
|
29/08/26, rework du bouton "Exporter" du menu ⋮ (bloc "Informations du
|
||||||
|
projet"), qui ne produisait jusqu'ici qu'un simple .json construit
|
||||||
|
côté client, sans les documents joints. Contenu du zip :
|
||||||
|
- manifest.json : toutes les données nécessaires pour recréer le dossier
|
||||||
|
(investissement, plateforme, investisseur, catégories/secteurs,
|
||||||
|
remboursements, projections, réinvestissements, historique, révisions,
|
||||||
|
pertes) + les métadonnées de chaque document (nom, taille, type, et son
|
||||||
|
chemin dans le zip) — en vue d'une future réimportation (pas encore
|
||||||
|
implémentée, cf. décision Olivier : seulement l'export pour l'instant).
|
||||||
|
- documents/<nom>.<ext> : les fichiers eux-mêmes, lus sur disque via
|
||||||
|
docsDir (partagé avec documents.js). Toujours produit même sans aucun
|
||||||
|
document — contrairement à l'export global de Mon compte
|
||||||
|
(GET /api/documents/export), qui lui exige au moins un document
|
||||||
|
puisque son seul contenu, ce sont les fichiers. ────────────────────── */
|
||||||
|
router.get('/:id/export', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inv = db.prepare(`
|
||||||
|
SELECT i.*, p.nom AS plateforme_nom, cp.nom AS categorie_nom,
|
||||||
|
c.nom AS compte_nom, c.type AS compte_type
|
||||||
|
FROM investissements i
|
||||||
|
JOIN plateformes p ON p.id = i.plateforme_id
|
||||||
|
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
|
||||||
|
LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id
|
||||||
|
LEFT JOIN comptes c ON c.id = i.compte_id
|
||||||
|
WHERE i.id = ?
|
||||||
|
`).get(req.user.id, req.params.id);
|
||||||
|
if (!inv) throw new HttpError(404, 'Not found');
|
||||||
|
|
||||||
|
const investisseur = db.prepare(
|
||||||
|
'SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id = ? AND user_id = ?'
|
||||||
|
).get(inv.investisseur_id, req.user.id);
|
||||||
|
|
||||||
|
const remboursements = db.prepare(
|
||||||
|
'SELECT * FROM remboursements WHERE investissement_id = ? ORDER BY date_remb'
|
||||||
|
).all(req.params.id);
|
||||||
|
const simul = db.prepare(
|
||||||
|
'SELECT * FROM simul_remboursements WHERE investissement_id = ? ORDER BY numero_echeance'
|
||||||
|
).all(req.params.id);
|
||||||
|
const historique = db.prepare(
|
||||||
|
'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC'
|
||||||
|
).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) }));
|
||||||
|
const revisions = db.prepare(
|
||||||
|
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC'
|
||||||
|
).all(req.params.id);
|
||||||
|
const pertes = db.prepare(
|
||||||
|
'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id ASC'
|
||||||
|
).all(req.params.id);
|
||||||
|
const reinvestissements = db.prepare(
|
||||||
|
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
|
||||||
|
).all(req.params.id);
|
||||||
|
const categories_inv = db.prepare(`
|
||||||
|
SELECT c.nom FROM investissement_categories_inv ic
|
||||||
|
JOIN categories_inv c ON c.id = ic.categorie_id
|
||||||
|
WHERE ic.investissement_id = ?
|
||||||
|
ORDER BY c.nom
|
||||||
|
`).all(req.params.id).map(r => r.nom);
|
||||||
|
const secteurs_inv = db.prepare(`
|
||||||
|
SELECT s.nom FROM investissement_secteurs_inv is2
|
||||||
|
JOIN secteurs_inv s ON s.id = is2.secteur_id
|
||||||
|
WHERE is2.investissement_id = ?
|
||||||
|
ORDER BY s.nom
|
||||||
|
`).all(req.params.id).map(r => r.nom);
|
||||||
|
|
||||||
|
const documents = db.prepare(
|
||||||
|
"SELECT * FROM documents WHERE user_id = ? AND entity_type = 'investissement' AND entity_id = ? ORDER BY created_at ASC"
|
||||||
|
).all(req.user.id, req.params.id);
|
||||||
|
|
||||||
|
// Fichiers du zip + métadonnées correspondantes dans le manifeste (le nom
|
||||||
|
// affiché sert de nom de fichier dans le zip, avec gestion des doublons —
|
||||||
|
// même logique que l'export global de Mon compte, cf. documents.js).
|
||||||
|
const usedNames = new Set();
|
||||||
|
const documentsMeta = [];
|
||||||
|
const entries = [];
|
||||||
|
for (const doc of documents) {
|
||||||
|
const baseLabel = sanitizeZipPart(doc.nom_affichage);
|
||||||
|
let finalName = `${baseLabel}.${doc.extension}`;
|
||||||
|
let n = 2;
|
||||||
|
while (usedNames.has(finalName)) { finalName = `${baseLabel} (${n}).${doc.extension}`; n++; }
|
||||||
|
usedNames.add(finalName);
|
||||||
|
const zipPath = `documents/${finalName}`;
|
||||||
|
|
||||||
|
documentsMeta.push({
|
||||||
|
nom_affichage: doc.nom_affichage,
|
||||||
|
nom_original: doc.nom_original,
|
||||||
|
extension: doc.extension,
|
||||||
|
mime_type: doc.mime_type,
|
||||||
|
taille_octets: doc.taille_octets,
|
||||||
|
categorie: doc.categorie,
|
||||||
|
zip_path: zipPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filePath = path.join(docsDir, doc.filename);
|
||||||
|
if (fs.existsSync(filePath)) entries.push({ name: zipPath, data: fs.readFileSync(filePath) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
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,
|
||||||
|
pays_exposition: inv.pays_exposition,
|
||||||
|
methode_remboursement: inv.methode_remboursement,
|
||||||
|
echeance_fin_de_mois: inv.echeance_fin_de_mois,
|
||||||
|
nom_compte_courant: inv.nom_compte_courant,
|
||||||
|
categorie: inv.categorie_nom || null,
|
||||||
|
compte: inv.compte_nom ? { nom: inv.compte_nom, type: inv.compte_type } : null,
|
||||||
|
},
|
||||||
|
plateforme: { nom: inv.plateforme_nom },
|
||||||
|
investisseur: investisseur || null,
|
||||||
|
categories_inv,
|
||||||
|
secteurs_inv,
|
||||||
|
remboursements: 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: 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: reinvestissements.map(r => ({
|
||||||
|
date_reinvestissement: r.date_reinvestissement, montant: r.montant, source: r.source, note: r.note,
|
||||||
|
})),
|
||||||
|
historique: historique.map(h => ({
|
||||||
|
type_evenement: h.type_evenement, changements: h.changements, notes: h.notes, created_at: h.created_at,
|
||||||
|
})),
|
||||||
|
revisions: 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: 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,
|
||||||
|
})),
|
||||||
|
documents: documentsMeta,
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.unshift({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) });
|
||||||
|
|
||||||
|
const zipBuf = createZip(entries);
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
const filename = `Dossier_Investissement_${req.params.id}_${ts}.zip`;
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(zipBuf);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/', (req, res, next) => {
|
router.post('/', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = Schema.parse(req.body);
|
const body = Schema.parse(req.body);
|
||||||
@@ -617,6 +788,12 @@ router.put('/:id/fiscalite-override', (req, res, next) => {
|
|||||||
|
|
||||||
router.delete('/:id', (req, res, next) => {
|
router.delete('/:id', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
// Documents liés à cet investissement (lignes + fichiers sur disque) :
|
||||||
|
// nettoyés avant l'investissement lui-même. entity_id est un lien
|
||||||
|
// polymorphe sans contrainte de clé étrangère — rien ne les supprimerait
|
||||||
|
// sinon, ils resteraient orphelins (demande Olivier 29/08/26).
|
||||||
|
deleteDocumentsForEntity(req.user.id, 'investissement', Number(req.params.id));
|
||||||
|
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
DELETE FROM investissements
|
DELETE FROM investissements
|
||||||
WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
|
WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import db from '../db/index.js';
|
import db from '../db/index.js';
|
||||||
import { HttpError } from '../middleware/errorHandler.js';
|
import { HttpError } from '../middleware/errorHandler.js';
|
||||||
import { createZip, readZip } from '../utils/zip.js';
|
import { createZip, readZip } from '../utils/zip.js';
|
||||||
|
import { deleteDocumentsForEntity } from './documents.js';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@@ -722,7 +723,7 @@ router.post('/:id/purge-donnees', (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const platId = plat.id;
|
const platId = plat.id;
|
||||||
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 };
|
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0, documents: 0 };
|
||||||
|
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
if (scope === 'depots_retraits' || scope === 'all') {
|
if (scope === 'depots_retraits' || scope === 'all') {
|
||||||
@@ -736,6 +737,12 @@ router.post('/:id/purge-donnees', (req, res, next) => {
|
|||||||
`).run(platId).changes;
|
`).run(platId).changes;
|
||||||
}
|
}
|
||||||
if (scope === 'investissements' || scope === 'all') {
|
if (scope === 'investissements' || scope === 'all') {
|
||||||
|
// Récupérés avant suppression : sert à nettoyer les documents liés à
|
||||||
|
// ces investissements juste après (aucune contrainte de clé étrangère
|
||||||
|
// ne les supprime automatiquement, cf. entity_id polymorphe) — une
|
||||||
|
// fois les investissements supprimés, leurs ids ne sont plus retrouvables.
|
||||||
|
const invIds = db.prepare('SELECT id FROM investissements WHERE plateforme_id = ?').all(platId).map(r => r.id);
|
||||||
|
|
||||||
// Compte avant suppression : les remboursements et l'échéancier simulé sont
|
// Compte avant suppression : les remboursements et l'échéancier simulé sont
|
||||||
// supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements.
|
// supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements.
|
||||||
counts.remboursements = db.prepare(`
|
counts.remboursements = db.prepare(`
|
||||||
@@ -747,6 +754,11 @@ router.post('/:id/purge-donnees', (req, res, next) => {
|
|||||||
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
|
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
|
||||||
`).get(platId).n;
|
`).get(platId).n;
|
||||||
counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes;
|
counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes;
|
||||||
|
|
||||||
|
// Documents liés à ces investissements (lignes + fichiers sur disque).
|
||||||
|
for (const invId of invIds) {
|
||||||
|
counts.documents += deleteDocumentsForEntity(req.user.id, 'investissement', invId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
tx();
|
tx();
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import notificationsRouter from './routes/notifications.js';
|
|||||||
import ticketsRouter from './routes/tickets.js';
|
import ticketsRouter from './routes/tickets.js';
|
||||||
import apiKeysRouter from './routes/apiKeys.js';
|
import apiKeysRouter from './routes/apiKeys.js';
|
||||||
import v1Router from './routes/v1/index.js';
|
import v1Router from './routes/v1/index.js';
|
||||||
|
import documentsRouter from './routes/documents.js';
|
||||||
import { requireApiKey } from './middleware/apiKey.js';
|
import { requireApiKey } from './middleware/apiKey.js';
|
||||||
import { swaggerSpec, swaggerUi } from './swagger.js';
|
import { swaggerSpec, swaggerUi } from './swagger.js';
|
||||||
import db from './db/index.js';
|
import db from './db/index.js';
|
||||||
@@ -157,6 +158,7 @@ app.use('/api', requireAuth, associationsInvRouter);
|
|||||||
app.use('/api/notifications', notificationsRouter);
|
app.use('/api/notifications', notificationsRouter);
|
||||||
app.use('/api/tickets', requireAuth, ticketsRouter);
|
app.use('/api/tickets', requireAuth, ticketsRouter);
|
||||||
app.use('/api/api-keys', requireAuth, apiKeysRouter);
|
app.use('/api/api-keys', requireAuth, apiKeysRouter);
|
||||||
|
app.use('/api/documents', requireAuth, documentsRouter);
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ function dosDateTime(d = new Date()) {
|
|||||||
return { time, date };
|
return { time, date };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nettoyage d'un segment de chemin ZIP ────────────────────────────────────
|
||||||
|
// Retire les caractères interdits dans un nom de fichier/dossier cross-platform
|
||||||
|
// (\ / : * ? " < > | + caractères de contrôle) tout en conservant accents et
|
||||||
|
// espaces. Partagé par tous les exports ZIP de l'app (documents, investissements...)
|
||||||
|
// pour que la même règle de nommage s'applique partout.
|
||||||
|
export function sanitizeZipPart(name) {
|
||||||
|
return String(name).replace(/[\\/:*?"<>|\u0000-\u001F]/g, ' ').replace(/\s+/g, ' ').trim() || 'Sans nom';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* createZip(entries) → Buffer
|
* createZip(entries) → Buffer
|
||||||
* entries: [{ name: string, data: Buffer|string }]
|
* entries: [{ name: string, data: Buffer|string }]
|
||||||
@@ -54,7 +63,14 @@ export function createZip(entries) {
|
|||||||
const lh = Buffer.alloc(30 + nameBuf.length);
|
const lh = Buffer.alloc(30 + nameBuf.length);
|
||||||
lh.writeUInt32LE(0x04034b50, 0);
|
lh.writeUInt32LE(0x04034b50, 0);
|
||||||
lh.writeUInt16LE(20, 4);
|
lh.writeUInt16LE(20, 4);
|
||||||
lh.writeUInt16LE(0, 6);
|
// Bit 11 (0x0800) = "Language encoding flag (EFS)" — indique que le nom
|
||||||
|
// de fichier est encodé en UTF-8. Sans ce bit, les extracteurs qui ne
|
||||||
|
// devinent pas l'UTF-8 par défaut (Windows Explorer notamment) réinterprètent
|
||||||
|
// les octets UTF-8 des noms accentués avec la page de code système, d'où le
|
||||||
|
// mojibake constaté par Olivier ("Billet_électronique" -> "Billet_ Ⓡlectronique").
|
||||||
|
// Les noms sont toujours écrits en UTF-8 ci-dessus (Buffer.from(name, 'utf8')),
|
||||||
|
// ce bit est donc toujours correct à poser.
|
||||||
|
lh.writeUInt16LE(0x0800, 6);
|
||||||
lh.writeUInt16LE(method, 8);
|
lh.writeUInt16LE(method, 8);
|
||||||
lh.writeUInt16LE(modTime, 10);
|
lh.writeUInt16LE(modTime, 10);
|
||||||
lh.writeUInt16LE(modDate, 12);
|
lh.writeUInt16LE(modDate, 12);
|
||||||
@@ -77,7 +93,7 @@ export function createZip(entries) {
|
|||||||
cd.writeUInt32LE(0x02014b50, 0);
|
cd.writeUInt32LE(0x02014b50, 0);
|
||||||
cd.writeUInt16LE(20, 4);
|
cd.writeUInt16LE(20, 4);
|
||||||
cd.writeUInt16LE(20, 6);
|
cd.writeUInt16LE(20, 6);
|
||||||
cd.writeUInt16LE(0, 8);
|
cd.writeUInt16LE(0x0800, 8); // même flag UTF-8 (EFS) que l'en-tête local, cf. commentaire ci-dessus
|
||||||
cd.writeUInt16LE(h.method, 10);
|
cd.writeUInt16LE(h.method, 10);
|
||||||
cd.writeUInt16LE(h.modTime, 12);
|
cd.writeUInt16LE(h.modTime, 12);
|
||||||
cd.writeUInt16LE(h.modDate, 14);
|
cd.writeUInt16LE(h.modDate, 14);
|
||||||
@@ -113,6 +129,25 @@ export function createZip(entries) {
|
|||||||
return Buffer.concat(chunks);
|
return Buffer.concat(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Protection contre les "zip bombs" ───────────────────────────────────
|
||||||
|
// readZip() est utilisé pour relire des zips produits en interne par l'appli
|
||||||
|
// (exports/sauvegardes admin — contenu maîtrisé) MAIS AUSSI pour des zips
|
||||||
|
// envoyés directement par un utilisateur (import de dossier d'investissement,
|
||||||
|
// import PFU/plateformes/référentiel) — donc potentiellement malveillants.
|
||||||
|
// Un zip conçu avec des données très répétitives peut annoncer quelques Ko
|
||||||
|
// compressés pour plusieurs Go décompressés (ratio extrême), ce qui saturerait
|
||||||
|
// la mémoire du process si rien ne le limite. Deux garde-fous complémentaires :
|
||||||
|
// 1. Rejet précoce si la taille décompressée ANNONCÉE dans l'en-tête central
|
||||||
|
// dépasse déjà la limite (évite même de lancer la décompression).
|
||||||
|
// 2. `maxOutputLength` passé à zlib : filet de sécurité si l'en-tête ment sur
|
||||||
|
// la taille annoncée — zlib interrompt la décompression dès que la sortie
|
||||||
|
// réelle dépasse la limite, sans jamais allouer plus que nécessaire.
|
||||||
|
// Limites généreuses par rapport à l'usage réel de l'appli (sauvegarde complète
|
||||||
|
// = base + logos/icons/documents, quelques dizaines de Mo en pratique) tout en
|
||||||
|
// bornant strictement le pire cas.
|
||||||
|
export const MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES = 500 * 1024 * 1024; // 500 Mo par entrée
|
||||||
|
export const MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024; // 1 Go cumulé par zip
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* readZip(buffer) → [{ name: string, data: Buffer }]
|
* readZip(buffer) → [{ name: string, data: Buffer }]
|
||||||
*/
|
*/
|
||||||
@@ -129,6 +164,7 @@ export function readZip(buffer) {
|
|||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
let pos = cdOffset;
|
let pos = cdOffset;
|
||||||
|
let totalUncompressed = 0;
|
||||||
|
|
||||||
for (let i = 0; i < entryCount; i++) {
|
for (let i = 0; i < entryCount; i++) {
|
||||||
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte');
|
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte');
|
||||||
@@ -141,13 +177,23 @@ export function readZip(buffer) {
|
|||||||
const localOffset = buffer.readUInt32LE(pos + 42);
|
const localOffset = buffer.readUInt32LE(pos + 42);
|
||||||
const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen);
|
const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen);
|
||||||
|
|
||||||
|
if (uncompSize > MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES) {
|
||||||
|
throw new Error(`ZIP invalide : l'entrée "${name}" dépasse la taille décompressée maximale autorisée (${Math.round(MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`);
|
||||||
|
}
|
||||||
|
totalUncompressed += uncompSize;
|
||||||
|
if (totalUncompressed > MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES) {
|
||||||
|
throw new Error(`ZIP invalide : la taille décompressée totale dépasse la limite autorisée (${Math.round(MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`);
|
||||||
|
}
|
||||||
|
|
||||||
// Read local file header to get actual extra field length
|
// Read local file header to get actual extra field length
|
||||||
const localNameLen = buffer.readUInt16LE(localOffset + 26);
|
const localNameLen = buffer.readUInt16LE(localOffset + 26);
|
||||||
const localExtraLen = buffer.readUInt16LE(localOffset + 28);
|
const localExtraLen = buffer.readUInt16LE(localOffset + 28);
|
||||||
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
|
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
|
||||||
|
|
||||||
const compData = buffer.subarray(dataStart, dataStart + compSize);
|
const compData = buffer.subarray(dataStart, dataStart + compSize);
|
||||||
const data = method === 0 ? compData : zlib.inflateRawSync(compData);
|
// maxOutputLength : filet de sécurité si l'en-tête ment sur uncompSize —
|
||||||
|
// voir commentaire au-dessus des constantes MAX_ZIP_*.
|
||||||
|
const data = method === 0 ? compData : zlib.inflateRawSync(compData, { maxOutputLength: MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES });
|
||||||
|
|
||||||
entries.push({ name, data: Buffer.from(data) });
|
entries.push({ name, data: Buffer.from(data) });
|
||||||
pos += 46 + nameLen + extraLen + commentLen;
|
pos += 46 + nameLen + extraLen + commentLen;
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export default function InvestissementFormModal({
|
|||||||
) : (
|
) : (
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||||||
Supprimer définitivement ?
|
Supprimer définitivement (documents inclus) ?
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||||
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import RembFormModal from '../components/RembFormModal.jsx';
|
|||||||
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
||||||
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
|
||||||
import { dayOfMonth } from '../utils/dateEcheances.js';
|
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';
|
import { xirr } from '../utils/xirr.js';
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
@@ -155,24 +155,121 @@ export default function InvestissementDetail() {
|
|||||||
const [bulkRembDone, setBulkRembDone] = useState(false);
|
const [bulkRembDone, setBulkRembDone] = useState(false);
|
||||||
const [reinvTab, setReinvTab] = useState('manuel'); // 'manuel' | 'auto'
|
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 () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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(`/investissements/${id}`),
|
||||||
api.get('/plateformes'),
|
api.get('/plateformes'),
|
||||||
api.get('/investissements/comptes-courants'),
|
api.get('/investissements/comptes-courants'),
|
||||||
api.get('/categories-inv'),
|
api.get('/categories-inv'),
|
||||||
api.get('/secteurs-inv'),
|
api.get('/secteurs-inv'),
|
||||||
|
api.get('/documents', { entity_type: 'investissement', entity_id: id }),
|
||||||
]);
|
]);
|
||||||
setInv(data);
|
setInv(data);
|
||||||
setPlats(p);
|
setPlats(p);
|
||||||
setComptesCourants(comptes);
|
setComptesCourants(comptes);
|
||||||
setCategoriesInv(catInv);
|
setCategoriesInv(catInv);
|
||||||
setSecteursInv(sectInv);
|
setSecteursInv(sectInv);
|
||||||
|
setDocuments(docs);
|
||||||
} finally { setLoading(false); }
|
} 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(() => { if (activeId) load(); /* eslint-disable-next-line */ }, [id, activeId]);
|
||||||
useEffect(() => { api.get('/pfu').then(setPfuRates).catch(() => {}); }, []);
|
useEffect(() => { api.get('/pfu').then(setPfuRates).catch(() => {}); }, []);
|
||||||
|
|
||||||
@@ -184,6 +281,7 @@ export default function InvestissementDetail() {
|
|||||||
setRembMenu(null);
|
setRembMenu(null);
|
||||||
setReinvMenu(null);
|
setReinvMenu(null);
|
||||||
setSimulMenu(null);
|
setSimulMenu(null);
|
||||||
|
setDocsMenu(null);
|
||||||
};
|
};
|
||||||
window.addEventListener('scroll', closeAll, true);
|
window.addEventListener('scroll', closeAll, true);
|
||||||
return () => window.removeEventListener('scroll', closeAll, true);
|
return () => window.removeEventListener('scroll', closeAll, true);
|
||||||
@@ -851,87 +949,25 @@ export default function InvestissementDetail() {
|
|||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Export dossier JSON ────────────────────────────────────────
|
// ── Export dossier ZIP (manifest.json + documents) ──────────────
|
||||||
const exportDossier = () => {
|
// 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 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 url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url; a.download = name; a.click();
|
a.href = url; a.download = `Dossier_Investissement_${id}_${ts}.zip`; a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(`Échec de l'export du dossier : ${e.message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Déclaration de perte définitive ──────────────────────────────
|
// ── Déclaration de perte définitive ──────────────────────────────
|
||||||
@@ -1879,6 +1915,88 @@ export default function InvestissementDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 des modifications ──────────────────────────── */}
|
||||||
{historique.length > 0 && (
|
{historique.length > 0 && (
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<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
|
<ConfirmModal
|
||||||
open={!!rowDeleteConfirm}
|
open={!!rowDeleteConfirm}
|
||||||
message={rowDeleteConfirm?.message}
|
message={rowDeleteConfirm?.message}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { useAuth } from '../context/AuthContext.jsx';
|
|||||||
import { useUi } from '../context/UiContext.jsx';
|
import { useUi } from '../context/UiContext.jsx';
|
||||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||||
import Modal from '../components/Modal.jsx';
|
import Modal from '../components/Modal.jsx';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||||
import { api } from '../api.js';
|
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';
|
import { withDevOverrides } from '../utils/devOverrides.js';
|
||||||
|
|
||||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||||
@@ -26,6 +27,9 @@ function IconTrash() {
|
|||||||
function IconServer() {
|
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>;
|
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 ────────────────────────────── */
|
/* ── Dropdown custom style Finary ────────────────────────────── */
|
||||||
const LANGUES = [
|
const LANGUES = [
|
||||||
@@ -1451,6 +1455,354 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Page principale ─────────────────────────────────────────── */
|
/* ── 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() {
|
export default function MonCompte() {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -1464,6 +1816,7 @@ export default function MonCompte() {
|
|||||||
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
|
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
|
||||||
{ id: 'api-keys', label: 'Clés API', icon: <IconKey /> },
|
{ id: 'api-keys', label: 'Clés API', icon: <IconKey /> },
|
||||||
{ id: 'mcp', label: 'Serveur MCP', icon: <IconServer /> },
|
{ id: 'mcp', label: 'Serveur MCP', icon: <IconServer /> },
|
||||||
|
{ id: 'documents', label: 'Mes documents', icon: <IconDocument /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1485,12 +1838,21 @@ export default function MonCompte() {
|
|||||||
</aside>
|
</aside>
|
||||||
{/* ── Contenu ─────────────────────────────────────── */}
|
{/* ── Contenu ─────────────────────────────────────── */}
|
||||||
<div className="account-content account-content-center">
|
<div className="account-content account-content-center">
|
||||||
|
{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">
|
<div className="account-content-narrow">
|
||||||
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
|
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
|
||||||
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
||||||
{section === 'api-keys' && <ApiKeysSection />}
|
{section === 'api-keys' && <ApiKeysSection />}
|
||||||
{section === 'mcp' && <McpServerSection goToApiKeys={() => setSection('api-keys')} />}
|
{section === 'mcp' && <McpServerSection goToApiKeys={() => setSection('api-keys')} />}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export default function ExportSection() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>Nouvel export</div>
|
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>Nouvel export</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
<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>
|
</div>
|
||||||
{genErr && <div className="error" style={{ marginTop: 8 }}>{genErr}</div>}
|
{genErr && <div className="error" style={{ marginTop: 8 }}>{genErr}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const DEFAULT = {
|
|||||||
mcpUrl: '',
|
mcpUrl: '',
|
||||||
allowRegistration: true,
|
allowRegistration: true,
|
||||||
minPasswordLength: 8,
|
minPasswordLength: 8,
|
||||||
|
documentsQuotaGo: 5,
|
||||||
|
documentsQuotaCount: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GeneralSection() {
|
export default function GeneralSection() {
|
||||||
@@ -59,6 +61,8 @@ export default function GeneralSection() {
|
|||||||
mcpUrl: dd.mcpUrl || '',
|
mcpUrl: dd.mcpUrl || '',
|
||||||
allowRegistration: dd.allowRegistration !== false,
|
allowRegistration: dd.allowRegistration !== false,
|
||||||
minPasswordLength: dd.minPasswordLength || 8,
|
minPasswordLength: dd.minPasswordLength || 8,
|
||||||
|
documentsQuotaGo: dd.documentsQuotaGo || 5,
|
||||||
|
documentsQuotaCount: dd.documentsQuotaCount || 500,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -76,6 +80,8 @@ export default function GeneralSection() {
|
|||||||
mcpUrl: form.mcpUrl.trim(),
|
mcpUrl: form.mcpUrl.trim(),
|
||||||
allowRegistration: form.allowRegistration,
|
allowRegistration: form.allowRegistration,
|
||||||
minPasswordLength: form.minPasswordLength,
|
minPasswordLength: form.minPasswordLength,
|
||||||
|
documentsQuotaGo: form.documentsQuotaGo,
|
||||||
|
documentsQuotaCount: form.documentsQuotaCount,
|
||||||
});
|
});
|
||||||
setSaved('ok');
|
setSaved('ok');
|
||||||
setTimeout(() => setSaved(null), 3000);
|
setTimeout(() => setSaved(null), 3000);
|
||||||
@@ -202,6 +208,51 @@ export default function GeneralSection() {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</div>
|
</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 */}
|
{/* Bouton sauvegarde */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ResultBanner from '../../components/ResultBanner.jsx';
|
|||||||
import PasswordStrength from '../../components/PasswordStrength.jsx';
|
import PasswordStrength from '../../components/PasswordStrength.jsx';
|
||||||
import PasswordInput from '../../components/PasswordInput.jsx';
|
import PasswordInput from '../../components/PasswordInput.jsx';
|
||||||
import { fmt, Badge, UserStatusBadge } from './adminHelpers.jsx';
|
import { fmt, Badge, UserStatusBadge } from './adminHelpers.jsx';
|
||||||
|
import { fmtOctets } from '../../utils/format.js';
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -83,6 +84,10 @@ function toRows(users) {
|
|||||||
Rôle: u.role === 'admin' ? 'Admin' : 'Utilisateur',
|
Rôle: u.role === 'admin' ? 'Admin' : 'Utilisateur',
|
||||||
'2FA': u.totp_enabled ? 'Oui' : 'Non',
|
'2FA': u.totp_enabled ? 'Oui' : 'Non',
|
||||||
'Inscrit le': u.created_at ? u.created_at.replace('T', ' ').slice(0, 16) : '',
|
'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) {
|
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(','));
|
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');
|
return '' + [headers.map(h => `"${h}"`).join(','), ...rows].join('\n');
|
||||||
}
|
}
|
||||||
@@ -612,6 +617,10 @@ export default function UsersSection({ currentUserId }) {
|
|||||||
<th>Rôle</th>
|
<th>Rôle</th>
|
||||||
<th>2FA</th>
|
<th>2FA</th>
|
||||||
<th>Inscrit le</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>
|
<th style={{ width: 48, paddingRight: 20 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -648,6 +657,10 @@ export default function UsersSection({ currentUserId }) {
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</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 }}>
|
<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' }}
|
<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)'; }}
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-2)'; e.currentTarget.style.color = 'var(--text)'; }}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ function IconBroom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PURGE_SCOPES = [
|
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: '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)' },
|
{ 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) {
|
if (scope === 'investissements' && counts.simul_remboursements > 0) {
|
||||||
parts.push(`${counts.simul_remboursements} échéance(s) simulée(s)`);
|
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.';
|
if (parts.length === 0) return 'Aucune donnée à supprimer trouvée pour cette plateforme.';
|
||||||
return `Supprimé : ${parts.join(', ')}.`;
|
return `Supprimé : ${parts.join(', ')}.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -773,31 +773,33 @@ function DossierImport({
|
|||||||
dossierInputRef, reloadHistory,
|
dossierInputRef, reloadHistory,
|
||||||
}) {
|
}) {
|
||||||
const missingInv = !activeId;
|
const missingInv = !activeId;
|
||||||
|
const [dossierAnalyzing, setDossierAnalyzing] = useState(false);
|
||||||
|
|
||||||
const onFileChange = (e) => {
|
const onFileChange = async (e) => {
|
||||||
const f = e.target.files[0];
|
const f = e.target.files[0];
|
||||||
setDossierFile(f || null);
|
setDossierFile(f || null);
|
||||||
setDossierPreview(null); setDossierResult(null); setDossierErr(null);
|
setDossierPreview(null); setDossierResult(null); setDossierErr(null);
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const reader = new FileReader();
|
setDossierAnalyzing(true);
|
||||||
reader.onload = (ev) => {
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(ev.target.result);
|
const fd = new FormData();
|
||||||
if (parsed.type !== 'dossier_investissement') {
|
fd.append('file', f);
|
||||||
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
|
const r = await api.postForm('/imports/dossier/preview', fd);
|
||||||
return;
|
setDossierPreview(r);
|
||||||
|
} catch (err) {
|
||||||
|
setDossierErr(err.message);
|
||||||
|
setDossierFile(null);
|
||||||
|
if (dossierInputRef.current) dossierInputRef.current.value = '';
|
||||||
|
} finally {
|
||||||
|
setDossierAnalyzing(false);
|
||||||
}
|
}
|
||||||
setDossierPreview(parsed);
|
|
||||||
} catch { setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.'); }
|
|
||||||
};
|
|
||||||
reader.readAsText(f);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onImport = async () => {
|
const onImport = async () => {
|
||||||
if (!dossierPreview) return;
|
if (!dossierPreview) return;
|
||||||
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
|
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
|
||||||
try {
|
try {
|
||||||
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
|
const r = await api.post('/imports/dossier/apply', { tempId: dossierPreview.tempId });
|
||||||
setDossierResult(r);
|
setDossierResult(r);
|
||||||
setDossierFile(null); setDossierPreview(null);
|
setDossierFile(null); setDossierPreview(null);
|
||||||
if (dossierInputRef.current) dossierInputRef.current.value = '';
|
if (dossierInputRef.current) dossierInputRef.current.value = '';
|
||||||
@@ -806,15 +808,16 @@ function DossierImport({
|
|||||||
finally { setDossierBusy(false); }
|
finally { setDossierBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const dp = dossierPreview;
|
const dp = dossierPreview?.manifest;
|
||||||
const inv = dp?.investissement;
|
const inv = dp?.investissement;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source de votre dossier d'investissement</h3>
|
<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 }}>
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
|
||||||
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
|
Restaure ou migre un dossier complet (investissement, remboursements, historique et documents) depuis un fichier
|
||||||
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
|
<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éé.
|
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -826,12 +829,15 @@ function DossierImport({
|
|||||||
|
|
||||||
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
|
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label>Fichier dossier <code>.json</code></label>
|
<label>Fichier dossier <code>.zip</code></label>
|
||||||
<input ref={dossierInputRef} type="file" accept=".json"
|
<input ref={dossierInputRef} type="file" accept=".zip"
|
||||||
disabled={missingInv} onChange={onFileChange} />
|
disabled={missingInv || dossierAnalyzing} onChange={onFileChange} />
|
||||||
</div>
|
</div>
|
||||||
</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>}
|
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
|
||||||
|
|
||||||
{dp && inv && (
|
{dp && inv && (
|
||||||
@@ -848,6 +854,7 @@ function DossierImport({
|
|||||||
<tr><td>Réinvestissements</td><td>{dp.reinvestissements?.length ?? 0} enregistrement(s)</td></tr>
|
<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>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>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>
|
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -865,6 +872,12 @@ function DossierImport({
|
|||||||
{dossierResult && (
|
{dossierResult && (
|
||||||
<div className="success-msg" style={{ marginTop: 12 }}>
|
<div className="success-msg" style={{ marginTop: 12 }}>
|
||||||
{dossierResult.action === 'created' ? '✔ Dossier créé avec succès.' : '✔ Dossier mis à jour avec succès.'}
|
{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
|
<button
|
||||||
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
|
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
|
||||||
|
|||||||
@@ -655,6 +655,27 @@ tr:hover td { background: var(--surface-2); }
|
|||||||
border: 1px solid rgba(16,185,129,.25);
|
border: 1px solid rgba(16,185,129,.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Filtre par catégorie — bloc "Mes documents" (Mon compte) ── */
|
||||||
|
.doc-cat-tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.doc-cat-tab {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 7px 14px; border-radius: 20px; border: 1px solid var(--border);
|
||||||
|
background: var(--surface); color: var(--text-muted);
|
||||||
|
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||||
|
transition: background .15s, border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
.doc-cat-tab:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
.doc-cat-tab.active {
|
||||||
|
border-color: var(--primary); color: var(--text); font-weight: 600;
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
.doc-cat-tab-badge {
|
||||||
|
min-width: 20px; height: 20px; border-radius: 10px; padding: 0 6px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; font-weight: 700; background: var(--primary); color: #fff;
|
||||||
|
}
|
||||||
|
.doc-group-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ── Preferences (Interface page) ───────────────────────────── */
|
/* ── Preferences (Interface page) ───────────────────────────── */
|
||||||
.pref-options {
|
.pref-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1368,6 +1389,17 @@ tr:hover td { background: var(--surface-2); }
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
|
/* Variante large — section "Mes documents" (tableau plus confortable à lire
|
||||||
|
large qu'un formulaire) : 70% de l'espace disponible sur grand écran,
|
||||||
|
pleine largeur en dessous du breakpoint mobile déjà utilisé ailleurs dans
|
||||||
|
l'app (cf. @media (min-width: 768px) plus bas dans ce fichier). */
|
||||||
|
.account-content-wide {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.account-content-wide { max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
/* AccountForm interne — profil + préférences côte-à-côte */
|
/* AccountForm interne — profil + préférences côte-à-côte */
|
||||||
.profile-page {
|
.profile-page {
|
||||||
|
|||||||
@@ -102,3 +102,12 @@ export const memberInitials = (m) => {
|
|||||||
// Entreprise : deux premières lettres de la raison sociale
|
// Entreprise : deux premières lettres de la raison sociale
|
||||||
return m.nom.slice(0, 2).toUpperCase();
|
return m.nom.slice(0, 2).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Formate une taille en octets en Ko/Mo/Go lisibles (documents, quotas). */
|
||||||
|
export const fmtOctets = (bytes) => {
|
||||||
|
if (bytes == null || Number.isNaN(bytes)) return '—';
|
||||||
|
if (bytes < 1024) return `${bytes} o`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} Go`;
|
||||||
|
};
|
||||||
Reference in new issue
Block a user