Dynamic Dashboard with Widget Library
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,3 +7,5 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
*-lock.json
|
*-lock.json
|
||||||
|
# Patent / IP documentation (local only)
|
||||||
|
/records
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
src/App.vue
34
src/App.vue
@@ -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();
|
||||||
|
|||||||
40
src/components/widgets/AlertsSummaryWidget.vue
Normal file
40
src/components/widgets/AlertsSummaryWidget.vue
Normal 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>
|
||||||
50
src/components/widgets/AssetScannerWidget.vue
Normal file
50
src/components/widgets/AssetScannerWidget.vue
Normal 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>
|
||||||
33
src/components/widgets/AssetStatusWidget.vue
Normal file
33
src/components/widgets/AssetStatusWidget.vue
Normal 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>
|
||||||
38
src/components/widgets/CategoryValueWidget.vue
Normal file
38
src/components/widgets/CategoryValueWidget.vue
Normal 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>
|
||||||
30
src/components/widgets/ContactLookupWidget.vue
Normal file
30
src/components/widgets/ContactLookupWidget.vue
Normal 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>
|
||||||
51
src/components/widgets/ContactQuickAddWidget.vue
Normal file
51
src/components/widgets/ContactQuickAddWidget.vue
Normal 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>
|
||||||
31
src/components/widgets/DeprByCategoryWidget.vue
Normal file
31
src/components/widgets/DeprByCategoryWidget.vue
Normal 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>
|
||||||
27
src/components/widgets/DeprTotalByBookWidget.vue
Normal file
27
src/components/widgets/DeprTotalByBookWidget.vue
Normal 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>
|
||||||
27
src/components/widgets/DeprYtdByBookWidget.vue
Normal file
27
src/components/widgets/DeprYtdByBookWidget.vue
Normal 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>
|
||||||
38
src/components/widgets/PortfolioStatsWidget.vue
Normal file
38
src/components/widgets/PortfolioStatsWidget.vue
Normal 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 & 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>
|
||||||
38
src/components/widgets/QuickAddAssetWidget.vue
Normal file
38
src/components/widgets/QuickAddAssetWidget.vue
Normal 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>
|
||||||
32
src/components/widgets/QuickLinksWidget.vue
Normal file
32
src/components/widgets/QuickLinksWidget.vue
Normal 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>
|
||||||
32
src/components/widgets/RecentActivityWidget.vue
Normal file
32
src/components/widgets/RecentActivityWidget.vue
Normal 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>
|
||||||
41
src/components/widgets/SystemStatusWidget.vue
Normal file
41
src/components/widgets/SystemStatusWidget.vue
Normal 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>
|
||||||
35
src/components/widgets/UpcomingMaintenanceWidget.vue
Normal file
35
src/components/widgets/UpcomingMaintenanceWidget.vue
Normal 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>
|
||||||
69
src/components/widgets/WidgetShell.vue
Normal file
69
src/components/widgets/WidgetShell.vue
Normal 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
53
src/dashboard/widgets.js
Normal 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)
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
<v-card class="metric span-3">
|
<v-spacer />
|
||||||
<div class="metric-label">Capitalized cost</div>
|
<template v-if="editing">
|
||||||
<div class="metric-value">{{ currency(dashboard.stats.total_cost) }}</div>
|
<v-menu :close-on-content-click="false">
|
||||||
<div class="metric-subtle">{{ dashboard.byCategory.length }} categories</div>
|
<template #activator="{ props }">
|
||||||
</v-card>
|
<v-btn v-bind="props" size="small" variant="tonal" prepend-icon="mdi-plus">Add widget</v-btn>
|
||||||
<v-card class="metric span-3">
|
</template>
|
||||||
<div class="metric-label">Disposed</div>
|
<v-card max-width="320" max-height="70vh" class="overflow-y-auto">
|
||||||
<div class="metric-value">{{ dashboard.stats.disposed_count }}</div>
|
<template v-for="group in groups" :key="group">
|
||||||
<div class="metric-subtle">Tracked gain/loss ready</div>
|
<v-list-subheader>{{ group }}</v-list-subheader>
|
||||||
</v-card>
|
<v-list density="compact" class="py-0">
|
||||||
<v-card class="metric span-3">
|
<v-list-item
|
||||||
<div class="metric-label">Open tasks</div>
|
v-for="w in widgetsIn(group)"
|
||||||
<div class="metric-value">{{ dashboard.upcomingTasks.length }}</div>
|
:key="w.type"
|
||||||
<div class="metric-subtle">Maintenance and reviews</div>
|
:prepend-icon="w.icon"
|
||||||
</v-card>
|
:title="w.title"
|
||||||
|
@click="addWidget(w.type)"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
</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-card class="span-7 panel-pad">
|
<v-alert v-if="message" type="success" density="compact" class="mb-3">{{ message }}</v-alert>
|
||||||
<div class="toolbar-row mb-4">
|
|
||||||
<h2 class="section-title">Category value</h2>
|
|
||||||
</div>
|
|
||||||
<div class="chart-box">
|
|
||||||
<Bar :data="categoryChart" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-4" style="overflow-x:auto">
|
|
||||||
<table class="asset-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Category</th>
|
|
||||||
<th class="text-right">Assets</th>
|
|
||||||
<th class="text-right">Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="row in dashboard.byCategory" :key="row.category">
|
|
||||||
<td class="font-weight-bold">{{ row.category }}</td>
|
|
||||||
<td class="text-right">{{ row.count }}</td>
|
|
||||||
<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">
|
<GridLayout
|
||||||
<div class="toolbar-row mb-4">
|
v-if="layout.length"
|
||||||
<h2 class="section-title">Activity</h2>
|
:layout="layout"
|
||||||
</div>
|
:col-num="12"
|
||||||
<v-list lines="two" density="compact">
|
:row-height="40"
|
||||||
<v-list-item v-for="log in dashboard.auditTrail" :key="log.id" prepend-icon="mdi-history">
|
:is-draggable="editing"
|
||||||
<v-list-item-title>{{ log.action }} {{ log.entity_type }}</v-list-item-title>
|
:is-resizable="editing"
|
||||||
<v-list-item-subtitle>{{ log.actor_name || 'System' }} · {{ shortDate(log.created_at) }}</v-list-item-subtitle>
|
:margin="[12, 12]"
|
||||||
</v-list-item>
|
:vertical-compact="true"
|
||||||
</v-list>
|
:use-css-transforms="true"
|
||||||
</v-card>
|
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);
|
},
|
||||||
|
mounted() { this.loadLayout(); },
|
||||||
|
methods: {
|
||||||
|
reg(item) { return WIDGETS[item.type] || {}; },
|
||||||
|
widgetsIn(group) {
|
||||||
|
return Object.entries(WIDGETS).filter(([, w]) => w.group === group).map(([type, w]) => ({ type, ...w }));
|
||||||
},
|
},
|
||||||
totalValue() {
|
sanitize(layout) {
|
||||||
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.value || 0), 0);
|
// 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user