bowler-chart/public/js/app.js

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;