From 7a54d1a3860d463fa9709c80ef09a71207e892ae Mon Sep 17 00:00:00 2001 From: mpuckett Date: Wed, 10 Jun 2026 01:22:02 -0500 Subject: [PATCH] Dynamic Dashboard with Widget Library --- .gitignore | 4 +- package.json | 1 + server/db.js | 6 + server/routes/dashboard.js | 45 +++- server/smoke-test.js | 19 ++ src/App.vue | 34 +-- .../widgets/AlertsSummaryWidget.vue | 40 ++++ src/components/widgets/AssetScannerWidget.vue | 50 ++++ src/components/widgets/AssetStatusWidget.vue | 33 +++ .../widgets/CategoryValueWidget.vue | 38 +++ .../widgets/ContactLookupWidget.vue | 30 +++ .../widgets/ContactQuickAddWidget.vue | 51 ++++ .../widgets/DeprByCategoryWidget.vue | 31 +++ .../widgets/DeprTotalByBookWidget.vue | 27 +++ .../widgets/DeprYtdByBookWidget.vue | 27 +++ .../widgets/PortfolioStatsWidget.vue | 38 +++ .../widgets/QuickAddAssetWidget.vue | 38 +++ src/components/widgets/QuickLinksWidget.vue | 32 +++ .../widgets/RecentActivityWidget.vue | 32 +++ src/components/widgets/SystemStatusWidget.vue | 41 ++++ .../widgets/UpcomingMaintenanceWidget.vue | 35 +++ src/components/widgets/WidgetShell.vue | 69 ++++++ src/dashboard/widgets.js | 53 +++++ src/utils/charts.js | 6 +- src/views/DashboardView.vue | 220 +++++++++++------- 25 files changed, 887 insertions(+), 113 deletions(-) create mode 100644 src/components/widgets/AlertsSummaryWidget.vue create mode 100644 src/components/widgets/AssetScannerWidget.vue create mode 100644 src/components/widgets/AssetStatusWidget.vue create mode 100644 src/components/widgets/CategoryValueWidget.vue create mode 100644 src/components/widgets/ContactLookupWidget.vue create mode 100644 src/components/widgets/ContactQuickAddWidget.vue create mode 100644 src/components/widgets/DeprByCategoryWidget.vue create mode 100644 src/components/widgets/DeprTotalByBookWidget.vue create mode 100644 src/components/widgets/DeprYtdByBookWidget.vue create mode 100644 src/components/widgets/PortfolioStatsWidget.vue create mode 100644 src/components/widgets/QuickAddAssetWidget.vue create mode 100644 src/components/widgets/QuickLinksWidget.vue create mode 100644 src/components/widgets/RecentActivityWidget.vue create mode 100644 src/components/widgets/SystemStatusWidget.vue create mode 100644 src/components/widgets/UpcomingMaintenanceWidget.vue create mode 100644 src/components/widgets/WidgetShell.vue create mode 100644 src/dashboard/widgets.js diff --git a/.gitignore b/.gitignore index 1f60014..f8b9870 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ node_modules .DS_Store dist build -*-lock.json \ No newline at end of file +*-lock.json +# Patent / IP documentation (local only) +/records diff --git a/package.json b/package.json index 90d6d60..c289452 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "fast-xml-parser": "^5.8.0", + "grid-layout-plus": "^1.1.1", "jsbarcode": "^3.12.3", "jsonwebtoken": "^9.0.3", "moment": "^2.30.1", diff --git a/server/db.js b/server/db.js index e2a36a2..ca6e320 100644 --- a/server/db.js +++ b/server/db.js @@ -352,6 +352,12 @@ function initialize() { 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 ( key TEXT PRIMARY KEY, value TEXT NOT NULL, diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 5e2c984..e5daea7 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -1,5 +1,7 @@ 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(); @@ -27,4 +29,45 @@ router.get('/dashboard', (req, res) => { 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; diff --git a/server/smoke-test.js b/server/smoke-test.js index 5417a9b..0911e60 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -1966,6 +1966,25 @@ async function main() { .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'); + // ---- 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'); } diff --git a/src/App.vue b/src/App.vue index c408e6e..3622924 100644 --- a/src/App.vue +++ b/src/App.vue @@ -91,11 +91,8 @@ `$${Number(value).toLocaleString()}` } } } - }, customFieldTypes, - dashboard: { stats: { ...emptyStats }, byCategory: [], byStatus: [], upcomingTasks: [], auditTrail: [] }, disposalPreview: null, drawerError: '', leaseSchedule: null, @@ -471,12 +454,6 @@ export default { allSelected() { 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() { // 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); @@ -1121,9 +1098,10 @@ export default { this.assets = data.assets; this.selectedAssetIds = this.selectedAssetIds.filter((id) => this.assets.some((asset) => asset.id === id)); }, - async loadDashboard() { - this.dashboard = await (await this.api('/api/dashboard')).json(); - }, + // The dashboard is now a self-loading widget designer (each widget fetches its own data and the view + // 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() { // Resolve the active company first so every other request is scoped to it. await this.loadCompanies(); diff --git a/src/components/widgets/AlertsSummaryWidget.vue b/src/components/widgets/AlertsSummaryWidget.vue new file mode 100644 index 0000000..c3bbec5 --- /dev/null +++ b/src/components/widgets/AlertsSummaryWidget.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/components/widgets/AssetScannerWidget.vue b/src/components/widgets/AssetScannerWidget.vue new file mode 100644 index 0000000..4d84b00 --- /dev/null +++ b/src/components/widgets/AssetScannerWidget.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/widgets/AssetStatusWidget.vue b/src/components/widgets/AssetStatusWidget.vue new file mode 100644 index 0000000..ab65cf6 --- /dev/null +++ b/src/components/widgets/AssetStatusWidget.vue @@ -0,0 +1,33 @@ + + diff --git a/src/components/widgets/CategoryValueWidget.vue b/src/components/widgets/CategoryValueWidget.vue new file mode 100644 index 0000000..7a1da66 --- /dev/null +++ b/src/components/widgets/CategoryValueWidget.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/widgets/ContactLookupWidget.vue b/src/components/widgets/ContactLookupWidget.vue new file mode 100644 index 0000000..e538433 --- /dev/null +++ b/src/components/widgets/ContactLookupWidget.vue @@ -0,0 +1,30 @@ + + diff --git a/src/components/widgets/ContactQuickAddWidget.vue b/src/components/widgets/ContactQuickAddWidget.vue new file mode 100644 index 0000000..4e4aac3 --- /dev/null +++ b/src/components/widgets/ContactQuickAddWidget.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/widgets/DeprByCategoryWidget.vue b/src/components/widgets/DeprByCategoryWidget.vue new file mode 100644 index 0000000..0fe3a8c --- /dev/null +++ b/src/components/widgets/DeprByCategoryWidget.vue @@ -0,0 +1,31 @@ + + diff --git a/src/components/widgets/DeprTotalByBookWidget.vue b/src/components/widgets/DeprTotalByBookWidget.vue new file mode 100644 index 0000000..e707c29 --- /dev/null +++ b/src/components/widgets/DeprTotalByBookWidget.vue @@ -0,0 +1,27 @@ + + diff --git a/src/components/widgets/DeprYtdByBookWidget.vue b/src/components/widgets/DeprYtdByBookWidget.vue new file mode 100644 index 0000000..c715510 --- /dev/null +++ b/src/components/widgets/DeprYtdByBookWidget.vue @@ -0,0 +1,27 @@ + + diff --git a/src/components/widgets/PortfolioStatsWidget.vue b/src/components/widgets/PortfolioStatsWidget.vue new file mode 100644 index 0000000..845c185 --- /dev/null +++ b/src/components/widgets/PortfolioStatsWidget.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/widgets/QuickAddAssetWidget.vue b/src/components/widgets/QuickAddAssetWidget.vue new file mode 100644 index 0000000..6112a3c --- /dev/null +++ b/src/components/widgets/QuickAddAssetWidget.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/components/widgets/QuickLinksWidget.vue b/src/components/widgets/QuickLinksWidget.vue new file mode 100644 index 0000000..3a58a6a --- /dev/null +++ b/src/components/widgets/QuickLinksWidget.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/components/widgets/RecentActivityWidget.vue b/src/components/widgets/RecentActivityWidget.vue new file mode 100644 index 0000000..95bb72d --- /dev/null +++ b/src/components/widgets/RecentActivityWidget.vue @@ -0,0 +1,32 @@ + + diff --git a/src/components/widgets/SystemStatusWidget.vue b/src/components/widgets/SystemStatusWidget.vue new file mode 100644 index 0000000..d4b0826 --- /dev/null +++ b/src/components/widgets/SystemStatusWidget.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/components/widgets/UpcomingMaintenanceWidget.vue b/src/components/widgets/UpcomingMaintenanceWidget.vue new file mode 100644 index 0000000..f36add9 --- /dev/null +++ b/src/components/widgets/UpcomingMaintenanceWidget.vue @@ -0,0 +1,35 @@ + + diff --git a/src/components/widgets/WidgetShell.vue b/src/components/widgets/WidgetShell.vue new file mode 100644 index 0000000..5d7cf47 --- /dev/null +++ b/src/components/widgets/WidgetShell.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/dashboard/widgets.js b/src/dashboard/widgets.js new file mode 100644 index 0000000..0cbcd83 --- /dev/null +++ b/src/dashboard/widgets.js @@ -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) + ]; +} diff --git a/src/utils/charts.js b/src/utils/charts.js index b56c8ef..df02db1 100644 --- a/src/utils/charts.js +++ b/src/utils/charts.js @@ -1,11 +1,15 @@ import { + ArcElement, BarElement, CategoryScale, Chart as ChartJS, Legend, LinearScale, + LineElement, + PointElement, Title, Tooltip } 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); diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index a9c4ffc..7ee87cc 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -1,97 +1,153 @@ + + + + +