Dynamic Dashboard with Widget Library

This commit is contained in:
2026-06-10 01:22:02 -05:00
parent 599465bdac
commit 7a54d1a386
25 changed files with 887 additions and 113 deletions

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ node_modules
dist dist
build build
*-lock.json *-lock.json
# Patent / IP documentation (local only)
/records

View File

@@ -28,6 +28,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"fast-xml-parser": "^5.8.0", "fast-xml-parser": "^5.8.0",
"grid-layout-plus": "^1.1.1",
"jsbarcode": "^3.12.3", "jsbarcode": "^3.12.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"moment": "^2.30.1", "moment": "^2.30.1",

View File

@@ -352,6 +352,12 @@ function initialize() {
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS dashboard_layouts (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
layout_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL, value TEXT NOT NULL,

View File

@@ -1,5 +1,7 @@
const express = require('express'); const express = require('express');
const { all, one } = require('../db'); const { all, now, one, parseJson, run } = require('../db');
const { bookLedger } = require('../services/books');
const { runReport } = require('../services/reportEngine');
const router = express.Router(); const router = express.Router();
@@ -27,4 +29,45 @@ router.get('/dashboard', (req, res) => {
res.json({ stats, byCategory, byStatus, upcomingTasks, auditTrail }); res.json({ stats, byCategory, byStatus, upcomingTasks, auditTrail });
}); });
// ---- Per-user dashboard layout (the drag-and-drop widget arrangement) --------
router.get('/dashboard/layout', (req, res) => {
const row = one('SELECT layout_json FROM dashboard_layouts WHERE user_id = ?', [req.user.id]);
res.json({ layout: row ? parseJson(row.layout_json, null) : null });
});
router.put('/dashboard/layout', (req, res) => {
const layout = Array.isArray(req.body.layout) ? req.body.layout : [];
run(
`INSERT INTO dashboard_layouts (user_id, layout_json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET layout_json = excluded.layout_json, updated_at = excluded.updated_at`,
[req.user.id, JSON.stringify(layout), now()]
);
res.json({ layout });
});
// ---- Depreciation aggregator for the dashboard charts (one round-trip) -------
// byBook: YTD + accumulated per enabled depreciation book; byCategory: accumulated by category (primary book).
router.get('/dashboard/depreciation', (req, res) => {
const cid = req.companyId || null;
const year = Number(req.query.year) || new Date().getFullYear();
const books = all(
"SELECT code, is_primary FROM books WHERE (? IS NULL OR entity_id = ?) AND enabled = 1 AND book_type != 'maintenance' ORDER BY is_primary DESC, sort_order, id",
[cid, cid]
);
const byBook = books.map((b) => {
const ledger = bookLedger(b.code, year, cid);
return { book: b.code, ytd: ledger ? ledger.summary.depreciation : 0, accumulated: ledger ? ledger.summary.accumulated : 0 };
});
const primary = (books.find((b) => b.is_primary) || books[0] || {}).code || 'GAAP';
let byCategory = [];
try {
byCategory = runReport('depreciation_summary', { book: primary, year, entity_id: cid }).rows
.map((r) => ({ category: r.category, accumulated: r.accumulated, depreciation: r.depreciation }));
} catch {
byCategory = [];
}
res.json({ year, book: primary, byBook, byCategory });
});
module.exports = router; module.exports = router;

View File

@@ -1966,6 +1966,25 @@ async function main() {
.filter((e) => e.source === `close:${yearClose.id}` && e.account_type === 'Retained earnings'); .filter((e) => e.source === `close:${yearClose.id}` && e.account_type === 'Retained earnings');
if (!reEntries.length) throw new Error('Year-end roll-forward did not post a Retained Earnings entry'); if (!reEntries.length) throw new Error('Year-end roll-forward did not post a Retained Earnings entry');
// ---- Dashboard designer: per-user layout + depreciation aggregator ----------
const freshLayout = await request('/api/dashboard/layout', { headers: auth });
if (freshLayout.layout !== null && !Array.isArray(freshLayout.layout)) throw new Error('Dashboard layout endpoint returned an unexpected shape');
const myLayout = [
{ i: 'portfolio-stats-1', type: 'portfolio-stats', x: 0, y: 0, w: 12, h: 3 },
{ i: 'asset-scanner-1', type: 'asset-scanner', x: 0, y: 3, w: 4, h: 5 }
];
await request('/api/dashboard/layout', { method: 'PUT', headers: auth, body: JSON.stringify({ layout: myLayout }) });
const savedLayout = (await request('/api/dashboard/layout', { headers: auth })).layout;
if (!Array.isArray(savedLayout) || savedLayout.length !== 2 || savedLayout[0].type !== 'portfolio-stats') throw new Error('Dashboard layout did not persist for the user');
// Per-user isolation: a different user has an independent (empty) layout.
const otherLayout = (await request('/api/dashboard/layout', { headers: limitedAuth })).layout;
if (Array.isArray(otherLayout) && otherLayout.length) throw new Error('Dashboard layout leaked across users');
// Depreciation aggregator (company A has a depreciating GAAP asset from the close block).
const deprAgg = await request('/api/dashboard/depreciation?year=2026', { headers: aHeaders });
if (!Array.isArray(deprAgg.byBook) || !deprAgg.byBook.some((b) => b.book === 'GAAP')) throw new Error('Dashboard depreciation aggregator missing byBook/GAAP');
if (!Array.isArray(deprAgg.byCategory)) throw new Error('Dashboard depreciation aggregator missing byCategory');
console.log('Smoke test passed'); console.log('Smoke test passed');
} }

View File

