Initial commit
This commit is contained in:
@@ -0,0 +1,591 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useInteretsChart } from '../context/InteretsChartContext.jsx';
|
||||
import { fmtEUR, fmtDate } from '../utils/format.js';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
|
||||
function hexToRgba(hex, a) {
|
||||
if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`;
|
||||
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
const round2 = v => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* DrillCellPanel
|
||||
*
|
||||
* Props :
|
||||
* cell { platId, platNom, annee, mois, moisLabel } | null
|
||||
* platId peut être null → toutes les plateformes
|
||||
* onClose () => void (masqué si alwaysOpen=true)
|
||||
* alwaysOpen bool — cache le bouton Fermer, ajuste le style
|
||||
* pfuRates []
|
||||
* activeView 'single'|'all'
|
||||
* activeId number|null
|
||||
* plateformes [] — pour le sélecteur (optionnel)
|
||||
* investissements [] — pour le calcul bulk (optionnel)
|
||||
* onBulkDone () => void — callback après validation en masse
|
||||
*/
|
||||
export default function DrillCellPanel({
|
||||
cell, onClose, alwaysOpen = false,
|
||||
pfuRates, activeView, activeId,
|
||||
onEditRecu, onEditProjet, refreshKey,
|
||||
investissements, plateformes, onBulkDone,
|
||||
}) {
|
||||
const {
|
||||
inclureInterets, setInclureInterets,
|
||||
inclureCapital, setInclureCapital,
|
||||
inclureCashback, setInclureCashback,
|
||||
netMode,
|
||||
showActual, showProjected,
|
||||
chartInterets, chartCapital, chartCashback,
|
||||
} = useInteretsChart();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
/* filterPlatId : '' = toutes, sinon l'id (string) de la plateforme sélectionnée dans le dropdown */
|
||||
const [filterPlatId, setFilterPlatId] = useState('');
|
||||
const [showRecus, setShowRecus] = useState(showActual);
|
||||
const [showProjetes, setShowProjetes] = useState(showProjected);
|
||||
|
||||
/* ── Bulk validation ── */
|
||||
const [bulkModal, setBulkModal] = useState(false);
|
||||
const [bulkItems, setBulkItems] = useState([]);
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false);
|
||||
const [bulkProgress, setBulkProgress] = useState(0);
|
||||
const [bulkDone, setBulkDone] = useState(false);
|
||||
|
||||
/* ── Reset filtres quand la cellule change ── */
|
||||
useEffect(() => {
|
||||
if (!cell) { setData(null); return; }
|
||||
setShowRecus(showActual);
|
||||
setShowProjetes(showProjected);
|
||||
/* Quand une cellule spécifique est cliquée, pré-sélectionner sa plateforme */
|
||||
setFilterPlatId(cell.platId ? String(cell.platId) : '');
|
||||
}, [cell?.platId, cell?.annee, cell?.mois]);
|
||||
|
||||
/* ── Fetch quand cellule OU filterPlatId change ── */
|
||||
useEffect(() => {
|
||||
if (!cell) { setData(null); return; }
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
const params = {
|
||||
annee: cell.annee,
|
||||
mois: cell.mois,
|
||||
...(filterPlatId ? { plateforme_id: filterPlatId } : {}),
|
||||
...(activeView === 'all' ? { scope: 'all' } : {}),
|
||||
};
|
||||
api.get('/dashboard/detail-cellule', params)
|
||||
.then(d => setData(d))
|
||||
.catch(() => setData({ recus: [], projetes: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cell?.annee, cell?.mois, filterPlatId, activeView, activeId, refreshKey]);
|
||||
|
||||
/* ── Taux PFU pour l'année de la cellule ── */
|
||||
const pfuReduction = useMemo(() => {
|
||||
if (!pfuRates?.length || !cell) return 0;
|
||||
const r = pfuRates.find(r => r.annee === cell.annee)
|
||||
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
|
||||
return (r.prelev_sociaux + r.impot_revenu) / 100;
|
||||
}, [pfuRates, cell]);
|
||||
|
||||
/* ── Données filtrées (la plateforme est gérée côté fetch) ── */
|
||||
const recus = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.recus;
|
||||
}, [data]);
|
||||
|
||||
const projetes = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.projetes;
|
||||
}, [data]);
|
||||
|
||||
if (!cell) return null;
|
||||
|
||||
/* ── Afficher la colonne Plateforme quand on est en mode "toutes" ── */
|
||||
const showPlatCol = !filterPlatId;
|
||||
const multiDetenteur = plateformes && new Set(plateformes.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
/* ── Calcul valeur ligne reçue ── */
|
||||
const recuRowValue = (r) => {
|
||||
let v = 0;
|
||||
if (inclureInterets) v += netMode ? r.interets_nets : r.interets_bruts;
|
||||
if (inclureCashback) v += r.cashback ?? 0;
|
||||
if (inclureCapital) v += r.capital ?? 0;
|
||||
return v;
|
||||
};
|
||||
|
||||
/* ── Calcul valeur ligne projetée ── */
|
||||
const projRowValue = (p) => {
|
||||
let v = 0;
|
||||
if (inclureInterets) v += netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus;
|
||||
if (inclureCapital) v += p.capital_prevu ?? 0;
|
||||
return v;
|
||||
};
|
||||
|
||||
/* ── Totaux ── */
|
||||
const totalRecus = recus.reduce((s, r) => s + recuRowValue(r), 0);
|
||||
const totalProjetes = projetes.reduce((s, p) => s + projRowValue(p), 0);
|
||||
const grandTotal = (showRecus ? totalRecus : 0) + (showProjetes ? totalProjetes : 0);
|
||||
|
||||
/* ── Colonnes dynamiques ── */
|
||||
const cols = [
|
||||
inclureInterets && { key: 'interets', label: netMode ? 'Intérêts nets' : 'Intérêts bruts', color: chartInterets },
|
||||
inclureCapital && { key: 'capital', label: 'Capital', color: chartCapital },
|
||||
inclureCashback && { key: 'cashback', label: 'Cashback', color: chartCashback },
|
||||
].filter(Boolean);
|
||||
|
||||
/* Date + Plateforme(opt) + Projet + Détenteur + cols + Total */
|
||||
const colCount = 3 + (showPlatCol ? 1 : 0) + cols.length + 1;
|
||||
|
||||
/* Titre du header */
|
||||
const headerTitle = filterPlatId
|
||||
? `${plateformes?.find(p => String(p.id) === filterPlatId)?.nom ?? 'Plateforme'} — ${cell.moisLabel} ${cell.annee}`
|
||||
: `Toutes les plateformes — ${cell.moisLabel} ${cell.annee}`;
|
||||
|
||||
/* ── Bulk : construction des payloads ── */
|
||||
const openBulkModal = () => {
|
||||
if (!projetes.length) return;
|
||||
const items = projetes.map(p => {
|
||||
const inv = investissements?.find(i => i.id === p.investissement_id);
|
||||
const plat = inv ? plateformes?.find(pl => pl.id === inv.plateforme_id) : null;
|
||||
const bruts = p.interets_prevus || 0;
|
||||
const year = p.date_prevue ? Number(p.date_prevue.slice(0, 4)) : cell.annee;
|
||||
const rates = pfuRates?.find(r => r.annee === year)
|
||||
?? (pfuRates?.length ? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]) : null);
|
||||
const methode = plat && plat.methode_remboursement !== 'choix_investisseur'
|
||||
? plat.methode_remboursement
|
||||
: (inv?.methode_remboursement || 'portefeuille');
|
||||
const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale;
|
||||
const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0;
|
||||
const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts;
|
||||
return {
|
||||
_label: p.nom_projet,
|
||||
_plat: p.plateforme_nom,
|
||||
_date: p.date_prevue,
|
||||
_capital: p.capital_prevu || 0,
|
||||
_interets: bruts,
|
||||
_total: (p.capital_prevu || 0) + bruts,
|
||||
investissement_id: p.investissement_id,
|
||||
date_remb: p.date_prevue,
|
||||
capital: p.capital_prevu || 0,
|
||||
cashback: 0,
|
||||
interets_bruts_avant_local: hasLocalTax ? bruts : 0,
|
||||
taxe_locale,
|
||||
interets_bruts: brutsApresLocal,
|
||||
prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0,
|
||||
prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0,
|
||||
statut: 'paye',
|
||||
notes: '',
|
||||
methode_remboursement: methode,
|
||||
compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '',
|
||||
};
|
||||
});
|
||||
setBulkItems(items);
|
||||
setBulkProgress(0);
|
||||
setBulkDone(false);
|
||||
setBulkProcessing(false);
|
||||
setBulkModal(true);
|
||||
};
|
||||
|
||||
const runBulk = async () => {
|
||||
setBulkProcessing(true);
|
||||
let done = 0;
|
||||
for (const item of bulkItems) {
|
||||
const { _label, _plat, _date, _capital, _interets, _total, ...payload } = item;
|
||||
try { await api.post('/remboursements', payload); } catch (e) { /* continuer */ }
|
||||
done++;
|
||||
setBulkProgress(done);
|
||||
}
|
||||
setBulkProcessing(false);
|
||||
setBulkDone(true);
|
||||
if (onBulkDone) onBulkDone();
|
||||
};
|
||||
|
||||
const closeBulkModal = () => {
|
||||
setBulkModal(false);
|
||||
setBulkItems([]);
|
||||
setBulkProgress(0);
|
||||
setBulkDone(false);
|
||||
setBulkProcessing(false);
|
||||
};
|
||||
|
||||
/* ── Rendu ── */
|
||||
return (
|
||||
<div style={{
|
||||
margin: alwaysOpen ? '0 24px 28px' : '0 24px 28px',
|
||||
padding: 0,
|
||||
border: '2px solid var(--primary)',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
overflowX: 'auto',
|
||||
background: 'var(--surface)',
|
||||
}}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
background: 'var(--primary)',
|
||||
color: '#fff',
|
||||
}}>
|
||||
<strong style={{ fontSize: '1rem' }}>{headerTitle}</strong>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{[
|
||||
{ active: inclureInterets, toggle: () => setInclureInterets(v => !v), label: netMode ? 'Intérêts nets' : 'Intérêts bruts' },
|
||||
{ active: inclureCapital, toggle: () => setInclureCapital(v => !v), label: 'Capital' },
|
||||
{ active: inclureCashback, toggle: () => setInclureCashback(v => !v), label: 'Cashback' },
|
||||
].map(btn => (
|
||||
<button key={btn.label} onClick={() => btn.toggle()} style={{
|
||||
border: `1px solid ${btn.active ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.3)'}`,
|
||||
background: btn.active ? 'rgba(255,255,255,0.2)' : 'transparent',
|
||||
borderRadius: 6, padding: '3px 10px',
|
||||
fontSize: '0.8rem', fontWeight: btn.active ? 700 : 400,
|
||||
color: '#fff', cursor: 'pointer', transition: 'all .15s',
|
||||
}}>{btn.label}</button>
|
||||
))}
|
||||
{!alwaysOpen && (
|
||||
<button onClick={onClose} title="Fermer" style={{
|
||||
background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.4)',
|
||||
borderRadius: 6, padding: '3px 10px', color: '#fff', cursor: 'pointer',
|
||||
fontSize: '0.85rem', fontWeight: 700,
|
||||
}}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Barre filtres ── */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
|
||||
padding: '10px 16px', borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-2)',
|
||||
}}>
|
||||
{/* Sélecteur plateforme */}
|
||||
{plateformes && plateformes.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<label style={{ fontSize: '0.8rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Plateforme</label>
|
||||
<select
|
||||
value={filterPlatId}
|
||||
onChange={e => setFilterPlatId(e.target.value)}
|
||||
style={{ fontSize: 'var(--fs-sm)', padding: '3px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}>
|
||||
<option value="">Toutes</option>
|
||||
{plateformes.map(p => <option key={p.id} value={String(p.id)}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Reçus / Projetés */}
|
||||
<div style={{ display: 'inline-flex', background: 'var(--surface)', borderRadius: 8, padding: 3, gap: 2, marginLeft: 'auto' }}>
|
||||
{[
|
||||
{ key: 'recus', label: 'Reçus', active: showRecus, toggle: () => setShowRecus(v => !v) },
|
||||
{ key: 'projetes', label: 'Projetés', active: showProjetes, toggle: () => setShowProjetes(v => !v) },
|
||||
].map(btn => (
|
||||
<button key={btn.key} onClick={() => btn.toggle()} style={{
|
||||
border: 'none', cursor: 'pointer', padding: '4px 14px', borderRadius: 6,
|
||||
fontSize: 'var(--fs-sm)',
|
||||
fontWeight: btn.active ? 600 : 400,
|
||||
background: btn.active ? 'var(--primary)' : 'transparent',
|
||||
color: btn.active ? '#fff' : 'var(--text-muted)',
|
||||
boxShadow: btn.active ? '0 1px 3px rgba(0,0,0,0.13)' : 'none',
|
||||
transition: 'all .15s',
|
||||
}}>{btn.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Corps ── */}
|
||||
{loading && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</div>
|
||||
)}
|
||||
|
||||
{!loading && data && (
|
||||
<div>
|
||||
<table className="drill-table" style={{ width: '100%', minWidth: '100%', fontSize: 'var(--fs-sm)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={thStyle}>Date</th>
|
||||
{showPlatCol && <th style={thStyle}>Plateforme</th>}
|
||||
<th style={{ ...thStyle, width: "100%" }}>Projet</th>
|
||||
<th style={thStyle}>Détenteur</th>
|
||||
{cols.map(c => (
|
||||
<th key={c.key} style={{ ...thStyle, ...numStyle, color: c.color }}>{c.label}</th>
|
||||
))}
|
||||
<th style={{ ...thStyle, ...numStyle }}>Total ligne</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* ── Section Reçus ── */}
|
||||
{showRecus && (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={colCount} style={{
|
||||
padding: '6px 12px', fontWeight: 700, fontSize: '0.78rem',
|
||||
background: hexToRgba(chartInterets, 0.07),
|
||||
color: 'var(--text-muted)', letterSpacing: '.04em', textTransform: 'uppercase',
|
||||
}}>
|
||||
Reçus ({recus.length})
|
||||
</td>
|
||||
</tr>
|
||||
{recus.length === 0 ? (
|
||||
<tr><td colSpan={colCount} style={{ padding: '10px 16px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
Aucun remboursement reçu ce mois
|
||||
</td></tr>
|
||||
) : recus.map(r => (
|
||||
<tr key={r.id}
|
||||
style={{ borderBottom: '1px solid var(--border)', cursor: onEditRecu ? 'pointer' : 'default' }}
|
||||
title={onEditRecu ? 'Modifier ce remboursement' : undefined}
|
||||
onClick={() => onEditRecu && onEditRecu(r)}
|
||||
>
|
||||
<td style={tdStyle}>{fmtDate(r.date_remb)}</td>
|
||||
{showPlatCol && <td style={{ ...tdStyle, fontWeight: 500 }}>{r.plateforme_nom || '—'}</td>}
|
||||
<td style={tdStyle}>{r.nom_projet}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{r.detenteur_nom || '—'}</td>
|
||||
{cols.map(c => {
|
||||
let v;
|
||||
if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts;
|
||||
else if (c.key === 'capital') v = r.capital ?? 0;
|
||||
else v = r.cashback ?? 0;
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(v)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(recuRowValue(r))}</td>
|
||||
</tr>
|
||||
))}
|
||||
{recus.length > 0 && (
|
||||
<tr className="drill-row-fixed" style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
|
||||
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>
|
||||
Sous-total reçus
|
||||
</td>
|
||||
{cols.map(c => {
|
||||
const sum = recus.reduce((s, r) => {
|
||||
if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts);
|
||||
if (c.key === 'capital') return s + (r.capital ?? 0);
|
||||
return s + (r.cashback ?? 0);
|
||||
}, 0);
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(sum)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(totalRecus)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
)}
|
||||
|
||||
{/* ── Section Projetés ── */}
|
||||
{showProjetes && (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={colCount} style={{
|
||||
padding: '6px 12px', fontWeight: 700, fontSize: '0.78rem',
|
||||
background: 'rgba(148,163,184,0.08)',
|
||||
color: 'var(--text-muted)', letterSpacing: '.04em', textTransform: 'uppercase',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>Projetés ({projetes.length})</span>
|
||||
{projetes.length > 0 && (
|
||||
<button
|
||||
onClick={() => openBulkModal()}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '3px 10px', borderRadius: 5, cursor: 'pointer',
|
||||
fontSize: '0.75rem', fontWeight: 600, letterSpacing: 'normal',
|
||||
textTransform: 'none',
|
||||
border: '1px solid var(--primary)',
|
||||
background: 'var(--primary)', color: '#fff',
|
||||
transition: 'opacity .15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Valider en masse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{projetes.length === 0 ? (
|
||||
<tr><td colSpan={colCount} style={{ padding: '10px 16px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
||||
Aucune projection ce mois
|
||||
</td></tr>
|
||||
) : projetes.map(p => (
|
||||
<tr key={p.id}
|
||||
style={{ borderBottom: '1px solid var(--border)', opacity: 0.85, cursor: onEditProjet ? 'pointer' : 'default' }}
|
||||
title={onEditProjet ? 'Saisir ce remboursement' : undefined}
|
||||
onClick={() => onEditProjet && onEditProjet(p)}
|
||||
>
|
||||
<td style={{ ...tdStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtDate(p.date_prevue)}</td>
|
||||
{showPlatCol && <td style={{ ...tdStyle, fontStyle: 'italic', fontWeight: 500 }}>{p.plateforme_nom || '—'}</td>}
|
||||
<td style={{ ...tdStyle, fontStyle: 'italic' }}>{p.nom_projet}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>{p.detenteur_nom || '—'}</td>
|
||||
{cols.map(c => {
|
||||
let v;
|
||||
if (c.key === 'interets') v = netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus;
|
||||
else if (c.key === 'capital') v = p.capital_prevu ?? 0;
|
||||
else v = 0;
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtEUR(v)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle, fontStyle: 'italic', color: 'var(--text-muted)' }}>{fmtEUR(projRowValue(p))}</td>
|
||||
</tr>
|
||||
))}
|
||||
{projetes.length > 0 && (
|
||||
<tr className="drill-row-fixed" style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
|
||||
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>
|
||||
Sous-total projetés
|
||||
</td>
|
||||
{cols.map(c => {
|
||||
const sum = projetes.reduce((s, p) => {
|
||||
if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus);
|
||||
if (c.key === 'capital') return s + (p.capital_prevu ?? 0);
|
||||
return s;
|
||||
}, 0);
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(sum)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(totalProjetes)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
)}
|
||||
|
||||
{/* ── Grand total ── */}
|
||||
<tfoot>
|
||||
<tr className="drill-row-fixed" style={{
|
||||
background: 'var(--primary)', color: '#fff',
|
||||
fontWeight: 700, fontSize: '0.9rem', pointerEvents: 'none',
|
||||
}}>
|
||||
<td colSpan={3 + (showPlatCol ? 1 : 0)} style={{ ...tdStyle, color: '#fff' }}>Total</td>
|
||||
{cols.map(c => {
|
||||
const sumR = showRecus ? recus.reduce((s, r) => {
|
||||
if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts);
|
||||
if (c.key === 'capital') return s + (r.capital ?? 0);
|
||||
return s + (r.cashback ?? 0);
|
||||
}, 0) : 0;
|
||||
const sumP = showProjetes ? projetes.reduce((s, p) => {
|
||||
if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus);
|
||||
if (c.key === 'capital') return s + (p.capital_prevu ?? 0);
|
||||
return s;
|
||||
}, 0) : 0;
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle, color: '#fff' }}>{fmtEUR(sumR + sumP)}</td>;
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle, color: '#fff', fontSize: '1rem' }}>
|
||||
{fmtEUR(grandTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modale validation en masse ── */}
|
||||
{bulkModal && (
|
||||
<Modal
|
||||
open={bulkModal}
|
||||
title={bulkDone ? 'Validation terminée' : `Valider ${bulkItems.length} remboursement${bulkItems.length > 1 ? 's' : ''}`}
|
||||
onClose={bulkProcessing ? undefined : closeBulkModal}
|
||||
width={620}
|
||||
footer={
|
||||
bulkDone ? (
|
||||
<button className="btn-primary" onClick={() => closeBulkModal()}>Fermer</button>
|
||||
) : bulkProcessing ? (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
Traitement en cours… {bulkProgress}/{bulkItems.length}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn-secondary" onClick={() => closeBulkModal()}>Annuler</button>
|
||||
<button className="btn-primary" onClick={() => runBulk()}>
|
||||
Enregistrer {bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{bulkProcessing && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3, background: 'var(--primary)',
|
||||
width: `${bulkItems.length > 0 ? (bulkProgress / bulkItems.length) * 100 : 0}%`,
|
||||
transition: 'width .3s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bulkDone && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, padding: '12px 0' }}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'var(--success)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontWeight: 600, fontSize: '1rem' }}>
|
||||
{bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''} enregistré{bulkItems.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.88rem' }}>
|
||||
{cell.moisLabel} {cell.annee}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!bulkDone && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 12px', color: 'var(--text-muted)', fontSize: '0.88rem' }}>
|
||||
Les remboursements suivants vont être créés d'après les projections de <strong>{cell.moisLabel} {cell.annee}</strong>.
|
||||
Les prélèvements fiscaux sont estimés à partir des taux PFU de l'année.
|
||||
</p>
|
||||
<div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
{showPlatCol && <th style={thStyle}>Plateforme</th>}
|
||||
<th style={{ ...thStyle, width: "100%" }}>Projet</th>
|
||||
<th style={{ ...thStyle, ...numStyle }}>Date</th>
|
||||
<th style={{ ...thStyle, ...numStyle }}>Capital</th>
|
||||
<th style={{ ...thStyle, ...numStyle }}>Intérêts bruts</th>
|
||||
<th style={{ ...thStyle, ...numStyle }}>Total brut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bulkItems.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{showPlatCol && <td style={tdStyle}>{item._plat || '—'}</td>}
|
||||
<td style={tdStyle}>{item._label}</td>
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtDate(item._date)}</td>
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(item._capital)}</td>
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(item._interets)}</td>
|
||||
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(item._total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style={{ background: 'var(--surface-2)', fontWeight: 700 }}>
|
||||
<td colSpan={showPlatCol ? 5 : 4} style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: '0.8em' }}>Total</td>
|
||||
<td style={{ ...tdStyle, ...numStyle }}>{fmtEUR(bulkItems.reduce((s, i) => s + i._total, 0))}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Styles partagés ── */
|
||||
const thStyle = {
|
||||
padding: '7px 12px',
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.78rem',
|
||||
letterSpacing: '.03em',
|
||||
color: 'var(--text-muted)',
|
||||
whiteSpace: 'nowrap',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
};
|
||||
const tdStyle = { padding: '7px 12px', verticalAlign: 'middle', whiteSpace: 'nowrap' };
|
||||
const numStyle = { textAlign: 'right' };
|
||||
Reference in New Issue
Block a user