import {
STORE_KEY, CURRENCIES, COMPANY_PUBLIC_ADDRESS, createDemoIban, createDemoBic, FX_SPREAD, CRYPTO_SPREAD,
createDemoState, loadDemoState, saveDemoState, moneyToCents, createCustomer, openAccount,
createInternalTransfer, convertBetweenAccounts, createExternalPayment, approvePayment,
createInterbankTransfer, approveInterbankTransfer, verifyDemoReference, createSupportTicket, updateSupportTicket, recordCashOperation,
issueCard, setCardStatus, setCardControls, loadPrepaidCard, simulateCardPurchase, openSavingsDeposit, matureSavingsDeposit,
createLoanApplication, approveLoanApplication, disburseLoan, makeLoanRepayment,
quoteExchange, executeExchangeTrade, simulateAtmWithdrawal, createSwiftMessage,
approveSwiftMessage, updateCaseStatus, updateCustomerKyc, submitDemoKycRequest, exportAsCsv, DEMO_BRAND,
createIncomingPayment, cancelPayment, createCryptoTransfer, createCryptoReceipt, approveCryptoTransfer, cancelCryptoTransfer,
createComplaint, updateComplaint, accountBalanceEur,
submitDemoOnboardingApplication, updateDemoOnboardingApplication,
getCustomer, getAccount, totalCustomerDepositsCents, isBalanced, addAudit, runBackgroundSimulationTick
} from './core.mjs';
import { supportedLanguages, translate } from './i18n.mjs';
import { ECB_DAILY_URL, CBK_FOREX_URL, ECB_FALLBACK_DATE, ECB_FALLBACK_RATES_PER_EUR, CBK_FALLBACK_DATE, CBK_FALLBACK_KES_PER_EUR, parseEcbDailyReference, parseCbkDailyReference } from './fx.mjs';
import { localAssistantReply } from './assistant.mjs';
import { INTEROP_IDEAS, INTEROP_FEATURES, INTEROP_SYSTEMS, interopLocalizedText, ensureInteropLabState, activeInteropSystem, selectInteropSystem, toggleInteropConnector, refreshInteropConnector, runInteropFeature, resetInteropLab } from './interop-lab.mjs';
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const main = $('#mainContent');
const nav = $('#primaryNav');
const modalRoot = $('#modalRoot');
const toastRegion = $('#toastRegion');
const languageSelect = $('#languageSelect');
const STAFF_LEVELS = [
{ id: 'platform_admin', level: 1, label: 'rolePlatformAdmin', scope: 'roleScopePlatform', operator: 'Luka Babić' },
{ id: 'group_admin', level: 2, label: 'roleGroupAdmin', scope: 'roleScopeGroup', operator: 'Ivana Radić' },
{ id: 'country_manager', level: 3, label: 'roleCountryManager', scope: 'roleScopeCountry', operator: 'David Novak' },
{ id: 'branch_manager', level: 4, label: 'roleBranchManager', scope: 'roleScopeBranch', operator: 'Maja Kovač' },
{ id: 'checker', level: 5, label: 'roleChecker', scope: 'roleScopeReview', operator: 'Ivan Novak' },
{ id: 'operations_maker', level: 6, label: 'roleMaker', scope: 'roleScopeOperations', operator: 'Maja Kovač' },
{ id: 'auditor', level: 7, label: 'roleAuditor', scope: 'roleScopeAudit', operator: 'Ana Kralj' }
];
let state = loadDemoState();
ensureInteropLabState(state);
const requestedLanguage = new URLSearchParams(location.search).get('lang');
const languageFromEntry = supportedLanguages.some(language => language.code === requestedLanguage) ? requestedLanguage : null;
if (languageFromEntry) state.settings.language = languageFromEntry;
let currentView = document.body.dataset.startView || 'overview';
let portalCustomerId = state.accounts.find(account => account.status === 'active' && state.customers.find(customer => customer.id === account.customerId)?.kyc === 'verified')?.customerId || state.customers[0]?.id;
let paymentTab = 'orders';
let amlTab = 'cases';
let supportTab = 'tickets';
let exchangeTab = 'fx';
let interopTab = 'workbench';
let interopCategory = 'all';
let exchangeSide = 'buy';
let exchangeCustomerId = null;
let currentSearchPopover;
let pendingConfirmation = null;
let ecbFetchPromise = null;
let cbkFetchPromise = null;
let optionalMarketFeed = { status: 'idle', reference: null, message: '' };
let optionalMarketFeedPromise = null;
const fxReferenceData = {
ecb: { status: 'bundled-sample', rateDate: ECB_FALLBACK_DATE, fetchedAt: null, source: 'Bundled ECB reference sample', rates: { EUR: 1, ...ECB_FALLBACK_RATES_PER_EUR } },
cbk: { status: 'bundled-sample', rateDate: CBK_FALLBACK_DATE, fetchedAt: null, source: 'Bundled CBK indicative-rate sample', kesPerEur: CBK_FALLBACK_KES_PER_EUR }
};
const assistantChats = new Map();
let referenceCheckResult = null;
let browserSimulationTimer = null;
let backgroundTickUnsubscribe = null;
let backgroundDesktopSupported = Boolean(window.adriaDemo?.setBackgroundPreferences);
const filterState = {
customers: { q: '', kyc: 'all', risk: 'all', page: 1 },
accounts: { q: '', status: 'all', currency: 'all', page: 1 },
payments: { q: '', status: 'all', page: 1 },
transactions: { q: '', type: 'all', page: 1 },
cards: { q: '', status: 'all', page: 1 },
savings: { q: '', status: 'all', page: 1 },
loans: { q: '', status: 'all', page: 1 },
cases: { q: '', severity: 'all', status: 'open', page: 1 },
complaints: { q: '', status: 'all', page: 1 },
branches: { page: 1 },
onboarding: { q: '', status: 'all', page: 1 },
ledger: { page: 1 }
};
const PAGE_SIZE = 12;
const navGroups = [
{ label: 'bankOperations', items: [
['overview', 'overview', 'overview'], ['customers', 'customers', 'users'], ['accounts', 'accounts', 'account'],
['payments', 'payments', 'payments'], ['cards', 'cards', 'card'], ['savings', 'savings', 'savings'], ['loans', 'loans', 'loan'],
['branches', 'branchesCash', 'branch'], ['atms', 'atmNetwork', 'atm']
] },
{ label: 'customerChannels', items: [['portal', 'customerPortal', 'portal'], ['onboarding', 'onlineOnboarding', 'verify'], ['support', 'callCentre', 'support']] },
{ label: 'controls', items: [['aml', 'amlKyc', 'shield'], ['complaints', 'complaints', 'support'], ['swift', 'swiftLab', 'swift'], ['exchange', 'exchange', 'exchange'], ['interop', 'interoperability', 'globe'], ['institutions', 'institutions', 'bank'], ['checker', 'localChecks', 'verify']] },
{ label: 'finance', items: [['ledger', 'ledger', 'ledger'], ['reports', 'reports', 'reports'], ['settings', 'settings', 'settings']] }
];
function visibleNavGroups() {
if (document.body.dataset.surface === 'client') return [{ label: 'customerChannels', items: [['portal', 'customerPortal', 'portal'], ['complaints', 'complaints', 'support'], ['exchange', 'exchange', 'exchange'], ['support', 'callCentre', 'support']] }];
return navGroups;
}
const iconPaths = {
overview: ' ',
users: ' ',
account: ' ',
payments: ' ',
card: ' ',
savings: ' ',
loan: ' ',
branch: ' ',
atm: ' ',
shield: ' ',
swift: ' ',
exchange: ' ',
ledger: ' ',
reports: ' ',
settings: ' ',
bell: ' ',
search: ' ',
bank: ' ',
portal: ' ',
globe: ' ',
spark: ' ',
download: ' ',
arrow: ' ',
clock: ' ',
shieldCheck: ' ',
plus: ' ',
support: ' ',
verify: ' ',
cash: ' ',
close: ' ',
dots: ' '
};
function icon(name, cls = '') { return `${iconPaths[name] || iconPaths.spark} `; }
function t(key) { return translate(state.settings.language || 'hr', key); }
function currentStaffRole() { return STAFF_LEVELS.find(role => role.id === state.settings.role) || STAFF_LEVELS[5]; }
function canWrite() { return currentStaffRole().level < 7; }
function canApprove() { return currentStaffRole().level <= 5; }
function canReviewCompliance() { return currentStaffRole().level <= 5; }
function canAdminister() { return currentStaffRole().level <= 2; }
function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
}
function locale() { return supportedLanguages.find(item => item.code === state.settings.language)?.locale || 'hr-HR'; }
function currency(valueCents, currencyCode = 'EUR', options = {}) {
const amount = Number(valueCents || 0) / 100;
try { return new Intl.NumberFormat(locale(), { style: 'currency', currency: currencyCode, maximumFractionDigits: options.digits ?? 2, minimumFractionDigits: options.minimum ?? 0 }).format(amount); }
catch { return `${amount.toFixed(2)} ${currencyCode}`; }
}
function currencyMajor(amount, currencyCode = 'EUR', maximumFractionDigits = 2) {
try { return new Intl.NumberFormat(locale(), { style: 'currency', currency: currencyCode, maximumFractionDigits, minimumFractionDigits: 0 }).format(Number(amount) || 0); }
catch { return `${Number(amount || 0).toFixed(2)} ${currencyCode}`; }
}
function compactCurrency(cents) {
const amount = (Number(cents) || 0) / 100;
try { return new Intl.NumberFormat(locale(), { style: 'currency', currency: 'EUR', notation: 'compact', maximumFractionDigits: 1 }).format(amount); }
catch { return `€${Math.round(amount).toLocaleString()}`; }
}
function fmtDate(value, withTime = false) {
if (!value || Number.isNaN(new Date(value).valueOf())) return '—';
try {
return new Intl.DateTimeFormat(locale(), withTime ? { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' } : { day: '2-digit', month: 'short', year: 'numeric' }).format(new Date(value));
} catch { return new Date(value).toLocaleDateString(); }
}
function fmtTime(value) {
if (!value || Number.isNaN(new Date(value).valueOf())) return '—';
return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
function initials(name) {
return String(name || 'AB').trim().split(/\s+/).slice(0, 2).map(word => word[0] || '').join('').toLocaleUpperCase(locale());
}
function customerById(id) { return state.customers.find(item => item.id === id); }
function accountById(id) { return state.accounts.find(item => item.id === id); }
function labelStatus(status) {
const key = ({ settled_simulation: 'settledSimulation', awaiting_approval: 'awaitingApproval', approved_simulation: 'settledSimulation', approved_undisbursed: 'approvedUndisbursed', repaid_simulation: 'repaidSimulation', matured_simulation: 'closed', in_review: 'investigating', in_progress: 'inProgress', cash_in: 'posted', active: 'active', synthetic_directory_match: 'directoryMatch', synthetic_account_match: 'accountMatch', synthetic_card_match: 'cardMatch', submitted_demo: 'submittedDemo', reviewed_demo: 'reviewedDemo', needs_info_demo: 'needsInfoDemo', closed_demo: 'closedDemo', format_only: 'formatOnly', format_checksum_only: 'formatChecksumOnly', luhn_format_only: 'luhnFormatOnly', checksum_invalid: 'checksumInvalid', format_invalid: 'formatInvalid', length_invalid: 'lengthInvalid', country_unsupported: 'countryUnsupported', not_found: 'notFound' })[status] || status;
return t(key) === key ? String(status || '—').replaceAll('_', ' ') : t(key);
}
function badge(status, extra = '') {
const normalized = String(status || 'neutral').toLowerCase();
const type = ['verified', 'active', 'online', 'posted', 'settled_simulation', 'approved_simulation', 'performing', 'repaid_simulation', 'closed', 'reviewed_demo'].includes(normalized) ? 'success'
: ['pending', 'expired', 'awaiting_approval', 'approved_undisbursed', 'investigating', 'warning', 'low', 'maintenance', 'submitted_demo', 'needs_info_demo'].includes(normalized) ? 'warning'
: ['high', 'blocked', 'restricted', 'escalated', 'cancelled', 'arrears', 'checksum_invalid', 'format_invalid', 'length_invalid', 'country_unsupported'].includes(normalized) ? 'danger'
: ['medium', 'edd', 'crypto', 'format_only', 'format_checksum_only', 'luhn_format_only'].includes(normalized) ? 'info' : 'neutral';
return ``;
}
function pageHeading(titleKey, subtitleKey, actions = '') {
const title = t(titleKey);
$('#crumbTitle').textContent = title;
const printAction = `${icon('reports')}${esc(t('printPage'))} `;
return `
${esc(t('safeLearning'))}
${esc(title)} ${esc(t(subtitleKey))}
${DEMO_BRAND} ${actions}${printAction}
`;
}
function renderWorkspaceContextBar() {
if (document.body.dataset.surface === 'client') return '';
const system = activeInteropSystem(state);
return `${esc(system.flag)} ${esc(t('interopContextBar'))} ${esc(system.name)} ${esc(system.currency)} · ${esc(system.city)} ${icon('exchange')}${esc(t('interopOpenWorkbench'))} `;
}
function renderInteropSystemSwitcher() {
const active = activeInteropSystem(state);
const cards = INTEROP_SYSTEMS.map(system => {
const isActive = system.id === active.id;
return `${esc(system.flag)} ${esc(isActive ? t('interopSystemActive') : t('interopFictionalSystem'))}
${esc(system.name)} ${esc(system.city)} · ${esc(system.country)}
${esc(t('interopSystemCurrency'))} ${esc(system.currency)} ${esc(t('interopSystemTimezone'))} ${esc(system.timezone)}
${esc(languageForSystemNote(system))}
${button('interop-system-select', isActive ? t('interopSystemActive') : t('interopSystemSwitch'), isActive ? 'small secondary' : 'small primary', isActive ? 'check' : 'exchange', `data-id="${esc(system.id)}" ${isActive ? 'disabled aria-current="true"' : ''}`)} `;
}).join('');
return ` ${esc(t('interopSystemProfile'))}${esc(t('interopSystemTitle'))} ${esc(t('interopSystemSub'))}
${esc(t('localOnly'))} ${cards}
i ${esc(t('interopSystemBoundary'))}
`;
}
function languageForSystemNote(system) {
return state.settings.language === 'hr' ? system.noteHr : system.noteEn;
}
function button(action, label, style = '', iconName = '', attrs = '') {
const staffConsole = document.body.dataset.surface !== 'client' && currentView !== 'portal';
const writeActions = ['new-customer', 'new-account', 'new-account-for', 'transfer', 'new-payment', 'incoming-payment', 'cancel-payment', 'crypto-send', 'crypto-receive', 'cancel-crypto', 'new-complaint', 'cash-operation', 'issue-card', 'new-deposit', 'new-loan', 'repay-loan', 'atm-withdrawal', 'new-swift', 'mature-savings', 'card-status', 'card-controls', 'card-load', 'card-purchase', 'card-cancel', 'reconcile-atms', 'new-interbank', 'new-support-ticket', 'support-status', 'account-conversion', 'toggle-background-simulation', 'onboarding-status', 'interop-connector-toggle', 'interop-connector-refresh', 'interop-feature-run', 'interop-system-select', 'interop-reset'];
const approvalActions = ['approve-loan', 'disburse-loan', 'approve-payment', 'approve-swift', 'approve-interbank', 'approve-crypto'];
const restricted = staffConsole && ((writeActions.includes(action) && !canWrite()) || (approvalActions.includes(action) && !canApprove()) || (action === 'reset-demo' && !canAdminister()));
const permissionAttrs = restricted ? `disabled title="${esc(!canWrite() ? t('roleReadOnly') : !canApprove() ? t('roleCheckerOnly') : t('roleAdminOnly'))}"` : '';
return `${iconName ? `${icon(iconName)} ` : ''}${esc(label)} `;
}
function panel(title, body, options = {}) {
const description = options.description ? `${esc(options.description)}
` : '';
const actions = options.actions ? `${options.actions}
` : '';
return ``;
}
function emptyState(text = t('noRecords')) { return `${esc(text)}
`; }
function pager(listName, page, count, pageSize = PAGE_SIZE) {
const pages = Math.max(1, Math.ceil(count / pageSize));
return ``;
}
function paged(items, listName, size = PAGE_SIZE) {
const filter = filterState[listName];
const pages = Math.max(1, Math.ceil(items.length / size));
if (filter.page > pages) filter.page = pages;
const from = (filter.page - 1) * size;
return { items: items.slice(from, from + size), footer: pager(listName, filter.page, items.length, size) };
}
function tableShell(headers, rows, footer = '', noRows = t('noRecords')) {
const body = rows.length ? rows.join('') : `${emptyState(noRows)} `;
return `${headers.map(value => `${esc(value)} `).join('')} ${body}
${footer}`;
}
function formatClient(customer, action = 'customer-open') {
if (!customer) return '— ';
return `${esc(initials(customer.name))} ${esc(customer.name)} ${esc(customer.id)} · ${esc(customer.kind === 'business' ? t('business') : t('personal'))} `;
}
function amountWithDirection(tx) {
const plus = tx.direction === 'in';
return `${plus ? '+' : '−'}${currency(tx.amountCents, tx.currency || 'EUR')} `;
}
function txLabel(tx) {
const map = {
internal: state.settings.language === 'hr' ? 'Interni prijenos' : 'Internal transfer',
cash_in: t('depositCash'), cash_out: t('withdrawCash'), atm: state.settings.language === 'hr' ? 'Podizanje na bankomatu' : 'ATM withdrawal',
savings_open: state.settings.language === 'hr' ? 'Oročena štednja' : 'Term savings placement',
savings_mature: state.settings.language === 'hr' ? 'Dospijeće štednje' : 'Savings maturity',
exchange: state.settings.language === 'hr' ? 'Zamjena valute / imovine' : 'FX / asset exchange',
sepa_out: state.settings.language === 'hr' ? 'SEPA plaćanje' : 'SEPA payment',
swift_out: state.settings.language === 'hr' ? 'Međunarodno plaćanje' : 'International payment',
card_purchase: state.settings.language === 'hr' ? 'Kartična kupnja' : 'Card purchase',
sepa_in: state.settings.language === 'hr' ? 'SEPA priljev' : 'SEPA credit transfer',
loan_disbursement: t('loanDisbursement'), loan_repayment: t('loanRepayment'),
salary: state.settings.language === 'hr' ? 'Isplata plaće' : 'Salary payment',
utility: state.settings.language === 'hr' ? 'Režijski račun' : 'Utility bill',
subscription: state.settings.language === 'hr' ? 'Pretplata' : 'Subscription',
merchant: state.settings.language === 'hr' ? 'Namira trgovca' : 'Merchant settlement',
fee: state.settings.language === 'hr' ? 'Naknada usluge' : 'Service fee',
refund: state.settings.language === 'hr' ? 'Povrat kartične transakcije' : 'Card refund',
standing: state.settings.language === 'hr' ? 'Trajni nalog' : 'Standing order',
crypto_in: state.settings.language === 'hr' ? 'Primitak kriptoimovine · simulacija' : 'Crypto receipt · simulation',
crypto_out: state.settings.language === 'hr' ? 'Slanje kriptoimovine · simulacija' : 'Crypto send · simulation'
};
return map[tx.type] || tx.label || tx.type;
}
function selectOptions(items, valueKey, labelFn, selected = '') {
return items.map(item => `${esc(labelFn(item))} `).join('');
}
function activeEurAccounts() { return state.accounts.filter(account => account.currency === 'EUR' && account.status === 'active'); }
function customerAccounts(customerId) { return state.accounts.filter(account => account.customerId === customerId); }
function accountLabel(account) {
const customer = customerById(account.customerId);
return `${account.id} · ${customer?.name || account.customerId} · ${account.currency} ${currency(account.balanceCents, account.currency)}`;
}
function prettyIban(value) {
const normalized = String(value || '').replaceAll(' ', '');
return normalized ? normalized.match(/.{1,4}/g).join(' ') : 'Domestic demo ID · no IBAN';
}
function prettyRoutingIdentifier(value) {
const normalized = String(value || '').trim();
return /^DEMO-[A-Z]{2}-/.test(normalized) ? normalized : prettyIban(normalized);
}
function accountRouteSummary(account) {
return `${prettyRoutingIdentifier(account.iban || account.accountNumber)} · ${account.bic || createDemoBic(account.countryCode || 'HR')} · DEMO only`;
}
function openPrintDocument(title, body) {
const root = $('#printRoot');
if (!root) { window.print(); return; }
root.innerHTML = `${body} `;
root.setAttribute('aria-hidden', 'false');
document.body.classList.add('printing-document');
const clear = () => {
root.innerHTML = '';
root.setAttribute('aria-hidden', 'true');
document.body.classList.remove('printing-document');
};
window.addEventListener('afterprint', clear, { once: true });
setTimeout(() => { try { window.print(); } catch { clear(); } }, 60);
setTimeout(clear, 60000);
}
function printStatement(customerId = portalCustomerId, accountId = '') {
const customer = customerById(customerId);
if (!customer) return;
const accounts = customerAccounts(customerId).filter(account => !accountId || account.id === accountId);
const transactions = state.transactions.filter(tx => tx.customerId === customerId && (!accountId || tx.accountId === accountId)).sort((a, b) => new Date(b.date) - new Date(a.date));
const accountRows = accounts.map(account => `${esc(account.id)} · ${esc(account.label)} ${esc(account.currency)} ${esc(accountRouteSummary(account))} ${esc(currency(account.balanceCents, account.currency))} `).join('');
const transactionRows = transactions.map(tx => `${esc(fmtDate(tx.date, true))} ${esc(tx.id)} · ${esc(txLabel(tx))} ${esc(tx.counterparty || tx.channel || '—')} ${esc(tx.reference || '—')} ${esc(labelStatus(tx.status))} ${tx.direction === 'in' ? '+' : '−'}${esc(currency(tx.amountCents, tx.currency || 'EUR'))} `).join('');
const title = t('accountStatement');
openPrintDocument(title, `${esc(title)} ${esc(customer.name)} · ${esc(customer.id)} · ${esc(customer.kind === 'business' ? t('business') : t('personal'))} · ${esc(customer.city)}, ${esc(customer.country)}
${DEMO_BRAND} — synthetic statement preview only. This is not a bank-issued document, proof of funds, account confirmation or live transaction history.
${esc(t('myAccounts'))} ${esc(t('account'))} ${esc(t('currency'))} ${esc(t('demoIbanOrDomesticId'))} / BIC · demo ${esc(t('balance'))} ${accountRows}
${esc(t('transactionHistory'))} · ${transactions.length} ${esc(t('date'))} ${esc(t('transaction'))} ${esc(t('counterparty'))} ${esc(t('reference'))} ${esc(t('status'))} ${esc(t('amount'))} ${transactionRows || `${esc(t('noTransactions'))} `}
Values are fictional local balances. Currencies without a configured reference are excluded from EUR-equivalent totals; no conversion is implied.
`);
}
function printTransaction(transactionId) {
const tx = state.transactions.find(item => item.id === transactionId);
if (!tx) return;
const customer = customerById(tx.customerId);
const account = accountById(tx.accountId);
openPrintDocument(t('transactionReceipt'), `${esc(t('transactionReceipt'))} ${esc(txLabel(tx))} · ${esc(tx.id)}
${DEMO_BRAND} — transaction rehearsal receipt only. No payment rail, cash dispenser, card network, blockchain or real funds were involved.
${esc(t('client'))} ${esc(customer?.name || tx.customerId)} · ${esc(customer?.id || '')} ${esc(t('account'))} ${esc(account?.id || tx.accountId || '—')} · ${esc(account?.currency || tx.currency || 'EUR')} ${esc(t('amount'))} ${esc(amountWithDirection(tx).replace(/<[^>]*>/g, ''))} ${esc(t('counterparty'))} ${esc(tx.counterparty || '—')} ${esc(t('reference'))} ${esc(tx.reference || '—')} ${esc(t('date'))} ${esc(fmtDate(tx.date, true))} ${esc(t('status'))} ${esc(labelStatus(tx.status))} ${esc(t('channel'))} ${esc(tx.channel || 'Local demo')}
`);
}
function printPayment(paymentId) {
const payment = state.payments.find(item => item.id === paymentId);
if (!payment) return;
const incoming = payment.direction === 'in';
openPrintDocument(incoming ? t('incomingPayment') : t('paymentInstruction'), `${esc(incoming ? t('incomingPayment') : t('paymentInstruction'))} ${esc(payment.id)} · ${esc(payment.rail)} · ${esc(labelStatus(payment.status))}
${DEMO_BRAND} — SEPA-shaped local rehearsal only. This is not a bank transfer instruction and was not transmitted or received.
${esc(t('client'))} ${esc(customerById(payment.customerId)?.name || payment.customerId)} ${esc(incoming ? t('sender') : t('beneficiary'))} ${esc(incoming ? payment.sender || 'Fictional remitter' : payment.beneficiary)} ${esc(t('country'))} ${esc(incoming ? payment.senderCountry || '—' : payment.beneficiaryCountry || '—')} ${esc(t('demoIbanOrDomesticId'))} · not routable ${esc(prettyRoutingIdentifier(incoming ? payment.senderIban : payment.beneficiaryIban))} BIC · demo only ${esc(incoming ? payment.senderBic || '—' : payment.beneficiaryBic || '—')} ${esc(t('amount'))} ${esc(currency(payment.amountCents, payment.currency))} ${esc(t('reference'))} ${esc(payment.reference)} ${esc(t('date'))} ${esc(fmtDate(payment.createdAt, true))}
`);
}
function printSwift(messageId) {
const message = state.swiftMessages.find(item => item.id === messageId);
if (!message) return;
openPrintDocument(t('swiftLab'), `${esc(t('swiftLab'))} · ${esc(message.type)} ${esc(message.id)} · ${esc(labelStatus(message.status))}
${DEMO_BRAND} — message-shaped draft only. No SWIFT, correspondent, screening, settlement or financial-messaging network is connected.
${esc(t('beneficiary'))} ${esc(message.beneficiary)} ${esc(t('country'))} ${esc(message.beneficiaryCountry || '—')} BIC · demo only ${esc(message.bic)} ${esc(t('demoIbanOrDomesticId'))} · not routable ${esc(prettyRoutingIdentifier(message.beneficiaryIban))} ${esc(t('amount'))} ${esc(currency(message.amountCents, message.currency))} ${esc(t('reference'))} ${esc(message.reference)}
Local-format preview · not transmitted ${esc(message.mt103 || '')} `);
}
function updateDemoRoutingDisplay(form) {
const country = form?.elements?.country?.value || 'HR';
const serial = Number(form?.elements?.accountSequence?.value || 1);
const iban = form?.elements?.iban || form?.elements?.senderIban;
const bic = form?.elements?.bic || form?.elements?.senderBic;
if (iban) iban.value = prettyRoutingIdentifier(createDemoIban(country, serial) || `DEMO-${country}-${String(serial).padStart(10, '0')}`);
const routeField = iban?.closest('.form-field');
if (routeField?.querySelector('label')) routeField.querySelector('label').textContent = t('demoIbanOrDomesticId');
if (bic) bic.value = createDemoBic(country);
}
function updateStaticText() {
document.documentElement.lang = state.settings.language || 'hr';
document.documentElement.dir = state.settings.language === 'ar' ? 'rtl' : 'ltr';
languageSelect.value = state.settings.language || 'hr';
$$('[data-i18n]').forEach(node => { node.textContent = t(node.dataset.i18n); });
$$('[data-i18n-placeholder]').forEach(node => { node.placeholder = t(node.dataset.i18nPlaceholder); });
const staffRole = currentStaffRole();
if ($('#operatorName')) $('#operatorName').textContent = state.settings.operator || staffRole.operator;
if ($('#operatorRole')) $('#operatorRole').textContent = `L${staffRole.level} · ${t(staffRole.label)}`;
if ($('.operator-profile .avatar')) $('.operator-profile .avatar').textContent = initials(state.settings.operator || staffRole.operator);
if ($('.top-avatar')) $('.top-avatar').textContent = initials(state.settings.operator || staffRole.operator);
const now = new Date();
const todayStamp = $('#todayStamp');
if (todayStamp) todayStamp.textContent = new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short' }).format(now);
if ($('#globalSearch')) $('#globalSearch').placeholder = t('searchPlaceholder');
}
function renderNavigation() {
const highPriorityCount = state.cases.filter(item => item.status !== 'closed').length;
const groups = visibleNavGroups();
nav.innerHTML = groups.map((group, groupIndex) => `${esc(t(group.label))}
${group.items.map(([view, labelKey, iconName]) => {
const badgeText = view === 'aml' && highPriorityCount ? `${Math.min(99, highPriorityCount)} ` : '';
return `${icon(iconName)} ${esc(t(labelKey))} ${badgeText} `;
}).join('')}${groupIndex < groups.length - 1 ? '
' : ''}`).join('');
}
function buildChart() {
const now = new Date();
const points = [];
for (let day = 13; day >= 0; day--) {
const date = new Date(now); date.setDate(now.getDate() - day);
const bucket = state.transactions.filter(tx => {
const txDate = new Date(tx.date);
return txDate.toDateString() === date.toDateString() && tx.status === 'posted';
});
const volume = bucket.reduce((sum, tx) => sum + tx.amountCents, 0) / 100;
points.push({ date, volume });
}
const max = Math.max(1800, ...points.map(item => item.volume));
const coords = points.map((item, index) => ({ x: 39 + index * 50, y: 131 - (item.volume / max) * 93 }));
const line = coords.map(point => `${point.x},${point.y}`).join(' ');
const area = `M ${coords[0].x} 139 L ${line.replaceAll(' ', ' L ')} L ${coords.at(-1).x} 139 Z`;
const labels = [0, 3, 6, 9, 13].map(index => {
const label = new Intl.DateTimeFormat(locale(), { day: 'numeric', month: 'short' }).format(points[index].date);
return `${esc(label)} `;
}).join('');
const grid = [35, 66, 97, 128].map(y => ` `).join('');
const last = coords.at(-1);
return ``;
}
function renderOverview() {
const now = new Date();
const dayStart = new Date(now); dayStart.setHours(0, 0, 0, 0);
const todayTransactions = state.transactions.filter(tx => new Date(tx.date) >= dayStart && tx.status === 'posted');
const todayCount = todayTransactions.length + state.payments.filter(payment => new Date(payment.createdAt) >= dayStart).length;
const posted30 = state.transactions.filter(tx => new Date(tx.date) >= new Date(now.getTime() - 30 * 86400000) && tx.status === 'posted');
const volume30 = posted30.reduce((sum, tx) => sum + tx.amountCents, 0);
const deposits = totalCustomerDepositsCents(state);
const liquidityEurCents = state.bankAccounts.reduce((sum, account) => {
const rate = CURRENCIES[account.currency]?.eurRate;
return Number.isFinite(rate) && rate > 0 ? sum + Math.round(account.balanceCents * rate) : sum;
}, 0);
const liquidityCents = liquidityEurCents;
const pendingKyc = state.customers.filter(customer => ['pending', 'expired', 'edd'].includes(customer.kyc)).length;
const openCases = state.cases.filter(item => item.status !== 'closed').length;
const activeCustomers = state.customers.filter(customer => customer.kyc === 'verified').length;
const companyLocation = state.meta.companyLocation || { city: 'Boulder', region: 'Colorado' };
const title = pageHeading('overview', 'dashSub', `${esc(t('demoOnly'))} ${esc(companyLocation.city)}, ${esc(companyLocation.region)} · listed address ${button('new-customer', t('newCustomer'), 'primary', 'plus')}`);
const metrics = `
${esc(t('totalDeposits'))} ${icon('savings')}
${esc(compactCurrency(deposits))}
${esc(t('paymentsToday'))} ${icon('payments')}
${todayCount}${esc(t('activity').toLowerCase())}
${esc(t('activeCustomers'))} ${icon('users')}
${activeCustomers}/ ${state.customers.length}
${esc(t('liquidity'))} ${icon('bank')}
${esc(compactCurrency(liquidityCents))}
`;
const activityRows = state.transactions.slice(0, 7).map(tx => {
const customer = customerById(tx.customerId);
return `${icon(tx.direction === 'in' ? 'arrow' : 'payments')} ${esc(txLabel(tx))} ${esc(customer?.name || tx.customerId)} · ${esc(tx.counterparty || tx.channel)}
${esc(amountWithDirection(tx))}${esc(fmtTime(tx.date))}
`;
}).join('');
const trend = `${esc(compactCurrency(volume30))} +8.4%
${esc(t('monthlyVolume'))} · ${esc(t('sampleValue').toLowerCase())}
${buildChart()}`;
const bankRows = state.bankAccounts.map((account, index) => {
const iconClass = index === 0 ? 'reserve' : '';
const label = index === 0 ? t('centralReserve') : index === 1 ? t('mainAccount') : account.label;
return `${icon(index === 0 ? 'bank' : 'account')} ${esc(label)} ${esc(account.bank)} · ${esc(account.ref)}
${esc(currency(account.balanceCents, account.currency))} `;
}).join('');
const branchPills = state.branches.map(branch => ` ${esc(branch.code)} · ${esc(branch.city)} `).join('');
const lower = `
${panel(t('mainAccount'), `
${esc(t('localSimulation'))} · EUR equivalent ${esc(t('cleared'))}
${esc(currency(liquidityCents))} ${esc(t('liquidityAvailable'))} ${bankRows.slice(0, 4)}
${branchPills}
`, { description: t('cashPositions'), actions: `
${esc(t('viewAll'))} → ` })}
${panel(t('quickActions'), `
${icon('users')} ${esc(t('openClient'))}${esc(t('customerRecord'))} ${icon('exchange')} ${esc(t('internalTransfer'))}${esc(t('localLedger'))} ${icon('cash')} ${esc(t('cashOperation'))}${esc(t('branchesCash'))} ${icon('card')} ${esc(t('issueCard'))}${esc(t('cards'))}
${icon('shieldCheck')} ${esc(t('openCases'))}${openCases} ${esc(t('openCase').toLowerCase())}
${esc(t('amlSub'))}
`, { description: t('localSimulation') })}
`;
return `${title}${metrics}${panel(t('monthlyVolume'), trend, { description: t('thisMonth'), actions: `
${esc(t('thisMonth'))} ${esc(t('thisWeek'))} ${esc(t('today'))}
` })}${panel(t('recentActivity'), `
${activityRows || emptyState(t('noTransactions'))}
`, { description: `${todayCount} ${t('processedToday').toLowerCase()}`, actions: `
${esc(t('viewAll'))} → ` })}
${lower}${renderAssistant('staff')}${panel(t('staffGuide'), renderProcedureGuide('staff'), { description: t('staffGuideSub') })}
`;
}
function assistantChat(audience, customerId = '') {
const key = audience === 'staff' ? 'staff' : `customer:${customerId}`;
if (!assistantChats.has(key)) assistantChats.set(key, []);
return assistantChats.get(key);
}
function renderAssistant(audience, customer = null) {
const messages = assistantChat(audience, customer?.id || '');
const promptList = audience === 'staff'
? [['assistantPromptAml', 'How should I review an AML alert?'], ['assistantPromptRates', 'Explain the ECB reference rates'], ['assistantPromptSwift', 'How do I prepare a SWIFT draft?']]
: [['assistantPromptBalance', 'Show my balances'], ['assistantPromptFx', 'How do I convert currency?'], ['assistantPromptCard', 'How can I lock a card?']];
const transcript = messages.length ? messages.slice(-6).map(message => `${message.role === 'user' ? esc(t('you')) : esc(t('localCopilot'))} ${esc(message.text)}
`).join('')
: `${esc(t('assistantWelcome'))}
`;
const body = `${transcript}
${promptList.map(([labelKey, question]) => `${esc(t(labelKey))} `).join('')}
${esc(t('assistantDisclaimer'))}
`;
return panel(t('assistantTitle'), body, { description: audience === 'staff' ? t('staffAssistantSub') : t('customerAssistantSub'), actions: 'LOCAL · RULE-BASED ' });
}
function renderProcedureGuide(audience) {
const steps = audience === 'staff'
? [['1 · Prepare', 'Use only fictional records; check role and maker/checker state.'], ['2 · Review', 'Use the synthetic AML/KYC queue; do not imply real screening.'], ['3 · Approve', 'Use the local demo checker; inspect the audit entry and ledger.'], ['4 · Explain', 'Tell the customer nothing was sent to a payment network.']]
: [['1 · Open', 'Choose a fictional profile and request a demo currency account.'], ['2 · Verify', 'Submit a synthetic KYC rehearsal; do not enter real ID details.'], ['3 · Manage', 'Try a local transfer, FX conversion, savings, card or credit flow.'], ['4 · Review', 'Check the activity history; no real funds move.']];
return `${steps.map(([title, text]) => `
${esc(title)} ${esc(text)}
`).join('')}
`;
}
function onboardingNextStep(status) {
const key = ({ submitted_demo: 'onboardingNextSubmitted', needs_info_demo: 'onboardingNextNeedsInfo', reviewed_demo: 'onboardingNextReviewed', closed_demo: 'onboardingNextClosed' })[status] || 'onboardingNextSubmitted';
return t(key);
}
function renderPortal() {
const customer = customerById(portalCustomerId) || state.customers[0];
if (!customer) return emptyState();
const accounts = customerAccounts(customer.id);
const eurBalance = accounts.filter(account => account.status === 'active').reduce((sum, account) => {
const eur = accountBalanceEur(state, account);
return eur === null ? sum : sum + Math.round(eur);
}, 0);
const cards = state.cards.filter(card => card.customerId === customer.id);
const loans = (state.loans || []).filter(loan => loan.customerId === customer.id);
const savingsDeposits = (state.deposits || []).filter(deposit => deposit.customerId === customer.id);
const transactions = state.transactions.filter(tx => tx.customerId === customer.id).slice(0, 6);
const pendingPayments = state.payments.filter(payment => payment.customerId === customer.id && payment.direction === 'out' && payment.status === 'awaiting_approval').slice(0, 4);
const customerCrypto = state.wallets.filter(wallet => wallet.customerId === customer.id && wallet.units > 0);
const customerComplaints = (state.complaints || []).filter(complaint => complaint.customerId === customer.id).slice(0, 3);
const customerOptions = state.customers.slice().sort((a, b) => a.name.localeCompare(b.name, locale())).map(item => `${esc(item.name)} · ${esc(item.id)} · ${esc(labelStatus(item.kyc))} `).join('');
const accountRows = accounts.length ? accounts.map(account => `${icon('account')} ${esc(account.label)} · ${esc(account.currency)} ${esc(accountRouteSummary(account))}
${esc(currency(account.balanceCents, account.currency))}${badge(account.status)}
`).join('') : emptyState(t('noAccounts'));
const cardRows = cards.length ? cards.slice(0, 4).map(card => {
const statusAction = ['active', 'locked'].includes(card.status) ? `${esc(card.status === 'active' ? t('lockCard') : t('unlockCard'))} ` : badge(card.status);
const loadAction = card.fundingType === 'prepaid' && card.status === 'active' ? `${esc(t('cardLoad'))} ` : '';
const purchaseAction = card.status === 'active' ? `${esc(t('simulateCardPurchase'))} ` : '';
const balance = card.fundingType === 'prepaid' ? ` · ${esc(t('prepaidBalance'))}: ${esc(currency(card.prepaidBalanceCents || 0))}` : '';
return `${icon('card')} ${esc(card.product)} · ${esc(t('cardTokenSuffix'))} ${esc(card.last4)} ${esc(t('status'))}: ${esc(labelStatus(card.status))} · ${esc(t('virtualTokenOnly'))}${balance}
${statusAction}${esc(t('cardControls'))} ${loadAction}${purchaseAction}
`;
}).join('') : emptyState(t('noRecords'));
const loanRows = loans.length ? loans.map(loan => `${icon('loan')} ${esc(loan.product)} · ${esc(currency(loan.outstandingCents))} ${esc(loan.id)} · ${esc(t('nextPayment'))}: ${esc(loan.nextPaymentAt ? fmtDate(loan.nextPaymentAt) : '—')}
${badge(loan.status)}
`).join('') : emptyState(t('noRecords'));
const savingsRows = savingsDeposits.length ? savingsDeposits.map(deposit => `${icon('savings')} ${esc(currency(deposit.principalCents))} · ${deposit.termMonths} ${esc(t('term').toLowerCase())} ${esc(deposit.id)} · ${esc(t('maturity'))}: ${esc(fmtDate(deposit.maturityAt))} · ${(deposit.annualRateBps / 100).toFixed(2)}% indicative
${badge(deposit.status)}
`).join('') : emptyState(t('noRecords'));
const activityRows = transactions.map(tx => `${icon(tx.direction === 'in' ? 'arrow' : 'payments')} ${esc(txLabel(tx))} ${esc(tx.counterparty || tx.channel)} · ${esc(fmtDate(tx.date, true))}
${esc(amountWithDirection(tx))}${esc(t('print'))}
`).join('');
const hasCurrencyPair = new Set(accounts.filter(account => account.status === 'active' && !CURRENCIES[account.currency]?.crypto).map(account => account.currency)).size > 1;
const actionButtons = `${icon('account')}${esc(t('openCurrencyAccount'))} ${icon('verify')}${esc(t('startDemoOnboarding'))} ${icon('exchange')}${esc(t('convertAccounts'))} ${icon('bank')}${esc(t('interbankTransfer'))} ${icon('shieldCheck')}${esc(t('startDemoKyc'))} ${icon('exchange')}${esc(t('newTransfer'))} ${icon('payments')}${esc(t('paySomeone'))} ${icon('arrow')}${esc(t('receivePayment'))} ${icon('loan')}${esc(t('applyForCredit'))} ${icon('savings')}${esc(t('newDeposit'))} ${icon('card')}${esc(t('manageCards'))} ${icon('globe')}${esc(t('openExchange'))} ${icon('arrow')}${esc(t('cryptoSend'))} ${icon('arrow')}${esc(t('cryptoReceive'))} ${icon('support')}${esc(t('contactSupport'))} ${icon('support')}${esc(t('newComplaint'))}
`;
const customerTickets = (state.supportTickets || []).filter(ticket => ticket.customerId === customer.id).slice(0, 3);
const ticketRows = customerTickets.length ? customerTickets.map(ticket => `${icon('support')} ${esc(ticket.subject)} ${esc(ticket.id)} · ${esc(fmtDate(ticket.createdAt, true))}
${badge(ticket.status)}
`).join('') : emptyState(t('noRecords'));
const demoApplications = (state.onboardingApplications || []).filter(application => application.customerId === customer.id).slice(0, 4);
const demoApplicationRows = demoApplications.length ? demoApplications.map(application => `${icon('verify')} ${esc(application.id)} · ${esc(t(application.reviewPath === 'enhanced_review_demo' ? 'onboardingEnhancedPath' : 'onboardingStandardPath'))} ${esc(fmtDate(application.submittedAt, true))} · ${esc(onboardingNextStep(application.status))}
${badge(application.status)}
`).join('') : emptyState(t('noOnboardingApplications'));
const onboardingPanel = panel(t('onlineOnboarding'), demoApplicationRows, { description: t('onboardingBoundary'), actions: `${esc(t('startDemoOnboarding'))} ` });
const pendingPaymentRows = pendingPayments.length ? pendingPayments.map(payment => `${icon('payments')} ${esc(payment.beneficiary)} · ${esc(currency(payment.amountCents, payment.currency))} ${esc(payment.id)} · ${esc(t('awaitingApproval'))} · not sent
${esc(t('stopPayment'))} `).join('') : emptyState(t('noPendingPayments'));
const cryptoRows = customerCrypto.length ? customerCrypto.slice(0, 4).map(wallet => `${esc(wallet.asset.slice(0, 2))} ${esc(wallet.asset)} · ${new Intl.NumberFormat(locale(), { maximumFractionDigits: CURRENCIES[wallet.asset]?.decimals || 2 }).format(wallet.units)} ${esc(t('digitalAssetNotice'))}
`).join('') : emptyState(t('noRecords'));
const complaintRows = customerComplaints.length ? customerComplaints.map(complaint => `${icon('support')} ${esc(complaint.subject)} ${esc(complaint.id)} · ${esc(complaint.customerVisibleResponse || t('complaintLocalOnly'))}
${badge(complaint.status)}
`).join('') : emptyState(t('noRecords'));
const standalonePortalLink = document.body.dataset.surface === 'client' ? '' : `${esc(t('openClientPortal'))} ↗ `;
return `${pageHeading('customerPortal', 'portalSub', `${standalonePortalLink}${esc(t('printStatement'))} ${esc(t('exportStatement'))} ${esc(t('demoOnly'))} `)} ADRIA BANK · ${esc(t('customerPortal'))}
${esc(t('welcomeBack'))}, ${esc(customer.name.split(' ')[0])} ${esc(customer.city)}, ${esc(customer.country)} · ${esc(customer.id)} · ${badge(customer.kyc)}
${esc(t('availableBalance'))} · ${esc(t('sampleValue'))} ${esc(compactCurrency(eurBalance))} ${accounts.filter(account => account.status === 'active').length} active multi-currency demo account(s) · ${esc(t('digitalFirstGoal'))}
${esc(t('selectProfile'))} ${customerOptions} ${icon('shieldCheck')}${esc(t('userPortalNotice'))} ${esc(t('noCustomerLogin'))}
${actionButtons}${esc(t('myAccounts'))} ${accounts.length} ${esc(t('allSynthetic'))}
${esc(t('myCards'))} ${cards.filter(card => card.status === 'active').length} ${esc(t('active'))}
${esc(t('myCredit'))} ${loans.filter(loan => ['performing','arrears'].includes(loan.status)).length} ${esc(t('loanBook'))}
${esc(t('myActivity'))} ${state.transactions.filter(tx => tx.customerId === customer.id).length} ${esc(t('localSimulation'))}
${panel(t('myAccounts'), accountRows, { description: t('accountsSub') })}${panel(t('myCredit'), loanRows, { description: t('loansSub') })}${panel(t('savings'), savingsRows, { description: t('savingsSub') })}
${panel(t('myCards'), cardRows, { description: t('cardsSub') })}${panel(t('myActivity'), `
${activityRows || emptyState(t('noTransactions'))}
`, { description: `${transactions.length} ${t('records')}` })}
${onboardingPanel}${panel(t('contactSupport'), ticketRows, { description: t('supportHistory') })}${panel(t('complaints'), complaintRows, { description: t('complaintDemoDisclosure'), actions: `${esc(t('newComplaint'))} ` })}${panel(t('wallets'), cryptoRows, { description: t('digitalAssetNotice'), actions: `${esc(t('cryptoSend'))} ` })}${panel(t('pendingPayments'), pendingPaymentRows, { description: t('stopBeforeApproval') })}${panel(t('customerGuide'), renderProcedureGuide('customer'), { description: t('digitalFirstGoal') })}
${state.settings.backgroundSimulationEnabled !== false ? `i ${esc(t('backgroundSafety'))} · ${esc(t('backgroundActive'))}
` : ''}${renderAssistant('customer', customer)}! ${esc(t('userPortalNotice'))}
`;
}
function renderCustomers() {
const f = filterState.customers;
const query = f.q.toLocaleLowerCase(locale());
const filtered = state.customers.filter(customer => {
const search = `${customer.id} ${customer.name} ${customer.city} ${customer.country} ${customer.email}`.toLocaleLowerCase(locale());
return (!query || search.includes(query)) && (f.kyc === 'all' || customer.kyc === f.kyc) && (f.risk === 'all' || customer.risk === f.risk);
}).sort((a, b) => a.name.localeCompare(b.name, locale()));
const page = paged(filtered, 'customers');
const rows = page.items.map(customer => `${formatClient(customer)} ${esc(customer.city)}, ${esc(customer.country)} ${esc(customer.segment)} ${badge(customer.risk)} ${badge(customer.kyc)} ${customerAccounts(customer.id).length} ${esc(fmtDate(customer.joinedAt))} ··· `);
const content = tableShell([t('client'), t('city'), t('segment'), t('risk'), t('kyc'), t('accounts'), t('date'), t('actions')], rows, page.footer);
const verified = state.customers.filter(customer => customer.kyc === 'verified').length;
const kycFollow = state.customers.length - verified;
return `${pageHeading('customers', 'customersSub', `${button('new-customer', t('newCustomer'), 'primary', 'plus')}${button('new-account', t('newAccount'), '', 'account')}`)}
${esc(t('totalClients'))} ${state.customers.length} ${esc(t('allSynthetic'))}
${esc(t('verified'))} KYC ${verified} ${Math.round(verified / Math.max(state.customers.length, 1) * 100)}%
${esc(t('kycPending'))} ${kycFollow} ${esc(t('nextReview'))}
${esc(t('openCases'))} ${state.cases.filter(item => item.status !== 'closed').length} ${esc(t('illustrative'))}
`;
}
function renderAccounts() {
const f = filterState.accounts;
const query = f.q.toLocaleLowerCase(locale());
const filtered = state.accounts.filter(account => {
const customer = customerById(account.customerId);
const text = `${account.id} ${account.accountNumber || ''} ${account.iban} ${account.label} ${customer?.name || ''} ${account.currency}`.toLocaleLowerCase(locale());
return (!query || text.includes(query)) && (f.status === 'all' || account.status === f.status) && (f.currency === 'all' || account.currency === f.currency);
});
const page = paged(filtered, 'accounts');
const rows = page.items.map(account => {
const customer = customerById(account.customerId);
return `${formatClient(customer)} ${esc(account.id)} ${esc(account.accountNumber || account.label)} ${esc(prettyRoutingIdentifier(account.iban || account.accountNumber))}${esc(account.identifierKind === 'invalid_demo_iban' ? 'deliberately invalid checksum' : 'domestic demo ID')} ${esc(account.bic || createDemoBic(account.countryCode))} ${esc(account.currency)}${CURRENCIES[account.currency]?.noEurReference ? `${esc(t('noReferenceRate'))} ` : ''} ${esc(currency(account.balanceCents, account.currency))} ${badge(account.status)} ${esc(fmtDate(account.openedAt))} ${esc(t('print'))} ··· `;
});
const balances = state.accounts.reduce((sum, account) => {
const eur = accountBalanceEur(state, account);
return eur === null ? sum : sum + Math.round(eur);
}, 0);
const nativeOnlyCount = state.accounts.filter(account => account.status === 'active' && accountBalanceEur(state, account) === null).length;
return `${pageHeading('accounts', 'accountsSub', `${button('new-account', t('newAccount'), 'primary', 'plus')}${button('transfer', t('internalTransfer'), '', 'exchange')}`)}
${esc(t('totalAccounts'))} ${state.accounts.length} ${esc(t('active'))}: ${state.accounts.filter(item => item.status === 'active').length}
${esc(t('totalDeposits'))} ${esc(compactCurrency(totalCustomerDepositsCents(state)))} ${esc(t('allSynthetic'))}
${esc(t('available'))} · EUR ${esc(compactCurrency(balances))} ${esc(t('localSimulation'))}
${esc(t('nativeOnlyPositions'))} ${nativeOnlyCount} ${esc(t('noReferenceRate'))}
${tableShell([t('client'), t('account'), t('demoIbanOrDomesticId'), 'BIC · DEMO', t('currency'), t('balance'), t('status'), t('date'), t('actions')], rows, page.footer)} `;
}
function renderPayments() {
const f = paymentTab === 'orders' ? filterState.payments : filterState.transactions;
const viewAction = `${esc(t('payments'))} ${esc(t('transactionHistory'))}
`;
let body;
if (paymentTab === 'orders') {
const query = f.q.toLocaleLowerCase(locale());
const filtered = state.payments.filter(payment => {
const customer = customerById(payment.customerId);
const text = `${payment.id} ${payment.beneficiary} ${payment.sender || ''} ${payment.beneficiaryIban} ${payment.reference} ${customer?.name || ''}`.toLocaleLowerCase(locale());
return (!query || text.includes(query)) && (f.status === 'all' || payment.status === f.status);
});
const page = paged(filtered, 'payments');
const rows = page.items.map(payment => {
const customer = customerById(payment.customerId);
const incoming = payment.direction === 'in';
const counterparty = incoming ? payment.sender || payment.beneficiary : payment.beneficiary;
const route = incoming ? payment.senderIban : payment.beneficiaryIban;
const country = incoming ? payment.senderCountry : payment.beneficiaryCountry;
const approveAction = payment.status === 'awaiting_approval' && canApprove() ? `${esc(incoming ? t('approveIncoming') : t('approve'))} ` : '';
const stopAction = !incoming && payment.status === 'awaiting_approval' && canWrite() ? `${esc(t('stopPayment'))} ` : '';
return `${esc(payment.id)} ${esc(payment.reference)} ${esc(incoming ? t('incoming') : t('outgoing'))} ${formatClient(customer)} ${esc(counterparty)}${esc(country || '—')} · ${esc(prettyRoutingIdentifier(route))} ${esc(incoming ? payment.senderBic || '—' : payment.beneficiaryBic || '—')} · DEMO BIC ${esc(payment.rail)} ${esc(currency(payment.amountCents, payment.currency))} ${badge(payment.status)} ${esc(fmtDate(payment.createdAt))} ${approveAction}${stopAction}${esc(t('print'))} `;
});
body = `${tableShell([t('reference'), t('direction'), t('client'), t('beneficiary'), t('rail'), t('amount'), t('status'), t('date'), t('actions')], rows, page.footer)}i ${esc(t('approvalNotice'))} ${esc(t('incomingPaymentDisclosure'))}
`;
} else {
const query = f.q.toLocaleLowerCase(locale());
const filtered = state.transactions.filter(tx => {
const customer = customerById(tx.customerId);
const text = `${tx.id} ${txLabel(tx)} ${tx.counterparty} ${tx.reference} ${customer?.name || ''} ${tx.channel}`.toLocaleLowerCase(locale());
return (!query || text.includes(query)) && (f.type === 'all' || tx.type === f.type);
});
const page = paged(filtered, 'transactions');
const rows = page.items.map(tx => `${esc(tx.id)} ${esc(tx.reference || '—')} ${formatClient(customerById(tx.customerId))} ${esc(txLabel(tx))} ${esc(tx.channel || '—')} ${esc(tx.counterparty || '—')} ${amountWithDirection(tx)} ${badge(tx.status)} ${esc(fmtDate(tx.date, true))} ${esc(t('print'))} `);
const typeOptions = [...new Set(state.transactions.map(tx => tx.type))];
body = `${tableShell([t('transaction'), t('client'), t('category'), t('counterparty'), t('amount'), t('status'), t('date'), t('actions')], rows, page.footer)}`;
}
const settled = state.payments.filter(payment => ['settled_simulation','approved_simulation'].includes(payment.status)).length;
const incomingCount = state.payments.filter(payment => payment.direction === 'in').length;
const awaiting = state.payments.filter(payment => payment.status === 'awaiting_approval').length;
return `${pageHeading('payments', 'paymentsSub', `${button('transfer', t('internalTransfer'), '', 'exchange')}${button('new-payment', t('newPayment'), '', 'plus')}${button('incoming-payment', t('receivePayment'), 'primary', 'arrow')}`)}${esc(t('payments'))} ${state.payments.length} ${esc(t('allSynthetic'))}
${esc(t('receivePayment'))} ${incomingCount} ${esc(t('noExternalFunds'))}
${esc(t('awaitingApproval'))} ${awaiting} ${esc(t('approvalRequired'))}
${esc(t('settledSimulation'))} ${settled} ${esc(t('localSimulation'))}
`;
}
function renderCards() {
const f = filterState.cards;
const query = f.q.toLocaleLowerCase(locale());
const filtered = state.cards.filter(card => {
const customer = customerById(card.customerId);
return (!query || `${card.id} ${card.product} ${customer?.name || ''} ${card.last4}`.toLocaleLowerCase(locale()).includes(query)) && (f.status === 'all' || card.status === f.status);
});
const page = paged(filtered, 'cards');
const rows = page.items.map(card => {
const customer = customerById(card.customerId);
const activeOrLocked = ['active', 'locked'].includes(card.status);
const nextStatus = card.status === 'active' ? 'locked' : 'active';
const statusAction = activeOrLocked ? `${esc(card.status === 'active' ? t('lockCard') : t('unlockCard'))} ` : '';
const controlsAction = activeOrLocked ? `${esc(t('cardControls'))} ` : '';
const loadAction = card.fundingType === 'prepaid' && card.status === 'active' ? `${esc(t('cardLoad'))} ` : '';
const purchaseAction = card.status === 'active' ? `${esc(t('simulateCardPurchase'))} ` : '';
const cancelAction = activeOrLocked ? `${esc(t('closeCard'))} ` : '';
const controls = card.controls || {};
const actionCell = canWrite() ? `${statusAction}${controlsAction}${loadAction}${purchaseAction}${cancelAction}
` : `${esc(t('roleReadOnly'))} `;
const limits = `${currency(card.dailyLimitCents)} / ${currency(card.monthlyLimitCents || 0)}`;
const flags = `${t('onlinePayments')}: ${t(controls.onlineEnabled === false ? 'disabled' : 'enabled')} · ${t('internationalPayments')}: ${t(controls.internationalEnabled ? 'enabled' : 'disabled')} · ${t('recurringPayments')}: ${t(controls.recurringEnabled === false ? 'disabled' : 'enabled')}`;
return `${formatClient(customer)} ${esc(card.product)} ${esc(card.formFactor === 'virtual' ? t('virtualTokenOnly') : card.formFactor)} DEMO · ${esc(card.last4)} ${esc(accountById(card.accountId)?.id || '—')} ${esc(limits)} ${card.fundingType === 'prepaid' ? `${esc(currency(card.prepaidBalanceCents || 0))} ` : '—'} ${badge(card.status)} ${esc(flags)} ${esc(fmtDate(card.issuedAt))} ${actionCell} `;
});
const active = state.cards.filter(card => card.status === 'active').length;
const locked = state.cards.filter(card => card.status === 'locked').length;
const prepaidValue = state.cards.reduce((sum, card) => sum + (card.fundingType === 'prepaid' ? card.prepaidBalanceCents || 0 : 0), 0);
const prepaidCount = state.cards.filter(card => card.fundingType === 'prepaid').length;
const stats = `${esc(t('cardProducts'))} ${state.cards.length} ${esc(t('virtualTokenOnly'))}
${esc(t('active'))} ${active} ${esc(t('statusChanged'))}
${esc(t('locked'))} ${locked} ${esc(t('localCardControls'))}
${esc(t('prepaidBalance'))} · ${prepaidCount} ${esc(currency(prepaidValue))} ${esc(t('localSimulation'))}
`;
const cardPreview = `ADRIA BANK · ${esc(t('virtualToken'))} DEMO
${esc(t('demoToken'))} · 4817
${esc(t('syntheticData'))} ${esc(t('noRealCardDetails'))}
adriabank
${esc(t('demoOnly'))} ${esc(t('virtualTokenOnly'))}
`;
return `${pageHeading('cards', 'cardsSub', `${button('issue-card', t('newCard'), 'primary', 'plus')}${button('card-purchase', t('simulateCardPurchase'), '', 'card')}`)}${stats}${tableShell([t('client'), t('product'), t('cardTokenSuffix'), t('account'), `${t('cardDailyLimit')} / ${t('cardMonthlyLimit')}`, t('prepaidBalance'), t('status'), t('cardUsageControls'), t('date'), t('actions')], rows, page.footer)} ${panel(t('productCard'), cardPreview, { description: t('localSimulation') })}
i ${esc(t('cardWorkflowBoundary'))}
`;
}
function renderSavings() {
const f = filterState.savings;
const query = f.q.toLocaleLowerCase(locale());
const filtered = state.deposits.filter(deposit => {
const customer = customerById(deposit.customerId);
return (!query || `${deposit.id} ${customer?.name || ''}`.toLocaleLowerCase(locale()).includes(query)) && (f.status === 'all' || deposit.status === f.status);
});
const page = paged(filtered, 'savings');
const rows = page.items.map(deposit => {
const customer = customerById(deposit.customerId);
const interest = Math.round(deposit.principalCents * deposit.annualRateBps / 10000 * deposit.termMonths / 12);
const matureButton = deposit.status === 'active' && canWrite() ? `${esc(t('mature'))} ` : '';
return `${esc(deposit.id)} ${formatClient(customer)} ${esc(currency(deposit.principalCents))} ${deposit.termMonths} ${esc(t('months3').split(' ')[1] || 'mo.')} ${(deposit.annualRateBps / 100).toFixed(2)}% ${esc(currency(interest))} ${esc(fmtDate(deposit.maturityAt))} ${badge(deposit.status)} ${matureButton} `;
});
const activeTotal = state.deposits.filter(item => item.status === 'active').reduce((sum, item) => sum + item.principalCents, 0);
const next = state.deposits.filter(item => item.status === 'active').sort((a,b) => new Date(a.maturityAt) - new Date(b.maturityAt)).slice(0, 4);
return `${pageHeading('savings', 'savingsSub', button('new-deposit', t('newDeposit'), 'primary', 'plus'))}${esc(t('termDeposits'))} ${state.deposits.filter(item => item.status === 'active').length} ${esc(t('active'))}
${esc(t('totalDeposits'))} ${esc(compactCurrency(activeTotal))} ${esc(t('allSynthetic'))}
${esc(t('maturitySoon'))} ${next.length} ${esc(t('sevenDays'))}
${esc(t('rate'))} 1.45–2.25% ${esc(t('sampleValue'))}
${tableShell([t('reference'), t('client'), t('amount'), t('term'), t('rate'), 'Indicative interest', t('maturity'), t('status'), t('actions')], rows, page.footer)} ${panel(t('maturitySoon'), next.length ? `
${next.map(item => `
${esc(customerById(item.customerId)?.name || item.customerId)} · ${esc(currency(item.principalCents))} ${esc(item.id)} · ${item.termMonths} ${esc(t('term').toLowerCase())}
${esc(fmtDate(item.maturityAt))} `).join('')}
` : emptyState(t('noRecords')), { description: t('illustrative') })}
! Indicative demo rate only. No customer contract, disclosure or deposit guarantee is created by this sandbox.
`;
}
function renderLoans() {
const f = filterState.loans;
const query = f.q.toLocaleLowerCase(locale());
const loanRows = state.loans || [];
const filtered = loanRows.filter(loan => {
const customer = customerById(loan.customerId);
const text = `${loan.id} ${loan.product} ${loan.purpose} ${customer?.name || ''} ${loan.customerId}`.toLocaleLowerCase(locale());
return (!query || text.includes(query)) && (f.status === 'all' || loan.status === f.status);
});
const page = paged(filtered, 'loans');
const rows = page.items.map(loan => {
const customer = customerById(loan.customerId);
let action = '';
if (loan.status === 'awaiting_approval' && canApprove()) action = `${esc(t('approveLoan'))} `;
else if (loan.status === 'approved_undisbursed' && canApprove()) action = `${esc(t('disburseLoan'))} `;
else if (['performing', 'arrears'].includes(loan.status) && canWrite()) action = `${esc(t('repayLoan'))} `;
return `${esc(loan.id)} ${esc(loan.product)} ${formatClient(customer)} ${esc(currency(loan.principalCents))} ${esc(currency(loan.outstandingCents))} ${(loan.annualRateBps / 100).toFixed(2)}% ${loan.nextPaymentAt ? esc(fmtDate(loan.nextPaymentAt)) : '—'} ${badge(loan.status)} ${action} `;
});
const funded = loanRows.filter(loan => ['performing', 'arrears'].includes(loan.status));
const bookCents = funded.reduce((sum, loan) => sum + loan.outstandingCents, 0);
const pendingCount = loanRows.filter(loan => ['awaiting_approval', 'approved_undisbursed'].includes(loan.status)).length;
const arrearsCents = funded.filter(loan => loan.status === 'arrears').reduce((sum, loan) => sum + loan.outstandingCents, 0);
const statusOptions = ['awaiting_approval', 'approved_undisbursed', 'performing', 'arrears', 'repaid_simulation'];
const sidePanel = `${esc(t('fundedLoans'))} ${funded.length}
${esc(t('awaitingApproval'))} ${pendingCount}
${esc(t('arrears'))} ${esc(currency(arrearsCents))}
${esc(t('settlementAccount'))} EUR · local demo
${esc(t('loanDisclosure'))}
`;
return `${pageHeading('loans', 'loansSub', button('new-loan', t('newLoan'), 'primary', 'plus'))}${esc(t('loanBook'))} ${esc(compactCurrency(bookCents))} ${esc(t('syntheticBalance'))}
${esc(t('fundedLoans'))} ${funded.length} ${esc(t('performing'))} · ${funded.filter(loan => loan.status === 'performing').length}
${esc(t('loanApplications'))} ${loanRows.filter(loan => loan.status === 'awaiting_approval').length} ${esc(t('awaitingApproval'))}
${esc(t('arrears'))} ${esc(compactCurrency(arrearsCents))} ${funded.filter(loan => loan.status === 'arrears').length} ${esc(t('loanCases'))}
${tableShell([t('reference'), t('client'), t('principal'), t('outstanding'), t('rate'), t('nextPayment'), t('status'), t('actions')], rows, page.footer)} ${panel(t('creditPortfolio'), sidePanel, { description: t('indicativeCredit') })}
i ${esc(t('loanAccountingNote'))}
`;
}
function renderBranches() {
const totalCash = state.branches.reduce((sum, branch) => sum + branch.cashCents, 0);
const activeAtms = state.atms.filter(atm => atm.status === 'online').length;
const branchCards = state.branches.map(branch => {
const atms = state.atms.filter(atm => atm.branchId === branch.id);
const availableCash = branch.cashCents;
return `${icon('branch')} ${esc(t('demoOnly'))}
${esc(branch.name)} ${esc(branch.code)} · ${esc(branch.city)}, ${esc(branch.country || 'Croatia')} · ${esc(branch.currency || 'EUR')} / ${esc((branch.language || 'hr').toUpperCase())}
`;
}).join('');
const marketCards = (state.plannedMarkets || []).map(market => {
const targetLanguage = supportedLanguages.find(language => language.code === market.language);
const languageName = targetLanguage?.label.replace(/^[A-Z]{2}\s+/, '') || market.language;
return `${esc(market.flag)} ${esc(t('planningOnly'))}
${esc(market.city)} ${esc(market.country)} · ${esc(market.market)}
${esc(t('marketPhase'))} ${esc(market.phase)}
`;
}).join('');
const companyLocation = state.meta.companyLocation || { ...COMPANY_PUBLIC_ADDRESS };
const headquartersCard = `🇺🇸 ${esc(t('publicCompanyAddress'))}
${esc(companyLocation.city)}, ${esc(companyLocation.region)} ${esc(companyLocation.street)} · ${esc(companyLocation.city)}, ${esc(companyLocation.region)} ${esc(companyLocation.postalCode)}
${esc(t('companyAddressListedBy'))} GNK DINAMO Ltd. website · FINA Info.BIZ
`;
const bankPos = `${state.branches.map(branch => `
${esc(branch.name)} ${esc(currency(branch.cashCents))}
`).join('')}
`;
return `${pageHeading('branchesCash', 'branchesSub', `${button('cash-operation', t('cashOperation'), 'primary', 'cash')}${button('transfer', t('internalTransfer'), '', 'exchange')}`)}${esc(t('simulatedBranches'))} ${state.branches.length} ${esc(t('syntheticBranch'))}
${esc(t('branchCash'))} ${esc(compactCurrency(totalCash))} ${esc(t('sampleValue'))}
${esc(t('machine'))} ${state.atms.length} ${activeAtms} ${esc(t('online').toLowerCase())} · demo
${esc(t('teller'))} desks ${state.branches.reduce((sum, branch) => sum + branch.tellers, 0)} ${esc(t('controlsNotWired'))}
${esc(t('simulatedBranches'))} · ${state.branches.length} ${branchCards}
${panel(t('cashCoverage'), bankPos, { description: t('branchCashNote') })}
i Cash counter actions post to the synthetic demo ledger only. Teller identity, dual control, counterfeit detection and end-of-day cash balancing are not implemented controls.
! ${esc(t('marketNotice'))}
${headquartersCard}${marketCards}
`;
}
function renderAtms() {
const totalCash = state.atms.reduce((sum, atm) => sum + atm.cashCents, 0);
const atms = state.atms.map(atm => {
const branch = state.branches.find(item => item.id === atm.branchId);
const statusClass = atm.status === 'online' ? 'success' : atm.status === 'low' ? 'warning' : atm.status === 'maintenance' ? 'neutral' : 'danger';
const action = atm.status === 'online' ? `${esc(t('simulateWithdrawal'))} ` : '';
return `${icon('atm')} ${esc(atm.name)} ${esc(atm.location)} · ${esc(branch?.code || '')}
${esc(currency(atm.cashCents))} ${esc(t(atm.status === 'low' ? 'warning' : atm.status))}
${action}
`;
}).join('');
return `${pageHeading('atmNetwork', 'atmSub', button('atm-withdrawal', t('simulateWithdrawal'), 'primary', 'cash'))}${esc(t('machine'))} ${state.atms.length} ${esc(t('allSynthetic'))}
${esc(t('online'))} ${state.atms.filter(atm => atm.status === 'online').length} ${esc(t('statusChanged'))}
${esc(t('atmCash'))} ${esc(compactCurrency(totalCash))} ${esc(t('sampleValue'))}
${esc(t('warning'))} ${state.atms.filter(atm => atm.status === 'low').length} ${esc(t('maintenance'))}: ${state.atms.filter(atm => atm.status === 'maintenance').length}
${atms}
${panel(t('reconciliation'), `
${esc(t('cash'))} · fleet ${esc(currency(totalCash))}
${esc(t('online'))} ${state.atms.filter(atm => atm.status === 'online').length} / ${state.atms.length}
${esc(t('lastCheck'))} ${esc(fmtTime(new Date().toISOString()))}
${icon('shieldCheck')}${esc(t('reconcile'))} `, { description: t('controlsNotWired') })}
! ATM switch, HSM, card processor, cash cassette sensors and physical dispensing are not connected. This action is a ledger rehearsal only.
`;
}
function renderAml() {
const openCases = state.cases.filter(item => item.status !== 'closed');
const highCount = openCases.filter(item => item.severity === 'high').length;
const kycDue = state.customers.filter(customer => customer.kyc !== 'verified');
const tabBar = `${esc(t('openCases'))} (${openCases.length}) KYC / onboarding (${kycDue.length})
`;
let body;
if (amlTab === 'cases') {
const f = filterState.cases;
const query = f.q.toLocaleLowerCase(locale());
const filtered = state.cases.filter(item => {
const customer = customerById(item.customerId);
const text = `${item.id} ${item.title} ${customer?.name || ''} ${item.transactionId || ''}`.toLocaleLowerCase(locale());
return item.status !== 'closed' && (!query || text.includes(query)) && (f.severity === 'all' || item.severity === f.severity) && (f.status === 'all' || item.status === f.status);
});
const page = paged(filtered, 'cases');
const rows = page.items.map(item => {
const customer = customerById(item.customerId);
const tx = state.transactions.find(entry => entry.id === item.transactionId);
return `${esc(item.id)} ${esc(fmtDate(item.createdAt, true))} ${formatClient(customer)} ${esc(item.title)} ${esc(tx ? `${txLabel(tx)} · ${currency(tx.amountCents)}` : 'Synthetic scenario')} ${badge(item.severity)} ${badge(item.status)} ${esc(item.assignedTo || '—')} ${esc(t('review'))} `;
});
body = `${tableShell([t('reference'), t('client'), t('review'), t('priority'), t('score'), t('status'), t('assigned'), t('actions')], rows, page.footer)}`;
} else {
const f = filterState.customers;
const filtered = kycDue.filter(customer => !f.q || `${customer.id} ${customer.name} ${customer.city}`.toLocaleLowerCase(locale()).includes(f.q.toLocaleLowerCase(locale()))).sort((a,b) => a.kyc.localeCompare(b.kyc));
const page = paged(filtered, 'customers');
const rows = page.items.map(customer => `${formatClient(customer)} ${esc(customer.city)}, ${esc(customer.country)} ${badge(customer.risk)} ${badge(customer.kyc)} ${esc(customer.kycRequest?.sourceOfFunds || customer.sourceOfFunds)} ${customer.kind === 'business' ? esc(customer.beneficialOwner || t('noData')) : '—'} ${esc(t('review'))} `);
body = `${tableShell([t('client'), t('city'), t('risk'), t('kyc'), t('sourceFunds'), t('beneficialOwner'), t('actions')], rows, page.footer)}`;
}
const rules = `${['amlRule1','amlRule2','amlRule3','amlRule4','amlRule5','amlRule6'].map((key, i) => `
${i + 1}. ${esc(t(key))} ${esc(t('illustrative'))} · AMLR (EU) 2024/1624 ${badge(i < 2 ? 'simulated' : 'notConnected')}
`).join('')}
`;
return `${pageHeading('amlKyc', 'amlSub', `${button('view-compliance', t('compliance'), '', 'shield')}`)}${esc(t('openCases'))} ${openCases.length} ${esc(t('illustrative'))}
${esc(t('high'))} priority ${highCount} ${esc(t('review'))}
KYC ${esc(t('pending'))} ${kycDue.length} ${esc(t('nextReview'))}
${esc(t('screening'))} ${esc(t('notConnected'))} ${esc(t('controlsNotWired'))}
${panel(t('compliance'), rules, { description: t('complianceBody') })}
! Alerts are synthetic test cases. This is not transaction monitoring, sanctions screening, PEP screening, a suspicious-transaction reporting system or an MLRO workflow.
EU AML package AMLR application date: 10 Jul 2027
`;
}
function renderSwift() {
const waiting = state.swiftMessages.filter(message => message.status === 'awaiting_approval').length;
const nostro = state.bankAccounts.filter(account => account.glCode === '1200').reduce((sum, account) => {
const rate = CURRENCIES[account.currency]?.eurRate;
return Number.isFinite(rate) && rate > 0 ? sum + account.balanceCents * rate : sum;
}, 0);
const rows = state.swiftMessages.map(message => {
const customer = customerById(message.customerId);
const approve = message.status === 'awaiting_approval' && canApprove() ? `${esc(t('approve'))} ` : '';
return `${esc(message.id)} ${esc(message.type)} · ${esc(message.direction)} ${formatClient(customer)} ${esc(message.beneficiary)}${esc(message.bic || '—')} ${esc(currency(message.amountCents, message.currency))} ${badge(message.status)} ${esc(fmtDate(message.createdAt, true))} ${approve}${esc(t('print'))} ··· `;
});
const mainBankAccount = state.bankAccounts.find(account => account.id === 'BK-002');
const list = `${esc(t('mainAccount'))} ${esc(currency(mainBankAccount?.balanceCents || 0))} ${esc(mainBankAccount?.ref || '')} · DEMO IBAN
${esc(t('nostro'))} ${esc(currency(Math.round(nostro * 100)))} ${state.bankAccounts.filter(account => account.glCode === '1200').length} sandbox positions
${esc(t('awaitingApproval'))} ${waiting} ${esc(t('controlsNotWired'))}
`;
return `${pageHeading('swiftLab', 'swiftSub', button('new-swift', t('newSwift'), 'primary', 'plus'))}! ${esc(t('swiftDisclaimer'))}. MT103 / ISO 20022 examples below are local rehearsal data and are never transmitted.
${panel(t('mainAccount'), list, { description: t('swiftFlow') })}${panel(t('swiftLab'), `
${esc(t('swiftMain'))} ${esc(mainBankAccount?.ref || '')}
${esc(t('swiftNostro'))} · EUR ${esc(state.bankAccounts.find(item => item.id === 'BK-003')?.ref || '')}
${esc(t('swiftNostro'))} · USD ${esc(state.bankAccounts.find(item => item.id === 'BK-004')?.ref || '')}
${esc(t('approvalRequired'))} `, { description: t('localSimulation') })}
${tableShell([t('reference'), t('client'), t('beneficiary'), t('amount'), t('status'), t('date'), t('actions')], rows)} ${panel('Message path', `
1 · ${esc(t('message'))} ${esc(t('simulated'))}
2 · ${esc(t('screening'))} / sanctions ${esc(t('notConnected'))}
3 · ${esc(t('approvals'))} · dual control ${esc(t('required'))}
4 · Network transmission ${esc(t('notConnected'))}
`, { description: t('controlsNotWired') })}${panel('ISO 20022 / SWIFT', `
This lab helps rehearse message fields, beneficiary validation, approvals and reconciliation concepts. A production integration needs authorised bank participation, connectivity, screening, reconciliation, security controls, business continuity and scheme-specific testing.
${esc(t('localOnly'))} MT103 format preview · pacs.008 sample records
`)}
`;
}
function fiatAssets() { return Object.keys(CURRENCIES).filter(code => code !== 'EUR' && !CURRENCIES[code].crypto && Number.isFinite(CURRENCIES[code].eurRate) && CURRENCIES[code].eurRate > 0 && CURRENCIES[code].exchangeEnabled !== false); }
function accountCurrencies() { return Object.keys(CURRENCIES).filter(code => code !== 'EUR' && !CURRENCIES[code].crypto); }
function digitalAssets() { return Object.keys(CURRENCIES).filter(code => CURRENCIES[code].crypto); }
function assetName(asset) {
const key = asset.toLowerCase();
return t(key) === key ? CURRENCIES[asset]?.name || asset : t(key);
}
function rateBoard(assets) {
return `${assets.map(asset => {
const item = CURRENCIES[asset];
const fiatRate = item.crypto ? item.eurRate : 1 / item.eurRate;
const rateDate = item.referenceDate || (item.referenceSource === 'CBK' ? CBK_FALLBACK_DATE : ECB_FALLBACK_DATE);
const feedStatus = item.referenceSource === 'CBK' ? fxReferenceData.cbk.status : fxReferenceData.ecb.status;
const rateStatus = item.referenceStatus || (String(feedStatus).startsWith('bundled-sample') ? feedStatus : 'bundled-sample');
const feed = item.crypto ? 'Offline illustrative sample' : item.referenceSource === 'CBK' ? `CBK daily indicative · ${rateDate} · ${rateStatus}` : item.referenceSource?.includes('peg') ? `ECB daily + USD/AED peg · ${rateDate} · ${rateStatus}` : `ECB daily · ${rateDate} · ${rateStatus}`;
return `
${esc(asset.slice(0, 2))} ${esc(assetName(asset))} ${esc(asset)}
${item.crypto ? esc(currencyMajor(item.eurRate, 'EUR', 2)) : `1 EUR = ${new Intl.NumberFormat(locale(), { maximumFractionDigits: ['JPY','KRW','ISK'].includes(asset) ? 2 : 4 }).format(fiatRate)} ${esc(asset)}`} ${esc(feed)} · reference only, not a transaction quote `;
}).join('')}
`;
}
function renderOptionalMarketFeed() {
let statusText = t('marketFeedIdle');
let resultMarkup = '';
if (optionalMarketFeed.status === 'loading') statusText = t('marketFeedLoading');
if (optionalMarketFeed.status === 'unconfigured') statusText = t('marketFeedNoKey');
if (optionalMarketFeed.status === 'unavailable') statusText = t('marketFeedUnavailable');
if (optionalMarketFeed.status === 'ready' && optionalMarketFeed.reference) {
const reference = optionalMarketFeed.reference;
statusText = t('marketFeedReturned');
resultMarkup = `1 EUR = ${new Intl.NumberFormat(locale(), { minimumFractionDigits: 4, maximumFractionDigits: 6 }).format(reference.rate)} USD ${esc(t('marketFeedAsOf'))}: ${esc(fmtDate(reference.timestamp, true))} · ${esc(reference.provider || 'Twelve Data')}
`;
}
const disabled = optionalMarketFeed.status === 'loading' ? 'disabled' : '';
return `${esc(statusText)}
${resultMarkup}i ${esc(t('marketFeedBoundary'))} ${esc(t('marketFeedSetup'))}
`;
}
async function refreshOptionalMarketFeed() {
if (optionalMarketFeedPromise) return optionalMarketFeedPromise;
optionalMarketFeed = { status: 'loading', reference: null, message: '' };
if (currentView === 'exchange') renderView();
optionalMarketFeedPromise = (async () => {
try {
let result;
if (typeof window.adriaDemo?.getOptionalMarketReference === 'function') {
result = await window.adriaDemo.getOptionalMarketReference();
} else {
const response = await fetch('/api/market/eur-usd', { cache: 'no-store', credentials: 'same-origin' });
result = await response.json();
}
optionalMarketFeed = result?.ok
? { status: 'ready', reference: result, message: '' }
: { status: result?.configured === false ? 'unconfigured' : 'unavailable', reference: null, message: result?.message || '' };
} catch {
optionalMarketFeed = { status: 'unavailable', reference: null, message: '' };
} finally {
optionalMarketFeedPromise = null;
if (currentView === 'exchange') renderView();
}
return optionalMarketFeed;
})();
return optionalMarketFeedPromise;
}
function applyEcbReference(reference) {
fxReferenceData.ecb = { status: 'daily-reference', rateDate: reference.date, fetchedAt: new Date().toISOString(), source: reference.source || ECB_DAILY_URL, rates: reference.rates };
for (const [code, item] of Object.entries(CURRENCIES)) {
if (item.ecbReference && Number.isFinite(reference.rates[code]) && reference.rates[code] > 0) {
item.eurRate = 1 / reference.rates[code];
item.referenceDate = reference.date;
item.referenceStatus = 'daily-reference';
}
}
if (Number.isFinite(reference.rates.USD) && reference.rates.USD > 0) {
CURRENCIES.AED.eurRate = 1 / (reference.rates.USD * 3.6725);
CURRENCIES.AED.referenceDate = reference.date;
CURRENCIES.AED.referenceStatus = 'daily-reference · USD/AED peg assumption';
}
}
function applyCbkReference(reference) {
fxReferenceData.cbk = { status: 'daily-indicative', rateDate: reference.date, fetchedAt: new Date().toISOString(), source: reference.source || CBK_FOREX_URL, kesPerEur: reference.kesPerEur };
CURRENCIES.KES.eurRate = 1 / reference.kesPerEur;
CURRENCIES.KES.referenceDate = reference.date;
CURRENCIES.KES.referenceStatus = 'daily-indicative';
}
async function readFxReference(kind) {
const electronMethod = kind === 'ecb' ? window.adriaDemo?.getEcbDailyReference : window.adriaDemo?.getCbkDailyReference;
if (typeof electronMethod === 'function') {
const result = await electronMethod();
if (!result?.ok) throw new Error(result?.error || `${kind.toUpperCase()} reference feed is unavailable.`);
return kind === 'ecb' ? parseEcbDailyReference(result.xml) : parseCbkDailyReference(result.html);
}
const response = await fetch(`/api/fx/${kind}`, { cache: 'no-store', credentials: 'same-origin' });
if (!response.ok) throw new Error(`${kind.toUpperCase()} reference endpoint returned ${response.status}.`);
const text = await response.text();
return kind === 'ecb' ? parseEcbDailyReference(text) : parseCbkDailyReference(text);
}
function refreshOneFxSource(kind) {
if (kind === 'ecb') {
if (!ecbFetchPromise) ecbFetchPromise = readFxReference('ecb').then(applyEcbReference).catch(error => {
if (fxReferenceData.ecb.status === 'bundled-sample') fxReferenceData.ecb.status = 'bundled-sample · feed unavailable';
throw error;
}).finally(() => { ecbFetchPromise = null; });
return ecbFetchPromise;
}
if (!cbkFetchPromise) cbkFetchPromise = readFxReference('cbk').then(applyCbkReference).catch(error => {
if (fxReferenceData.cbk.status === 'bundled-sample') fxReferenceData.cbk.status = 'bundled-sample · feed unavailable';
throw error;
}).finally(() => { cbkFetchPromise = null; });
return cbkFetchPromise;
}
async function refreshFxReferences(notify = false) {
const results = await Promise.allSettled([refreshOneFxSource('ecb'), refreshOneFxSource('cbk')]);
const success = results.some(result => result.status === 'fulfilled');
if (notify) toast(success ? t('fxRefreshComplete') : t('fxRefreshUnavailable'), success ? 'success' : 'warning');
if (currentView === 'exchange' && !modalRoot.childElementCount) renderView();
return success;
}
function conversionReferenceDetails(fromCode, toCode) {
const codes = [fromCode, toCode];
const parts = [];
if (codes.includes('KES')) parts.push(`CBK indicative ${fxReferenceData.cbk.rateDate}`);
if (codes.some(code => CURRENCIES[code]?.ecbReference || code === 'AED')) parts.push(`ECB daily ${fxReferenceData.ecb.rateDate}`);
return { referenceDate: [...new Set(parts.map(part => part.split(' ').at(-1)))].join(' / '), source: [...new Set(parts)].join(' + ') || 'Bundled indicative demo sample' };
}
function walletValue(wallet) {
const item = CURRENCIES[wallet.asset];
return item && Number.isFinite(item.eurRate) && item.eurRate > 0 ? wallet.units * item.eurRate : null;
}
function renderExchange() {
const assets = exchangeTab === 'fx' ? fiatAssets() : digitalAssets();
const scopeCustomerId = document.body.dataset.surface === 'client' ? portalCustomerId : exchangeCustomerId;
const accountIds = state.accounts.filter(account => account.currency === 'EUR' && account.status === 'active' && customerById(account.customerId)?.kyc === 'verified' && (!scopeCustomerId || account.customerId === scopeCustomerId));
const customerWallets = state.wallets.filter(wallet => wallet.units > 0.00000001 && (!scopeCustomerId || wallet.customerId === scopeCustomerId));
const totalWalletEur = customerWallets.reduce((sum, wallet) => sum + (walletValue(wallet) ?? 0), 0);
const inputLabel = exchangeSide === 'buy' ? t('amountInputBuy') : t('amountInputSell');
const sideButtons = `${esc(t('buy'))} ${esc(t('sell'))}
`;
const assetOptions = assets.map(asset => `${esc(asset)} · ${esc(assetName(asset))} `).join('');
const accountOptions = accountIds.map(account => `${esc(account.id)} · ${esc(customerById(account.customerId)?.name || '')} · ${esc(account.currency)} ${esc(currency(account.balanceCents, account.currency))} `).join('');
const walletRows = customerWallets.length ? customerWallets.slice(0, 9).map(wallet => `${esc(wallet.asset.slice(0, 2))} ${esc(assetName(wallet.asset))} · ${esc(wallet.asset)} ${new Intl.NumberFormat(locale(), { maximumFractionDigits: CURRENCIES[wallet.asset]?.decimals || 2 }).format(wallet.units)} ${esc(wallet.asset)} · ${esc(customerById(wallet.customerId)?.name || wallet.customerId)}
${walletValue(wallet) === null ? 'No reference' : esc(currencyMajor(walletValue(wallet)))}${esc(t('sampleValue'))}
`).join('') : emptyState('No demo wallet positions yet. Complete a simulated buy to create one.');
const visibleTrades = state.exchangeTrades.filter(trade => !scopeCustomerId || trade.customerId === scopeCustomerId);
const trades = visibleTrades.slice(0, 8).map(trade => `${esc(trade.id)} ${esc(fmtDate(trade.createdAt, true))} ${formatClient(customerById(trade.customerId))} ${esc(t(trade.side))} ${esc(trade.asset)} ${new Intl.NumberFormat(locale(), { maximumFractionDigits: CURRENCIES[trade.asset]?.decimals || 2 }).format(trade.units)} ${esc(trade.asset)} ${esc(currency(trade.eurCents))} ${esc(currency(trade.spreadCents))} ${badge(trade.status)} `);
const exchangeScope = scopeCustomerId ? `${esc(t('customerPortal'))}: ${esc(customerById(scopeCustomerId)?.name || scopeCustomerId)} ` : '';
const pageIntro = pageHeading('exchange', 'exchangeSub', `${exchangeScope}${esc(t('demoOnly'))} ${button('account-conversion', t('convertAccounts'), '', 'exchange')}${icon('globe')}Separate desk ↗ `);
const form = ``;
const scopedWalletTransfers = (state.walletTransfers || []).filter(transfer => !scopeCustomerId || transfer.customerId === scopeCustomerId);
const transferRows = scopedWalletTransfers.slice(0, 12).map(transfer => {
const incoming = transfer.direction === 'in';
const label = incoming ? t('cryptoIncoming') : t('cryptoOutgoing');
const address = incoming ? transfer.source : transfer.destination;
const canCancel = transfer.status === 'awaiting_approval' && (document.body.dataset.surface === 'client' ? transfer.customerId === portalCustomerId : canWrite());
const actions = `${transfer.status === 'awaiting_approval' && canApprove() ? `${esc(t('approveCrypto'))} ` : ''}${canCancel ? `${esc(t('cancelCrypto'))} ` : ''}`;
return `${esc(transfer.id)} ${esc(fmtDate(transfer.createdAt, true))} ${document.body.dataset.surface === 'client' ? '' : formatClient(customerById(transfer.customerId))}${esc(label)} ${new Intl.NumberFormat(locale(), { maximumFractionDigits: CURRENCIES[transfer.asset]?.decimals || 8 }).format(transfer.units)} ${esc(transfer.asset)}${esc(address || '')} ${esc(transfer.network)} ${badge(transfer.status)} ${actions || '—'} `;
});
const walletQueue = panel(t('walletQueue'), tableShell([t('reference'), ...(document.body.dataset.surface === 'client' ? [] : [t('client')]), t('amount'), t('channel'), t('status'), t('actions')], transferRows, '', t('noWalletTransfers')), { description: t('digitalAssetNotice'), actions: `${esc(t('cryptoSend'))} ${esc(t('cryptoReceive'))} ` });
const wallets = panel(t('portfolio'), `${esc(t('sampleValue'))} · ${esc(t('localSimulation'))} ${customerWallets.length} positions
${esc(currencyMajor(totalWalletEur))} ${esc(t('eurValue'))} · ${esc(t('notLivePrice'))} ${walletRows}
`, { description: t('controlsNotWired'), actions: `${esc(t('cryptoSend'))} ${esc(t('cryptoReceive'))} ` });
const rates = panel(t('quoteTable'), rateBoard(assets), { description: t('quoteDisclosure'), actions: `${esc(t('refreshReference'))} ${esc(t('notLivePrice'))} ` });
const history = panel(t('recentTrades'), tableShell([t('reference'), t('client'), t('direction'), t('amount'), 'EUR', t('fee'), t('status')], trades), { description: `${visibleTrades.length} ${t('records')}` });
return `${pageIntro} ADRIA EXCHANGE · SEPARATE DESK
${esc(t('exchange'))} Dedicated treasury workflow · fiat FX + a strictly offline digital-asset rehearsal ledger
⇄ ${esc(t('fx'))} ◈ ${esc(t('crypto'))} ${esc(t('notConnected'))}
${rates}${renderOptionalMarketFeed()}
${form}
${wallets}${walletQueue}${history}
${esc(t('quoteDisclosure'))} FIAT and crypto virtual balances are local sample records and do not leave this application.
`;
}
function renderLedger() {
const assetAccounts = state.glAccounts.filter(account => account.group === 'asset');
const liabilityAccounts = state.glAccounts.filter(account => account.group === 'liability');
const assets = assetAccounts.reduce((sum, account) => sum + account.balanceCents, 0);
const liabilities = liabilityAccounts.reduce((sum, account) => sum + account.balanceCents, 0);
const equity = state.glAccounts.filter(account => ['equity','income'].includes(account.group)).reduce((sum, account) => sum + account.balanceCents, 0) - state.glAccounts.filter(account => account.group === 'expense').reduce((sum, account) => sum + account.balanceCents, 0);
const f = filterState.ledger;
const page = paged(state.ledgerJournals, 'ledger');
const rows = page.items.map(journal => `${esc(journal.id)} ${esc(journal.ref || '—')} ${esc(journal.description)} ${esc(fmtDate(journal.at, true))} ${journal.lines.length} ${esc(currency(journal.debitCents))} ${esc(currency(journal.creditCents))} ${badge(journal.debitCents === journal.creditCents ? 'verified' : 'high')} ··· `);
const glRows = state.glAccounts.map(account => `${esc(account.code)} ${esc(account.name)} ${esc(account.group)} ${esc(account.normal === 'debit' ? t('debit') : t('credit'))} ${esc(currency(account.balanceCents))} `);
const bankRows = state.bankAccounts.map(account => `${esc(account.label)} ${esc(account.bank)} ${esc(account.ref)} ${esc(account.currency)} ${esc(currency(account.balanceCents, account.currency))} ${badge('simulated')} `);
return `${pageHeading('ledger', 'ledgerSub', `${button('export-json', t('exportJson'), '', 'download')}${button('download-csv', t('downloadCsv'), 'primary', 'download')}`)}${esc(t('totalAssets'))} ${esc(compactCurrency(assets))} ${esc(t('sampleValue'))}
${esc(t('customerLiabilities'))} ${esc(compactCurrency(liabilities))} ${esc(t('customerDeposits'))}
${esc(t('capital'))} + P&L ${esc(compactCurrency(equity))} ${esc(t('openingBalance'))}
${esc(t('journal'))} ${state.ledgerJournals.length} ${isBalanced(state) ? '✓ ' + t('cleared') : t('review')}
i Initial positions are a static teaching scenario. Demo journals enforce equal debit and credit totals; this is not a chart of accounts, IFRS/CRR return, statutory ledger or reconciliation to real bank statements.
${tableShell([t('code'), t('reference'), t('description'), 'Lines', t('debit'), t('credit'), t('status'), t('actions')], rows, page.footer)} ${tableShell([t('code'), t('account'), t('category'), 'Normal balance', t('balance')], glRows)} ${tableShell([t('account'), t('bank'), t('reference'), t('currency'), t('balance'), t('status')], bankRows)}
`;
}
const regulatorySources = [
{ title: 'Delaware Office of the State Bank Commissioner', url: 'https://banking.delaware.gov/', tag: 'USA · STATE BANKING' },
{ title: 'FDIC · deposit insurance and bank data', url: 'https://www.fdic.gov/', tag: 'USA · BANK DATA' },
{ title: 'HNB · authorisation and supervision of credit institutions', url: 'https://www.hnb.hr/en/core-functions/supervision/basics-and-objectives', tag: 'CROATIA · LICENSING' },
{ title: 'ECB · daily euro foreign exchange reference rates', url: 'https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html', tag: 'DAILY INDICATIVE DATA' },
{ title: 'Central Bank of Kenya · indicative foreign exchange rates', url: 'https://www.centralbank.go.ke/forex/', tag: 'KENYA · INDICATIVE DATA' },
{ title: 'Central Bank of the UAE · licensing', url: 'https://centralbank.ae/en/licensing/', tag: 'UAE · LICENSING' },
{ title: 'Japan Financial Services Agency', url: 'https://www.fsa.go.jp/en/', tag: 'JAPAN · REGULATOR' },
{ title: 'Monetary Authority of Singapore', url: 'https://www.mas.gov.sg/', tag: 'SINGAPORE · REGULATOR' },
{ title: 'FINMA · Swiss Financial Market Supervisory Authority', url: 'https://www.finma.ch/en/', tag: 'SWITZERLAND · REGULATOR' },
{ title: 'BaFin · German Federal Financial Supervisory Authority', url: 'https://www.bafin.de/EN/Homepage/homepage_node.html', tag: 'GERMANY · REGULATOR' },
{ title: 'UK Prudential Regulation Authority', url: 'https://www.bankofengland.co.uk/prudential-regulation', tag: 'UK · PRUDENTIAL' },
{ title: 'UK Financial Conduct Authority', url: 'https://www.fca.org.uk/', tag: 'UK · CONDUCT' },
{ title: 'EU Capital Requirements Regulation (CRR) and Directive (CRD)', url: 'https://eur-lex.europa.eu/eli/reg/2013/575/oj/eng', tag: 'PRUDENTIAL' },
{ title: 'Croatia · Consumer Credit Act NN 86/2026 · effective 20 November 2026', url: 'https://narodne-novine.nn.hr/eli/sluzbeni/2026/86/1071', tag: 'CREDIT' },
{ title: 'EU Consumer Credit Directive 2023/2225 · applies from 20 November 2026', url: 'https://eur-lex.europa.eu/EN/legal-content/summary/consumer-credit-agreements-2023.html', tag: 'CONSUMER CREDIT' },
{ title: 'HNB · Loans and prior authorisation to grant credit', url: 'https://www.hnb.hr/en/about-us/consumer-protection/key-information/loans', tag: 'CREDIT AUTHORISATION' },
{ title: 'EU AML Regulation 2024/1624 (AMLR) · applies 10 July 2027', url: 'https://eur-lex.europa.eu/eli/reg/2024/1624/oj/eng', tag: 'AML/CFT' },
{ title: 'EBA · remote customer onboarding guidelines', url: 'https://www.eba.europa.eu/publications-and-media/press-releases/eba-publishes-guidelines-remote-customer-onboarding', tag: 'KYC' },
{ title: 'Croatian Ministry of Finance · FIU / suspicious-transaction reporting', url: 'https://mfin.gov.hr/istaknute-teme/ured-za-sprjecavanje-pranja-novca/143', tag: 'FIU' },
{ title: 'EU Transfer of Funds Regulation 2023/1113 · crypto Travel Rule', url: 'https://eur-lex.europa.eu/eli/reg/2023/1113/oj/eng', tag: 'TRAVEL RULE' },
{ title: 'MiCA Regulation 2023/1114 · crypto-asset services', url: 'https://eur-lex.europa.eu/eli/reg/2023/1114/oj/eng', tag: 'CRYPTO' },
{ title: 'Hanfa · MiCA crypto authorisation in Croatia', url: 'https://www.hanfa.hr/news/provision-of-crypto-asset-services-from-1-july-2026-crypto-licence-and-registration-in-hanfa-registers-mandatory-for-providers-consumers-urged-to-exercise-caution/', tag: 'CROATIA' },
{ title: 'DORA Regulation 2022/2554 · applies 17 January 2025', url: 'https://eur-lex.europa.eu/eli/reg/2022/2554/oj/eng', tag: 'ICT RISK' },
{ title: 'EU instant payments Regulation 2024/886 · payee verification', url: 'https://eur-lex.europa.eu/eli/reg/2024/886/oj/eng', tag: 'PAYMENTS' },
{ title: 'Croatian Deposit Insurance Agency · insured amount / €100,000 limit', url: 'https://www.haod.hr/sustav-osiguranja-depozita/osigurani-iznos', tag: 'DEPOSIT GUARANTEE' },
{ title: 'GDPR · Regulation (EU) 2016/679', url: 'https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng', tag: 'PRIVACY' },
{ title: 'PSD2 · payment services and strong customer authentication', url: 'https://eur-lex.europa.eu/eli/dir/2015/2366/oj/eng', tag: 'PAYMENTS' }
];
function renderReports() {
const monthly = state.transactions.filter(tx => new Date(tx.date) > new Date(Date.now() - 30 * 86400000));
const riskCounts = ['low','medium','high'].map(risk => state.customers.filter(customer => customer.risk === risk).length);
const channelCounts = ['Card','SEPA','Branch','ATM','Mobile'].map(channel => state.transactions.filter(tx => tx.channel.includes(channel)).length);
const maxChannel = Math.max(1, ...channelCounts);
const sourceRows = regulatorySources.map(source => `${esc(source.tag)} ${esc(source.title)} ↗ ${esc(t('official'))} `);
const riskSum = Math.max(1, riskCounts.reduce((sum, value) => sum + value, 0));
const riskPanel = `
${esc(t('low'))} ${riskCounts[0]} ${esc(t('medium'))} ${riskCounts[1]} ${esc(t('high'))} ${riskCounts[2]}
`;
const channelPanel = `${['Card','SEPA','Branch','ATM','Mobile'].map((channel, i) => `
${esc(channel)} ${channelCounts[i]}
`).join('')}
`;
return `${pageHeading('reports', 'reportsSub', `${button('download-csv', t('downloadCsv'), '', 'download')}${button('export-json', t('exportJson'), 'primary', 'download')}`)}${esc(t('totalMovement'))} · 30d ${esc(compactCurrency(monthly.reduce((sum, tx) => sum + tx.amountCents, 0)))} ${monthly.length} ${esc(t('transaction').toLowerCase())}
${esc(t('totalClients'))} ${state.customers.length} ${esc(t('allSynthetic'))}
${esc(t('openCases'))} ${state.cases.filter(item => item.status !== 'closed').length} ${esc(t('illustrative'))}
${esc(t('journal'))} controls ${isBalanced(state) ? 'PASS' : 'CHECK'} ${state.ledgerJournals.length} balanced demo postings
${panel(t('customersRisk'), riskPanel, { description: t('sampleValue') })}${panel(t('paymentMix'), channelPanel, { description: t('monthlyVolume') })}
! Not a complete legal inventory. National implementation, supervisory guidance, product perimeter, group structure and rule changes require Croatian banking, AML, payments, crypto, privacy, tax, consumer and prudential counsel plus direct regulator engagement.
${tableShell([t('category'), t('description'), t('status')], sourceRows)} `;
}
function renderInstitutions() {
const banks = state.demoBanks || [];
const transfers = state.interbankTransfers || [];
const bankRows = banks.map(bank => `${esc(bank.name)} ${esc(bank.id)} ${esc(bank.country)} · ${esc(bank.countryCode)} ${esc(bank.currency)} ${esc(bank.bic)} · test-shaped ${esc(t('regulatorLink'))} ↗ ${esc(t('fictionalNotConnected'))} `);
const transferRows = transfers.map(transfer => {
const action = transfer.status === 'awaiting_approval' && canApprove() ? `${esc(t('simulateSettlement'))} ` : transfer.status === 'awaiting_approval' ? `${esc(t('awaitingApproval'))} ` : badge(transfer.status);
return `${esc(transfer.id)} ${esc(fmtDate(transfer.createdAt, true))} ${esc(customerById(transfer.customerId)?.name || transfer.customerId)}${esc(transfer.fromAccountId)} ${esc(transfer.beneficiary)}${esc(transfer.toBank)} · ${esc(transfer.beneficiaryAccount)} ${esc(currency(transfer.amountCents, transfer.currency))} ${action} `;
});
const beneficiaryRows = (state.interbankAccounts || []).slice(0, 10).map(account => {
const bank = banks.find(item => item.id === account.bankId);
return `${esc(account.id)} ${esc(bank?.name || account.bankId)} ${esc(account.accountName)} ${esc(account.accountNumber)} ${esc(account.currency)} ${esc(t('syntheticRecord'))} `;
});
return `${pageHeading('institutions', 'institutionsSub', `${button('new-interbank', t('interbankTransfer'), 'primary', 'exchange')}${button('local-checker', t('localChecks'), '', 'verify')}`)}${esc(t('fictionalBanks'))} ${banks.length} ${esc(t('fictionalNotConnected'))}
${esc(t('demoBeneficiaries'))} ${(state.interbankAccounts || []).length} ${esc(t('syntheticRecord'))}
${esc(t('interbankQueue'))} ${transfers.filter(item => item.status === 'awaiting_approval').length} ${esc(t('localCheckerOnly'))}
${esc(t('proposedMarkets'))} ${(state.plannedMarkets || []).length} ${esc(t('planningOnly'))}
! ${esc(t('interbankDemoDisclosure'))}
${renderInteropSystemSwitcher()}${tableShell([t('institution'), t('country'), t('currency'), t('bic'), t('regulator'), t('connectivity')], bankRows)} ${tableShell([t('reference'), t('client'), t('beneficiary'), t('amount'), t('actions')], transferRows)} ${panel(t('fictionalBeneficiaryDirectory'), tableShell([t('reference'), t('institution'), t('beneficiary'), t('accountNumber'), t('currency'), t('status')], beneficiaryRows), { description: t('syntheticDirectoryOnly') })}${panel(t('internationalMarkets'), `
${(state.plannedMarkets || []).map(market => `
${esc(market.flag)} ${esc(market.city)} · ${esc(market.currency)} ${esc(market.regulator)} · ${esc(market.language.toUpperCase())}
${esc(t('regulatorLink'))} ↗ `).join('')}
`, { description: t('planningOnly') })}
`;
}
function renderChecker() {
const result = referenceCheckResult ? `${esc(referenceCheckResult.label)} ${badge(referenceCheckResult.status)}
${esc(referenceCheckResult.detail)}
${esc(referenceCheckResult.note)} ` : `${icon('verify')}
${esc(t('checkerNoResult'))}
`;
const pending = (state.interbankTransfers || []).filter(transfer => transfer.status === 'awaiting_approval');
const pendingRows = pending.map(transfer => `${esc(transfer.id)} ${esc(transfer.toBank)} ${esc(transfer.beneficiary)} ${esc(currency(transfer.amountCents, transfer.currency))} ${canApprove() ? `${esc(t('simulateSettlement'))} ` : `${esc(t('checkerRequired'))} `} `);
const checkForm = ``;
return `${pageHeading('localChecks', 'localChecksSub', `${button('clear-check-result', t('clear'), '', 'close')}${button('refresh-fx', t('refreshReference'), '', 'exchange')}`)}! ${esc(t('checkerDisclaimer'))}
${panel(t('interbankQueue'), tableShell([t('reference'), t('institution'), t('beneficiary'), t('amount'), t('actions')], pendingRows), { description: t('makerCheckerSub') })}${panel(t('checkSteps'), `
1 · ${esc(t('chooseDemoRecord'))} ${esc(t('checkerStepOne'))}
2 · ${esc(t('inspectResult'))} ${esc(t('checkerStepTwo'))}
3 · ${esc(t('recordOutcome'))} ${esc(t('checkerStepThree'))}
`, { description: t('trainingOnly') })}
`;
}
function renderSupport() {
const allTickets = state.supportTickets || [];
const customerOnly = document.body.dataset.surface === 'client';
const tickets = customerOnly ? allTickets.filter(ticket => ticket.customerId === portalCustomerId) : allTickets;
const rows = tickets.map(ticket => {
const customer = customerById(ticket.customerId);
const nextStatus = ticket.status === 'open' ? 'in_progress' : ticket.status === 'resolved' ? 'open' : 'resolved';
const actionLabel = ticket.status === 'open' ? t('startHandling') : ticket.status === 'resolved' ? t('reopenTicket') : t('resolveTicket');
const action = !customerOnly && canWrite() ? `${esc(actionLabel)} ` : badge(ticket.status);
return `${esc(ticket.id)} ${esc(fmtDate(ticket.createdAt, true))} ${customer ? formatClient(customer) : esc(ticket.customerId)} ${esc(ticket.subject)} ${esc(ticket.category)} · ${esc(ticket.message)} ${esc(ticket.assignedTo || t('unassigned'))} ${customerOnly ? '—' : ` `} ${badge(ticket.status)} ${action} `;
});
const headingActions = customerOnly ? button('portal-support', t('newSupportTicket'), 'primary', 'plus') : button('new-support-ticket', t('newSupportTicket'), 'primary', 'plus');
return `${pageHeading('callCentre', customerOnly ? 'supportCustomerSub' : 'supportStaffSub', headingActions)}! ${esc(t('supportDemoDisclosure'))}
${esc(t('openTickets'))} ${tickets.filter(ticket => ticket.status === 'open').length} ${esc(t('localQueue'))}
${esc(t('inProgress'))} ${tickets.filter(ticket => ticket.status === 'in_progress').length} ${esc(t('callCentreRehearsal'))}
${esc(t('resolvedTickets'))} ${tickets.filter(ticket => ticket.status === 'resolved').length} ${esc(t('localOnly'))}
${esc(t('callCentre'))} ${esc(t('notConnected'))} ${esc(t('controlsNotWired'))}
${tableShell([t('reference'), t('client'), t('subject'), t('assigned'), t('staffNote'), t('status'), t('actions')], rows)} ${panel(t('callHandlingGuide'), `
1 · ${esc(t('greetAndScope'))} ${esc(t('callStepOne'))}
2 · ${esc(t('verifyDemoOnly'))} ${esc(t('callStepTwo'))}
3 · ${esc(t('routeAndRecord'))} ${esc(t('callStepThree'))}
4 · ${esc(t('closeAndExplain'))} ${esc(t('callStepFour'))}
`, { description: t('supportDemoDisclosure') })}
`;
}
function renderComplaints() {
const customerOnly = document.body.dataset.surface === 'client';
const scoped = (state.complaints || []).filter(complaint => !customerOnly || complaint.customerId === portalCustomerId);
const f = filterState.complaints;
const query = f.q.toLocaleLowerCase(locale());
const filtered = scoped.filter(complaint => {
const customer = customerById(complaint.customerId);
const text = `${complaint.id} ${complaint.subject} ${complaint.category} ${customer?.name || ''}`.toLocaleLowerCase(locale());
return (!query || text.includes(query)) && (f.status === 'all' || complaint.status === f.status);
});
const page = paged(filtered, 'complaints');
const rows = page.items.map(complaint => {
const customer = customerById(complaint.customerId);
const response = complaint.customerVisibleResponse ? `${esc(t('complaintResponse'))}: ${esc(complaint.customerVisibleResponse)} ` : '';
return `${esc(complaint.id)} ${esc(fmtDate(complaint.createdAt, true))} ${customerOnly ? '' : `${formatClient(customer)} `}${esc(complaint.subject)} ${esc(complaint.category)} · ${esc(complaint.priority === 'high_demo' ? t('high') : t('standardDemo'))} ${response}${badge(complaint.status)} ${esc(complaint.assignedTo || t('unassigned'))} ${customerOnly ? '—' : esc(fmtDate(complaint.practiceTargetAt))} ${esc(t('complaintAction'))} `;
});
const openCount = scoped.filter(complaint => !['resolved'].includes(complaint.status)).length;
const responseCount = scoped.filter(complaint => complaint.status === 'response_ready').length;
const resolvedCount = scoped.filter(complaint => complaint.status === 'resolved').length;
const createAction = customerOnly ? 'portal-complaint' : 'new-complaint';
const titleSub = customerOnly ? 'complaintDemoDisclosure' : 'complaintWorkflow';
const statusValues = ['received', 'investigating', 'response_ready', 'resolved', 'escalated', 'reopened'];
return `${pageHeading('complaints', titleSub, button(createAction, t('newComplaint'), 'primary', 'plus'))}! ${esc(t('complaintDemoDisclosure'))}
${esc(t('complaintQueue'))} ${scoped.length} ${esc(t('allSynthetic'))}
${esc(t('openCases'))} ${openCount} ${esc(t('inReview'))}
${esc(t('responseReady'))} ${responseCount} ${esc(t('localOnly'))}
${esc(t('resolved'))} ${resolvedCount} ${esc(t('localSimulation'))}
${tableShell([t('reference'), ...(customerOnly ? [] : [t('client')]), t('subject'), t('status'), t('assigned'), t('complaintTarget'), t('actions')], rows, page.footer)}i ${esc(t('complaintWorkflow'))}
`;
}
function renderInteroperability() {
const lab = ensureInteropLabState(state);
const system = activeInteropSystem(state);
const language = state.settings.language || 'hr';
const copy = (item, hrKey, enKey) => interopLocalizedText(item, language, item.idea ? 'feature' : 'idea', hrKey === 'detailHr' || hrKey === 'descriptionHr' ? 'description' : 'title');
const systemBanks = lab.bankConnectors.filter(item => item.systemId === system.id);
const systemVenues = lab.venues.filter(item => item.systemId === system.id);
const enabledBanks = systemBanks.filter(item => item.status === 'connected_demo').length;
const enabledVenues = systemVenues.filter(item => item.status === 'connected_demo').length;
const readScopes = systemBanks.filter(item => item.status === 'connected_demo').reduce((sum, item) => sum + (item.permissions || []).length, 0);
const latest = (lab.lastResult?.systemId === system.id ? lab.lastResult : null) || lab.runs.find(run => run.systemId === system.id) || null;
const latestFeature = latest ? INTEROP_FEATURES.find(item => item.id === latest.featureId) : null;
const latestSummary = latest ? (language === 'hr' ? latest.summaryHr : latest.summaryEn) : '';
const ideasById = new Map(INTEROP_IDEAS.map(idea => [idea.id, idea]));
const categoryKeys = ['banking', 'payments', 'controls', 'crypto', 'operations'];
const categoryLabel = category => t(({ banking: 'interopCategoryBanking', payments: 'interopCategoryPayments', controls: 'interopCategoryControls', crypto: 'interopCategoryCrypto', operations: 'interopCategoryOperations' })[category] || 'interopCategoryBanking');
const statusBadge = connector => `${esc(t(connector.status === 'connected_demo' ? 'interopActive' : 'interopPaused'))} `;
const scopeLabel = scope => ({ balances: t('balance'), transactions: t('transactionHistory') })[scope] || scope;
const connectorCard = (connector, bank = false) => {
const scopes = bank ? (connector.permissions || []).map(scopeLabel).join(' · ') || '—' : Object.keys(connector.markets || {}).join(' · ');
const detail = bank ? `${connector.country} · ${connector.protocol}` : `${connector.region} · ${connector.feeBps} bp sample fee`;
const toggleLabel = connector.status === 'connected_demo' ? t('interopPause') : t('interopEnable');
const toggleIcon = connector.status === 'connected_demo' ? 'close' : 'plus';
const toggle = button('interop-connector-toggle', toggleLabel, 'small', toggleIcon, `data-id="${esc(connector.id)}" ${lab.killSwitch ? 'disabled' : ''}`);
const refresh = bank && connector.status === 'connected_demo' ? button('interop-connector-refresh', t('interopRefresh'), 'small', 'clock', `data-id="${esc(connector.id)}" ${lab.killSwitch ? 'disabled' : ''}`) : '';
const lastSync = bank && connector.lastSyncAt ? `${esc(t('interopSnapshot'))}: ${esc(fmtDate(connector.lastSyncAt, true))} ` : '';
const latency = Number.isFinite(connector.latencyMs) ? `${esc(t('interopLatency'))}: ${connector.latencyMs} ms ` : '';
return `${icon(bank ? 'bank' : 'exchange')}
${esc(connector.name)} ${esc(detail)}
${statusBadge(connector)}
${esc(t(bank ? 'interopPermissions' : 'interopProtocol'))} ${esc(scopes)} ${latency}${lastSync}
`;
};
const tabs = `${esc(t('interopWorkbench'))} ${esc(t('interopIdeas'))}
`;
const headerActions = `${button('interop-export', t('exportJson'), '', 'download')}${button('interop-reset', t('interopReset'), '', 'close')}`;
const heading = pageHeading('interoperability', 'interopSub', `${headerActions}${esc(t('localOnly'))} `);
const boundary = `! ${esc(t('interopBoundary'))}
`;
const systemSwitcher = renderInteropSystemSwitcher();
if (interopTab === 'ideas') {
const ideaCards = INTEROP_IDEAS.map((idea, index) => `${String(index + 1).padStart(2, '0')}
${esc(categoryLabel(idea.category || 'banking'))} ${esc(copy(idea, 'hr', 'en'))} ${esc(copy(idea, 'detailHr', 'detailEn'))}
${esc(t('demoOnly'))} `).join('');
const ideaView = panel(t('interopIdeas'), `${INTEROP_IDEAS.length} ${esc(t('interopAdditionalIdeas'))} ${esc(t('interopIdeasIntro'))}
${ideaCards}
`, { description: `${INTEROP_IDEAS.length} · ${t('localOnly')}` });
return `${heading}${tabs}${boundary}${systemSwitcher}${ideaView}`;
}
const bankCards = systemBanks.map(connector => connectorCard(connector, true)).join('');
const venueCards = systemVenues.map(connector => connectorCard(connector)).join('');
const categoryCounts = Object.fromEntries(categoryKeys.map(category => [category, INTEROP_FEATURES.filter(feature => feature.category === category).length]));
const categoryTabs = `${esc(t('interopCategoryAll'))}${INTEROP_FEATURES.length} ${categoryKeys.map(category => `${esc(categoryLabel(category))}${categoryCounts[category]} `).join('')}
`;
const visibleFeatures = INTEROP_FEATURES.filter(feature => interopCategory === 'all' || feature.category === interopCategory);
const features = visibleFeatures.map(feature => {
const idea = ideasById.get(feature.idea);
const index = INTEROP_FEATURES.indexOf(feature);
const isKillSwitch = feature.id === 'kill-switch';
const actionName = isKillSwitch && lab.killSwitch ? t('interopRestore') : t('interopRun');
const actionIcon = isKillSwitch && lab.killSwitch ? 'arrow' : 'spark';
return `${String(index + 1).padStart(2, '0')} ${esc(categoryLabel(feature.category))}
${esc(copy(feature, 'hr', 'en'))} ${esc(copy(feature, 'descriptionHr', 'descriptionEn'))}
${esc(copy(idea, 'hr', 'en'))}
${button('interop-feature-run', actionName, 'small primary', actionIcon, `data-id="${esc(feature.id)}" ${isKillSwitch && lab.killSwitch ? 'aria-pressed="true"' : ''}`)} `;
}).join('');
const latestResult = latest ? ` ${esc(t('interopResult'))}
${esc(latestFeature ? copy(latestFeature, 'hr', 'en') : latest.featureName || latest.featureId || t('interopWorkbench'))} ${esc(fmtDate(latest.at, true))} ${esc(latestSummary || '')}
${esc(t('interopJsonEvidence'))} ${esc(JSON.stringify(latest.evidence || {}, null, 2))} ` : `${icon('spark')}
${esc(t('interopNoActivity'))}
`;
const scopedRuns = lab.runs.filter(run => run.systemId === system.id);
const activityRows = scopedRuns.slice(0, 8).map(run => {
const feature = INTEROP_FEATURES.find(item => item.id === run.featureId);
const label = feature ? copy(feature, 'hr', 'en') : run.featureName || run.featureId || (language === 'hr' ? 'Lokalna aktivnost konektora' : 'Local connector activity');
return `${icon(run.featureId === 'kill-switch' ? 'shield' : 'clock')} ${esc(label)} ${esc(language === 'hr' ? run.summaryHr || run.summaryEn || '' : run.summaryEn || run.summaryHr || '')}
${esc(fmtDate(run.at, true))} `;
}).join('');
const activityPanel = panel(t('interopActivity'), activityRows ? `${activityRows}
` : `${esc(t('interopNoActivity'))}
`, { description: t('interopExportHint') });
const killSwitchState = lab.killSwitch ? `! ${esc(t('interopKillSwitch'))}: ${esc(t('interopPaused'))}. ${enabledBanks + enabledVenues} ${esc(t('interopEnabledCount'))}.
` : '';
return `${heading}${tabs}${boundary}${systemSwitcher}${killSwitchState}${esc(t('interopConnectorCount'))} ${systemBanks.length + systemVenues.length} ${esc(system.name)}
${esc(t('interopBanks'))} ${enabledBanks} / ${systemBanks.length} ${esc(t('interopReadOnlyValue'))}
${esc(t('interopVenues'))} ${enabledVenues} / ${systemVenues.length} ${esc(t('interopNoOrders'))}
${esc(t('interopReadScopes'))} ${readScopes} ${esc(t('interopMostRecent'))}: ${latest ? esc(fmtDate(latest.at, true)) : '—'}
${panel(t('interopBanks'), `
${bankCards || `
${esc(t('interopNoActivity'))}
`}
`, { description: t('interopBankPanelSub') })}
${panel(t('interopVenues'), `
${venueCards || `
${esc(t('interopNoActivity'))}
`}
`, { description: t('interopVenuePanelSub') })}
${panel(t('interopResult'), latestResult, { description: t('interopRunSuccess') })}
${activityPanel}
${panel(t('interopFunctions'), `${categoryTabs}${features}
`, { description: `${visibleFeatures.length} / ${INTEROP_FEATURES.length} · ${t('interopWorkbench')}` })}`;
}
function renderSettings() {
const langOptions = supportedLanguages.map(language => `${esc(language.label)} `).join('');
const activeRole = currentStaffRole();
const roleRows = STAFF_LEVELS.map(role => `L${role.level} ${esc(t(role.label))} ${role.id === activeRole.id ? esc(t('active')) : '—'} ${esc(t(role.scope))} `);
const accessPanel = panel(t('roleCapabilities'), `i ${esc(t('roleAccessHelp'))}
${tableShell([t('roleLevel'), t('operator'), t('roleCapabilities')], roleRows)}`, { description: '7 simulated banking role levels' });
const auditRows = state.auditEvents.slice(0, 7).map(event => `${icon(event.severity === 'warning' ? 'shield' : 'clock')} ${esc(event.action)} ${esc(event.actor)} · ${esc(event.detail)}
${esc(fmtDate(event.at, true))} `).join('');
const settingsPanel = panel(t('settings'), `${esc(t('languageSelect') || 'Interface language')} HR · EN · DE · FR · ES · AR · SW · JA · ZH
${langOptions} ${esc(t('operator'))} ${esc(state.settings.operator)} · L${activeRole.level} · ${esc(t(activeRole.label))}
${esc(t('changeRole'))} ${esc(t('localOnlyTitle'))} ${esc(t('localOnlyBody'))}
LOCAL ${esc(t('syntheticData'))} ${state.customers.length} customers · ${state.accounts.length} accounts · ${state.transactions.length} movements
DEMO ${esc(t('isBalanced') || 'Double-entry checks')} ${esc(t('ledgerSub'))}
${isBalanced(state) ? 'PASS' : 'CHECK'} `, { description: t('settingsSub') });
const desktopControls = Boolean(window.adriaDemo?.setBackgroundPreferences);
const intervalOptions = [1, 5, 15, 60].map(minutes => `${minutes} min `).join('');
const backgroundActive = state.settings.backgroundSimulationEnabled !== false;
const lastBackground = state.settings.backgroundLastTickAt ? fmtDate(state.settings.backgroundLastTickAt, true) : t('backgroundNotRunYet');
const backgroundPanel = panel(t('backgroundSimulationTitle'), `i ${esc(t('backgroundSimulationDescription'))}
${esc(t('status'))} ${esc(t(backgroundActive ? 'backgroundActive' : 'backgroundPaused'))}
${esc(t('backgroundRunCount'))} ${state.meta.backgroundSequence || 0}
${esc(t('backgroundLastTick'))} ${esc(lastBackground)}
${esc(t('backgroundInterval'))} ${esc(t('backgroundSafety'))}
${intervalOptions} ${button('toggle-background-simulation', t(backgroundActive ? 'pauseBackground' : 'startBackground'), backgroundActive ? '' : 'primary')}
${esc(t('keepRunningInTray'))}${esc(t('keepRunningInTrayHelp'))}
${esc(t('startWithWindows'))}${esc(t('startWithWindowsHelp'))}
${desktopControls ? '' : `${esc(t('desktopBackgroundOnly'))}
`}${state.settings.backgroundLastError ? `! ${esc(state.settings.backgroundLastError)}
` : ''}`, { description: t('backgroundSimulationTitle') });
const sources = `${regulatorySources.map(source => `
${esc(source.title)} ${esc(source.tag)} Open ↗ `).join('')}
`;
return `${pageHeading('settings', 'settingsSub', `${button('download-csv', t('downloadCsv'), '', 'download')}${button('export-json', t('exportJson'), '', 'download')}${button('reset-demo', t('resetDemo'), 'danger', 'close')}`)}${settingsPanel}${backgroundPanel}${accessPanel}${panel(t('compliance'), `
i ${esc(t('complianceBody'))}
${sources}
Snapshot date Reviewed 10 Oct 2026 · not legal advice
`, { description: t('regulatorySources') || 'Selected primary sources' })}
${panel(t('history'), `
${auditRows}
`, { description: `${state.auditEvents.length} local events` })}
${icon('close')}${esc(t('resetDemo'))}
! The application does not implement production authentication, encryption/key management, immutable audit, bank settlement, access governance, DR, regulatory reporting or privacy controls.
`;
}
function renderOnboarding() {
const applications = state.onboardingApplications || [];
const filter = filterState.onboarding;
const query = filter.q.toLocaleLowerCase(locale());
const filtered = applications.filter(application => {
const customer = customerById(application.customerId);
return (filter.status === 'all' || application.status === filter.status)
&& (!query || `${application.id} ${customer?.id || application.customerId} ${application.countryCode} ${application.customerKind}`.toLocaleLowerCase(locale()).includes(query));
}).sort((a, b) => new Date(b.submittedAt) - new Date(a.submittedAt));
const page = paged(filtered, 'onboarding');
const rows = page.items.map(application => {
const customer = customerById(application.customerId);
const productLabels = application.products.map(product => t(({ personal_account: 'onboardingProductPersonal', multi_currency: 'onboardingProductMultiCurrency', virtual_debit: 'onboardingProductVirtualDebit', prepaid_virtual: 'onboardingProductPrepaid', business_account: 'onboardingProductBusiness' })[product] || product)).join(' · ');
const reviewAction = `${esc(t('review'))} `;
let statusActions = `${esc(t('noAction'))} `;
if (canReviewCompliance() && application.status === 'submitted_demo') {
statusActions = `${esc(t('markReviewedDemo'))} ${esc(t('requestDemoInfo'))} ${esc(t('closeDemoApplication'))}
`;
} else if (canReviewCompliance() && application.status === 'needs_info_demo') {
statusActions = `${esc(t('markReviewedDemo'))} ${esc(t('closeDemoApplication'))}
`;
}
return `${esc(application.id)} ${esc(fmtDate(application.submittedAt, true))} ${customer ? formatClient(customer) : esc(application.customerId)}${esc(application.customerKind === 'business' ? t('business') : t('personal'))} · ${esc(application.countryCode)} ${esc(productLabels)} ${esc(t(application.reviewPath === 'enhanced_review_demo' ? 'onboardingEnhancedPath' : 'onboardingStandardPath'))} ${esc(t(application.identityScenario))} · ${esc(t(application.screeningScenario))} ${badge(application.status)} ${reviewAction} ${statusActions} `;
});
const pending = applications.filter(application => ['submitted_demo', 'needs_info_demo'].includes(application.status)).length;
const reviewed = applications.filter(application => application.status === 'reviewed_demo').length;
const enhanced = applications.filter(application => application.reviewPath === 'enhanced_review_demo').length;
const statusOptions = ['submitted_demo', 'needs_info_demo', 'reviewed_demo', 'closed_demo'];
return `${pageHeading('onlineOnboarding', 'onlineOnboardingSub', `${esc(t('demoOnly'))} `)}${esc(t('demoApplications'))} ${applications.length} ${esc(t('allSynthetic'))}
${esc(t('pending'))} ${pending} ${esc(t('localQueue'))}
${esc(t('completedReviews'))} ${reviewed} ${esc(t('trainingOnly'))}
${esc(t('enhancedReviewDemo'))} ${enhanced} ${esc(t('noExternalScreening'))}
! ${esc(t('onboardingBoundary'))}
${tableShell([t('applicationCode'), t('client'), t('requestedProducts'), t('reviewPath'), t('status'), t('actions')], rows, page.footer, t('noOnboardingApplications'))} ${panel(t('onboardingReviewGuide'), `1 · ${esc(t('onboardingGuideRead'))} ${esc(t('onboardingGuideOne'))}
2 · ${esc(t('onboardingGuideReview'))} ${esc(t('onboardingGuideTwo'))}
3 · ${esc(t('onboardingGuideRecord'))} ${esc(t('onboardingGuideThree'))}
`, { description: t('trainingOnly') })}`;
}
function renderView() {
updateStaticText();
renderNavigation();
const renderers = {
overview: renderOverview, portal: renderPortal, onboarding: renderOnboarding, customers: renderCustomers, accounts: renderAccounts, payments: renderPayments,
cards: renderCards, savings: renderSavings, loans: renderLoans, branches: renderBranches, atms: renderAtms,
aml: renderAml, complaints: renderComplaints, swift: renderSwift, exchange: renderExchange, interop: renderInteroperability, institutions: renderInstitutions, checker: renderChecker, support: renderSupport, ledger: renderLedger, reports: renderReports, settings: renderSettings
};
main.innerHTML = `${renderWorkspaceContextBar()}${(renderers[currentView] || renderOverview)()}`;
const titleKey = ({ branches: 'branchesCash', atms: 'atmNetwork', aml: 'amlKyc', complaints: 'complaints', swift: 'swiftLab', portal: 'customerPortal', onboarding: 'onlineOnboarding', institutions: 'institutions', interop: 'interoperability', checker: 'localChecks', support: 'callCentre' })[currentView] || currentView;
document.title = `${t(titleKey)} · Adria Bank Demo`;
updateBackgroundRuntimeView();
document.body.classList.toggle('exchange-page', currentView === 'exchange');
document.body.classList.toggle('interop-page', currentView === 'interop');
document.body.classList.toggle('client-surface', document.body.dataset.surface === 'client');
$('#appShell').classList.remove('sidebar-open');
}
function navigate(view, pushHash = true) {
const allowedViews = visibleNavGroups().flatMap(group => group.items.map(([key]) => key));
if (!allowedViews.includes(view)) view = document.body.dataset.surface === 'client' ? 'portal' : 'overview';
if (view !== 'exchange') exchangeCustomerId = null;
currentView = view;
if (pushHash && location.hash !== `#${view}`) history.replaceState(null, '', `#${view}`);
renderView();
main.focus({ preventScroll: true });
}
function persist(render = true) {
try { saveDemoState(state); }
catch (error) { toast(`Local storage could not be updated: ${error.message}`, 'error'); }
if (render) renderView();
}
function updateBackgroundIndicator() {
const indicator = $('#backgroundLoopIndicator');
if (!indicator) return;
const active = state.settings.backgroundSimulationEnabled !== false;
const interval = state.settings.backgroundIntervalMinutes || 5;
indicator.textContent = `${t(active ? 'backgroundActive' : 'backgroundPaused')} · ${interval} min`;
indicator.classList.toggle('paused', !active);
indicator.title = `${t('backgroundSimulationDescription')} ${state.settings.backgroundLastTickAt ? `${t('backgroundLastTick')}: ${fmtDate(state.settings.backgroundLastTickAt, true)}` : t('backgroundNotRunYet')}`;
}
function updateBackgroundRuntimeView() {
updateBackgroundIndicator();
const count = $('#backgroundRunCountValue');
if (count) count.textContent = String(state.meta.backgroundSequence || 0);
const last = $('#backgroundLastTickValue');
if (last) last.textContent = state.settings.backgroundLastTickAt ? fmtDate(state.settings.backgroundLastTickAt, true) : t('backgroundNotRunYet');
}
function executeBackgroundTick(payload = {}) {
if (state.settings.backgroundSimulationEnabled === false) return;
try {
const result = runBackgroundSimulationTick(state, { now: payload.scheduledAt ? new Date(payload.scheduledAt) : new Date() });
if (!result.updated) return;
delete state.settings.backgroundLastError;
persist(false);
updateBackgroundRuntimeView();
if (!modalRoot.childElementCount) renderView();
} catch (error) {
state.settings.backgroundLastError = `${new Date().toISOString()} · ${String(error?.message || error).slice(0, 240)}`;
persist(false);
updateBackgroundRuntimeView();
console.error('Local synthetic background cycle failed:', error);
}
}
async function syncBackgroundRuntime() {
const preferences = {
enabled: state.settings.backgroundSimulationEnabled !== false,
intervalMinutes: state.settings.backgroundIntervalMinutes || 5,
keepRunningInTray: Boolean(state.settings.keepRunningInTray),
startWithWindows: Boolean(state.settings.startWithWindows)
};
if (window.adriaDemo?.setBackgroundPreferences) {
clearInterval(browserSimulationTimer);
browserSimulationTimer = null;
if (!backgroundTickUnsubscribe && window.adriaDemo.onBackgroundTick) {
backgroundTickUnsubscribe = window.adriaDemo.onBackgroundTick(executeBackgroundTick);
}
try {
const result = await window.adriaDemo.setBackgroundPreferences(preferences);
backgroundDesktopSupported = Boolean(result?.supported);
if (!backgroundDesktopSupported && state.settings.startWithWindows) {
state.settings.startWithWindows = false;
persist(false);
}
} catch (error) {
backgroundDesktopSupported = false;
console.warn('Desktop background preferences could not be applied:', error);
}
} else {
backgroundDesktopSupported = false;
if (browserSimulationTimer) clearInterval(browserSimulationTimer);
browserSimulationTimer = preferences.enabled
? setInterval(() => executeBackgroundTick({ source: 'browser-background-loop' }), preferences.intervalMinutes * 60 * 1000)
: null;
}
updateBackgroundRuntimeView();
}
function toast(message, type = 'success') {
const node = document.createElement('div');
node.className = `toast ${type === 'error' ? 'error' : type === 'warning' ? 'warning' : ''}`;
node.innerHTML = `${type === 'error' ? '!' : type === 'warning' ? '⚠' : '✓'} ${esc(message)} `;
toastRegion.append(node);
setTimeout(() => node.remove(), 4200);
}
function closeModal() { modalRoot.innerHTML = ''; pendingConfirmation = null; }
function formField(labelKey, control, help = '', full = false) {
return `${esc(t(labelKey))} ${control}${help ? `${esc(help)} ` : ''}
`;
}
function makeSelect(name, options, placeholder, value = '') {
return `${esc(placeholder)} ${options.map(option => `${esc(option.label)} `).join('')} `;
}
const DEMO_COUNTRIES = [
['HR', 'Croatia'], ['SI', 'Slovenia'], ['AT', 'Austria'], ['DE', 'Germany'], ['FR', 'France'], ['ES', 'Spain'],
['IT', 'Italy'], ['GB', 'United Kingdom'], ['CH', 'Switzerland'], ['US', 'United States'], ['KE', 'Kenya'],
['AE', 'United Arab Emirates'], ['JP', 'Japan'], ['SG', 'Singapore']
];
function demoCountrySelect(name = 'country', selected = 'HR') {
return `${DEMO_COUNTRIES.map(([code, country]) => `${code} · ${esc(country)} `).join('')} `;
}
function modalShell(title, subtitle, formType, body, wide = false, submitLabel = 'create') {
const actions = formType === 'onboarding'
? `${esc(t('cancel'))} ${esc(t('onboardingBack'))} ${esc(t('onboardingNext'))} ${esc(t(submitLabel))}
`
: `${esc(t('cancel'))} ${esc(t(submitLabel))}
`;
return ``;
}
function openModal(type, data = {}) {
const accounts = activeEurAccounts().filter(account => !data.customerId || account.customerId === data.customerId);
let title = '', subtitle = '', body = '', wide = false;
switch (type) {
case 'customer': {
title = t('newCustomer'); subtitle = t('clientOnboardingNotice');
body = ``;
break;
}
case 'account': {
title = t('newAccount'); subtitle = t('accountHelp');
const customers = state.customers.slice().sort((a,b) => a.name.localeCompare(b.name, locale())).map(customer => ({ value: customer.id, label: `${customer.name} · ${customer.id} · ${labelStatus(customer.kyc)}` }));
const accountCurrencyOptions = ['EUR', ...accountCurrencies()].map(code => `${esc(code)} · ${esc(CURRENCIES[code].name)} `).join('');
body = ``;
break;
}
case 'transfer': {
title = t('internalTransfer'); subtitle = t('approvalNotice'); wide = true;
const transferAccounts = state.accounts.filter(account => account.status === 'active' && (!data.customerId || account.customerId === data.customerId));
const options = transferAccounts.map(account => ({ value: account.id, label: accountLabel(account) }));
body = ``;
break;
}
case 'account-conversion': {
title = t('convertAccounts'); subtitle = t('indicativeRate'); wide = true;
const eligibleAccounts = state.accounts.filter(account => account.status === 'active' && customerById(account.customerId)?.kyc === 'verified' && (!data.customerId || account.customerId === data.customerId) && !CURRENCIES[account.currency]?.crypto);
const options = eligibleAccounts.map(account => ({ value: account.id, label: accountLabel(account) }));
const context = data.customerId ? customerById(data.customerId)?.name || data.customerId : t('all');
const sourceLabel = `${fxReferenceData.ecb.source} · ${fxReferenceData.ecb.rateDate}; ${fxReferenceData.cbk.source} · ${fxReferenceData.cbk.rateDate}`;
body = ``;
break;
}
case 'interbank-transfer': {
title = t('interbankTransfer'); subtitle = t('interbankDemoDisclosure'); wide = true;
const transferAccounts = state.accounts.filter(account => account.status === 'active' && customerById(account.customerId)?.kyc === 'verified' && (!data.customerId || account.customerId === data.customerId));
const accountOptions = transferAccounts.map(account => ({ value: account.id, label: accountLabel(account) }));
const destinations = (state.interbankAccounts || []).filter(account => account.status === 'active').map(account => {
const bank = state.demoBanks?.find(item => item.id === account.bankId);
return { value: account.id, label: `${bank?.name || account.bankId} · ${account.accountNumber} · ${account.currency}` };
});
body = ``;
break;
}
case 'support-ticket': {
title = t('newSupportTicket'); subtitle = t('supportDemoDisclosure'); wide = true;
const customers = state.customers.slice().sort((a, b) => a.name.localeCompare(b.name, locale())).map(customer => ({ value: customer.id, label: `${customer.name} · ${customer.id}` }));
body = ``;
break;
}
case 'kyc-request': {
const customer = customerById(data.customerId || portalCustomerId);
if (!customer) return;
title = t('startDemoKyc'); subtitle = t('kycFlowDisclosure'); wide = true;
const sourceOptions = [['salary', 'kycSourceSalary'], ['business_revenue', 'kycSourceBusiness'], ['savings', 'kycSourceSavings'], ['pension', 'kycSourcePension'], ['investment', 'kycSourceInvestment'], ['other_synthetic', 'kycSourceOther']].map(([value, label]) => `${esc(t(label))} `).join('');
const purposeOptions = [['daily_personal', 'kycPurposeDaily'], ['salary_receipts', 'kycPurposeSalary'], ['business_operations', 'kycPurposeBusiness'], ['savings', 'kycPurposeSavings'], ['cross_border_practice', 'kycPurposeCrossBorder'], ['other_synthetic', 'kycPurposeOther']].map(([value, label]) => `${esc(t(label))} `).join('');
const docItems = [['synthetic_identity_sample', 'syntheticIdentitySample'], ['synthetic_address_sample', 'syntheticAddressSample'], ...(customer.kind === 'business' ? [['synthetic_register_sample', 'syntheticRegisterSample'], ['synthetic_ownership_chart', 'syntheticOwnershipChart']] : [])].map(([value, label]) => `${esc(t(label))} `).join('');
body = `i ${esc(t('kycFlowDisclosure'))} · ${esc(customer.name)} · ${esc(customer.kind === 'business' ? t('business') : t('personal'))}
`;
break;
}
case 'payment': {
title = t('newPayment'); subtitle = t('approvalNotice'); wide = true;
const options = accounts.map(account => ({ value: account.id, label: accountLabel(account) }));
body = ``;
break;
}
case 'incoming-payment': {
title = t('receivePayment'); subtitle = t('incomingPaymentDisclosure'); wide = true;
const options = accounts.filter(account => customerById(account.customerId)?.kyc === 'verified').map(account => ({ value: account.id, label: accountLabel(account) }));
body = ``;
break;
}
case 'cash': {
title = t('cashOperation'); subtitle = t('branchCashNote');
const accountOptions = accounts.map(account => ({ value: account.id, label: accountLabel(account) }));
const branches = state.branches.map(branch => ({ value: branch.id, label: branch.name }));
body = ``;
break;
}
case 'card': {
title = t('newCard'); subtitle = t('clientOnboardingNotice');
const choices = accounts.filter(account => customerById(account.customerId)?.kyc === 'verified').map(account => ({ value: account.id, label: accountLabel(account) }));
body = ``;
break;
}
case 'card-controls': {
const card = state.cards.find(item => item.id === data.cardId);
if (!card || !['active', 'locked'].includes(card.status) || (data.customerId && card.customerId !== data.customerId)) return;
title = t('cardControls'); subtitle = `${card.product} · ${t('cardTokenSuffix')} ${card.last4} · ${t('localSimulation')}`;
const controls = card.controls || {};
body = ` `;
wide = true;
break;
}
case 'card-load': {
const card = state.cards.find(item => item.id === data.cardId);
if (!card || card.fundingType !== 'prepaid' || card.status !== 'active' || (data.customerId && card.customerId !== data.customerId)) return;
title = t('cardLoad'); subtitle = `${card.product} · ${t('prepaidBalance')}: ${currency(card.prepaidBalanceCents || 0)} · ${t('localSimulation')}`;
body = ` `;
wide = true;
break;
}
case 'card-purchase': {
const scopedCustomerId = data.customerId || (document.body.dataset.surface === 'client' || currentView === 'portal' ? portalCustomerId : '');
const eligibleCards = state.cards.filter(card => card.status === 'active' && (!scopedCustomerId || card.customerId === scopedCustomerId));
if (data.cardId && !eligibleCards.some(card => card.id === data.cardId)) return;
title = t('simulateCardPurchase'); subtitle = t('localSimulation'); wide = true;
const cardOptions = eligibleCards.map(card => ({ value: card.id, label: `${card.product} · ${card.id} · DEMO ${card.last4}` }));
const selectedCardId = data.cardId || cardOptions[0]?.value || '';
body = ``;
break;
}
case 'deposit': {
title = t('newDeposit'); subtitle = t('savingsSub');
const choices = accounts.filter(account => customerById(account.customerId)?.kyc === 'verified').map(account => ({ value: account.id, label: accountLabel(account) }));
body = ``;
break;
}
case 'loan': {
title = t('newLoan'); subtitle = t('loansSub');
const choices = accounts.filter(account => account.currency === 'EUR' && account.status === 'active' && customerById(account.customerId)?.kyc === 'verified' && customerById(account.customerId)?.risk !== 'high').map(account => ({ value: account.id, label: accountLabel(account) }));
body = ``;
break;
}
case 'loan-repayment': {
const loan = state.loans.find(item => item.id === data.loanId);
if (!loan || !['performing', 'arrears'].includes(loan.status)) return;
title = t('repayLoan'); subtitle = `${loan.id} · ${currency(loan.outstandingCents)} ${t('outstanding').toLocaleLowerCase(locale())}`;
const account = accountById(loan.accountId);
const interest = Math.round(loan.outstandingCents * loan.annualRateBps / 10000 / 12);
const principalPart = Math.min(loan.outstandingCents, Math.max(100, Math.round(loan.outstandingCents / loan.termMonths)));
const suggested = ((principalPart + interest) / 100).toFixed(2);
body = ``;
break;
}
case 'atm': {
title = t('simulateWithdrawal'); subtitle = t('atmSub');
const online = state.atms.filter(atm => atm.status === 'online').map(atm => ({ value: atm.id, label: `${atm.id} · ${atm.name} · ${currency(atm.cashCents)}` }));
const choices = accounts.map(account => ({ value: account.id, label: accountLabel(account) }));
body = ``;
break;
}
case 'swift': {
title = t('newSwift'); subtitle = t('swiftDisclaimer'); wide = true;
const choices = accounts.map(account => ({ value: account.id, label: accountLabel(account) }));
body = ``;
break;
}
case 'crypto-transfer': {
title = t('cryptoSend'); subtitle = t('digitalAssetNotice'); wide = true;
const cryptoOwnerScope = data.customerId || (document.body.dataset.surface === 'client' || currentView === 'portal' ? portalCustomerId : '');
const owners = state.customers.filter(customer => customer.kyc === 'verified' && (!cryptoOwnerScope || customer.id === cryptoOwnerScope) && state.wallets.some(wallet => wallet.customerId === customer.id && wallet.units > 0));
const customerChoices = owners.map(customer => ({ value: customer.id, label: `${customer.name} · ${customer.id}` }));
const walletOwnerId = data.customerId || owners[0]?.id;
const assetChoices = digitalAssets().map(code => ({ value: code, label: `${code} · ${CURRENCIES[code].name}` }));
body = ``;
break;
}
case 'crypto-receipt': {
title = t('cryptoReceive'); subtitle = t('digitalAssetNotice'); wide = true;
const cryptoOwnerScope = data.customerId || (document.body.dataset.surface === 'client' || currentView === 'portal' ? portalCustomerId : '');
const owners = state.customers.filter(customer => customer.kyc === 'verified' && (!cryptoOwnerScope || customer.id === cryptoOwnerScope));
const customerChoices = owners.map(customer => ({ value: customer.id, label: `${customer.name} · ${customer.id}` }));
const assetChoices = digitalAssets().map(code => ({ value: code, label: `${code} · ${CURRENCIES[code].name}` }));
body = ``;
break;
}
case 'complaint': {
title = t('newComplaint'); subtitle = t('complaintDemoDisclosure'); wide = true;
const fixedCustomerId = data.customerId || (document.body.dataset.surface === 'client' || currentView === 'portal' ? portalCustomerId : '');
const customers = state.customers.filter(customer => !fixedCustomerId || customer.id === fixedCustomerId).map(customer => ({ value: customer.id, label: `${customer.name} · ${customer.id} · ${labelStatus(customer.kyc)}` }));
body = ``;
break;
}
case 'onboarding': {
const customer = customerById(data.customerId || portalCustomerId);
if (!customer) return;
title = t('startDemoOnboarding'); subtitle = t('onboardingFlowIntro'); wide = true;
const marketOptions = (state.plannedMarkets || []).map(market => ({ value: market.countryCode, label: `${market.countryCode} · ${market.country}` }));
const defaultMarket = marketOptions.some(option => option.value === customer.country) ? customer.country : 'HR';
const typeOptions = [{ value: customer.kind, label: t(customer.kind) }];
const productOptions = [
['personal_account', 'onboardingProductPersonal'], ['multi_currency', 'onboardingProductMultiCurrency'],
['virtual_debit', 'onboardingProductVirtualDebit'], ['prepaid_virtual', 'onboardingProductPrepaid'],
['business_account', 'onboardingProductBusiness']
].filter(([value]) => customer.kind === 'business' || value !== 'business_account');
const requiredProduct = customer.kind === 'business' ? 'business_account' : 'personal_account';
const productFields = productOptions.map(([value, key]) => `${esc(t(key))} `).join('');
body = `01 ${esc(t('onboardingStageProfile'))}02 ${esc(t('onboardingStageChecks'))}03 ${esc(t('onboardingStageReview'))}
`;
break;
}
case 'onboarding-review': {
const application = (state.onboardingApplications || []).find(item => item.id === data.applicationId);
if (!application) return;
const customer = customerById(application.customerId);
const products = application.products.map(product => t(({ personal_account: 'onboardingProductPersonal', multi_currency: 'onboardingProductMultiCurrency', virtual_debit: 'onboardingProductVirtualDebit', prepaid_virtual: 'onboardingProductPrepaid', business_account: 'onboardingProductBusiness' })[product] || product)).join(' · ');
const history = (application.history || []).map(item => `${esc(labelStatus(item.status))} · ${esc(item.actor)} ${esc(item.note)}
${esc(fmtDate(item.at, true))} `).join('');
title = `${t('applicationCode')} · ${application.id}`; subtitle = t('onboardingBoundary');
body = `${esc(t('customer'))} ${esc(customer?.id || application.customerId)} · ${esc(t(application.customerKind))}
${esc(t('country'))} ${esc(application.countryCode)} · ${esc(t('status'))}: ${esc(labelStatus(application.status))}
${esc(t('requestedProducts'))} ${esc(products)}
${esc(t('reviewPath'))} ${esc(t(application.reviewPath === 'enhanced_review_demo' ? 'onboardingEnhancedPath' : 'onboardingStandardPath'))}
${esc(t('identityScenario'))} ${esc(t(application.identityScenario))}
${esc(t('screeningScenario'))} ${esc(t(application.screeningScenario))}
! ${esc(t('onboardingBoundary'))}
${history || emptyState(t('noRecords'))}
${esc(t('close'))}
`;
modalRoot.innerHTML = ``;
return;
}
case 'confirm': {
title = data.title || t('operationTitle'); subtitle = data.subtitle || t('localSimulation');
body = `${data.tone === 'danger' ? '!' : 'i'} ${esc(data.message || '')}
${esc(t('cancel'))} ${esc(data.confirmLabel || t('approve'))}
`;
modalRoot.innerHTML = ``;
pendingConfirmation = data.onConfirm;
return;
}
default: return;
}
const submitLabel = type === 'card-controls' ? 'save' : type === 'card-load' ? 'cardLoad' : type === 'card-purchase' ? 'simulateCardPurchase' : type === 'onboarding' ? 'submitDemoApplication' : 'create';
modalRoot.innerHTML = modalShell(title, subtitle, type, body, wide, submitLabel);
if (type === 'account-conversion') updateAccountConversionQuote();
if (['payment', 'incoming-payment', 'swift'].includes(type)) updateDemoRoutingDisplay($('form', modalRoot));
const first = $('input:not([type="hidden"]):not([type="checkbox"]),select', modalRoot);
if (first && type !== 'confirm') setTimeout(() => first.focus(), 30);
}
function openDrawer(title, subtitle, body) {
modalRoot.innerHTML = ``;
}
function openCustomer(customerId) {
const customer = customerById(customerId);
if (!customer) return;
const accounts = customerAccounts(customerId);
const recent = state.transactions.filter(tx => tx.customerId === customerId).slice(0, 8);
const customerLoans = (state.loans || []).filter(loan => loan.customerId === customerId);
const amlCases = state.cases.filter(item => item.customerId === customerId && item.status !== 'closed');
const kycAction = customer.kyc === 'verified' || !canReviewCompliance() ? '' : `${esc(t('markVerified'))} `;
const body = `${esc(initials(customer.name))} ${esc(customer.name)} ${esc(customer.id)} · ${esc(customer.kind === 'business' ? t('business') : t('personal'))} · ${esc(customer.city)}, ${esc(customer.country)}
${kycAction}
${esc(t('kyc'))} ${badge(customer.kyc)}
${esc(t('riskProfile'))} ${badge(customer.risk)}
${esc(t('segment'))} ${esc(customer.segment)}
${esc(t('sourceFunds'))} ${esc(customer.sourceOfFunds)}
${esc(t('expectedActivity'))} ${esc(currency(customer.expectedMonthlyCents))}
${esc(t('branch'))} ${esc(state.branches.find(item => item.id === customer.branchId)?.name || '—')}
${esc(t('owner'))} ${esc(customer.kind === 'business' ? customer.beneficialOwner || t('noData') : t('noData'))}
${esc(t('date'))} ${esc(fmtDate(customer.joinedAt))}
${esc(t('openCases'))} ${amlCases.length}
${accounts.length ? accounts.map(account => `${icon('account')} ${esc(account.id)} · ${esc(account.label)} ${esc(account.iban)} · ${esc(account.currency)}
${esc(currency(account.balanceCents, account.currency))}${badge(account.status)}
`).join('') : emptyState(t('noAccounts'))}
${esc(t('loans'))} · ${esc(t('demoOnly'))} ${customerLoans.length ? customerLoans.map(loan => `${icon('loan')} ${esc(loan.id)} · ${esc(loan.product)} ${esc(currency(loan.outstandingCents))} ${esc(t('outstanding').toLocaleLowerCase(locale()))} · ${(loan.annualRateBps / 100).toFixed(2)}%
${badge(loan.status)}
`).join('') : emptyState(t('noRecords'))}
${esc(t('transactionHistory'))} ${recent.length ? `${recent.map(tx => `
${icon(tx.direction === 'in' ? 'arrow' : 'payments')} ${esc(txLabel(tx))} ${esc(tx.counterparty)} · ${esc(fmtDate(tx.date, true))}
${esc(amountWithDirection(tx))}${esc(labelStatus(tx.status))}
`).join('')}
` : emptyState(t('noTransactions'))}i ${esc(t('clientOnboardingNotice'))}
`;
openDrawer(t('customerRecord'), `${customer.id} · ${customer.email}`, body);
}
function openKycDetails(customerId) {
const customer = customerById(customerId);
if (!customer) return;
const request = customer.kycRequest;
const selectedKeys = {
synthetic_identity_sample: 'syntheticIdentitySample', synthetic_address_sample: 'syntheticAddressSample',
synthetic_register_sample: 'syntheticRegisterSample', synthetic_ownership_chart: 'syntheticOwnershipChart'
};
const documents = request?.mockDocuments?.length ? request.mockDocuments.map(key => `${esc(t(selectedKeys[key] || 'mockDocuments'))} ${esc(t('syntheticData'))} · no uploaded file ${badge('simulated')}
`).join('') : `i ${esc(t('noKycRequest'))}
`;
const actions = canReviewCompliance() ? `${esc(t('markVerified'))} ${esc(t('enhancedReviewDemo'))}
` : `i ${esc(t('roleReadOnly'))}
`;
const body = `! ${esc(t('kycFlowDisclosure'))}
${esc(t('client'))} ${esc(customer.name)} · ${esc(customer.id)}
${esc(t('kyc'))} ${badge(customer.kyc)}
${esc(t('riskProfile'))} ${badge(customer.risk)}
${esc(t('sourceFunds'))} ${esc(request?.sourceOfFunds || customer.sourceOfFunds || '—')}
${esc(t('kycPurpose'))} ${esc(request?.purpose || '—')}
${esc(t('expectedMonthlyBand'))} ${esc(request?.expectedMonthlyBand || '—')}
${esc(t('riskScenario'))} ${esc(t(request?.syntheticRiskScenario === 'enhanced_review_demo' ? 'enhancedReviewDemo' : 'standardDemo'))}
${esc(t('date'))} ${esc(request?.submittedAt ? fmtDate(request.submittedAt, true) : '—')}
${esc(t('ownershipReview'))} ${request?.ownershipSectionReviewed ? esc(t('simulated')) : '—'}
${esc(t('kycDocuments'))} ${documents}${actions}! Changing a demo KYC label does not identify a real person, validate an owner or screen a sanctions/PEP list.
`;
openDrawer(t('kycReview'), `${customer.id} · ${customer.kind === 'business' ? t('business') : t('personal')} · ${t('localSimulation')}`, body);
}
function openComplaint(complaintId) {
const complaint = (state.complaints || []).find(item => item.id === complaintId);
if (!complaint) return;
const customer = customerById(complaint.customerId);
const customerOnly = document.body.dataset.surface === 'client';
if (customerOnly && complaint.customerId !== portalCustomerId) { toast(t('roleReadOnly'), 'warning'); return; }
const history = (complaint.history || []).map(entry => `${esc(t(entry.status) || labelStatus(entry.status))} ${esc(fmtDate(entry.at, true))} · ${esc(entry.note || '')}
`).join('');
const reviewer = !customerOnly && currentView === 'complaints' && canReviewCompliance();
const response = complaint.customerVisibleResponse ? `i ${esc(t('complaintResponse'))}: ${esc(complaint.customerVisibleResponse)}
` : '';
const controls = reviewer ? `${esc(t('complaintInternalNote'))}
${[['investigating','investigating'],['response_ready','responseReady'],['resolved','resolved'],['escalated','escalated'],['reopened','reopened']].map(([status,key]) => `${esc(t(key))} `).join('')}
` : '';
const body = `! ${esc(t('complaintDemoDisclosure'))}
${esc(t('reference'))} ${esc(complaint.id)}
${esc(t('client'))} ${esc(customer?.name || complaint.customerId)}
${esc(t('complaintCategory'))} ${esc(complaint.category)}
${esc(t('status'))} ${badge(complaint.status)}
${esc(t('priority'))} ${esc(t(complaint.priority === 'high_demo' ? 'high' : 'standardDemo'))}
${esc(t('complaintTarget'))} ${esc(fmtDate(complaint.practiceTargetAt))} · illustrative
${esc(complaint.subject)} ${esc(complaint.message)}
i ${esc(complaint.acknowledgement || t('complaintLocalOnly'))}
${response}
${esc(t('complaintHistory'))} ${history}
${controls}`;
openDrawer(`${complaint.id} · ${t('complaints')}`, `${customer?.name || complaint.customerId} · ${t('localSimulation')}`, body);
}
function openCase(caseId) {
const item = state.cases.find(entry => entry.id === caseId);
if (!item) return;
const customer = customerById(item.customerId);
const tx = state.transactions.find(entry => entry.id === item.transactionId);
const caseActions = canReviewCompliance() ? `${esc(t('markInvestigating'))} ${esc(t('escalate'))} ${esc(t('resolve'))}
` : `i ${esc(t('roleReadOnly'))}
`;
const body = `! ${esc(item.note)}
${esc(t('priority'))} ${badge(item.severity)}
${esc(t('score'))} ${item.score} / 100
${esc(t('status'))} ${badge(item.status)}
${esc(t('assigned'))} ${esc(item.assignedTo || '—')}
${esc(t('client'))} ${esc(customer?.name || item.customerId)}
${esc(t('transaction'))} ${esc(tx ? `${tx.id} · ${currency(tx.amountCents)}` : '—')}
${esc(item.title)} Rule: ${esc(item.ruleCode)} · opened ${esc(fmtDate(item.createdAt, true))}
${esc(t('note'))}
${caseActions}! Demo alert only. No real transaction is blocked, reported to the Croatian FIU, or screened against a live list.
`;
openDrawer(item.id, `${item.title} · ${customer?.name || item.customerId}`, body);
}
function openSwiftDetails(id) {
const item = state.swiftMessages.find(message => message.id === id);
if (!item) return;
const body = `! ${esc(t('swiftDisclaimer'))}
${esc(t('type'))} ${esc(item.type)}
${esc(t('status'))} ${badge(item.status)}
${esc(t('beneficiary'))} ${esc(item.beneficiary)}
${esc(t('bic'))} ${esc(item.bic || '—')}
${esc(t('amount'))} ${esc(currency(item.amountCents, item.currency))}
${esc(t('reference'))} ${esc(item.reference)}
MT103 local preview · not sent ${esc(item.mt103 || `:20:${item.id}\n:23B:CRED\n:32A:DEMO\n:59:${item.beneficiary}\n:57A:${item.bic || 'TESTBICXXX'}`)} `;
openDrawer(item.id, t('swiftDisclaimer'), body);
}
function openJournalDetails(id) {
const journal = state.ledgerJournals.find(item => item.id === id);
if (!journal) return;
const lines = journal.lines.map(line => `${esc(line.accountCode)} ${esc(state.glAccounts.find(account => account.code === line.accountCode)?.name || '')} ${esc(line.debitCents ? currency(line.debitCents) : '—')} ${esc(line.creditCents ? currency(line.creditCents) : '—')} `);
openDrawer(journal.id, journal.description, `${esc(t('date'))} ${esc(fmtDate(journal.at, true))}
${esc(t('reference'))} ${esc(journal.ref || '—')}
${esc(t('debit'))} / ${esc(t('credit'))} ${esc(currency(journal.debitCents))} = ${esc(currency(journal.creditCents))}
${tableShell([t('code'), t('account'), t('debit'), t('credit')], lines)}${esc(t('cleared'))} Double-entry check: ${journal.debitCents === journal.creditCents ? 'PASS' : 'FAIL'}
`);
}
function openNotifications() {
const openCases = state.cases.filter(item => item.status !== 'closed').length;
const approvals = state.payments.filter(item => item.status === 'awaiting_approval').length + state.swiftMessages.filter(item => item.status === 'awaiting_approval').length + (state.walletTransfers || []).filter(item => item.status === 'awaiting_approval').length;
const body = `${openCases} synthetic AML cases need review Illustrative scenario alerts · no real screening
Open → ${approvals} instructions in checker queue Payment / SWIFT drafts · offline only
Review → Local demo environment running All data stored in this browser profile
OK `;
openDrawer(t('notifications') === 'notifications' ? 'Notifications' : t('notifications'), t('localSimulation'), body);
}
function toggleRole() {
const index = STAFF_LEVELS.findIndex(role => role.id === state.settings.role);
const role = STAFF_LEVELS[(index + 1 + STAFF_LEVELS.length) % STAFF_LEVELS.length];
state.settings.role = role.id;
state.settings.operator = role.operator;
addAudit(state, 'Demo staff level switched', `Level ${role.level} · ${t(role.label)}; local-only simulation`);
persist();
toast(`${t('roleLevel')} ${role.level}: ${t(role.label)}`, 'success');
}
function downloadBlob(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url; anchor.download = filename; document.body.append(anchor); anchor.click(); anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportJson() {
const snapshot = { ...state, exportedAt: new Date().toISOString(), demoOnly: true, documentBrand: DEMO_BRAND, disclaimer: 'Fictional local training data. Not a live bank, payment, identity, SWIFT, card, crypto or compliance system.' };
downloadBlob('adria-bank-demo-scenario.json', JSON.stringify(snapshot, null, 2), 'application/json');
toast(t('exportReady'));
}
function exportInteropLabJson() {
const lab = ensureInteropLabState(state);
const snapshot = {
documentType: 'synthetic_interoperability_lab_export', documentBrand: DEMO_BRAND, generatedAt: new Date().toISOString(), demoOnly: true,
disclaimer: t('interopBoundary'), systems: INTEROP_SYSTEMS, activeSystemId: lab.activeSystemId, bankConnectors: lab.bankConnectors, venues: lab.venues,
runs: lab.runs, alerts: lab.alerts, disputes: lab.disputes, demoSessions: lab.demoSessions, killSwitch: lab.killSwitch,
containsCustomerData: false, containsExternalMarketData: false, containsCredentials: false, containsRealIdentifiers: false
};
downloadBlob('adria-bank-demo-interoperability-lab.json', JSON.stringify(snapshot, null, 2), 'application/json');
toast(t('exportReady'));
}
function exportCsv() {
downloadBlob('adria-bank-demo-transactions.csv', '\ufeff' + exportAsCsv(state), 'text/csv;charset=utf-8');
toast(t('exportReady'));
}
function csvCell(value) {
let text = String(value ?? '');
if (/^[=+@\-\t\r]/.test(text)) text = `'${text}`;
return `\"${text.replaceAll('\"', '\"\"')}\"`;
}
function exportStatementCsv(customerId = portalCustomerId) {
const customer = customerById(customerId);
if (!customer) return;
const accounts = customerAccounts(customerId);
const transactions = state.transactions.filter(tx => tx.customerId === customerId).sort((a, b) => new Date(b.date) - new Date(a.date));
const rows = [
['document_type', 'fictional_demo_account_statement'],
['document_brand', DEMO_BRAND],
['demo_only', 'true'],
['customer_id', customer.id],
['customer_name', customer.name],
['customer_kind', customer.kind],
['created_at', new Date().toISOString()],
[],
['account_id', 'account_label', 'country_code', 'demo_iban_or_domestic_id', 'demo_bic', 'currency', 'native_balance', 'routing_ready'],
...accounts.map(account => [account.id, account.label, account.countryCode || customer.country, account.iban || account.accountNumber, account.bic || createDemoBic(account.countryCode || customer.country), account.currency, (account.balanceCents / 100).toFixed(2), 'false']),
[],
['transaction_id', 'date', 'account_id', 'type', 'direction', 'amount', 'currency', 'counterparty', 'reference', 'status', 'demo_only', 'document_brand'],
...transactions.map(tx => [tx.id, tx.date, tx.accountId, tx.type, tx.direction, (tx.amountCents / 100).toFixed(2), tx.currency, tx.counterparty, tx.reference, tx.status, 'true', DEMO_BRAND])
];
downloadBlob(`adria-bank-demo-statement-${customer.id}.csv`, '\ufeff' + rows.map(row => row.map(csvCell).join(',')).join('\r\n'), 'text/csv;charset=utf-8');
toast(t('exportReady'));
}
function printCurrentPage() {
const titleKey = ({ branches: 'branchesCash', atms: 'atmNetwork', aml: 'amlKyc', complaints: 'complaints', swift: 'swiftLab', portal: 'customerPortal', onboarding: 'onlineOnboarding', institutions: 'institutions', interop: 'interoperability', checker: 'localChecks', support: 'callCentre' })[currentView] || currentView;
const clone = main.cloneNode(true);
clone.querySelectorAll('.heading-actions, .panel-actions, .toolbar, .table-pager, button, input, select, textarea, form, .assistant-form, .toast').forEach(node => node.remove());
openPrintDocument(t(titleKey), clone.innerHTML);
}
function handleSearchPopover(query) {
if (!currentSearchPopover) {
currentSearchPopover = document.createElement('div');
currentSearchPopover.className = 'command-popover';
currentSearchPopover.id = 'commandPopover';
document.body.append(currentSearchPopover);
}
const value = query.trim().toLocaleLowerCase(locale());
if (!value) { currentSearchPopover.innerHTML = `${esc(t('search'))} · ${esc(t('customers'))}, ${esc(t('accounts'))}, ${esc(t('transactionHistory'))}
${icon('spark')} ${esc(t('safeLearning'))} ${esc(t('syntheticData'))}
`; return; }
const results = [];
state.customers.filter(customer => `${customer.id} ${customer.name} ${customer.city}`.toLocaleLowerCase(locale()).includes(value)).slice(0, 5).forEach(customer => results.push({ kind: 'customer', icon: 'users', title: customer.name, sub: `${customer.id} · ${customer.city}`, id: customer.id }));
state.accounts.filter(account => `${account.id} ${account.iban}`.toLocaleLowerCase(locale()).includes(value)).slice(0, 4).forEach(account => results.push({ kind: 'account', icon: 'account', title: account.id, sub: `${customerById(account.customerId)?.name || ''} · ${currency(account.balanceCents)}`, id: account.customerId }));
state.transactions.filter(tx => `${tx.id} ${tx.reference} ${tx.counterparty} ${txLabel(tx)}`.toLocaleLowerCase(locale()).includes(value)).slice(0, 4).forEach(tx => results.push({ kind: 'transaction', icon: 'payments', title: txLabel(tx), sub: `${tx.id} · ${currency(tx.amountCents)} · ${fmtDate(tx.date)}`, id: tx.customerId }));
currentSearchPopover.innerHTML = `${results.length} ${esc(t('results'))} · ${esc(t('demoOnly'))}
${results.length ? results.map(result => `${icon(result.icon)} ${esc(result.title)} ${esc(result.sub)} `).join('') : `${esc(t('noRecords'))}
`}`;
}
function closeSearchPopover() { currentSearchPopover?.remove(); currentSearchPopover = null; }
function changeOnboardingStep(offset) {
const form = modalRoot.querySelector('form[data-form="onboarding"]');
if (!form) return false;
const pages = [...form.querySelectorAll('[data-onboarding-step]')];
const currentStep = Number(form.dataset.activeStep) || 1;
const nextStep = Math.max(1, Math.min(pages.length, currentStep + offset));
if (nextStep > currentStep) {
const currentPage = pages.find(page => Number(page.dataset.onboardingStep) === currentStep);
const invalid = [...(currentPage?.querySelectorAll('input, select, textarea') || [])].find(field => field.required && !field.checkValidity());
if (invalid) { invalid.reportValidity?.(); invalid.focus(); return false; }
}
form.dataset.activeStep = String(nextStep);
pages.forEach(page => {
const active = Number(page.dataset.onboardingStep) === nextStep;
page.hidden = !active;
page.setAttribute('aria-hidden', String(!active));
});
form.querySelectorAll('[data-onboarding-progress]').forEach(step => {
const number = Number(step.dataset.onboardingProgress);
step.classList.toggle('active', number === nextStep);
step.classList.toggle('complete', number < nextStep);
if (number === nextStep) step.setAttribute('aria-current', 'step');
else step.removeAttribute('aria-current');
});
const back = form.querySelector('[data-action="onboarding-back"]');
const next = form.querySelector('[data-action="onboarding-next"]');
const submit = form.querySelector('[data-action="onboarding-submit"]');
if (back) back.hidden = nextStep === 1;
if (next) next.hidden = nextStep === pages.length;
if (submit) submit.hidden = nextStep !== pages.length;
form.querySelector(`[data-onboarding-step="${nextStep}"] input:not([type="hidden"]):not([type="checkbox"]), [data-onboarding-step="${nextStep}"] select`)?.focus();
return true;
}
function submitForm(form, event) {
event.preventDefault();
if (form.dataset.form === 'onboarding' && (Number(form.dataset.activeStep) || 1) < 3) { changeOnboardingStep(1); return; }
const formType = form.dataset.form;
if (formType === 'onboarding' && !form.checkValidity()) {
const invalid = [...form.querySelectorAll('input, select, textarea')].find(field => field.required && !field.checkValidity());
invalid?.reportValidity?.(); invalid?.focus();
return;
}
const readOnlySafeForms = ['assistant-staff', 'assistant-customer', 'reference-check'];
if (document.body.dataset.surface !== 'client' && currentView !== 'portal' && !canWrite() && !readOnlySafeForms.includes(formType)) { toast(t('roleReadOnly'), 'warning'); return; }
const formData = new FormData(form);
const get = key => formData.get(key);
try {
switch (form.dataset.form) {
case 'onboarding': {
const application = submitDemoOnboardingApplication(state, {
customerId: get('customerId'), customerKind: get('customerKind'), countryCode: get('countryCode'),
products: formData.getAll('products'), identityScenario: get('identityScenario'), screeningScenario: get('screeningScenario'),
syntheticConfirmed: formData.get('syntheticConfirmed') === 'on', noRealDataConfirmed: formData.get('noRealDataConfirmed') === 'on'
});
closeModal(); persist(); toast(`${t('onboardingSubmitted')} · ${application.id}`); break;
}
case 'customer':
createCustomer(state, { name: get('name'), kind: get('kind'), city: get('city'), country: get('country'), branchId: get('branchId'), email: get('email'), syntheticConfirmed: formData.get('syntheticConfirmed') === 'on' });
closeModal(); persist(); toast(t('customerCreated')); break;
case 'account':
openAccount(state, get('customerId'), get('currency'));
closeModal(); persist(); toast(t('accountOpened')); break;
case 'transfer':
createInternalTransfer(state, { fromAccountId: get('fromAccountId'), toAccountId: get('toAccountId'), amountCents: moneyToCents(get('amount')), reference: get('reference') });
closeModal(); persist(); toast(t('transferSaved')); break;
case 'account-conversion': {
const details = conversionReferenceDetails(getAccount(state, get('fromAccountId'))?.currency, getAccount(state, get('toAccountId'))?.currency);
convertBetweenAccounts(state, { fromAccountId: get('fromAccountId'), toAccountId: get('toAccountId'), amountCents: moneyToCents(get('amount')), referenceDate: details.referenceDate, source: details.source });
closeModal(); persist(); toast(t('fxConversionComplete')); break;
}
case 'interbank-transfer': {
const transfer = createInterbankTransfer(state, { fromAccountId: get('fromAccountId'), toAccountId: get('toAccountId'), amountCents: moneyToCents(get('amount')), reference: get('reference'), purpose: get('purpose') });
closeModal(); persist(); toast(`${t('interbankQueued')} · ${transfer.id}`, 'warning'); break;
}
case 'support-ticket': {
const ticket = createSupportTicket(state, { customerId: get('customerId'), category: get('category'), subject: get('subject'), message: get('message'), syntheticConfirmed: formData.get('syntheticConfirmed') === 'on' });
closeModal(); persist(); toast(`${t('ticketCreated')} · ${ticket.id}`); break;
}
case 'assistant-staff':
case 'assistant-customer': {
const audience = form.dataset.form.endsWith('staff') ? 'staff' : 'customer';
const customer = audience === 'customer' ? customerById(portalCustomerId) : null;
const question = String(get('question') || '').trim();
if (!question) break;
const messages = assistantChat(audience, customer?.id || '');
messages.push({ role: 'user', text: question });
const context = { rateDate: fxReferenceData.ecb.rateDate, accountSummary: customer ? customerAccounts(customer.id).filter(account => account.status === 'active').map(account => `${account.currency} ${currency(account.balanceCents, account.currency)}`).join(' · ') : '' };
messages.push({ role: 'assistant', text: localAssistantReply({ question, audience, context, language: state.settings.language }) });
if (messages.length > 12) messages.splice(0, messages.length - 12);
renderView();
break;
}
case 'reference-check': {
referenceCheckResult = verifyDemoReference(state, { type: get('type'), value: get('value') });
renderView();
break;
}
case 'payment':
createExternalPayment(state, { accountId: get('accountId'), beneficiary: get('beneficiary'), country: get('country'), bic: get('bic'), iban: get('iban'), amountCents: moneyToCents(get('amount')), rail: get('rail'), reference: get('reference'), notes: get('notes') });
closeModal(); persist(); toast(t('paymentQueued'), 'warning'); break;
case 'incoming-payment':
createIncomingPayment(state, { accountId: get('accountId'), sender: get('sender'), country: get('country'), amountCents: moneyToCents(get('amount')), rail: get('rail'), reference: get('reference'), notes: get('notes') });
closeModal(); persist(); toast(t('paymentQueued'), 'warning'); break;
case 'crypto-transfer': {
const transfer = createCryptoTransfer(state, { customerId: get('customerId'), asset: get('asset'), units: get('units'), destination: get('destination') });
closeModal(); persist(); toast(`${t('cryptoQueued')} · ${transfer.id}`, 'warning'); break;
}
case 'crypto-receipt': {
const transfer = createCryptoReceipt(state, { customerId: get('customerId'), asset: get('asset'), units: get('units'), sender: get('sender') });
closeModal(); persist(); toast(`${t('cryptoQueued')} · ${transfer.id}`, 'warning'); break;
}
case 'complaint': {
const complaint = createComplaint(state, { customerId: get('customerId'), category: get('category'), subject: get('subject'), message: get('message'), priority: get('priority'), syntheticConfirmed: formData.get('syntheticConfirmed') === 'on' });
closeModal(); persist(); toast(`${t('complaintCreated')} · ${complaint.id}`); break;
}
case 'kyc-request': {
submitDemoKycRequest(state, get('customerId'), formData.get('syntheticConfirmed') === 'on', { sourceOfFunds: get('sourceOfFunds'), purpose: get('purpose'), expectedMonthlyBand: get('expectedMonthlyBand'), mockDocuments: formData.getAll('mockDocuments'), ownershipSectionReviewed: formData.get('ownershipSectionReviewed') === 'on', syntheticRiskScenario: get('riskScenario') });
closeModal(); persist(); toast(t('kycRequestSaved'), 'warning'); break;
}
case 'cash':
recordCashOperation(state, { operation: get('operation'), branchId: get('branchId'), accountId: get('accountId'), amountCents: moneyToCents(get('amount')) });
closeModal(); persist(); toast(t('cashComplete')); break;
case 'card':
issueCard(state, { accountId: get('accountId'), product: get('product') });
closeModal(); persist(); toast(t('cardCreated')); break;
case 'card-controls':
setCardControls(state, get('cardId'), {
dailyLimitCents: moneyToCents(get('dailyLimit')), monthlyLimitCents: moneyToCents(get('monthlyLimit')),
onlineEnabled: formData.get('onlineEnabled') === 'on',
internationalEnabled: formData.get('internationalEnabled') === 'on',
recurringEnabled: formData.get('recurringEnabled') === 'on'
});
closeModal(); persist(); toast(t('cardControlsSaved')); break;
case 'card-load':
loadPrepaidCard(state, get('cardId'), moneyToCents(get('amount')));
closeModal(); persist(); toast(t('cardLoaded')); break;
case 'card-purchase':
simulateCardPurchase(state, {
cardId: get('cardId'), amountCents: moneyToCents(get('amount')), merchant: get('merchant'),
channel: get('channel'), international: formData.get('international') === 'on'
});
closeModal(); persist(); toast(t('cardPurchaseComplete')); break;
case 'deposit':
openSavingsDeposit(state, { accountId: get('accountId'), amountCents: moneyToCents(get('amount')), termMonths: Number(get('termMonths')) });
closeModal(); persist(); toast(t('depositOpened')); break;
case 'loan':
createLoanApplication(state, { accountId: get('accountId'), amountCents: moneyToCents(get('amount')), termMonths: Number(get('termMonths')), product: get('product'), purpose: get('purpose') });
closeModal(); persist(); toast(t('loanApplied'), 'warning'); break;
case 'loan-repayment':
makeLoanRepayment(state, { loanId: get('loanId'), accountId: get('accountId'), amountCents: moneyToCents(get('amount')) });
closeModal(); persist(); toast(t('loanRepaid')); break;
case 'atm':
simulateAtmWithdrawal(state, { atmId: get('atmId'), accountId: get('accountId'), amountCents: moneyToCents(get('amount')) });
closeModal(); persist(); toast(t('atmComplete')); break;
case 'swift':
createSwiftMessage(state, { accountId: get('accountId'), beneficiary: get('beneficiary'), country: get('country'), bank: get('bank'), bic: get('bic'), iban: get('iban'), amountCents: moneyToCents(get('amount')), purpose: get('purpose') });
closeModal(); persist(); toast(t('swiftPrepared'), 'warning'); break;
case 'exchange': {
if (get('acknowledge') !== 'on') throw new Error(t('quoteDisclosure'));
const trade = executeExchangeTrade(state, { accountId: get('accountId'), side: exchangeSide, asset: get('asset'), amount: Number(get('amount')) });
persist(); toast(`${t('tradeComplete')} · ${trade.id}`); break;
}
}
} catch (error) {
toast(error.message || t('invalid'), 'error');
}
}
function handleAction(actionElement, sourceEvent) {
const action = actionElement.dataset.action;
const id = actionElement.dataset.id;
const displayedProfileId = document.getElementById('portalCustomerSelect')?.value;
if (displayedProfileId && state.customers.some(customer => customer.id === displayedProfileId)) portalCustomerId = displayedProfileId;
const writeActions = ['new-customer', 'new-account', 'new-account-for', 'transfer', 'new-payment', 'incoming-payment', 'cancel-payment', 'crypto-send', 'crypto-receive', 'cancel-crypto', 'new-complaint', 'cash-operation', 'issue-card', 'new-deposit', 'new-loan', 'repay-loan', 'atm-withdrawal', 'new-swift', 'mature-savings', 'card-status', 'card-controls', 'card-load', 'card-purchase', 'card-cancel', 'case-status', 'kyc-verify', 'kyc-edd', 'reconcile-atms', 'new-interbank', 'new-support-ticket', 'support-status', 'account-conversion', 'toggle-background-simulation', 'onboarding-status', 'interop-connector-toggle', 'interop-connector-refresh', 'interop-feature-run', 'interop-system-select', 'interop-reset'];
const approvalActions = ['approve-loan', 'disburse-loan', 'approve-payment', 'approve-swift', 'approve-interbank', 'approve-crypto'];
const complianceActions = ['case-status', 'kyc-verify', 'kyc-edd', 'complaint-status', 'onboarding-status'];
if (document.body.dataset.surface !== 'client' && currentView !== 'portal' && writeActions.includes(action) && !canWrite()) { toast(t('roleReadOnly'), 'warning'); return; }
if (approvalActions.includes(action) && !canApprove()) { toast(t('roleCheckerOnly'), 'warning'); return; }
if (complianceActions.includes(action) && !canReviewCompliance()) { toast(t('roleComplianceOnly'), 'warning'); return; }
if (action === 'reset-demo' && !canAdminister()) { toast(t('roleAdminOnly'), 'warning'); return; }
try {
switch (action) {
case 'toggle-sidebar': $('#appShell').classList.toggle('sidebar-open'); break;
case 'modal-close': closeModal(); break;
case 'modal-scrim': if (sourceEvent.target === actionElement) closeModal(); break;
case 'drawer-scrim': if (sourceEvent.target === actionElement) closeModal(); break;
case 'portal-open-account': openModal('account', { customerId: portalCustomerId }); break;
case 'portal-onboarding': openModal('onboarding', { customerId: portalCustomerId }); break;
case 'onboarding-detail': openModal('onboarding-review', { applicationId: id }); break;
case 'onboarding-next': changeOnboardingStep(1); break;
case 'onboarding-back': changeOnboardingStep(-1); break;
case 'onboarding-status': updateDemoOnboardingApplication(state, id, actionElement.dataset.status); persist(); toast(t('statusChanged')); break;
case 'portal-kyc': openModal('kyc-request', { customerId: portalCustomerId }); break;
case 'portal-transfer': openModal('transfer', { customerId: portalCustomerId }); break;
case 'portal-convert': openModal('account-conversion', { customerId: portalCustomerId }); break;
case 'portal-interbank': openModal('interbank-transfer', { customerId: portalCustomerId }); break;
case 'portal-support': openModal('support-ticket', { customerId: portalCustomerId }); break;
case 'portal-payment': openModal('payment', { customerId: portalCustomerId }); break;
case 'portal-incoming': openModal('incoming-payment', { customerId: portalCustomerId }); break;
case 'portal-crypto-send': openModal('crypto-transfer', { customerId: portalCustomerId }); break;
case 'portal-crypto-receive': openModal('crypto-receipt', { customerId: portalCustomerId }); break;
case 'portal-complaint': openModal('complaint', { customerId: portalCustomerId }); break;
case 'new-complaint': openModal('complaint'); break;
case 'new-payment': openModal('payment'); break;
case 'incoming-payment': openModal('incoming-payment'); break;
case 'portal-loan': openModal('loan', { customerId: portalCustomerId }); break;
case 'portal-deposit': openModal('deposit', { customerId: portalCustomerId }); break;
case 'portal-card': openModal('card', { customerId: portalCustomerId }); break;
case 'card-controls': openModal('card-controls', { cardId: id, ...(document.body.dataset.surface === 'client' || currentView === 'portal' ? { customerId: portalCustomerId } : {}) }); break;
case 'card-load': openModal('card-load', { cardId: id, ...(document.body.dataset.surface === 'client' || currentView === 'portal' ? { customerId: portalCustomerId } : {}) }); break;
case 'card-purchase': openModal('card-purchase', { cardId: id || '', ...(document.body.dataset.surface === 'client' || currentView === 'portal' ? { customerId: portalCustomerId } : {}) }); break;
case 'card-cancel': {
const card = state.cards.find(item => item.id === id);
if (!card || ((document.body.dataset.surface === 'client' || currentView === 'portal') && card.customerId !== portalCustomerId)) throw new Error(t('roleReadOnly'));
openModal('confirm', { title: t('closeCard'), subtitle: t('localSimulation'), message: `${t('cardCancellationNote')} ${card.fundingType === 'prepaid' ? t('prepaidCancellationRefund') : ''}`, tone: 'danger', confirmLabel: t('closeCard'), onConfirm: () => { try { setCardStatus(state, id, 'cancelled'); closeModal(); persist(); toast(t('cardCancelled')); } catch (error) { closeModal(); toast(error.message, 'error'); } } });
break;
}
case 'portal-exchange': exchangeCustomerId = portalCustomerId; exchangeTab = 'fx'; navigate('exchange'); break;
case 'new-customer': openModal('customer'); break;
case 'new-account': openModal('account'); break;
case 'new-account-for': closeModal(); openModal('account', { customerId: id }); break;
case 'account-conversion': openModal('account-conversion', (document.body.dataset.surface === 'client' || currentView === 'portal') ? { customerId: portalCustomerId } : exchangeCustomerId ? { customerId: exchangeCustomerId } : {}); break;
case 'new-interbank': openModal('interbank-transfer'); break;
case 'new-support-ticket': openModal('support-ticket'); break;
case 'crypto-send': openModal('crypto-transfer'); break;
case 'crypto-receive': openModal('crypto-receipt'); break;
case 'local-checker': navigate('checker'); break;
case 'refresh-fx': void refreshFxReferences(true); break;
case 'fetch-market-reference': void refreshOptionalMarketFeed(); break;
case 'clear-check-result': referenceCheckResult = null; renderView(); break;
case 'assistant-prompt': {
const audience = actionElement.dataset.audience === 'staff' ? 'staff' : 'customer';
const customer = audience === 'customer' ? customerById(portalCustomerId) : null;
const question = actionElement.dataset.question || '';
const messages = assistantChat(audience, customer?.id || '');
messages.push({ role: 'user', text: question });
const context = { rateDate: fxReferenceData.ecb.rateDate, accountSummary: customer ? customerAccounts(customer.id).filter(account => account.status === 'active').map(account => `${account.currency} ${currency(account.balanceCents, account.currency)}`).join(' · ') : '' };
messages.push({ role: 'assistant', text: localAssistantReply({ question, audience, context, language: state.settings.language }) });
if (messages.length > 12) messages.splice(0, messages.length - 12);
renderView();
break;
}
case 'support-status': {
const note = document.getElementById(`support-note-${id}`)?.value || '';
updateSupportTicket(state, id, actionElement.dataset.status, note);
persist(); toast(t('ticketUpdated')); break;
}
case 'transfer': openModal('transfer'); break;
case 'cash-operation': openModal('cash'); break;
case 'issue-card': openModal('card'); break;
case 'new-deposit': openModal('deposit'); break;
case 'new-loan': openModal('loan'); break;
case 'repay-loan': openModal('loan-repayment', { loanId: id }); break;
case 'approve-loan':
openModal('confirm', { title: t('approveLoan'), subtitle: t('checkerRole'), message: t('approveLoanConfirm'), confirmLabel: t('approveLoan'), onConfirm: () => { try { approveLoanApplication(state, id); closeModal(); persist(); toast(t('loanApproved')); } catch (error) { closeModal(); toast(error.message, 'error'); } } }); break;
case 'disburse-loan':
openModal('confirm', { title: t('disburseLoan'), subtitle: t('localSimulation'), message: t('disburseLoanConfirm'), confirmLabel: t('disburseLoan'), onConfirm: () => { try { disburseLoan(state, id); closeModal(); persist(); toast(t('loanDisbursed')); } catch (error) { closeModal(); toast(error.message, 'error'); } } }); break;
case 'atm-withdrawal': openModal('atm', { atmId: id }); break;
case 'new-swift': openModal('swift'); break;
case 'interop-tab': interopTab = actionElement.dataset.tab === 'ideas' ? 'ideas' : 'workbench'; renderView(); break;
case 'interop-category': interopCategory = ['all', 'banking', 'payments', 'controls', 'crypto', 'operations'].includes(actionElement.dataset.category) ? actionElement.dataset.category : 'all'; renderView(); break;
case 'interop-context-open': interopTab = 'workbench'; navigate('interop'); break;
case 'interop-system-select': {
const system = selectInteropSystem(state, id);
interopCategory = 'all';
persist();
toast(`${t('interopSystemChanged')}: ${system.name}`, 'success');
break;
}
case 'interop-connector-toggle': {
const connector = toggleInteropConnector(state, id);
persist();
toast(`${connector.name} · ${t(connector.status === 'connected_demo' ? 'interopActive' : 'interopPaused')}`, 'success');
break;
}
case 'interop-connector-refresh': {
const connector = refreshInteropConnector(state, id);
persist();
toast(`${connector.name} · ${t('interopSnapshot')}`, 'success');
break;
}
case 'interop-feature-run': {
const run = runInteropFeature(state, id);
persist();
if (['audit-export', 'audit-evidence-pack', 'portability-kit'].includes(id)) exportInteropLabJson();
toast(`${t('interopRunSuccess')}: ${state.settings.language === 'hr' ? run.summaryHr : run.summaryEn}`, 'success');
break;
}
case 'interop-export': exportInteropLabJson(); break;
case 'interop-reset':
openModal('confirm', { title: t('interopReset'), subtitle: t('localOnly'), message: t('interopResetConfirm'), tone: 'warning', confirmLabel: t('interopReset'), onConfirm: () => { resetInteropLab(state); interopTab = 'workbench'; closeModal(); persist(); toast(t('interopReset'), 'success'); } });
break;
case 'reset-demo': openModal('confirm', { title: t('resetDemo'), message: t('resetConfirm'), tone: 'danger', confirmLabel: t('resetDemo'), onConfirm: () => { localStorage.removeItem(STORE_KEY); state = createDemoState(); ensureInteropLabState(state); portalCustomerId = state.accounts.find(account => account.status === 'active' && state.customers.find(customer => customer.id === account.customerId)?.kyc === 'verified')?.customerId || state.customers[0]?.id; exchangeCustomerId = null; currentView = 'overview'; closeModal(); persist(); toast(t('statusChanged')); } }); break;
case 'confirm-pending': if (pendingConfirmation) pendingConfirmation(); break;
case 'download-csv': exportCsv(); break;
case 'export-json': exportJson(); break;
case 'operator-menu': toggleRole(); break;
case 'toggle-background-simulation': {
state.settings.backgroundSimulationEnabled = state.settings.backgroundSimulationEnabled === false;
persist();
void syncBackgroundRuntime();
toast(t(state.settings.backgroundSimulationEnabled ? 'backgroundStarted' : 'backgroundPausedToast'), state.settings.backgroundSimulationEnabled ? 'success' : 'warning');
break;
}
case 'notifications': openNotifications(); break;
case 'customer-open': openCustomer(id); break;
case 'print-current': printCurrentPage(); break;
case 'print-statement': printStatement(id || portalCustomerId, actionElement.dataset.accountId || ''); break;
case 'export-statement': exportStatementCsv(id || portalCustomerId); break;
case 'print-transaction': printTransaction(id); break;
case 'print-payment': printPayment(id); break;
case 'print-swift': printSwift(id); break;
case 'case-open': openCase(id); break;
case 'kyc-review': openKycDetails(id); break;
case 'complaint-open': openComplaint(id); break;
case 'swift-open': openSwiftDetails(id); break;
case 'journal-open': openJournalDetails(id); break;
case 'payment-tab': paymentTab = actionElement.dataset.tab; renderView(); break;
case 'aml-tab': amlTab = actionElement.dataset.tab; renderView(); break;
case 'exchange-tab': exchangeTab = actionElement.dataset.tab; renderView(); break;
case 'exchange-side': exchangeSide = actionElement.dataset.side; renderView(); break;
case 'approve-interbank':
openModal('confirm', { title: t('simulateSettlement'), subtitle: t('checkerRole'), message: t('interbankSettlementConfirm'), confirmLabel: t('simulateSettlement'), onConfirm: () => { try { approveInterbankTransfer(state, id); closeModal(); persist(); toast(t('interbankSettled')); } catch (error) { closeModal(); toast(error.message, 'error'); } } }); break;
case 'approve-payment':
openModal('confirm', { title: t('approve'), subtitle: t('checkerRole'), message: `${t('approvalNotice')} Payment ${id} will only change the fictional local ledger. No money is transmitted or received.`, confirmLabel: t('approve'), onConfirm: () => { try { approvePayment(state, id); closeModal(); persist(); toast(t('approvedNotice')); } catch (error) { closeModal(); toast(error.message, 'error'); } } }); break;
case 'cancel-payment': {
const payment = state.payments.find(item => item.id === id);
if (!payment || payment.direction !== 'out' || payment.status !== 'awaiting_approval') throw new Error(t('stopBeforeApproval'));
if ((document.body.dataset.surface === 'client' || currentView === 'portal') && payment.customerId !== portalCustomerId) throw new Error(t('roleReadOnly'));
openModal('confirm', { title: t('stopPayment'), subtitle: t('localSimulation'), message: `${t('stopBeforeApproval')} ${payment.id} is still in the local maker/checker queue.`, tone: 'danger', confirmLabel: t('stopPayment'), onConfirm: () => { try { cancelPayment(state, id); closeModal(); persist(); toast(t('statusChanged'), 'warning'); } catch (error) { closeModal(); toast(error.message, 'error'); } } });
break;
}
case 'approve-crypto': {
openModal('confirm', { title: t('approveCrypto'), subtitle: t('checkerRole'), message: `${t('digitalAssetNotice')} No wallet provider or blockchain is contacted.`, confirmLabel: t('approveCrypto'), onConfirm: () => { try { approveCryptoTransfer(state, id); closeModal(); persist(); toast(t('cryptoApproved')); } catch (error) { closeModal(); toast(error.message, 'error'); } } });
break;
}
case 'cancel-crypto': {
const transfer = (state.walletTransfers || []).find(item => item.id === id);
if (!transfer || transfer.status !== 'awaiting_approval') throw new Error(t('cancelCrypto'));
if ((document.body.dataset.surface === 'client' || currentView === 'portal') && transfer.customerId !== portalCustomerId) throw new Error(t('roleReadOnly'));
openModal('confirm', { title: t('cancelCrypto'), subtitle: t('localSimulation'), message: `${t('digitalAssetNotice')} This only releases a local reservation or removes a pending receipt.`, tone: 'danger', confirmLabel: t('cancelCrypto'), onConfirm: () => { try { cancelCryptoTransfer(state, id); closeModal(); persist(); toast(t('cryptoCancelled'), 'warning'); } catch (error) { closeModal(); toast(error.message, 'error'); } } });
break;
}
case 'approve-swift':
openModal('confirm', { title: t('approve'), subtitle: t('checkerRole'), message: `${t('swiftDisclaimer')}. Approve the local rehearsal only?`, confirmLabel: t('approve'), onConfirm: () => { try { approveSwiftMessage(state, id); closeModal(); persist(); toast(t('approvedNotice')); } catch (error) { closeModal(); toast(error.message, 'error'); } } }); break;
case 'mature-savings':
openModal('confirm', { title: t('mature'), message: 'This will move the principal and indicative interest to the linked synthetic current account. No real interest is earned.', confirmLabel: t('mature'), onConfirm: () => { try { matureSavingsDeposit(state, id); closeModal(); persist(); toast(t('statusChanged')); } catch (error) { closeModal(); toast(error.message, 'error'); } } }); break;
case 'card-status': {
const card = state.cards.find(item => item.id === id);
if (!card || ((document.body.dataset.surface === 'client' || currentView === 'portal') && card.customerId !== portalCustomerId)) throw new Error(t('roleReadOnly'));
setCardStatus(state, id, actionElement.dataset.status); persist(); toast(t('statusChanged')); break;
}
case 'kyc-edd':
openModal('confirm', { title: t('enhancedReviewDemo'), subtitle: t('localSimulation'), message: t('kycFlowDisclosure'), confirmLabel: t('enhancedReviewDemo'), onConfirm: () => { try { updateCustomerKyc(state, id, 'edd'); closeModal(); persist(); toast(t('statusChanged')); } catch (error) { closeModal(); toast(error.message, 'error'); } } }); break;
case 'kyc-verify':
openModal('confirm', { title: t('markVerified'), message: 'This only changes a fictional status field. It does not verify identity, beneficial ownership, PEP or sanctions.', confirmLabel: t('markVerified'), onConfirm: () => { updateCustomerKyc(state, id, 'verified'); closeModal(); persist(); toast(t('statusChanged')); } }); break;
case 'case-status': {
const note = $('#caseReviewNote')?.value || '';
updateCaseStatus(state, id, actionElement.dataset.status, note); closeModal(); persist(); toast(t('caseUpdated')); break;
}
case 'complaint-status': {
const note = $('#complaintReviewNote')?.value || '';
updateComplaint(state, id, actionElement.dataset.status, note); closeModal(); persist(); toast(t('complaintUpdated')); break;
}
case 'reconcile-atms':
addAudit(state, 'ATM balances reconciled (demo)', 'Synthetic ATM totals checked against simulated general ledger.');
persist(); toast(t('statusChanged')); break;
case 'page': {
const name = actionElement.dataset.list;
const target = filterState[name];
if (!target) break;
target.page = Math.max(1, target.page + (actionElement.dataset.dir === 'next' ? 1 : -1));
renderView(); break;
}
case 'search-result':
closeSearchPopover(); $('#globalSearch').value = '';
if (actionElement.dataset.kind === 'customer' || actionElement.dataset.kind === 'transaction' || actionElement.dataset.kind === 'account') openCustomer(actionElement.dataset.id);
break;
case 'view-compliance': navigate('settings'); break;
case 'modal-close': closeModal(); break;
}
} catch (error) { toast(error.message || t('invalid'), 'error'); }
}
// Main event delegation keeps the bundled UI offline and avoids remote scripts.
document.addEventListener('click', event => {
const viewButton = event.target.closest('[data-view]');
if (viewButton) { event.preventDefault(); navigate(viewButton.dataset.view); return; }
const actionElement = event.target.closest('[data-action]');
if (actionElement) { handleAction(actionElement, event); return; }
if (currentSearchPopover && !event.target.closest('#commandPopover') && !event.target.closest('#globalSearch')) closeSearchPopover();
});
document.addEventListener('submit', event => {
const form = event.target.closest('form[data-form]');
if (form) submitForm(form, event);
});
document.addEventListener('input', event => {
const input = event.target;
if (input.id === 'globalSearch') { handleSearchPopover(input.value); return; }
if (input.closest('#modalRoot form[data-form="account-conversion"]')) { updateAccountConversionQuote(); return; }
if (input.id === 'exchangeAmount' || input.id === 'exchangeAsset') { updateExchangeQuote(); return; }
if (input.matches('[data-filter-list][data-filter-field]')) {
const listName = input.dataset.filterList;
const field = input.dataset.filterField;
if (filterState[listName]) {
filterState[listName][field] = input.value;
filterState[listName].page = 1;
const position = input.selectionStart;
const selector = `[data-filter-list="${listName}"][data-filter-field="${field}"]`;
renderView();
const next = $(selector);
if (next) { next.focus(); try { next.setSelectionRange(position, position); } catch { /* select elements do not support selection */ } }
}
}
});
document.addEventListener('change', event => {
const input = event.target;
if (input.name === 'type' && input.closest('form[data-form="reference-check"]')) {
const valueField = input.form?.elements?.value;
if (valueField) valueField.inputMode = input.value === 'pan' ? 'numeric' : 'text';
return;
}
if (input.matches('[data-demo-country]')) { updateDemoRoutingDisplay(input.closest('form')); return; }
if (input.closest('#modalRoot form[data-form="account-conversion"]')) { updateAccountConversionQuote(); return; }
if (input.id === 'backgroundInterval') {
const interval = Number(input.value);
if ([1, 5, 15, 60].includes(interval)) { state.settings.backgroundIntervalMinutes = interval; persist(false); void syncBackgroundRuntime(); renderView(); }
return;
}
if (input.id === 'backgroundKeepInTray') {
state.settings.keepRunningInTray = input.checked;
if (!input.checked) state.settings.startWithWindows = false;
persist(false); void syncBackgroundRuntime(); renderView(); return;
}
if (input.id === 'backgroundStartWithWindows') {
state.settings.startWithWindows = input.checked && state.settings.keepRunningInTray && backgroundDesktopSupported;
persist(false); void syncBackgroundRuntime(); renderView(); return;
}
if (input.id === 'portalCustomerSelect') { portalCustomerId = input.value; renderView(); return; }
if (input.id === 'languageSelect' || input.id === 'settingsLanguage') {
state.settings.language = input.value;
persist(); return;
}
if (input.matches('[data-filter-list][data-filter-field]')) {
const listName = input.dataset.filterList;
const field = input.dataset.filterField;
if (filterState[listName]) { filterState[listName][field] = input.value; filterState[listName].page = 1; renderView(); }
}
});
document.addEventListener('keydown', event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && $('#globalSearch')) { event.preventDefault(); $('#globalSearch').focus(); $('#globalSearch').select(); }
if (event.key === 'Escape') { closeSearchPopover(); if (modalRoot.childElementCount) closeModal(); }
});
window.addEventListener('hashchange', () => {
const view = location.hash.slice(1);
if (view && view !== currentView) navigate(view, false);
});
function updateAccountConversionQuote() {
const box = $('#accountConversionQuote');
if (!box) return;
const form = box.closest('form[data-form="account-conversion"]');
const from = state.accounts.find(account => account.id === form?.elements.fromAccountId?.value);
const to = state.accounts.find(account => account.id === form?.elements.toAccountId?.value);
const amount = Number(form?.elements.amount?.value || 0);
if (!from || !to || !Number.isFinite(amount) || amount <= 0) {
box.innerHTML = `${esc(t('conversionRate'))} —
${esc(t('indicativeSpread'))} 0.35%
${esc(t('indicativeOutput'))} —
`;
return;
}
if (from.currency === to.currency) {
box.innerHTML = `${esc(t('conversionRate'))} ${esc(t('conversionNeedDistinct'))}
${esc(t('indicativeSpread'))} 0.35%
${esc(t('indicativeOutput'))} —
`;
return;
}
const sourceMeta = CURRENCIES[from.currency];
const targetMeta = CURRENCIES[to.currency];
if (!sourceMeta || !targetMeta || sourceMeta.crypto || targetMeta.crypto || !Number.isFinite(sourceMeta.eurRate) || sourceMeta.eurRate <= 0 || !Number.isFinite(targetMeta.eurRate) || targetMeta.eurRate <= 0) {
box.innerHTML = `${esc(t('noReferenceRate'))}
`;
return;
}
try {
const amountCents = moneyToCents(amount);
const sourceEurCents = Math.round(amountCents * sourceMeta.eurRate);
const quantumCents = 10 ** (2 - targetMeta.decimals);
const targetCents = Math.floor((sourceEurCents * (1 - FX_SPREAD) / targetMeta.eurRate) / quantumCents) * quantumCents;
const rate = sourceMeta.eurRate / targetMeta.eurRate;
const rateText = `1 ${from.currency} ≈ ${new Intl.NumberFormat(locale(), { maximumFractionDigits: targetMeta.decimals ? 4 : 2 }).format(rate)} ${to.currency}`;
box.innerHTML = `${esc(t('conversionRate'))} ${esc(rateText)}
${esc(t('indicativeSpread'))} ${(FX_SPREAD * 100).toFixed(2)}%
${esc(t('indicativeOutput'))} ${esc(currency(targetCents, to.currency))}
`;
} catch { /* incomplete form input is normal */ }
}
function updateExchangeQuote() {
const quoteBox = $('#exchangeQuote');
if (!quoteBox) return;
const asset = $('#exchangeAsset')?.value || (exchangeTab === 'fx' ? fiatAssets()[0] : digitalAssets()[0]);
const raw = Number($('#exchangeAmount')?.value || 0);
if (!raw || raw <= 0) {
quoteBox.innerHTML = `${esc(t('indicativeRate'))} ${esc(t('notLivePrice'))}
${esc(t('fee'))} —
${esc(t('expectedOutput'))} —
`;
return;
}
try {
const quote = quoteExchange(asset, exchangeSide, raw);
const assetRate = CURRENCIES[asset].eurRate;
const quoteText = CURRENCIES[asset].crypto ? `1 ${asset} = ${currencyMajor(assetRate)} ` : `1 EUR = ${new Intl.NumberFormat(locale(), { maximumFractionDigits: asset === 'JPY' ? 2 : 4 }).format(1 / assetRate)} ${asset}`;
const out = exchangeSide === 'buy' ? `${new Intl.NumberFormat(locale(), { maximumFractionDigits: CURRENCIES[asset].decimals }).format(quote.units)} ${asset}` : currencyMajor(quote.payoutEur);
quoteBox.innerHTML = `${esc(t('indicativeRate'))} ${esc(quoteText)}
${esc(t('fee'))} · ${(quote.spread * 100).toFixed(2)}% ${esc(currencyMajor(quote.grossEur * quote.spread))}
${esc(t('expectedOutput'))} ${esc(out)}
`;
} catch { /* incomplete input is expected while typing */ }
}
function initialize() {
// If this is served as the separate exchange desk, begin directly on that module.
const requested = new URLSearchParams(location.search).get('module') || location.hash.slice(1);
if (requested && visibleNavGroups().some(group => group.items.some(([view]) => view === requested))) currentView = requested;
updateStaticText();
renderView();
if (!localStorage.getItem(STORE_KEY) || languageFromEntry) { try { saveDemoState(state); } catch { /* storage can be disabled; session remains usable */ } }
void refreshFxReferences(false);
void syncBackgroundRuntime();
setInterval(() => { void refreshFxReferences(false); }, 6 * 60 * 60 * 1000);
}
initialize();