@@ -91,11 +91,8 @@
<v-main id="main-content" :key="`company-${activeCompanyId}`" tabindex="-1"> <v-main id="main-content" :key="`company-${activeCompanyId}`" tabindex="-1">
<DashboardView <DashboardView
v-if="activeView === 'dashboard'" v-if="activeView === 'dashboard'"
:category-chart="categoryChart" :token="token"
:chart-options="chartOptions" @navigate="activeView = $event"
:currency="currency"
:dashboard="dashboard"
:short-date="shortDate"
/> />
<AssetsView <AssetsView
v-if="activeView === 'assets'" v-if="activeView === 'assets'"
@@ -340,13 +337,6 @@ import { blankAsset, defaultValueForField, makeDefaultBooks, mergeBooks, normali
import { currency, shortDate } from './utils/format'; import { currency, shortDate } from './utils/format';
import { bookItems, customFieldTypes, darkThemes, fontScaleMap, navItems, statusItems, themeItems } from './constants'; import { bookItems, customFieldTypes, darkThemes, fontScaleMap, navItems, statusItems, themeItems } from './constants';
const emptyStats = {
asset_count: 0,
total_cost: 0,
disposed_count: 0,
in_service_count: 0
};
export default { export default {
components: { components: {
AdminView, AdminView,
@@ -398,14 +388,7 @@ export default {
assignmentSaving: false, assignmentSaving: false,
assignmentSummary: {}, assignmentSummary: {},
bookItems, bookItems,
chartOptions: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { callback: (value) => `$${Number(value).toLocaleString()}` } } }
},
customFieldTypes, customFieldTypes,
dashboard: { stats: { ...emptyStats }, byCategory: [], byStatus: [], upcomingTasks: [], auditTrail: [] },
disposalPreview: null, disposalPreview: null,
drawerError: '', drawerError: '',
leaseSchedule: null, leaseSchedule: null,
@@ -471,12 +454,6 @@ export default {
allSelected() { allSelected() {
return this.assets.length > 0 && this.selectedAssetIds.length === this.assets.length; return this.assets.length > 0 && this.selectedAssetIds.length === this.assets.length;
}, },
categoryChart() {
return {
labels: this.dashboard.byCategory.map((row) => row.category),
datasets: [{ data: this.dashboard.byCategory.map((row) => row.value), backgroundColor: '#2457a6' }]
};
},
categoryItems() { categoryItems() {
// Admin-managed categories, plus any value already in use so existing assets stay selectable. // Admin-managed categories, plus any value already in use so existing assets stay selectable.
const managed = this.references.filter((item) => item.type === 'category').map((item) => item.name); const managed = this.references.filter((item) => item.type === 'category').map((item) => item.name);
@@ -1121,9 +1098,10 @@ export default {
this.assets = data.assets; this.assets = data.assets;
this.selectedAssetIds = this.selectedAssetIds.filter((id) => this.assets.some((asset) => asset.id === id)); this.selectedAssetIds = this.selectedAssetIds.filter((id) => this.assets.some((asset) => asset.id === id));
}, },
async loadDashboard() { // The dashboard is now a self-loading widget designer (each widget fetches its own data and the view
this.dashboard = await (await this.api('/api/dashboard')).json(); // remounts on company switch), so App no longer pre-fetches dashboard data. Retained as a no-op since
}, // many mutation handlers still call it to "refresh the dashboard".
async loadDashboard() {},
async loadInitial() { async loadInitial() {
// Resolve the active company first so every other request is scoped to it. // Resolve the active company first so every other request is scoped to it.
await this.loadCompanies(); await this.loadCompanies();

View File

@@ -0,0 +1,40 @@
<template>
<WidgetShell title="Alerts" icon="mdi-bell-alert-outline" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<div class="stat-grid">
<div class="stat crit"><div class="val">{{ critical }}</div><div class="lbl">Critical</div></div>
<div class="stat warn"><div class="val">{{ warning }}</div><div class="lbl">Warning</div></div>
<div class="stat"><div class="val">{{ total }}</div><div class="lbl">Open total</div></div>
</div>
<div v-if="!total && !loading" class="quiet text-caption mt-2">No open alerts.</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, critical: 0, warning: 0, total: 0 }; },
mounted() { this.load(); },
methods: {
async load() {
this.loading = true;
try {
const alerts = (await (await apiRequest('/api/alerts?status=open', this.token)).json()).alerts || [];
this.critical = alerts.filter((a) => a.severity === 'critical').length;
this.warning = alerts.filter((a) => a.severity === 'warning').length;
this.total = alerts.length;
} catch { this.critical = this.warning = this.total = 0; } finally { this.loading = false; }
}
}
};
</script>
<style scoped>
.stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stat { border: 1px solid rgba(128,128,128,.18); border-radius: 8px; padding: 10px; text-align: center; }
.stat.crit { border-color: rgba(176,0,32,.5); }
.stat.warn { border-color: rgba(178,107,0,.5); }
.val { font-size: 1.6rem; font-weight: 700; }
.lbl { font-size: .72rem; opacity: .65; text-transform: uppercase; }
</style>

View File

@@ -0,0 +1,50 @@
<template>
<WidgetShell title="Asset lookup / scan" icon="mdi-barcode-scan" :loading="loading" :editing="editing" @refresh="() => {}" @remove="$emit('remove')">
<v-text-field
v-model="code"
density="compact"
placeholder="Scan or type an asset ID / barcode / serial"
prepend-inner-icon="mdi-magnify"
hide-details
autofocus
@keyup.enter="lookup"
/>
<div v-if="asset" class="result mt-3">
<div class="font-weight-bold">{{ asset.asset_id }} {{ asset.description }}</div>
<div class="kv">
<span>Status</span><span><v-chip size="x-small" variant="tonal">{{ (asset.status || '').replace('_', ' ') }}</v-chip></span>
<span>Category</span><span>{{ asset.category || '—' }}</span>
<span>Cost</span><span>{{ currency(asset.acquisition_cost) }}</span>
<span>Location</span><span>{{ asset.location || '—' }}</span>
</div>
</div>
<div v-else-if="message" class="quiet text-body-2 mt-3">{{ message }}</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
import { currency } from '../../utils/format';
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { code: '', asset: null, message: '', loading: false }; },
methods: {
currency,
async lookup() {
const code = this.code.trim();
if (!code) return;
this.loading = true; this.asset = null; this.message = '';
try { this.asset = (await (await apiRequest(`/api/assets/lookup?code=${encodeURIComponent(code)}`, this.token)).json()).asset || null; }
catch (e) { this.message = e.message || `No asset matches "${code}".`; }
finally { this.loading = false; }
}
}
};
</script>
<style scoped>
.result { border: 1px solid rgba(128,128,128,.2); border-radius: 8px; padding: 10px 12px; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; margin-top: 6px; font-size: .85rem; }
.kv span:nth-child(odd) { opacity: .6; }
</style>

View File

@@ -0,0 +1,33 @@
<template>
<WidgetShell title="Assets by status" icon="mdi-chart-donut" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<div v-if="rows.length" style="height:200px"><Doughnut :data="chartData" :options="opts" /></div>
<div v-else-if="!loading" class="quiet text-body-2">No assets yet.</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { Doughnut } from 'vue-chartjs';
import '../../utils/charts';
import { apiRequest } from '../../services/api';
const COLORS = ['#2457a6', '#3f9d5a', '#c9892f', '#9b59b6', '#b00020', '#5b7083'];
export default {
components: { WidgetShell, Doughnut },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, rows: [], opts: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { boxWidth: 12, font: { size: 11 } } } } } }; },
computed: {
chartData() {
return { labels: this.rows.map((r) => this.label(r.status)), datasets: [{ data: this.rows.map((r) => r.count), backgroundColor: this.rows.map((_, i) => COLORS[i % COLORS.length]) }] };
}
},
mounted() { this.load(); },
methods: {
label(s) { return String(s || '').replace(/_/g, ' '); },
async load() {
this.loading = true;
try { this.rows = (await (await apiRequest('/api/dashboard', this.token)).json()).byStatus; }
catch { this.rows = []; } finally { this.loading = false; }
}
}
};
</script>

