Files
AtreidesAssetsFree/client/pages/portfolios.js
T

420 lines
19 KiB
JavaScript
Raw Normal View History

2026-10-30 12:28:48 +01:00
// Generated, do not edit. sha256:86d4e38921922eb14e5463ba3c4d6855cd2e5276d5295930d9126848cf48e05c
import { t, localeTag } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
import { esc } from '../ui/escape.js';
import * as dialog from '../ui/dialog.js';
import { setPageHint } from '../ui/page-hint.js';
import { getSettings, updateSettings } from '../data/preferences.js';
import { firstPositionDate } from '../data/positions.js';
import { switchPortfolio, reloadWhenBack, refreshActivePortfolio } from '../portfolio.js';
import { backupDb, exportDb, restoreDb, startFresh } from './db-file.js';
import { openPopover, closePopover } from '../ui/popover.js';
import { expanders } from '../ui/expand.js';
import { PLUS, CHEVRON } from '../ui/icons.js';
import { currencyName } from '../ui/format.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { counterText, limitFor, locked } from '../license.js';
import { counterHtml } from '../ui/lock.js';
import { baseCurrency } from '../app-settings.js';
import { openSetup } from '../onboarding/setup.js';
async function api(pad, body) {
const opties = body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: {};
const r = await fetch(pad, opties);
if (r.status === 401) {
location.replace(CONFIG.AUTH.LOGIN_PATH);
throw new Error(t('common.sessionExpired'));
}
const uit = await r.json().catch(() => null);
if (!r.ok) throw new Error(serverFoutTekst(uit) || `HTTP ${r.status}`);
return uit;
}
const datum = (iso) => (iso ? new Date(iso).toLocaleDateString(localeTag()) : null);
const DAG_MS = 24 * 60 * 60 * 1000;
const backupTeOud = (iso) => !iso || Date.now() - new Date(iso).getTime() > CONFIG.BACKUP.REMIND_AFTER_DAYS * DAG_MS;
function menuItems(p, { full, beheer }) {
if (!beheer) return [];
const kopie = p.heeftDatabase && !full ? [['kopieer', t('portfolios.copy')]] : [];
return p.actief
? [...kopie, ['exporteer', t('dbfile.export')], ['terugzetten', t('dbfile.import')], ['inrichten', t('setup.rerun')],
['leeg', t('dbfile.fresh')]]
: [...kopie, ['hernoem', t('portfolios.rename')], ['verwijder', t('portfolios.remove')]];
}
function feitHtml(label, waarde, { late = false } = {}) {
return `<div class="pf-fact${late ? ' is-late' : ''}"><dt>${label}</dt><dd>${waarde}</dd></div>`;
}
const soortenVan = (telling, enumNaam) => ENUMS[enumNaam.toUpperCase()].filter(k => telling?.[k]);
function perSoortHtml(label, telling, enumNaam, regels) {
const getal = (n) => esc(Number(n).toLocaleString(localeTag()));
const soorten = soortenVan(telling, enumNaam);
const totaal = soorten.reduce((s, k) => s + telling[k], 0);
const lijst = soorten.map(k => `<li><span>${esc(t(`enums.${enumNaam}.${k}`))}</span><span>${getal(telling[k])}</span></li>`).join('');
return `<div class="pf-fact"><dt>${label}</dt><dd>${getal(totaal)}</dd>
<ul class="pf-kinds" style="--pf-lines:${Number(regels) || 1}">${lijst}</ul></div>`;
}
function kaartHtml(p, { full, beheer, regels }) {
const k = p.kerncijfers;
const getal = (n) => esc(Number(n).toLocaleString(localeTag()));
const sub = !p.heeftDatabase ? t('portfolios.noDatabase')
: k && k.munt ? esc(k.eersteJaar ? t('portfolios.cardSince', { munt: k.munt, jaar: k.eersteJaar }) : k.munt)
: '';
const feiten = !k ? '' : `<dl class="pf-facts">
${perSoortHtml(t('portfolios.factAssets'), k.assets, 'asset_kind', regels)}
${perSoortHtml(t('portfolios.factAccounts'), k.accounts, 'account_kind', regels)}
${feitHtml(t('portfolios.factEvents'), getal(k.boekingen))}
${feitHtml(t('portfolios.factValuations'), getal(k.balansen))}
${feitHtml(t('portfolios.factLastUsed'), esc(datum(p.laatstGebruikt) || t('portfolios.never')))}
${beheer ? feitHtml(t('portfolios.factBackup'), esc(datum(k.laatsteBackup) || t('portfolios.backupNever')),
{ late: backupTeOud(k.laatsteBackup) }) : '<div class="pf-fact"></div>'}
</dl>`;
const backupKlasse = k && backupTeOud(k.laatsteBackup) ? 'btn-primary' : 'btn-secondary';
const hoofd = !p.actief
? `<button class="btn btn-secondary btn-sm" data-actie="open" data-naam="${esc(p.naam)}">${t('portfolios.activate')}</button>`
: `<button class="btn btn-secondary btn-sm" data-actie="instellingen" data-naam="${esc(p.naam)}">${t('portfolios.settingsButton')}</button>
${beheer ? `<button class="btn ${backupKlasse} btn-sm" data-actie="backup">${t('dbfile.backup')}</button>` : ''}`;
const menu = menuItems(p, { full, beheer }).length
? `<button class="btn btn-ghost btn-sm pf-more" data-menu="${esc(p.naam)}" title="${esc(t('portfolios.more'))}" aria-label="${esc(t('portfolios.more'))}">…</button>`
: '';
return `<div class="card pf-card${p.actief ? ' is-open' : ''}">
<div class="pf-head">
<div class="pf-name">${esc(p.naam)}</div>
${p.demo ? `<span class="badge">${t('portfolios.demoBadge')}</span>` : ''}
${p.actief ? `<span class="badge badge-accent pf-head-end">${t('portfolios.openNow')}</span>` : ''}
</div>
<div class="pf-sub t-body-sm t-muted">${sub}</div>
${feiten}
<div class="pf-actions">${hoofd}${menu}</div>
</div>`;
}
function archiefRijHtml(a) {
return `<div class="flex gap-sm" style="align-items:center; flex-wrap:wrap">
<div style="flex:1 1 12rem">
<div class="t-body">${esc(a.naam)}</div>
<div class="t-body-sm t-muted">${esc(datum(a.gearchiveerd) || '')}</div>
</div>
<button class="btn btn-danger btn-sm" data-actie="wis" data-naam="${esc(a.naam)}">${t('portfolios.purge')}</button>
</div>`;
}
function authHtml(auth) {
const stand = auth.bron === 'omgeving' ? t('portfolios.authEnv')
: auth.ingesteld ? t('portfolios.authSet')
: t('portfolios.authNone');
const knoppen = auth.wijzigbaar
? `<button class="btn btn-secondary btn-sm" data-actie="auth-zet">${t('portfolios.authChange')}</button>
${auth.ingesteld ? `<button class="btn btn-ghost btn-sm" data-actie="auth-leeg">${t('portfolios.authClear')}</button>` : ''}`
: '';
const uitloggen = auth.ingesteld
? `<button class="btn btn-ghost btn-sm" data-actie="auth-uit">${t('portfolios.authLogout')}</button>`
: '';
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.authTitle')}</div>
<div class="t-body-sm mt-sm">${stand}</div>
<div class="flex gap-sm mt-md" style="flex-wrap:wrap">${knoppen}${uitloggen}</div>
</div>`;
}
function startJaarHtml({ gekozen, eersteJaar, huidigJaar }) {
const AUTO = CONFIG.START_YEAR.AUTO;
let inhoud;
if (eersteJaar == null) {
inhoud = `<div class="t-body-sm mt-sm">${t('portfolios.startYearEmpty')}</div>`;
} else {
const eersteKolom = Math.min(Math.max(eersteJaar + 1, gekozen), huidigJaar);
const stand = gekozen === AUTO
? t('portfolios.startYearAutoState', { jaar: eersteKolom })
: t('portfolios.startYearState', { jaar: eersteKolom, eerste: eersteJaar });
const jaren = [];
for (let y = eersteJaar; y <= huidigJaar; y++) jaren.push(y);
if (gekozen !== AUTO && !jaren.includes(gekozen)) jaren.unshift(gekozen);
const opties = [`<option value="${AUTO}"${gekozen === AUTO ? ' selected' : ''}>${t('portfolios.startYearAuto')}</option>`]
.concat(jaren.map(y => `<option value="${y}"${y === gekozen ? ' selected' : ''}>${y}</option>`));
inhoud = `<div class="t-body-sm mt-sm">${stand}</div>
<div class="form-group mt-md" style="margin-bottom:0">
<label class="form-label" for="startjaar">${t('portfolios.startYearLabel')}</label>
<select class="input" id="startjaar" style="max-width:320px">${opties.join('')}</select>
</div>`;
}
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.startYearTitle')}</div>
${inhoud}
</div>`;
}
function rendementHtml({ gekozen }) {
const opties = ENUMS.MWR_METHOD
.map(m => `<option value="${m}"${m === gekozen ? ' selected' : ''}>${t(`portfolios.mwr_${m}`)}</option>`)
.join('');
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.mwrTitle')}</div>
<div class="form-group mt-sm" style="margin-bottom:0">
<label class="form-label" for="mwrmethode">${t('portfolios.mwrLabel')}</label>
<select class="input" id="mwrmethode" style="max-width:320px">${opties}</select>
</div>
</div>`;
}
async function instellingenHtml() {
const [auth, instellingen, eerstePositie] = await Promise.all([
api('/api/auth/status'), getSettings(), firstPositionDate(),
]);
return authHtml(auth) + startJaarHtml({
gekozen: Number(instellingen.start_year) || CONFIG.START_YEAR.AUTO,
eersteJaar: eerstePositie ? Number(eerstePositie.slice(0, 4)) : null,
huidigJaar: new Date().getFullYear(),
}) + rendementHtml({ gekozen: String(instellingen.mwr_method || 'dietz') });
}
async function openInstellingen(naam) {
const html = await instellingenHtml();
const klaar = dialog.detail({ title: t('portfolios.settingsOf', { naam }), html: `<div data-pf-settings>${html}</div>` });
const host = document.querySelector('[data-pf-settings]');
host?.closest('.dialog')?.classList.add('pf-settings-dialog');
const ververs = async () => { if (host?.isConnected) host.innerHTML = await instellingenHtml(); };
const fout = (e) => dialog.alert({ title: t('dialog.error'), message: e.message });
if (host) {
host.onclick = async (ev) => {
const knop = ev.target.closest('[data-actie]');
if (!knop) return;
try {
if (knop.dataset.actie === 'auth-zet' && await authZetten()) await ververs();
if (knop.dataset.actie === 'auth-leeg' && await authLeegmaken()) await ververs();
if (knop.dataset.actie === 'auth-uit') await uitloggen();
} catch (e) { fout(e); }
};
host.onchange = async (ev) => {
const veld = ev.target.matches('#mwrmethode') ? { mwr_method: ev.target.value }
: ev.target.matches('#startjaar') ? { start_year: Number(ev.target.value) } : null;
if (!veld) return;
try {
await updateSettings(veld);
document.dispatchEvent(new Event('settings-changed'));
await ververs();
} catch (e) { fout(e); }
};
}
await klaar;
}
export async function render(el) {
const [overzicht, expand] = await Promise.all([
api('/api/portfolios'), expanders('portfolios', { archive: false }),
]);
refreshActivePortfolio();
const beheer = !locked('portfolios');
const max = limitFor('portfolios');
const eigen = overzicht.portefeuilles.filter(p => !p.demo).length;
const full = max !== null && eigen >= max;
const aangemaakt = p => p.kerncijfers?.aangemaakt || '';
const lijst = [...overzicht.portefeuilles]
.sort((a, b) => aangemaakt(a).localeCompare(aangemaakt(b)) || a.naam.localeCompare(b.naam, localeTag()));
const regels = Math.max(1, ...lijst.map(p => Math.max(
soortenVan(p.kerncijfers?.assets, 'asset_kind').length, soortenVan(p.kerncijfers?.accounts, 'account_kind').length)));
const nieuwTegel = full
? `<div class="pf-new is-full">${counterHtml(counterText('portfolios', eigen))}</div>`
: `<div class="pf-new"><button class="btn btn-new pf-new-btn" data-actie="nieuw" title="${esc(t('portfolios.create'))}"
aria-label="${esc(t('portfolios.create'))}">${PLUS}</button></div>`;
const archief = beheer && overzicht.archief.length
? `<details class="card pf-card pf-archive" ${expand.attr('archive')}>
<summary><span class="chev-btn">${CHEVRON}</span><span>${t('portfolios.archiveTitle')}</span>
<span class="badge pf-head-end">${overzicht.archief.length}</span></summary>
<div class="pf-archive-list">${overzicht.archief.map(archiefRijHtml).join('')}</div>
</details>`
: '';
el.innerHTML = `
<div class="pf-grid${beheer ? '' : ' is-stacked'}">
${lijst.map(p => kaartHtml(p, { full, beheer, regels })).join('')}
${nieuwTegel}
</div>
${archief ? `<div class="pf-grid pf-grid-archive">${archief}</div>` : ''}`;
expand.bind(el);
const instellingenUitleg = [
{ heading: t('portfolios.authTitle') }, t('portfolios.authHint'),
{ heading: t('portfolios.startYearTitle') }, t('portfolios.startYearHint'),
{ heading: t('portfolios.mwrTitle') },
t('portfolios.mwrHint'), t('portfolios.mwrState_dietz'), t('portfolios.mwrState_irr'),
];
setPageHint(el, {
title: t('portfolios.hintTitle'),
rows: beheer ? [
{ heading: t('portfolios.title') },
t('portfolios.intro'), t('portfolios.createHint'), t('portfolios.createCurrencyHint'),
{ heading: t('portfolios.backupTitle') }, t('dbfile.hint'), t('portfolios.backupLateHint', { dagen: CONFIG.BACKUP.REMIND_AFTER_DAYS }),
...instellingenUitleg,
{ heading: t('portfolios.archiveTitle') }, t('portfolios.archiveIntro'),
] : [
{ heading: t('portfolios.title') }, t('portfolios.intro'), t('portfolios.demoHint'),
...instellingenUitleg,
],
});
const opnieuw = () => render(el);
const fout = (e) => dialog.alert({ title: t('dialog.error'), message: e.message });
async function doe(actie, naam) {
try {
switch (actie) {
case 'open': await open(naam); break;
case 'nieuw': if (await nieuw()) await opnieuw(); break;
case 'kopieer': if (await kopieer(naam)) await opnieuw(); break;
case 'instellingen': await openInstellingen(naam); break;
case 'backup': if (await backupDb()) await opnieuw(); break;
case 'exporteer': await exportDb(); await opnieuw(); break;
case 'terugzetten': restoreDb(opnieuw); break;
case 'leeg': await startFresh(opnieuw); break;
case 'inrichten': await openSetup({ rerun: true }); await opnieuw(); break;
case 'hernoem': if (await hernoem(naam)) await opnieuw(); break;
case 'verwijder': if (await verwijder(naam)) await opnieuw(); break;
case 'wis': if (await wis(naam)) await opnieuw(); break;
}
} catch (e) { fout(e); }
}
function openMenu(knop) {
const p = lijst.find(x => x.naam === knop.dataset.menu);
if (!p) return;
const { el: pop, position } = openPopover(knop, { className: 'popover new-menu' });
pop.innerHTML = menuItems(p, { full, beheer }).map(([actie, label]) =>
`<button class="btn btn-ghost btn-sm new-menu-item" data-actie="${actie}">${esc(label)}</button>`).join('');
position();
pop.onclick = (ev) => {
const item = ev.target.closest('[data-actie]');
if (!item) return;
closePopover();
doe(item.dataset.actie, p.naam);
};
}
el.onclick = (ev) => {
const menuKnop = ev.target.closest('[data-menu]');
if (menuKnop) { openMenu(menuKnop); return; }
const knop = ev.target.closest('[data-actie]');
if (knop) doe(knop.dataset.actie, knop.dataset.naam);
};
}
async function open(naam) {
if (!await dialog.confirm({ message: t('portfolios.activateConfirm', { naam }) })) return;
const uit = await switchPortfolio(naam);
if (!uit.supervised) {
await dialog.alert({ message: t('portfolios.activateNoSupervisor', { naam }) });
return;
}
dialog.notice({ message: t('portfolios.activateWaiting') });
await reloadWhenBack();
}
async function nieuw() {
const res = await dialog.form({
title: t('portfolios.create'),
fields: [
{ key: 'naam', label: t('portfolios.createName'), placeholder: t('portfolios.createNameHint') },
{
key: 'munt', label: t('portfolios.createCurrency'), type: 'select',
options: CONFIG.PORTFOLIO.BASE_CURRENCIES.map(code => ({ value: code, label: `${currencyName(code)} (${code})` })),
value: baseCurrency(),
},
],
confirmLabel: t('portfolios.createConfirm'),
});
if (!res) return false;
const uit = await api('/api/portfolios/create', { naam: res.naam, kopieVan: null, baseCurrency: res.munt });
await dialog.alert({ message: t('portfolios.createDone', { naam: uit.naam }) });
return true;
}
async function kopieer(bron) {
const res = await dialog.form({
title: t('portfolios.copyTitle', { naam: bron }),
fields: [{ key: 'naam', label: t('portfolios.copyName'), placeholder: t('portfolios.createNameHint') }],
confirmLabel: t('portfolios.copyConfirm'),
});
if (!res) return false;
const uit = await api('/api/portfolios/create', { naam: res.naam, kopieVan: bron, baseCurrency: null });
await dialog.alert({ message: t('portfolios.copyDone', { naam: uit.naam, bron }) });
return true;
}
async function hernoem(naam) {
const res = await dialog.form({
title: t('portfolios.rename'),
fields: [{ key: 'naar', label: t('portfolios.renameTo'), value: naam }],
confirmLabel: t('portfolios.rename'),
});
if (!res) return false;
const uit = await api('/api/portfolios/rename', { van: naam, naar: res.naar });
await dialog.alert({ message: t('portfolios.renameDone', { naam: uit.naar }) });
return true;
}
async function verwijder(naam) {
const res = await dialog.form({
title: t('portfolios.remove'),
fields: [{ key: 'bevestiging', label: t('portfolios.removeConfirm', { naam }) }],
confirmLabel: t('portfolios.remove'),
});
if (!res) return false;
if (res.bevestiging.trim() !== naam) {
await dialog.alert({ title: t('dialog.error'), message: t('portfolios.removeMismatch') });
return false;
}
await api('/api/portfolios/archive', { naam });
await dialog.alert({ message: t('portfolios.removeDone', { naam }) });
return true;
}
async function wis(naam) {
const res = await dialog.form({
title: t('portfolios.purge'),
fields: [{ key: 'bevestiging', label: t('portfolios.purgeConfirm', { naam }) }],
confirmLabel: t('portfolios.purge'),
});
if (!res) return false;
if (res.bevestiging.trim() !== naam) {
await dialog.alert({ title: t('dialog.error'), message: t('portfolios.removeMismatch') });
return false;
}
await api('/api/portfolios/purge', { naam });
await dialog.alert({ message: t('portfolios.purgeDone', { naam }) });
return true;
}
async function authZetten() {
const res = await dialog.form({
title: t('portfolios.authChange'),
fields: [{ key: 'password', label: t('portfolios.authPassword'), type: 'password' }],
confirmLabel: t('portfolios.authSave'),
});
if (!res) return false;
await api('/api/auth/set', { password: res.password });
await dialog.alert({ message: t('portfolios.authDone') });
return true;
}
async function uitloggen() {
if (!await dialog.confirm({ message: t('portfolios.authLogoutConfirm') })) return;
await api('/api/logout', {});
location.replace(CONFIG.AUTH.LOGIN_PATH);
}
async function authLeegmaken() {
if (!await dialog.confirm({ message: t('portfolios.authClearConfirm'), danger: true })) return false;
await api('/api/auth/clear', {});
await dialog.alert({ message: t('portfolios.authClearedDone') });
return true;
}