'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;