View File

@@ -0,0 +1,38 @@
<template>
<WidgetShell title="Category value" icon="mdi-shape-outline" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<div v-if="rows.length" style="height:160px"><Bar :data="chartData" :options="opts" /></div>
<table v-if="rows.length" class="mini-table mt-2">
<thead><tr><th>Category</th><th class="r">Assets</th><th class="r">Value</th></tr></thead>
<tbody><tr v-for="r in rows" :key="r.category"><td>{{ r.category }}</td><td class="r">{{ r.count }}</td><td class="r">{{ currency(r.value) }}</td></tr></tbody>
</table>
<div v-if="!rows.length && !loading" class="quiet text-body-2">No assets yet.</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { Bar } from 'vue-chartjs';
import '../../utils/charts';
import { apiRequest } from '../../services/api';
import { currency } from '../../utils/format';
export default {
components: { WidgetShell, Bar },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, rows: [], opts: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { ticks: { callback: (v) => '$' + Number(v).toLocaleString() } } } } }; },
computed: { chartData() { return { labels: this.rows.map((r) => r.category), datasets: [{ data: this.rows.map((r) => r.value), backgroundColor: '#2457a6' }] }; } },
mounted() { this.load(); },
methods: {
currency,
async load() {
this.loading = true;
try { this.rows = (await (await apiRequest('/api/dashboard', this.token)).json()).byCategory; }
catch { this.rows = []; } finally { this.loading = false; }
}
}
};
</script>
<style scoped>
.mini-table { width: 100%; border-collapse: collapse; font-size: .82rem; }
.mini-table th, .mini-table td { padding: 3px 6px; border-bottom: 1px solid rgba(128,128,128,.14); text-align: left; }
.mini-table .r { text-align: right; }
</style>

View File

@@ -0,0 +1,30 @@
<template>
<WidgetShell title="Contact lookup" icon="mdi-account-search-outline" :loading="loading" :editing="editing" @refresh="search" @remove="$emit('remove')">
<v-text-field v-model="q" density="compact" placeholder="Name, organization, or email" prepend-inner-icon="mdi-magnify" hide-details @keyup.enter="search" />
<v-list density="compact" class="py-0 mt-1">
<v-list-item v-for="c in rows" :key="c.id" class="px-0">
<v-list-item-title class="text-body-2">{{ name(c) }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">{{ (c.type || '').replace('_', ' ') }}<span v-if="c.email"> · {{ c.email }}</span><span v-if="c.phone"> · {{ c.phone }}</span></v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="searched && !rows.length && !loading"><span class="quiet text-body-2">No matching contacts.</span></v-list-item>
</v-list>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { q: '', rows: [], loading: false, searched: false }; },
methods: {
name(c) { return c.organization || [c.first_name, c.last_name].filter(Boolean).join(' ') || 'Contact'; },
async search() {
this.loading = true; this.searched = true;
try { this.rows = ((await (await apiRequest(`/api/contacts?search=${encodeURIComponent(this.q.trim())}`, this.token)).json()).contacts || []).slice(0, 8); }
catch { this.rows = []; } finally { this.loading = false; }
}
}
};
</script>

View File

@@ -0,0 +1,51 @@
<template>
<WidgetShell title="Quick-add contact" icon="mdi-account-plus-outline" :loading="saving" :editing="editing" @refresh="() => {}" @remove="$emit('remove')">
<v-select v-model="form.type" :items="types" item-title="title" item-value="value" density="compact" label="Type" hide-details class="mb-2" />
<div v-if="isOrg" class="mb-2"><v-text-field v-model="form.organization" density="compact" :label="form.type === 'work_location' ? 'Location name *' : 'Organization *'" hide-details /></div>
<div v-else class="two mb-2">
<v-text-field v-model="form.first_name" density="compact" label="First name" hide-details />
<v-text-field v-model="form.last_name" density="compact" label="Last name *" hide-details />
</div>
<v-text-field v-model="form.email" density="compact" label="Email" hide-details class="mb-2" />
<v-btn color="primary" size="small" :disabled="!valid" :loading="saving" prepend-icon="mdi-content-save" @click="save">Add contact</v-btn>
<v-alert v-if="message" type="success" density="compact" class="mt-2">{{ message }}</v-alert>
<v-alert v-if="error" type="error" density="compact" class="mt-2">{{ error }}</v-alert>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
function blank() { return { type: 'vendor', organization: '', first_name: '', last_name: '', email: '' }; }
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() {
return {
form: blank(), saving: false, message: '', error: '',
types: [
{ title: 'Vendor', value: 'vendor' }, { title: 'Service provider', value: 'service_provider' },
{ title: 'Employee', value: 'employee' }, { title: 'Contractor', value: 'contractor' },
{ title: 'Work location', value: 'work_location' }, { title: 'Other', value: 'other' }
]
};
},
computed: {
isOrg() { return ['vendor', 'service_provider', 'work_location'].includes(this.form.type); },
valid() { return this.isOrg ? Boolean(this.form.organization) : Boolean(this.form.last_name); }
},
methods: {
async save() {
this.saving = true; this.message = ''; this.error = '';
try {
const contact = (await (await apiRequest('/api/contacts', this.token, { method: 'POST', body: JSON.stringify(this.form) })).json()).contact;
this.message = `Added ${contact.organization || [contact.first_name, contact.last_name].filter(Boolean).join(' ')}.`;
this.form = blank();
} catch (e) { this.error = e.message; } finally { this.saving = false; }
}
}
};
</script>
<style scoped>
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
</style>

