Files
foodlinkk-command-center/cockpit/revenue-cockpit.js
T

423 lines
14 KiB
JavaScript

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;
},
};
}