423 lines
14 KiB
JavaScript
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;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|