View File

@@ -0,0 +1,31 @@
<template>
<WidgetShell title="Depreciation to date by category" icon="mdi-chart-line" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<div v-if="rows.length" style="height:200px"><Line :data="chartData" :options="opts" /></div>
<div v-else-if="!loading" class="quiet text-body-2">No depreciation by category yet.</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { Line } from 'vue-chartjs';
import '../../utils/charts';
import { apiRequest } from '../../services/api';
export default {
components: { WidgetShell, Line },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, rows: [], opts: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: (v) => '$' + Number(v).toLocaleString() } } } } }; },
computed: {
chartData() {
return { labels: this.rows.map((r) => r.category), datasets: [{ data: this.rows.map((r) => r.accumulated), borderColor: '#9b59b6', backgroundColor: 'rgba(155,89,182,.15)', fill: true, tension: 0.25, pointRadius: 3 }] };
}
},
mounted() { this.load(); },
methods: {
async load() {
this.loading = true;
try { this.rows = (await (await apiRequest('/api/dashboard/depreciation', this.token)).json()).byCategory.filter((r) => r.accumulated); }
catch { this.rows = []; } finally { this.loading = false; }
}
}
};
</script>

View File

@@ -0,0 +1,27 @@
<template>
<WidgetShell title="Total depreciation by book" icon="mdi-chart-bar-stacked" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<div v-if="rows.length" style="height:200px"><Bar :data="chartData" :options="opts" /></div>
<div v-else-if="!loading" class="quiet text-body-2">No accumulated depreciation yet.</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { Bar } from 'vue-chartjs';
import '../../utils/charts';
import { apiRequest } from '../../services/api';
export default {
components: { WidgetShell, Bar },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, rows: [], opts: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: (v) => '$' + Number(v).toLocaleString() } } } } }; },
computed: { chartData() { return { labels: this.rows.map((r) => r.book), datasets: [{ data: this.rows.map((r) => r.accumulated), backgroundColor: '#3f9d5a' }] }; } },
mounted() { this.load(); },
methods: {
async load() {
this.loading = true;
try { this.rows = (await (await apiRequest('/api/dashboard/depreciation', this.token)).json()).byBook.filter((r) => r.accumulated); }
catch { this.rows = []; } finally { this.loading = false; }
}
}
};
</script>

View File

@@ -0,0 +1,27 @@
<template>
<WidgetShell :title="`YTD depreciation by book (${year})`" icon="mdi-chart-bar" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<div v-if="rows.length" style="height:200px"><Bar :data="chartData" :options="opts" /></div>
<div v-else-if="!loading" class="quiet text-body-2">No depreciation booked this year.</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { Bar } from 'vue-chartjs';
import '../../utils/charts';
import { apiRequest } from '../../services/api';
export default {
components: { WidgetShell, Bar },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, rows: [], year: new Date().getFullYear(), opts: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: (v) => '$' + Number(v).toLocaleString() } } } } }; },
computed: { chartData() { return { labels: this.rows.map((r) => r.book), datasets: [{ data: this.rows.map((r) => r.ytd), backgroundColor: '#2457a6' }] }; } },
mounted() { this.load(); },
methods: {
async load() {
this.loading = true;
try { const d = await (await apiRequest('/api/dashboard/depreciation', this.token)).json(); this.year = d.year; this.rows = d.byBook.filter((r) => r.ytd); }
catch { this.rows = []; } finally { this.loading = false; }
}
}
};
</script>

View File

@@ -0,0 +1,38 @@
<template>
<WidgetShell title="Portfolio" icon="mdi-chart-box-outline" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<div class="stat-grid">
<div class="stat"><div class="lbl">Assets</div><div class="val">{{ s.asset_count }}</div><div class="sub">{{ s.in_service_count }} in service</div></div>
<div class="stat"><div class="lbl">Capitalized cost</div><div class="val">{{ currency(s.total_cost) }}</div></div>
<div class="stat"><div class="lbl">Disposed</div><div class="val">{{ s.disposed_count }}</div><div class="sub">gain/loss tracked</div></div>
<div class="stat"><div class="lbl">Open tasks</div><div class="val">{{ tasks }}</div><div class="sub">maintenance &amp; reviews</div></div>
</div>
<div v-if="error" class="quiet text-caption">{{ error }}</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
import { currency } from '../../utils/format';
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, error: '', tasks: 0, s: { asset_count: 0, in_service_count: 0, total_cost: 0, disposed_count: 0 } }; },
mounted() { this.load(); },
methods: {
currency,
async load() {
this.loading = true; this.error = '';
try { const d = await (await apiRequest('/api/dashboard', this.token)).json(); this.s = d.stats; this.tasks = d.upcomingTasks.length; }
catch (e) { this.error = e.message; } finally { this.loading = false; }
}
}
};
</script>
<style scoped>
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
.stat { border: 1px solid rgba(128,128,128,.18); border-radius: 8px; padding: 10px 12px; }
.lbl { font-size: .72rem; text-transform: uppercase; letter-spacing: .4px; opacity: .65; }
.val { font-size: 1.5rem; font-weight: 700; line-height: 1.2; }
.sub { font-size: .72rem; opacity: .6; }
</style>

View File

