diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue
index ad886ee4..1555f7c3 100644
--- a/frontend/src/views/Dashboard.vue
+++ b/frontend/src/views/Dashboard.vue
@@ -69,14 +69,24 @@
{{ stats.totalassets || 0 }}
-
-
- {{ stats.activeassets || 0 }} in use
- /
-
- {{ stats.inrepair || 0 }} in repair
+
+
+ {{ row.name }}
+ {{ row.count }}
+
+
+
+
+ Active records only, dual-bay
+ pairs counted once.
+
+ {{ stats.inactiveexcluded }} deactivated record{{ stats.inactiveexcluded === 1 ? '' : 's' }}
+ excluded.
@@ -133,6 +143,17 @@ const summaryloaded = ref(false)
// hold - and to what the guest role reaches, when nobody is signed in.
const navitems = ref([])
+// Every status in the total, biggest first. Sorted rather than fixed-order
+// because the statuses are seed data a site can change - hardcoding "In Use,
+// In Repair, Retired" would silently omit a status somebody added.
+const statusrows = computed(() => {
+ const statuses = stats.value.statuses || {}
+ return Object.keys(statuses)
+ .map((name) => ({ name, count: statuses[name] }))
+ .filter((row) => row.count > 0)
+ .sort((a, b) => b.count - a.count)
+})
+
const openable = computed(
// The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/'))
@@ -224,11 +245,18 @@ span.assets-k { color: var(--text); font-weight: 600; }
border-bottom: none;
}
-.assets-status {
- margin: 0.5rem 0 0;
- font-size: 0.85rem;
+/* Status rows sit UNDER the total, indented and quieter, because they break
+ the total down rather than adding to it. Same weight as the type rows would
+ read as more things to add up. */
+.assets-sub { border-bottom: none; padding: 0.15rem 0 0.15rem 0.75rem; }
+.assets-subk { color: var(--text-light); font-size: 0.85rem; }
+a.assets-subk:hover { text-decoration: none; }
+.assets-subv { font-weight: 500; font-size: 0.85rem; color: var(--text-light); }
+
+.assets-caption {
+ margin: 0.6rem 0 0;
+ font-size: 0.78rem;
+ line-height: 1.45;
color: var(--text-light);
}
-.assets-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
-.assets-zero { color: var(--text-light); }
diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py
index d8350d15..5d197e22 100644
--- a/shopdb/core/api/dashboard.py
+++ b/shopdb/core/api/dashboard.py
@@ -38,6 +38,19 @@ def _count_by_type(assettype):
return query.count()
+def _inactive_excluded():
+ """Counted-type assets held back by isactive, i.e. soft-deleted.
+
+ Reported so the page can account for itself. A total that silently drops
+ rows is the reason somebody compares it with a list page and finds a
+ difference they cannot explain.
+ """
+ return (db.session.query(Asset).join(AssetType)
+ .filter(Asset.isactive == False,
+ AssetType.assettype.in_(COUNTEDTYPES))
+ .count())
+
+
COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer',
'measuring_tool')
@@ -69,6 +82,10 @@ def _countedassets():
@require_permission('assets.view')
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
+ # Imported here, as in the helpers above: the dualpath service reaches back
+ # into models, so a module-level import is circular.
+ from shopdb.core.services.dualpath import dualpath_single_machine_enabled
+
machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
@@ -99,6 +116,22 @@ def get_dashboard():
'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
+ # What the totals actually cover, so the page can say so rather than
+ # leaving a reader to work it out from a number that surprised them.
+ # Every count here filters isactive, and the dual-path collapse is a
+ # per-site toggle - asserting it unconditionally would be false at a
+ # site that has it off.
+ 'countsactiveonly': True,
+ 'dualpathcollapsed': bool(dualpath_single_machine_enabled()),
+ # Every status, not just the two that had callers. The counts were
+ # already built for the rollup above and then thrown away, so a reader
+ # could see "In Use" and had no way to learn where the rest went.
+ 'statuses': status_dict,
+ # A DIFFERENT thing to a status, and the one that makes a total
+ # disagree with a list page. isactive is the soft-delete flag: these
+ # rows are excluded from every count above, so they are in no status
+ # bucket and cannot be found by adding the buckets up.
+ 'inactiveexcluded': _inactive_excluded(),
# Structured data
'counts': {
'machines': machine_count,