Show every status the asset total contains, and what it excludes
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m5s

Asked twice what "All assets" covered, which is the signal a label is
underspecified rather than a reader being slow.

The endpoint already counted every status to build its rollup and then returned
two of them, so a reader could see In Use and had no way to learn where the rest
of the total went. It now returns them all, and the panel lists them under the
total - indented and quieter, because they break the total down rather than
adding to it.

Three things this makes visible that were not guessable:

Retired assets are IN the total. Retired is a status on an active record, not
the same as deactivated, and seven of them sit inside this site's 610.

There is an "Inventory" status. I would have hardcoded In Use, In Repair and
Retired and silently dropped it. The rows are built from whatever the API
returns, sorted by count, so a status a site adds appears rather than
disappearing - which matters for sites adopting with their own seed data.

Rows held back by isactive are in NO status bucket, so they cannot be found by
adding the buckets up. That gap is exactly what makes a dashboard total
disagree with a list page, so the count is reported and captioned.

The caption states the counting policy - active records only, dual-bay pairs
counted once - and the dual-path clause renders only when that site toggle is
on, since asserting it unconditionally would be false at a site that has it off.

Full suite 2182, frontend 314.
This commit is contained in:
cproudlock
2026-08-30 15:18:13 -04:00
parent 4c8291628f
commit 511c3ffcee
2 changed files with 74 additions and 13 deletions

View File

@@ -69,14 +69,24 @@
<span class="assets-v">{{ stats.totalassets || 0 }}</span>
</div>
<!-- Status, on one line, and explicitly labelled as status. In
repair is muted at zero rather than given a card of its own to
announce that nothing is wrong. -->
<p class="assets-status">
<span class="assets-inuse">{{ stats.activeassets || 0 }}</span> in use
<span aria-hidden="true"> / </span>
<span :class="{ 'assets-zero': !stats.inrepair }">
{{ stats.inrepair || 0 }} in repair
<!-- Every status the total contains, not just In Use. These were
counted server-side for the rollup and then thrown away, so a
reader could see one number and had no way to learn where the
rest of the total went. Retired assets in particular ARE in
the total, which is not guessable. -->
<div v-for="row in statusrows" :key="row.name" class="assets-row assets-sub">
<span class="assets-k assets-subk">{{ row.name }}</span>
<span class="assets-v assets-subv">{{ row.count }}</span>
</div>
<!-- What the total covers. Asked twice what the number meant, which
is the signal a label is underspecified. -->
<p class="assets-caption">
Active records only<span v-if="stats.dualpathcollapsed">, dual-bay
pairs counted once</span>.
<span v-if="stats.inactiveexcluded">
{{ stats.inactiveexcluded }} deactivated record{{ stats.inactiveexcluded === 1 ? '' : 's' }}
excluded.
</span>
</p>
</section>
@@ -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); }
</style>

View File

@@ -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,