@@ -0,0 +1,38 @@
<template>
<WidgetShell title="Quick-add asset" icon="mdi-plus-box-outline" :loading="saving" :editing="editing" @refresh="() => {}" @remove="$emit('remove')">
<v-text-field v-model="form.description" density="compact" label="Description *" hide-details class="mb-2" />
<v-text-field v-model="form.category" density="compact" label="Category *" hide-details class="mb-2" />
<div class="two">
<v-text-field v-model.number="form.acquisition_cost" density="compact" type="number" prefix="$" label="Cost" hide-details />
<v-text-field v-model="form.in_service_date" density="compact" type="date" label="In service" hide-details />
</div>
<v-btn color="primary" size="small" class="mt-3" :disabled="!form.description || !form.category" :loading="saving" prepend-icon="mdi-content-save" @click="save">Add asset</v-btn>
<v-alert v-if="message" type="success" density="compact" class="mt-2">{{ message }}</v-alert>
<v-alert v-if="error" type="error" density="compact" class="mt-2">{{ error }}</v-alert>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
function blank() { return { description: '', category: '', acquisition_cost: 0, in_service_date: new Date().toISOString().slice(0, 10) }; }
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { form: blank(), saving: false, message: '', error: '' }; },
methods: {
async save() {
this.saving = true; this.message = ''; this.error = '';
try {
const body = { ...this.form, acquired_date: this.form.in_service_date };
const asset = (await (await apiRequest('/api/assets', this.token, { method: 'POST', body: JSON.stringify(body) })).json()).asset;
this.message = `Created ${asset.asset_id}.`;
this.form = blank();
} catch (e) { this.error = e.message; } finally { this.saving = false; }
}
}
};
</script>
<style scoped>
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
</style>

View File

@@ -0,0 +1,32 @@
<template>
<WidgetShell title="Quick links" icon="mdi-link-variant" :editing="editing" @refresh="() => {}" @remove="$emit('remove')">
<div class="link-grid">
<v-btn v-for="l in links" :key="l.view" variant="tonal" size="small" :prepend-icon="l.icon" class="justify-start" @click="go(l.view)">{{ l.label }}</v-btn>
</div>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
export default {
components: { WidgetShell },
inject: { dashboardNavigate: { default: null } },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() {
return {
links: [
{ label: 'Assets', view: 'assets', icon: 'mdi-archive-outline' },
{ label: 'Scan', view: 'scan', icon: 'mdi-barcode-scan' },
{ label: 'Maintenance', view: 'pm', icon: 'mdi-wrench-outline' },
{ label: 'Reports', view: 'reports', icon: 'mdi-chart-bar' },
{ label: 'Period Close', view: 'close', icon: 'mdi-calendar-lock' },
{ label: 'Contacts', view: 'contacts', icon: 'mdi-card-account-details-outline' }
]
};
},
methods: { go(view) { if (this.dashboardNavigate) this.dashboardNavigate(view); } }
};
</script>
<style scoped>
.link-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }
</style>

View File

@@ -0,0 +1,32 @@
<template>
<WidgetShell title="Recent activity" icon="mdi-history" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<v-list density="compact" class="py-0">
<v-list-item v-for="log in rows" :key="log.id" class="px-0">
<template #prepend><v-icon icon="mdi-circle-small" /></template>
<v-list-item-title class="text-body-2">{{ log.action }} {{ log.entity_type }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">{{ log.actor_name || 'System' }} · {{ shortDate(log.created_at) }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="!rows.length && !loading"><span class="quiet text-body-2">No recent activity.</span></v-list-item>
</v-list>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
import { shortDate } from '../../utils/format';
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, rows: [] }; },
mounted() { this.load(); },
methods: {
shortDate,
async load() {
this.loading = true;
try { this.rows = (await (await apiRequest('/api/dashboard', this.token)).json()).auditTrail; }
catch { this.rows = []; } finally { this.loading = false; }
}
}
};
</script>

View File

@@ -0,0 +1,41 @@
<template>
<WidgetShell title="System status" icon="mdi-heart-pulse" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<div class="status-row">
<v-icon :icon="ok ? 'mdi-check-circle' : 'mdi-alert-circle'" :color="ok ? 'success' : 'error'" />
<span class="font-weight-medium">{{ ok ? 'All systems operational' : 'Service unreachable' }}</span>
</div>
<table class="kv mt-2">
<tr><td>Application</td><td>{{ health.app || '—' }}</td></tr>
<tr><td>API</td><td><v-chip size="x-small" :color="ok ? 'success' : 'error'" variant="tonal">{{ ok ? 'online' : 'offline' }}</v-chip></td></tr>
<tr><td>Database</td><td class="mono text-truncate">{{ dbName }}</td></tr>
<tr><td>Checked</td><td>{{ checkedAt }}</td></tr>
</table>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, ok: false, health: {}, checkedAt: '' }; },
computed: { dbName() { return this.health.database ? String(this.health.database).split('/').pop() : '—'; } },
mounted() { this.load(); },
methods: {
async load() {
this.loading = true;
try { this.health = await (await apiRequest('/api/health', this.token)).json(); this.ok = Boolean(this.health.ok); }
catch { this.ok = false; this.health = {}; }
finally { this.loading = false; this.checkedAt = new Date().toLocaleTimeString(); }
}
}
};
</script>
<style scoped>
.status-row { display: flex; align-items: center; gap: 8px; }
.kv { width: 100%; font-size: .82rem; }
.kv td { padding: 3px 0; }
.kv td:first-child { opacity: .6; width: 38%; }
.mono { font-family: monospace; font-size: .78rem; }
</style>

View File

