function revenueCockpit() { return { loading: false, saving: false, savedAt: '', viewMode: 'cockpit', goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' }, projects: [], dbProjects: [], sheetRows: [], selectedId: null, selectedKey: null, expandedKey: null, meta: {}, aggregates: {}, filterQ: '', filterStyle: '', filterCategory: '', filterHasMargin: false, agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'], taskForm: { agent_name: 'research', title: '', description: '' }, styleFilters: [ { id: 'green', label: 'Live' }, { id: 'red', label: 'Inactief' }, { id: 'orange', label: 'Oranje' }, { id: 'yellow', label: 'Strategisch' }, { id: 'blue', label: 'Blauw' }, { id: 'white', label: 'Neutraal' }, ], categoryFilters: [ { id: 'deal', label: 'Deal' }, { id: 'initiative', label: 'Initiatief' }, { id: 'strategic', label: 'Strategisch' }, ], get selectedRow() { return this.dbProjects.find((p) => p.id === this.selectedId) || null; }, get selectedProject() { if (!this.selectedKey) return null; return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null; }, async init() { localStorage.setItem('foodlinkk-persona', 'ceo'); await this.loadLive(); await this.loadDbQuiet(); }, projectKey(p) { return String(p.source_row || '') + '|' + (p.name || ''); }, fmtNum(n) { if (n == null || n === '' || isNaN(n)) return ''; return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }, fmtEuro(n) { if (n == null || n === '' || isNaN(n)) return '—'; return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); }, fmtEuroCompact(n) { if (n == null || n === '' || isNaN(n)) return '—'; const v = Number(n); if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M'; if (v >= 1000) return '€' + Math.round(v / 1000) + 'k'; return '€' + Math.round(v); }, fmtDate(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }); } catch (e) { return iso; } }, categoryLabel(c) { if (c === 'deal') return 'Deal'; if (c === 'strategic') return 'Strategisch'; return 'Initiatief'; }, parseNum(text) { const t = String(text || '').trim(); if (!t || t === '—') return null; const n = parseFloat(t.replace(/\./g, '').replace(',', '.')); return isNaN(n) ? null : n; }, filteredProjects() { const q = (this.filterQ || '').trim().toLowerCase(); return this.projects.filter((p) => { if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false; if (this.filterCategory && p.category !== this.filterCategory) return false; if (this.filterHasMargin && !p.margin_month) return false; if (q) { const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase(); if (!hay.includes(q)) return false; } return true; }); }, clearFilters() { this.filterQ = ''; this.filterStyle = ''; this.filterCategory = ''; this.filterHasMargin = false; }, truncate(s, n) { if (!s) return ''; const t = String(s).replace(/\s+/g, ' ').trim(); return t.length <= n ? t : t.slice(0, n).trim() + '…'; }, styleColor(style) { const map = { green: '#22c55e', red: '#ef4444', orange: '#f59e0b', yellow: '#eab308', blue: '#3b82f6', white: '#64748b', }; return map[style] || map.white; }, foodlinkkIndexFrom(list) { const idx = list.findIndex((p) => (p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) || /foodlinkk food marketing/i.test(p.name) ); return idx >= 0 ? idx : list.length; }, visualRows() { const items = this.filteredProjects(); const flIdx = this.foodlinkkIndexFrom(items); return items.map((p, i) => { let logoCell = null; let brand = 'cucina'; if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk'; if (i === 0) logoCell = 'cucina'; else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk'; return { ...p, logoCell, brand }; }); }, maxMargin(field) { const vals = this.filteredProjects().map((p) => p[field] || 0); return Math.max(...vals, 1); }, marginBarPct(p, field) { if (!p[field]) return 0; return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100)); }, sharePct(p) { const total = this.aggregates.total_margin_month || 0; if (!p.margin_month || !total) return 0; return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10); }, donutStyle(p) { const pct = this.sharePct(p); const color = this.styleColor(p.row_style); return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, rgba(148,163,184,0.15) ' + pct + '% 100%)'; }, toggleExpand(p) { const key = this.projectKey(p); this.expandedKey = this.expandedKey === key ? null : key; this.selectedKey = key; this.taskForm.title = ''; this.taskForm.description = p.next_steps || ''; }, selectProject(p) { this.toggleExpand(p); }, mergeDbIds() { const byName = {}; for (const d of this.dbProjects) { const k = (d.name || '').trim().toLowerCase(); if (k) byName[k] = d.id; } this.projects = this.projects.map((p) => ({ ...p, db_id: byName[(p.name || '').trim().toLowerCase()] || null, })); }, async loadLive() { this.loading = true; try { const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json()); if (!r.ok) throw new Error(r.error || 'Excel laden mislukt'); this.goals = r.goals || {}; this.projects = r.projects || []; this.aggregates = { ...(r.aggregates || {}), project_count: r.project_count || this.projects.length, }; this.meta = { source_file: r.source_file, sheet_name: r.sheet_name, file_mtime: r.file_mtime, parsed_at: r.parsed_at, }; this.mergeDbIds(); } catch (e) { if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error'); } this.loading = false; }, async loadDbQuiet() { try { const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json()); this.dbProjects = r.projects || []; this.mergeDbIds(); } catch (e) { console.error(e); } }, foodlinkkIndex() { const idx = this.dbProjects.findIndex((p) => (p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) || /foodlinkk food marketing/i.test(p.name) ); return idx >= 0 ? idx : this.dbProjects.length; }, buildSheetRows() { const flIdx = this.foodlinkkIndex(); this.sheetRows = this.dbProjects.map((p, i) => { let logoCell = null; let logoSpan = 1; if (i === 0) { logoCell = 'cucina'; logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length; } else if (i === flIdx && flIdx < this.dbProjects.length) { logoCell = 'foodlinkk'; logoSpan = this.dbProjects.length - flIdx; } return { ...p, logoCell, logoSpan }; }); }, async switchToSheet() { this.viewMode = 'sheet'; await this.loadDb(); }, async loadDb() { this.loading = true; try { const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json()); this.goals = r.stats?.goals || this.goals; this.dbProjects = r.projects || []; this.buildSheetRows(); } catch (e) { if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error'); } this.loading = false; }, editStart(ev) { const el = ev.target; if (el.classList.contains('rc-cell-num')) { const raw = el.textContent.trim(); if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1'); } requestAnimationFrame(() => { const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(range); }); }, markSaved() { const now = new Date(); this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }, async saveGoalsField(field, el) { const val = el.innerText.trim(); if (this.goals[field] === val) return; this.saving = true; try { const r = await fetch('/api/revenue-cockpit/goals', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: val }), }).then((x) => x.json()); this.goals = r.goals || this.goals; this.markSaved(); } catch (e) { if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error'); } this.saving = false; }, async saveProjectField(row, field, el) { let val; if (field === 'margin_month' || field === 'margin_year') { val = this.parseNum(el.innerText); el.textContent = this.fmtNum(val); } else { val = el.innerText.trim(); } if (String(row[field] ?? '') === String(val ?? '')) return; const body = { [field]: val }; if (field === 'margin_month' && val != null) { body.margin_year = val * 12; } this.saving = true; try { const r = await fetch('/api/revenue-cockpit/projects/' + row.id, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then((x) => x.json()); const updated = r.project || {}; Object.assign(row, updated); row.row_style = updated.row_style || row.row_style; const pi = this.dbProjects.findIndex((p) => p.id === row.id); if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated }; if (field === 'margin_month' && body.margin_year != null) { const yearCell = el.nextElementSibling; if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year); row.margin_year = body.margin_year; } this.markSaved(); await this.loadLive(); } catch (e) { if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error'); } this.saving = false; }, selectRow(row) { this.selectedId = row.id; this.taskForm.title = ''; this.taskForm.description = row.next_steps || ''; }, async setRowStyle(style) { if (!this.selectedRow) return; try { await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ row_style: style }), }); this.selectedRow.row_style = style; const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id); if (pi >= 0) this.dbProjects[pi].row_style = style; this.buildSheetRows(); this.markSaved(); await this.loadLive(); } catch (e) { if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error'); } }, async assignTask() { const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject; const pid = row?.id || row?.db_id; if (!pid || !this.taskForm.title.trim()) return; try { await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent_name: this.taskForm.agent_name, title: this.taskForm.title.trim(), description: this.taskForm.description, delegate_herman: true, }), }); if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success'); this.taskForm.title = ''; } catch (e) { if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error'); } }, async takeSnapshot() { await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' }); if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success'); }, async importExcel() { if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return; this.loading = true; try { const r = await fetch('/api/revenue-cockpit/import-from-excel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ replace: true }), }).then((x) => x.json()); if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success'); await this.loadLive(); await this.loadDbQuiet(); if (this.viewMode === 'sheet') await this.loadDb(); } catch (e) { if (window.Cockpit) Cockpit.toast('Import mislukt', 'error'); } this.loading = false; }, }; }