243 lines
6.7 KiB
JavaScript
243 lines
6.7 KiB
JavaScript
'use strict';
|
|
|
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
const STATUS_OPTIONS = [
|
|
{ value: 'green', label: 'On track' },
|
|
{ value: 'amber', label: 'At risk' },
|
|
{ value: 'red', label: 'Off track' },
|
|
{ value: 'none', label: 'No data' },
|
|
];
|
|
|
|
function bowlerApp() {
|
|
return {
|
|
me: null,
|
|
isAdmin: false,
|
|
schemes: [],
|
|
year: new Date().getFullYear(),
|
|
years: [],
|
|
projects: [],
|
|
loading: true,
|
|
error: '',
|
|
months: MONTHS,
|
|
statusOptions: STATUS_OPTIONS,
|
|
|
|
// Dialog state
|
|
cell: { kpiId: null, kpiName: '', month: 1, value: '', status: 'none', note: '' },
|
|
proj: { id: null, name: '', description: '' },
|
|
kpi: { id: null, projectId: null, name: '', unit: '', goal_text: '', description: '' },
|
|
|
|
async init() {
|
|
const y = new Date().getFullYear();
|
|
this.years = [y - 2, y - 1, y, y + 1];
|
|
try {
|
|
const me = await API.get('/api/me');
|
|
this.me = me.user;
|
|
this.isAdmin = me.user.role === 'admin';
|
|
} catch (_) {
|
|
window.location.href = '/login.html';
|
|
return;
|
|
}
|
|
await this.loadSchemes();
|
|
await this.loadChart();
|
|
},
|
|
|
|
async loadSchemes() {
|
|
try {
|
|
const { schemes } = await API.get('/api/schemes');
|
|
this.schemes = schemes;
|
|
const scheme = Theme.resolve(schemes, this.me.preferred_scheme_id);
|
|
await Theme.apply(scheme);
|
|
} catch (_) {}
|
|
},
|
|
|
|
async loadChart() {
|
|
this.loading = true;
|
|
this.error = '';
|
|
try {
|
|
const data = await API.get(`/api/chart?year=${this.year}`);
|
|
this.projects = data.projects;
|
|
} catch (e) {
|
|
this.error = e.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
canEditAny() {
|
|
return this.projects.some((p) => p.can_edit);
|
|
},
|
|
|
|
// ---- Theme ----
|
|
async changeScheme(id) {
|
|
const scheme = this.schemes.find((s) => s.id === Number(id));
|
|
await Theme.apply(scheme);
|
|
try {
|
|
await API.patch('/api/me/scheme', { scheme_id: scheme ? scheme.id : null });
|
|
this.me.preferred_scheme_id = scheme ? scheme.id : null;
|
|
} catch (e) {
|
|
this.toast(e.message);
|
|
}
|
|
},
|
|
|
|
// ---- Cell editing ----
|
|
openCell(project, kpi, m) {
|
|
if (!project.can_edit) return;
|
|
this.cell = {
|
|
kpiId: kpi.id,
|
|
kpiName: kpi.name,
|
|
month: m.month,
|
|
value: m.value ?? '',
|
|
status: m.status || 'none',
|
|
note: m.note ?? '',
|
|
};
|
|
this.$refs.cellDialog.showModal();
|
|
},
|
|
async saveCell() {
|
|
try {
|
|
const { entry } = await API.put('/api/entries', {
|
|
kpi_id: this.cell.kpiId,
|
|
year: this.year,
|
|
month: this.cell.month,
|
|
value: this.cell.value,
|
|
status: this.cell.status,
|
|
note: this.cell.note,
|
|
});
|
|
// Patch local state in place.
|
|
for (const p of this.projects) {
|
|
const k = p.kpis.find((x) => x.id === this.cell.kpiId);
|
|
if (k) {
|
|
const slot = k.months[this.cell.month - 1];
|
|
slot.value = entry.value;
|
|
slot.status = entry.status;
|
|
slot.note = entry.note;
|
|
}
|
|
}
|
|
this.$refs.cellDialog.close();
|
|
} catch (e) {
|
|
this.toast(e.message);
|
|
}
|
|
},
|
|
|
|
// ---- Project CRUD ----
|
|
openNewProject() {
|
|
this.proj = { id: null, name: '', description: '' };
|
|
this.$refs.projectDialog.showModal();
|
|
},
|
|
openEditProject(p) {
|
|
this.proj = { id: p.id, name: p.name, description: p.description || '' };
|
|
this.$refs.projectDialog.showModal();
|
|
},
|
|
async saveProject() {
|
|
try {
|
|
if (this.proj.id) {
|
|
await API.patch(`/api/projects/${this.proj.id}`, {
|
|
name: this.proj.name,
|
|
description: this.proj.description,
|
|
});
|
|
} else {
|
|
await API.post('/api/projects', {
|
|
name: this.proj.name,
|
|
description: this.proj.description,
|
|
});
|
|
}
|
|
this.$refs.projectDialog.close();
|
|
await this.loadChart();
|
|
} catch (e) {
|
|
this.toast(e.message);
|
|
}
|
|
},
|
|
async deleteProject(p) {
|
|
if (!confirm(`Delete project "${p.name}" and all its KPIs?`)) return;
|
|
try {
|
|
await API.del(`/api/projects/${p.id}`);
|
|
await this.loadChart();
|
|
} catch (e) {
|
|
this.toast(e.message);
|
|
}
|
|
},
|
|
|
|
// ---- KPI CRUD ----
|
|
openNewKpi(project) {
|
|
this.kpi = { id: null, projectId: project.id, name: '', unit: '', goal_text: '', description: '' };
|
|
this.$refs.kpiDialog.showModal();
|
|
},
|
|
openEditKpi(project, k) {
|
|
this.kpi = {
|
|
id: k.id,
|
|
projectId: project.id,
|
|
name: k.name,
|
|
unit: k.unit || '',
|
|
goal_text: k.goal_text || '',
|
|
description: k.description || '',
|
|
};
|
|
this.$refs.kpiDialog.showModal();
|
|
},
|
|
async saveKpi() {
|
|
try {
|
|
if (this.kpi.id) {
|
|
await API.patch(`/api/kpis/${this.kpi.id}`, {
|
|
name: this.kpi.name,
|
|
unit: this.kpi.unit,
|
|
goal_text: this.kpi.goal_text,
|
|
description: this.kpi.description,
|
|
});
|
|
} else {
|
|
await API.post('/api/kpis', {
|
|
project_id: this.kpi.projectId,
|
|
name: this.kpi.name,
|
|
unit: this.kpi.unit,
|
|
goal_text: this.kpi.goal_text,
|
|
description: this.kpi.description,
|
|
});
|
|
}
|
|
this.$refs.kpiDialog.close();
|
|
await this.loadChart();
|
|
} catch (e) {
|
|
this.toast(e.message);
|
|
}
|
|
},
|
|
async deleteKpi(k) {
|
|
if (!confirm(`Delete KPI "${k.name}"?`)) return;
|
|
try {
|
|
await API.del(`/api/kpis/${k.id}`);
|
|
await this.loadChart();
|
|
} catch (e) {
|
|
this.toast(e.message);
|
|
}
|
|
},
|
|
|
|
// ---- Sparkline (numeric months only) ----
|
|
sparkline(kpi) {
|
|
const pts = kpi.months
|
|
.map((m, i) => ({ i, v: parseFloat(m.value) }))
|
|
.filter((p) => !Number.isNaN(p.v));
|
|
if (pts.length < 2) return '';
|
|
const xs = pts.map((p) => p.i);
|
|
const vs = pts.map((p) => p.v);
|
|
const minV = Math.min(...vs);
|
|
const maxV = Math.max(...vs);
|
|
const range = maxV - minV || 1;
|
|
const W = 88;
|
|
const H = 24;
|
|
const coords = pts.map((p) => {
|
|
const x = (p.i / 11) * W;
|
|
const y = H - ((p.v - minV) / range) * (H - 4) - 2;
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
});
|
|
return coords.join(' ');
|
|
},
|
|
|
|
async logout() {
|
|
try { await API.post('/api/logout'); } catch (_) {}
|
|
window.location.href = '/login.html';
|
|
},
|
|
|
|
toast(msg) {
|
|
this.error = msg;
|
|
if (typeof window.ui === 'function') window.ui('#snackbar');
|
|
},
|
|
};
|
|
}
|
|
|
|
window.bowlerApp = bowlerApp;
|