diff --git a/frontend/src/components/DashboardCards.vue b/frontend/src/components/DashboardCards.vue
index 847f3a1..4ac99d4 100644
--- a/frontend/src/components/DashboardCards.vue
+++ b/frontend/src/components/DashboardCards.vue
@@ -17,14 +17,16 @@
- -
+
-
{{ row.title }}
{{ row.title }}
- {{ row.detail }}
+ {{ row.detail }}
{{ chip.text }}
@@ -173,6 +175,12 @@ defineExpose({ load })
min-width: 0;
}
.dc-row:last-child { border-bottom: none; }
+/* Stacked: a label on one line, prose beneath. Inline, the label is truncated
+ to make room for text that is then truncated anyway, and neither reads. */
+.dc-row-stacked { flex-direction: column; align-items: stretch; gap: 0.15rem; }
+.dc-row-stacked .dc-row-title { max-width: 100%; }
+.dc-row-stacked .dc-row-detail { white-space: normal; color: var(--text-light); }
+.dc-row-stacked .dc-row-meta { margin-left: 0; padding-left: 0; }
.dc-row-title {
font-weight: 600;
color: var(--link);
diff --git a/frontend/src/components/dashboardCards.js b/frontend/src/components/dashboardCards.js
index 8f9e236..21fb137 100644
--- a/frontend/src/components/dashboardCards.js
+++ b/frontend/src/components/dashboardCards.js
@@ -43,6 +43,14 @@ export function mapDetail(card, item) {
return key ? item[key] : ''
}
+// The untruncated text behind a shortened detail. Same rule as elsewhere: the
+// tooltip explains, it never carries the only copy of something essential.
+export function mapDetailTip(card, item) {
+ const key = card.map && card.map.detailtooltip
+ const full = key ? (item[key] || '') : ''
+ return full && full !== mapDetail(card, item) ? full : ''
+}
+
export function mapMeta(card, item) {
const meta = (card.map && card.map.meta) || []
return meta
@@ -115,6 +123,7 @@ export function cardRows(card) {
title: mapTitle(card, item),
titletip: mapTitleTip(card, item),
detail: mapDetail(card, item),
+ detailtip: mapDetailTip(card, item),
meta: mapMeta(card, item),
chips: mapChips(card, item),
link: mapLink(card, item),
diff --git a/frontend/src/components/dashboardCards.spec.js b/frontend/src/components/dashboardCards.spec.js
index db7779b..5cdfb39 100644
--- a/frontend/src/components/dashboardCards.spec.js
+++ b/frontend/src/components/dashboardCards.spec.js
@@ -202,3 +202,27 @@ describe('chips and tooltips', () => {
.toEqual([])
})
})
+
+describe('stacked rows', () => {
+ const card = {
+ render: 'list',
+ layout: 'stacked',
+ map: { title: 'typename', detail: 'message', detailtooltip: 'fullmessage' },
+ _data: [{ typename: 'Recertification', message: 'Short text', fullmessage: 'Short text' }],
+ }
+
+ it('offers no tooltip when the detail is already whole', () => {
+ // A tooltip repeating what is on screen is noise, and it makes the cursor
+ // change for no reason.
+ expect(cardRows(card)[0].detailtip).toBe('')
+ })
+
+ it('offers the full text when the detail was trimmed', () => {
+ const trimmed = { ...card, _data: [{
+ typename: 'General', message: 'Start of a long message...',
+ fullmessage: 'Start of a long message that continues well past the card',
+ }] }
+ expect(cardRows(trimmed)[0].detailtip)
+ .toBe('Start of a long message that continues well past the card')
+ })
+})
diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py
index d38bfb1..034636f 100644
--- a/plugins/notifications/api/routes.py
+++ b/plugins/notifications/api/routes.py
@@ -897,10 +897,16 @@ def dashboard_active():
out = []
for row in rows:
notificationtype = getattr(row, 'notificationtype', None)
+ text = (row.notification or '').strip()
out.append({
'notificationid': row.notificationid,
- 'message': (row.notification or '').strip()[:120],
- 'typename': notificationtype.typename if notificationtype else None,
+ # Trimmed for the card, whole text for the tooltip. A notification
+ # is prose written for the floor, so the first hundred characters
+ # are usually enough to recognise which one it is, and the rest is
+ # there when it is not.
+ 'message': text[:100] + ('...' if len(text) > 100 else ''),
+ 'fullmessage': text,
+ 'typename': notificationtype.typename if notificationtype else 'Notification',
'endtime': row.endtime.isoformat() + 'Z' if row.endtime else None,
})
return success_response(out)
diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py
index a13be3a..54570ea 100644
--- a/plugins/notifications/plugin.py
+++ b/plugins/notifications/plugin.py
@@ -211,11 +211,15 @@ class NotificationsPlugin(BasePlugin):
'severity': 'info',
'empty': 'hide',
'position': 60,
+ # Stacked, not inline: the type is a label and the message is
+ # prose. On one line the type gets truncated to make room for
+ # text that then gets truncated anyway, and neither is
+ # readable.
+ 'layout': 'stacked',
'map': {
'title': 'typename',
'detail': 'message',
- 'meta': [{'key': 'endtime', 'label': 'until',
- 'format': 'date'}],
+ 'detailtooltip': 'fullmessage',
'link': '/notifications/{notificationid}',
},
},
diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py
index 1aaed1e..80d912e 100644
--- a/plugins/printers/api/asset_routes.py
+++ b/plugins/printers/api/asset_routes.py
@@ -1020,12 +1020,11 @@ def _get_low_supplies_data():
if has_low:
# location name for the report row
- location_name = None
- if asset.locationid:
- from shopdb.api import Location
- loc = db.session.get(Location, asset.locationid)
- if loc:
- location_name = loc.locationname
+ # Via the relationship, the way the printers list does it. The
+ # previous lookup went through db.session.get on locationid, and
+ # every row came back with no location even where one is set.
+ location_name = (asset.location.locationname
+ if asset.location else None)
results.append({
'printerid': printer.printerid,
@@ -1441,27 +1440,37 @@ def dashboard_supplies():
"""
data = _get_low_supplies_data()
+ threshold = 5
+ setting = Setting.query.filter_by(key='printers_dashboardpercent').first()
+ if setting and (setting.value or '').strip():
+ try:
+ threshold = int(setting.value)
+ except (TypeError, ValueError):
+ pass
+
rows = []
for printer in data.get('printers', []):
- criticals = [s for s in printer['supplies'] if s['status'] == 'critical']
- lows = [s for s in printer['supplies'] if s['status'] == 'low']
- if not criticals and not lows:
+ # The CARD is tighter than the report. The report lists anything the
+ # thresholds call low, which is the right scope for planning an order;
+ # the dashboard is asking what to walk out and change today, and a
+ # cartridge at 18% is not that. Anything at or below the threshold.
+ depleted = [supply for supply in printer['supplies']
+ if isinstance(supply.get('remaining'), (int, float))
+ and supply['remaining'] <= threshold]
+ if not depleted:
continue
- # Criticals first. Every depleted supply is listed, not just the worst
- # class - whoever walks out there wants to carry both cartridges.
+ depleted.sort(key=lambda supply: supply['remaining'])
rows.append({
'printerid': printer['printerid'],
'printername': printer['printername'] or printer['assetnumber'],
'location': printer['location'] or 'No location set',
- 'iscritical': bool(criticals),
- # One chip per depleted supply: what is low, and on hover what to
- # order to fix it.
+ 'iscritical': any(s['status'] == 'critical' for s in depleted),
'supplies': [{
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
supply.get('remaining')),
'title': _reordertip(supply),
'level': supply.get('status'),
- } for supply in criticals + lows],
+ } for supply in depleted],
})
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py
index b4df675..3e6a955 100644
--- a/plugins/printers/plugin.py
+++ b/plugins/printers/plugin.py
@@ -286,6 +286,21 @@ class PrintersPlugin(BasePlugin):
return [printerscli]
+ def get_settings_defaults(self) -> List[Dict]:
+ """Settings this plugin owns for the dashboard card."""
+ return [
+ {
+ 'key': 'printers_dashboardpercent',
+ 'value': '5',
+ 'valuetype': 'integer',
+ 'category': 'printers',
+ 'description': 'Supply percentage at or below which a printer '
+ 'appears on the dashboard. Tighter than the '
+ 'low-supplies report, which is for planning an '
+ 'order rather than walking out to change one.',
+ },
+ ]
+
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard card: printers needing a cartridge.
diff --git a/plugins/warranty/api/routes.py b/plugins/warranty/api/routes.py
index 232565b..7bf52f7 100644
--- a/plugins/warranty/api/routes.py
+++ b/plugins/warranty/api/routes.py
@@ -399,13 +399,16 @@ def warranty_report():
def dashboard_expiring():
"""Warranties running out, and ones that already have.
- Already-expired first, then soonest. Expired stays on the list rather than
- dropping off: a machine out of warranty is a purchasing decision someone
- still has to make, and silently removing it the day it lapses is how it gets
- missed entirely.
+ Identified the way the floor identifies them: the PC's hostname and the
+ MACHINE it drives. Nobody walks out looking for an asset number, and a
+ warranty row for a bay PC is only actionable if you know which bay.
- Horizon is a setting because 90 days suits a site that budgets quarterly and
- nobody else (ADR-015).
+ No dates. "Expired" or "expiring" is the whole decision - the exact day
+ matters when you are placing the order, which is what the warranty report
+ is for, not when you are scanning a board.
+
+ Already-expired stays listed rather than dropping off the day it lapses,
+ which is how one gets missed entirely.
"""
from shopdb.api import Setting
@@ -421,7 +424,7 @@ def dashboard_expiring():
horizon = today + timedelta(days=days)
rows = []
- query = (db.session.query(Warranty, WarrantyAsset, Asset)
+ query = (db.session.query(Warranty, Asset)
.join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid)
.join(Asset, Asset.assetid == WarrantyAsset.assetid)
.filter(Warranty.isactive.is_(True),
@@ -429,16 +432,33 @@ def dashboard_expiring():
Warranty.enddate <= horizon,
Asset.isactive.is_(True)))
- for warranty, _link, asset in query.all():
+ for warranty, asset in query.all():
remaining = (warranty.enddate - today).days
+ # Reuses the machine lookup behind the warranty page's machine column,
+ # so the board and the report agree about which bay a PC belongs to.
+ machine = _related_machine(asset)
rows.append({
'assetid': asset.assetid,
- 'assetnumber': asset.assetnumber or asset.name or str(asset.assetid),
- 'enddate': warranty.enddate.isoformat(),
- 'provider': warranty.provider,
- 'daysleft': remaining,
+ 'hostname': _hostname(asset) or asset.assetnumber or asset.name,
+ 'machinenumber': machine['machinenumber'] if machine else None,
'state': 'expired' if remaining < 0 else 'expiring',
+ 'daysleft': remaining,
})
rows.sort(key=lambda r: r['daysleft'])
return success_response(rows[:50])
+
+
+def _hostname(asset):
+ """The PC's hostname, when the covered asset is a PC.
+
+ Optional import: a lean site may not install the computers plugin, and a
+ warranty on a printer or a machine has no hostname at all. Falls back to
+ the asset number, which is what the row shows either way.
+ """
+ try:
+ from plugins.computers.models import Computer
+ except ImportError:
+ return None
+ computer = Computer.query.filter_by(assetid=asset.assetid).first()
+ return computer.hostname if computer else None
diff --git a/plugins/warranty/plugin.py b/plugins/warranty/plugin.py
index 70c1bc4..5e9111f 100644
--- a/plugins/warranty/plugin.py
+++ b/plugins/warranty/plugin.py
@@ -133,12 +133,13 @@ class WarrantyPlugin(BasePlugin):
'empty': 'hide',
'position': 50,
'map': {
- 'title': 'assetnumber',
+ 'title': 'hostname',
'detail': 'state',
- 'meta': [
- {'key': 'enddate', 'label': 'ends', 'format': 'date'},
- {'key': 'provider'},
- ],
+ # The bay, which is how the floor identifies the PC. No
+ # date: expired or expiring is the whole decision when
+ # scanning a board, and the exact day belongs on the report
+ # you order from.
+ 'meta': [{'key': 'machinenumber', 'label': 'machine'}],
'link': '/assets/{assetid}',
},
},