Card refinements
This commit is contained in:
@@ -17,14 +17,16 @@
|
|||||||
|
|
||||||
<!-- exceptions / list: one line per thing, each linking to itself -->
|
<!-- exceptions / list: one line per thing, each linking to itself -->
|
||||||
<ul v-else class="dc-rows">
|
<ul v-else class="dc-rows">
|
||||||
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row">
|
<li v-for="(row, index) in visibleRows(card)" :key="index" class="dc-row"
|
||||||
|
:class="{ 'dc-row-stacked': card.layout === 'stacked' }">
|
||||||
<router-link v-if="row.link" :to="row.link" class="dc-row-title"
|
<router-link v-if="row.link" :to="row.link" class="dc-row-title"
|
||||||
:title="row.titletip || undefined">
|
:title="row.titletip || undefined">
|
||||||
{{ row.title }}
|
{{ row.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else class="dc-row-title dc-row-nolink"
|
<span v-else class="dc-row-title dc-row-nolink"
|
||||||
:title="row.titletip || undefined">{{ row.title }}</span>
|
:title="row.titletip || undefined">{{ row.title }}</span>
|
||||||
<span v-if="row.detail" class="dc-row-detail">{{ row.detail }}</span>
|
<span v-if="row.detail" class="dc-row-detail"
|
||||||
|
:title="row.detailtip || undefined">{{ row.detail }}</span>
|
||||||
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
|
<span v-for="(chip, c) in row.chips" :key="c" class="dc-chip"
|
||||||
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
|
:class="'dc-chip-' + (chip.level || 'low')" :title="chip.title">
|
||||||
{{ chip.text }}
|
{{ chip.text }}
|
||||||
@@ -173,6 +175,12 @@ defineExpose({ load })
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.dc-row:last-child { border-bottom: none; }
|
.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 {
|
.dc-row-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--link);
|
color: var(--link);
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export function mapDetail(card, item) {
|
|||||||
return key ? item[key] : ''
|
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) {
|
export function mapMeta(card, item) {
|
||||||
const meta = (card.map && card.map.meta) || []
|
const meta = (card.map && card.map.meta) || []
|
||||||
return meta
|
return meta
|
||||||
@@ -115,6 +123,7 @@ export function cardRows(card) {
|
|||||||
title: mapTitle(card, item),
|
title: mapTitle(card, item),
|
||||||
titletip: mapTitleTip(card, item),
|
titletip: mapTitleTip(card, item),
|
||||||
detail: mapDetail(card, item),
|
detail: mapDetail(card, item),
|
||||||
|
detailtip: mapDetailTip(card, item),
|
||||||
meta: mapMeta(card, item),
|
meta: mapMeta(card, item),
|
||||||
chips: mapChips(card, item),
|
chips: mapChips(card, item),
|
||||||
link: mapLink(card, item),
|
link: mapLink(card, item),
|
||||||
|
|||||||
@@ -202,3 +202,27 @@ describe('chips and tooltips', () => {
|
|||||||
.toEqual([])
|
.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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -897,10 +897,16 @@ def dashboard_active():
|
|||||||
out = []
|
out = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
notificationtype = getattr(row, 'notificationtype', None)
|
notificationtype = getattr(row, 'notificationtype', None)
|
||||||
|
text = (row.notification or '').strip()
|
||||||
out.append({
|
out.append({
|
||||||
'notificationid': row.notificationid,
|
'notificationid': row.notificationid,
|
||||||
'message': (row.notification or '').strip()[:120],
|
# Trimmed for the card, whole text for the tooltip. A notification
|
||||||
'typename': notificationtype.typename if notificationtype else None,
|
# 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,
|
'endtime': row.endtime.isoformat() + 'Z' if row.endtime else None,
|
||||||
})
|
})
|
||||||
return success_response(out)
|
return success_response(out)
|
||||||
|
|||||||
@@ -211,11 +211,15 @@ class NotificationsPlugin(BasePlugin):
|
|||||||
'severity': 'info',
|
'severity': 'info',
|
||||||
'empty': 'hide',
|
'empty': 'hide',
|
||||||
'position': 60,
|
'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': {
|
'map': {
|
||||||
'title': 'typename',
|
'title': 'typename',
|
||||||
'detail': 'message',
|
'detail': 'message',
|
||||||
'meta': [{'key': 'endtime', 'label': 'until',
|
'detailtooltip': 'fullmessage',
|
||||||
'format': 'date'}],
|
|
||||||
'link': '/notifications/{notificationid}',
|
'link': '/notifications/{notificationid}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1020,12 +1020,11 @@ def _get_low_supplies_data():
|
|||||||
|
|
||||||
if has_low:
|
if has_low:
|
||||||
# location name for the report row
|
# location name for the report row
|
||||||
location_name = None
|
# Via the relationship, the way the printers list does it. The
|
||||||
if asset.locationid:
|
# previous lookup went through db.session.get on locationid, and
|
||||||
from shopdb.api import Location
|
# every row came back with no location even where one is set.
|
||||||
loc = db.session.get(Location, asset.locationid)
|
location_name = (asset.location.locationname
|
||||||
if loc:
|
if asset.location else None)
|
||||||
location_name = loc.locationname
|
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
'printerid': printer.printerid,
|
'printerid': printer.printerid,
|
||||||
@@ -1441,27 +1440,37 @@ def dashboard_supplies():
|
|||||||
"""
|
"""
|
||||||
data = _get_low_supplies_data()
|
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 = []
|
rows = []
|
||||||
for printer in data.get('printers', []):
|
for printer in data.get('printers', []):
|
||||||
criticals = [s for s in printer['supplies'] if s['status'] == 'critical']
|
# The CARD is tighter than the report. The report lists anything the
|
||||||
lows = [s for s in printer['supplies'] if s['status'] == 'low']
|
# thresholds call low, which is the right scope for planning an order;
|
||||||
if not criticals and not lows:
|
# 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
|
continue
|
||||||
# Criticals first. Every depleted supply is listed, not just the worst
|
depleted.sort(key=lambda supply: supply['remaining'])
|
||||||
# class - whoever walks out there wants to carry both cartridges.
|
|
||||||
rows.append({
|
rows.append({
|
||||||
'printerid': printer['printerid'],
|
'printerid': printer['printerid'],
|
||||||
'printername': printer['printername'] or printer['assetnumber'],
|
'printername': printer['printername'] or printer['assetnumber'],
|
||||||
'location': printer['location'] or 'No location set',
|
'location': printer['location'] or 'No location set',
|
||||||
'iscritical': bool(criticals),
|
'iscritical': any(s['status'] == 'critical' for s in depleted),
|
||||||
# One chip per depleted supply: what is low, and on hover what to
|
|
||||||
# order to fix it.
|
|
||||||
'supplies': [{
|
'supplies': [{
|
||||||
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
||||||
supply.get('remaining')),
|
supply.get('remaining')),
|
||||||
'title': _reordertip(supply),
|
'title': _reordertip(supply),
|
||||||
'level': supply.get('status'),
|
'level': supply.get('status'),
|
||||||
} for supply in criticals + lows],
|
} for supply in depleted],
|
||||||
})
|
})
|
||||||
|
|
||||||
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
|
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
|
||||||
|
|||||||
@@ -286,6 +286,21 @@ class PrintersPlugin(BasePlugin):
|
|||||||
|
|
||||||
return [printerscli]
|
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]:
|
def get_dashboard_widgets(self) -> List[Dict]:
|
||||||
"""Dashboard card: printers needing a cartridge.
|
"""Dashboard card: printers needing a cartridge.
|
||||||
|
|
||||||
|
|||||||
@@ -399,13 +399,16 @@ def warranty_report():
|
|||||||
def dashboard_expiring():
|
def dashboard_expiring():
|
||||||
"""Warranties running out, and ones that already have.
|
"""Warranties running out, and ones that already have.
|
||||||
|
|
||||||
Already-expired first, then soonest. Expired stays on the list rather than
|
Identified the way the floor identifies them: the PC's hostname and the
|
||||||
dropping off: a machine out of warranty is a purchasing decision someone
|
MACHINE it drives. Nobody walks out looking for an asset number, and a
|
||||||
still has to make, and silently removing it the day it lapses is how it gets
|
warranty row for a bay PC is only actionable if you know which bay.
|
||||||
missed entirely.
|
|
||||||
|
|
||||||
Horizon is a setting because 90 days suits a site that budgets quarterly and
|
No dates. "Expired" or "expiring" is the whole decision - the exact day
|
||||||
nobody else (ADR-015).
|
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
|
from shopdb.api import Setting
|
||||||
|
|
||||||
@@ -421,7 +424,7 @@ def dashboard_expiring():
|
|||||||
horizon = today + timedelta(days=days)
|
horizon = today + timedelta(days=days)
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
query = (db.session.query(Warranty, WarrantyAsset, Asset)
|
query = (db.session.query(Warranty, Asset)
|
||||||
.join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid)
|
.join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid)
|
||||||
.join(Asset, Asset.assetid == WarrantyAsset.assetid)
|
.join(Asset, Asset.assetid == WarrantyAsset.assetid)
|
||||||
.filter(Warranty.isactive.is_(True),
|
.filter(Warranty.isactive.is_(True),
|
||||||
@@ -429,16 +432,33 @@ def dashboard_expiring():
|
|||||||
Warranty.enddate <= horizon,
|
Warranty.enddate <= horizon,
|
||||||
Asset.isactive.is_(True)))
|
Asset.isactive.is_(True)))
|
||||||
|
|
||||||
for warranty, _link, asset in query.all():
|
for warranty, asset in query.all():
|
||||||
remaining = (warranty.enddate - today).days
|
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({
|
rows.append({
|
||||||
'assetid': asset.assetid,
|
'assetid': asset.assetid,
|
||||||
'assetnumber': asset.assetnumber or asset.name or str(asset.assetid),
|
'hostname': _hostname(asset) or asset.assetnumber or asset.name,
|
||||||
'enddate': warranty.enddate.isoformat(),
|
'machinenumber': machine['machinenumber'] if machine else None,
|
||||||
'provider': warranty.provider,
|
|
||||||
'daysleft': remaining,
|
|
||||||
'state': 'expired' if remaining < 0 else 'expiring',
|
'state': 'expired' if remaining < 0 else 'expiring',
|
||||||
|
'daysleft': remaining,
|
||||||
})
|
})
|
||||||
|
|
||||||
rows.sort(key=lambda r: r['daysleft'])
|
rows.sort(key=lambda r: r['daysleft'])
|
||||||
return success_response(rows[:50])
|
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
|
||||||
|
|||||||
@@ -133,12 +133,13 @@ class WarrantyPlugin(BasePlugin):
|
|||||||
'empty': 'hide',
|
'empty': 'hide',
|
||||||
'position': 50,
|
'position': 50,
|
||||||
'map': {
|
'map': {
|
||||||
'title': 'assetnumber',
|
'title': 'hostname',
|
||||||
'detail': 'state',
|
'detail': 'state',
|
||||||
'meta': [
|
# The bay, which is how the floor identifies the PC. No
|
||||||
{'key': 'enddate', 'label': 'ends', 'format': 'date'},
|
# date: expired or expiring is the whole decision when
|
||||||
{'key': 'provider'},
|
# scanning a board, and the exact day belongs on the report
|
||||||
],
|
# you order from.
|
||||||
|
'meta': [{'key': 'machinenumber', 'label': 'machine'}],
|
||||||
'link': '/assets/{assetid}',
|
'link': '/assets/{assetid}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user