@@ -0,0 +1,35 @@
<template>
<WidgetShell title="Upcoming maintenance" icon="mdi-wrench-clock" :loading="loading" :editing="editing" @refresh="load" @remove="$emit('remove')">
<v-list density="compact" class="py-0">
<v-list-item v-for="(a, i) in rows" :key="i" class="px-0">
<template #prepend><v-icon :icon="a.severity === 'critical' ? 'mdi-alert' : 'mdi-clock-outline'" :color="a.severity === 'critical' ? 'error' : 'warning'" size="small" /></template>
<v-list-item-title class="text-body-2">{{ a.title }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">due {{ a.due_date || '—' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="!rows.length && !loading"><span class="quiet text-body-2">No upcoming maintenance.</span></v-list-item>
</v-list>
</WidgetShell>
</template>
<script>
import WidgetShell from './WidgetShell.vue';
import { apiRequest } from '../../services/api';
export default {
components: { WidgetShell },
props: { token: { type: String, required: true }, editing: { type: Boolean, default: false }, config: { type: Object, default: () => ({}) } },
emits: ['remove'],
data() { return { loading: false, rows: [] }; },
mounted() { this.load(); },
methods: {
async load() {
this.loading = true;
try {
const alerts = (await (await apiRequest('/api/alerts?status=open', this.token)).json()).alerts || [];
this.rows = alerts
.filter((a) => /mainten|pm|service/i.test(a.type || ''))
.sort((a, b) => String(a.due_date || '').localeCompare(String(b.due_date || '')))
.slice(0, 5);
} catch { this.rows = []; } finally { this.loading = false; }
}
}
};
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="widget-shell">
<div class="widget-handle" :class="{ 'is-edit': editing }">
<v-icon :icon="icon" size="small" class="mr-2" />
<span class="widget-title">{{ title }}</span>
<v-spacer />
<div class="widget-actions">
<v-progress-circular v-if="loading" indeterminate size="14" width="2" class="mr-1" />
<v-btn icon="mdi-refresh" size="x-small" variant="text" title="Refresh" @click="$emit('refresh')" />
<v-btn v-if="editing" icon="mdi-close" size="x-small" variant="text" color="error" title="Remove" @click="$emit('remove')" />
</div>
</div>
<div class="widget-body">
<slot />
</div>
</div>
</template>
<script>
// Common chrome for every dashboard widget: a draggable title bar (.widget-handle) with refresh/remove
// actions (.widget-actions are excluded from the drag), and a scrollable body slot.
export default {
props: {
title: { type: String, required: true },
icon: { type: String, default: 'mdi-view-dashboard-outline' },
editing: { type: Boolean, default: false },
loading: { type: Boolean, default: false }
},
emits: ['refresh', 'remove']
};
</script>
<style scoped>
.widget-shell {
height: 100%;
display: flex;
flex-direction: column;
background: rgb(var(--v-theme-surface));
border: 1px solid rgba(128, 128, 128, 0.22);
border-radius: 10px;
overflow: hidden;
}
.widget-handle {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 8px 6px 12px;
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
background: rgba(128, 128, 128, 0.06);
min-height: 38px;
}
.widget-handle.is-edit {
cursor: move;
background: rgba(var(--v-theme-primary), 0.08);
}
.widget-title {
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.widget-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
}
</style>

53
src/dashboard/widgets.js Normal file
View File

@@ -0,0 +1,53 @@
// Dashboard widget registry — the single source of truth for the palette and for rendering. Each entry
// maps a widget `type` to its component, title, icon, palette group, and default/min grid sizing
// (grid-layout-plus units: a 12-column grid with ~40px rows). Layout items reference these by `type`.
import PortfolioStatsWidget from '../components/widgets/PortfolioStatsWidget.vue';
import CategoryValueWidget from '../components/widgets/CategoryValueWidget.vue';
import AssetStatusWidget from '../components/widgets/AssetStatusWidget.vue';
import RecentActivityWidget from '../components/widgets/RecentActivityWidget.vue';
import SystemStatusWidget from '../components/widgets/SystemStatusWidget.vue';
import AlertsSummaryWidget from '../components/widgets/AlertsSummaryWidget.vue';
import UpcomingMaintenanceWidget from '../components/widgets/UpcomingMaintenanceWidget.vue';
import QuickLinksWidget from '../components/widgets/QuickLinksWidget.vue';
import DeprYtdByBookWidget from '../components/widgets/DeprYtdByBookWidget.vue';
import DeprTotalByBookWidget from '../components/widgets/DeprTotalByBookWidget.vue';
import DeprByCategoryWidget from '../components/widgets/DeprByCategoryWidget.vue';
import AssetScannerWidget from '../components/widgets/AssetScannerWidget.vue';
import QuickAddAssetWidget from '../components/widgets/QuickAddAssetWidget.vue';
import ContactLookupWidget from '../components/widgets/ContactLookupWidget.vue';
import ContactQuickAddWidget from '../components/widgets/ContactQuickAddWidget.vue';
export const WIDGETS = {
'portfolio-stats': { title: 'Portfolio overview', icon: 'mdi-chart-box-outline', group: 'Overview', component: PortfolioStatsWidget, w: 12, h: 3, minW: 4, minH: 3 },
'category-value': { title: 'Category value', icon: 'mdi-shape-outline', group: 'Overview', component: CategoryValueWidget, w: 6, h: 8, minW: 3, minH: 5 },
'asset-status': { title: 'Assets by status', icon: 'mdi-chart-donut', group: 'Overview', component: AssetStatusWidget, w: 4, h: 6, minW: 3, minH: 5 },
'recent-activity': { title: 'Recent activity', icon: 'mdi-history', group: 'Overview', component: RecentActivityWidget, w: 4, h: 8, minW: 3, minH: 4 },
'alerts-summary': { title: 'Alerts summary', icon: 'mdi-bell-alert-outline', group: 'Overview', component: AlertsSummaryWidget, w: 3, h: 4, minW: 2, minH: 3 },
'system-status': { title: 'System status', icon: 'mdi-heart-pulse', group: 'Overview', component: SystemStatusWidget, w: 3, h: 5, minW: 2, minH: 4 },
'upcoming-maintenance': { title: 'Upcoming maintenance', icon: 'mdi-wrench-clock', group: 'Maintenance', component: UpcomingMaintenanceWidget, w: 4, h: 6, minW: 3, minH: 4 },
'depr-ytd-book': { title: 'YTD depreciation by book', icon: 'mdi-chart-bar', group: 'Financial', component: DeprYtdByBookWidget, w: 4, h: 6, minW: 3, minH: 5 },
'depr-total-book': { title: 'Total depreciation by book', icon: 'mdi-chart-bar-stacked', group: 'Financial', component: DeprTotalByBookWidget, w: 4, h: 6, minW: 3, minH: 5 },
'depr-category': { title: 'Depreciation by category', icon: 'mdi-chart-line', group: 'Financial', component: DeprByCategoryWidget, w: 4, h: 6, minW: 3, minH: 5 },
'asset-scanner': { title: 'Asset lookup / scan', icon: 'mdi-barcode-scan', group: 'Tools', component: AssetScannerWidget, w: 4, h: 5, minW: 3, minH: 4 },
'quick-add-asset': { title: 'Quick-add asset', icon: 'mdi-plus-box-outline', group: 'Tools', component: QuickAddAssetWidget, w: 4, h: 7, minW: 3, minH: 6 },
'contact-lookup': { title: 'Contact lookup', icon: 'mdi-account-search-outline', group: 'Tools', component: ContactLookupWidget, w: 4, h: 6, minW: 3, minH: 4 },
'contact-quick-add': { title: 'Quick-add contact', icon: 'mdi-account-plus-outline', group: 'Tools', component: ContactQuickAddWidget, w: 4, h: 7, minW: 3, minH: 6 },
'quick-links': { title: 'Quick links', icon: 'mdi-link-variant', group: 'Tools', component: QuickLinksWidget, w: 4, h: 4, minW: 2, minH: 3 }
};
export const WIDGET_GROUPS = ['Overview', 'Financial', 'Maintenance', 'Tools'];
// A sensible starter dashboard for users who have not customized one yet.
export function defaultLayout() {
const item = (type, x, y, w, h) => ({ i: `${type}-${x}-${y}`, type, x, y, w, h });
return [
item('portfolio-stats', 0, 0, 12, 3),
item('depr-ytd-book', 0, 3, 4, 6),
item('depr-total-book', 4, 3, 4, 6),
item('upcoming-maintenance', 8, 3, 4, 6),
item('category-value', 0, 9, 6, 8),
item('recent-activity', 6, 9, 3, 8),
item('alerts-summary', 9, 9, 3, 4),
item('system-status', 9, 13, 3, 4)
];
}

View File

@@ -1,11 +1,15 @@
import { import {
ArcElement,
BarElement, BarElement,
CategoryScale, CategoryScale,
Chart as ChartJS, Chart as ChartJS,
Legend, Legend,
LinearScale, LinearScale,
LineElement,
PointElement,
Title, Title,
Tooltip Tooltip
} from 'chart.js'; } from 'chart.js';
ChartJS.register(BarElement, CategoryScale, LinearScale, Legend, Title, Tooltip); // Bar/Line/Doughnut elements used across the dashboard widgets and reports.
ChartJS.register(ArcElement, BarElement, CategoryScale, LinearScale, LineElement, PointElement, Legend, Title, Tooltip);

View File

@@ -1,97 +1,153 @@
<template> <template>
<section class="content-grid"> <section class="dashboard-designer">
<v-card class="metric span-3"> <div class="toolbar-row mb-3">
<div class="metric-label">Assets</div> <div>
<div class="metric-value">{{ dashboard.stats.asset_count }}</div> <h2 class="section-title">Dashboard</h2>
<div class="metric-subtle">{{ dashboard.stats.in_service_count }} in service</div> <div v-if="editing" class="quiet text-caption">Drag widgets by their title bar; drag the bottom-right corner to resize.</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Capitalized cost</div>
<div class="metric-value">{{ currency(dashboard.stats.total_cost) }}</div>
<div class="metric-subtle">{{ dashboard.byCategory.length }} categories</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Disposed</div>
<div class="metric-value">{{ dashboard.stats.disposed_count }}</div>
<div class="metric-subtle">Tracked gain/loss ready</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Open tasks</div>
<div class="metric-value">{{ dashboard.upcomingTasks.length }}</div>
<div class="metric-subtle">Maintenance and reviews</div>
</v-card>
<v-card class="span-7 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Category value</h2>
</div> </div>
<div class="chart-box"> <v-spacer />
<Bar :data="categoryChart" :options="chartOptions" /> <template v-if="editing">
</div> <v-menu :close-on-content-click="false">
<div class="mt-4" style="overflow-x:auto"> <template #activator="{ props }">
<table class="asset-table"> <v-btn v-bind="props" size="small" variant="tonal" prepend-icon="mdi-plus">Add widget</v-btn>
<thead> </template>
<tr> <v-card max-width="320" max-height="70vh" class="overflow-y-auto">
<th>Category</th> <template v-for="group in groups" :key="group">
<th class="text-right">Assets</th> <v-list-subheader>{{ group }}</v-list-subheader>
<th class="text-right">Value</th> <v-list density="compact" class="py-0">
</tr> <v-list-item
</thead> v-for="w in widgetsIn(group)"
<tbody> :key="w.type"
<tr v-for="row in dashboard.byCategory" :key="row.category"> :prepend-icon="w.icon"
<td class="font-weight-bold">{{ row.category }}</td> :title="w.title"
<td class="text-right">{{ row.count }}</td> @click="addWidget(w.type)"
<td class="text-right">{{ currency(row.value) }}</td> />
</tr>
<tr v-if="!dashboard.byCategory.length">
<td colspan="3" class="quiet">No assets yet.</td>
</tr>
</tbody>
<tfoot v-if="dashboard.byCategory.length">
<tr>
<td class="font-weight-bold">Total</td>
<td class="text-right font-weight-bold">{{ totalAssets }}</td>
<td class="text-right font-weight-bold">{{ currency(totalValue) }}</td>
</tr>
</tfoot>
</table>
</div>
</v-card>
<v-card class="span-5 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Activity</h2>
</div>
<v-list lines="two" density="compact">
<v-list-item v-for="log in dashboard.auditTrail" :key="log.id" prepend-icon="mdi-history">
<v-list-item-title>{{ log.action }} {{ log.entity_type }}</v-list-item-title>
<v-list-item-subtitle>{{ log.actor_name || 'System' }} · {{ shortDate(log.created_at) }}</v-list-item-subtitle>
</v-list-item>
</v-list> </v-list>
</template>
</v-card> </v-card>
</v-menu>
<v-btn size="small" variant="text" prepend-icon="mdi-restore" @click="resetDefault">Reset</v-btn>
<v-btn size="small" variant="text" @click="cancelEdit">Cancel</v-btn>
<v-btn size="small" color="primary" :loading="saving" prepend-icon="mdi-content-save" @click="save">Save</v-btn>
</template>
<v-btn v-else size="small" color="primary" variant="tonal" prepend-icon="mdi-pencil" @click="startEdit">Edit layout</v-btn>
</div>
<v-alert v-if="message" type="success" density="compact" class="mb-3">{{ message }}</v-alert>
<GridLayout
v-if="layout.length"
:layout="layout"
:col-num="12"
:row-height="40"
:is-draggable="editing"
:is-resizable="editing"
:margin="[12, 12]"
:vertical-compact="true"
:use-css-transforms="true"
class="dash-grid"
>
<GridItem
v-for="item in layout"
:key="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
:i="item.i"
:min-w="reg(item).minW"
:min-h="reg(item).minH"
drag-allow-from=".widget-handle"
drag-ignore-from=".widget-actions"
>
<component :is="reg(item).component" :token="token" :editing="editing" :config="item.config || {}" @remove="remove(item)" />
</GridItem>
</GridLayout>
<div v-else class="empty-dash quiet">
<v-icon icon="mdi-view-dashboard-outline" size="48" class="mb-2" />
<div>Your dashboard is empty.</div>
<v-btn class="mt-3" color="primary" variant="tonal" prepend-icon="mdi-pencil" @click="startEdit">Edit layout</v-btn>
</div>
</section> </section>
</template> </template>
<script> <script>
import { Bar } from 'vue-chartjs'; import { GridLayout, GridItem } from 'grid-layout-plus';
import '../utils/charts'; import { apiRequest } from '../services/api';
import { WIDGETS, WIDGET_GROUPS, defaultLayout } from '../dashboard/widgets';
// Drag-and-drop dashboard designer. Renders the user's saved widget layout on a free-form 12-column grid
// (grid-layout-plus). In Edit mode the user adds widgets from the palette, drags/resizes them, and saves;
// the layout persists per user. Widget data re-scopes to the active company automatically (the view is
// remounted on company switch by App.vue). Provides a navigate() to widgets (e.g. Quick links).
export default { export default {
components: { Bar }, components: { GridLayout, GridItem },
props: { props: { token: { type: String, required: true } },
categoryChart: { type: Object, required: true }, emits: ['navigate'],
chartOptions: { type: Object, required: true }, provide() {
currency: { type: Function, required: true }, return { dashboardNavigate: (view) => this.$emit('navigate', view) };
dashboard: { type: Object, required: true },
shortDate: { type: Function, required: true }
}, },
computed: { data() {
totalAssets() { return { layout: [], editing: false, saving: false, message: '', snapshot: null, groups: WIDGET_GROUPS };
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.count || 0), 0);
}, },
totalValue() { mounted() { this.loadLayout(); },
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.value || 0), 0); methods: {
reg(item) { return WIDGETS[item.type] || {}; },
widgetsIn(group) {
return Object.entries(WIDGETS).filter(([, w]) => w.group === group).map(([type, w]) => ({ type, ...w }));
},
sanitize(layout) {
// Keep only known widget types and the fields we persist.
return (Array.isArray(layout) ? layout : [])
.filter((it) => it && WIDGETS[it.type])
.map((it) => ({ i: it.i, type: it.type, x: it.x, y: it.y, w: it.w, h: it.h, config: it.config || undefined }));
},
async loadLayout() {
try {
const saved = (await (await apiRequest('/api/dashboard/layout', this.token)).json()).layout;
const clean = this.sanitize(saved);
this.layout = clean.length ? clean : defaultLayout();
} catch {
this.layout = defaultLayout();
}
},
startEdit() { this.snapshot = JSON.parse(JSON.stringify(this.layout)); this.editing = true; this.message = ''; },
cancelEdit() { if (this.snapshot) this.layout = this.snapshot; this.editing = false; },
resetDefault() { this.layout = defaultLayout(); },
addWidget(type) {
const w = WIDGETS[type];
const maxY = this.layout.reduce((m, it) => Math.max(m, it.y + it.h), 0);
this.layout.push({ i: `${type}-${Date.now()}`, type, x: 0, y: maxY, w: w.w, h: w.h });
},
remove(item) { this.layout = this.layout.filter((it) => it.i !== item.i); },
async save() {
this.saving = true;
try {
const payload = this.sanitize(this.layout);
await apiRequest('/api/dashboard/layout', this.token, { method: 'PUT', body: JSON.stringify({ layout: payload }) });
this.editing = false;
this.message = 'Dashboard saved.';
} catch (e) { this.message = `Save failed: ${e.message}`; } finally { this.saving = false; }
} }
} }
}; };
</script> </script>
<!-- grid-layout-plus ships only SCSS; these are the minimal runtime styles it needs (global). -->
<style>
.vgl-layout { position: relative; box-sizing: border-box; }
.vgl-item { box-sizing: border-box; transition: 200ms ease; transition-property: left, top, right; }
.vgl-item--transform { right: auto; left: 0; transition-property: transform; }
.vgl-item--no-touch { touch-action: none; }
.vgl-item--placeholder { z-index: 2; user-select: none; background: rgb(var(--v-theme-primary)); opacity: 0.18; border-radius: 10px; transition-duration: 100ms; }
.vgl-item--dragging { z-index: 3; user-select: none; transition: none; }
.vgl-item--resizing { z-index: 3; user-select: none; opacity: 0.7; }
.vgl-item__resizer { position: absolute; right: 0; bottom: 0; box-sizing: border-box; width: 14px; height: 14px; cursor: se-resize; }
.vgl-item__resizer::before { position: absolute; inset: 0 3px 3px 0; content: ''; border: 0 solid rgba(128,128,128,.8); border-right-width: 2px; border-bottom-width: 2px; }
</style>
<style scoped>
.empty-dash { text-align: center; padding: 60px 20px; border: 1px dashed rgba(128,128,128,.3); border-radius: 12px; }
.dash-grid { margin: 0 -6px; }
</style>