diff --git a/frontend/src/views/ShopfloorDashboard.vue b/frontend/src/views/ShopfloorDashboard.vue
index f98521f..4ce6bc7 100644
--- a/frontend/src/views/ShopfloorDashboard.vue
+++ b/frontend/src/views/ShopfloorDashboard.vue
@@ -1,818 +1,872 @@
-
-
-
-
-
-
-
-
-
- {{ n.notification }}
-
-
-
-
-
- Employee Recognition
-
-
-
-
-
-
-
-
-
{{ rec.notification }}
-
-
-
-
-
-
-
-
- Recertification Required ({{ recertifications.length }})
- {{ recertRangeLabel }}
-
-
- {{ msg }}
-
-
-
-
-
-
{{ rec.employeename || rec.employeesso }}
-
-
-
-
-
-
-
- Current Notifications
-
-
-
-
-
-
{{ n.notification }}
-
-
- RESOLVED
-
-
- {{ formatTime(n.starttime) }}
- - {{ formatTime(n.endtime) }}
-
-
-
-
-
- {{ n.ticketnumber }}
-
- {{ n.ticketnumber }}
-
-
-
-
-
-
-
- Upcoming
-
-
-
-
-
{{ n.notification }}
-
- {{ formatDateTime(n.starttime) }}
-
-
-
-
- {{ n.ticketnumber }}
-
- {{ n.ticketnumber }}
-
-
-
-
-
-
-
- No active notifications
-
-
- Loading...
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ n.notification }}
+
+
+
+
+
+ {{ group.typename }}
+
+
+
+
+
+
+
+
+
+
{{ rec.notification }}
+
+
+
+
+
+
+
+
+ {{ group.typename }} ({{ group.items.length }})
+ {{ gridRangeLabel(group) }}
+
+
+ {{ msg }}
+
+
+
+
+
+
+ {{ rec.employeename || rec.employeesso }}
+
+
+
{{ rec.notification }}
+
+
+
+
+
+
+
+
+
+ Current Notifications
+
+
+
+
+
+
{{ n.notification }}
+
+
+ RESOLVED
+
+
+ {{ formatTime(n.starttime) }}
+ - {{ formatTime(n.endtime) }}
+
+
+
+
+
+ {{ n.ticketnumber }}
+
+ {{ n.ticketnumber }}
+
+
+
+
+
+
+
+ Upcoming
+
+
+
+
+
{{ n.notification }}
+
+ {{ formatDateTime(n.starttime) }}
+
+
+
+
+ {{ n.ticketnumber }}
+
+ {{ n.ticketnumber }}
+
+
+
+
+
+
+
+ No active notifications
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py
index 844652e..cf9275b 100644
--- a/plugins/notifications/api/routes.py
+++ b/plugins/notifications/api/routes.py
@@ -157,6 +157,13 @@ def _apply_display_fields(t, data):
if ds not in _DISPLAY_STYLES:
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
t.displaystyle = ds
+ if 'boardcategory' in data:
+ category = (data.get('boardcategory') or '').strip()
+ if len(category) > 50:
+ return "boardcategory must be 50 characters or fewer"
+ # Blank stores as NULL: "no category" is the absence of one, not the
+ # empty-string category that every uncategorised type would share.
+ t.boardcategory = category or None
if 'gracewindowminutes' in data:
raw = data.get('gracewindowminutes')
raw = 0 if raw in (None, '') else raw
@@ -178,11 +185,11 @@ def _config_version():
reach pages that are already open."""
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
parts = [
- "%s|%s|%s|%d|%d|%s|%s|%s|%d|%d" % (
+ "%s|%s|%s|%d|%d|%s|%s|%s|%d|%d|%s" % (
t.notificationtypeid, t.typecolor, t.displaystyle,
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
- int(t.gracewindowminutes or 0),
+ int(t.gracewindowminutes or 0), t.boardcategory or '',
)
for t in types
]
@@ -779,8 +786,11 @@ def get_shopfloor_notifications():
'resolved': is_resolved,
'typename': ntype.typename if ntype else None,
'typecolor': ntype.typecolor if ntype else None,
- # Per-type display behavior the dashboard groups/renders by.
+ # Per-type display behavior the dashboard groups/renders by. A
+ # boardcategory puts several types in one row under that name;
+ # blank gives the type a row of its own.
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
+ 'boardcategory': (ntype.boardcategory or '') if ntype else '',
}
# Employee info (photo only when the type wants it)
diff --git a/plugins/notifications/frontend/views/NotificationTypesList.vue b/plugins/notifications/frontend/views/NotificationTypesList.vue
index 1303047..40e717d 100644
--- a/plugins/notifications/frontend/views/NotificationTypesList.vue
+++ b/plugins/notifications/frontend/views/NotificationTypesList.vue
@@ -32,7 +32,10 @@
{{ t.typecolor }}
-
{{ t.displaystyle || 'standard' }}
+
+ {{ t.displaystyle || 'standard' }}
+ {{ t.boardcategory }}
+
split
photo
@@ -87,6 +90,17 @@
+
+ Board category (optional)
+
+
+ Types sharing a category share one row on the shopfloor board,
+ under the category name. Blank gives this type a row of its own.
+ Only types with the same display style can share a row.
+
+
+
Keep showing after end (minutes)
"
@@ -100,7 +106,8 @@ class NotificationType(db.Model):
'splitperemployee': bool(self.splitperemployee),
'showemployeephoto': bool(self.showemployeephoto),
'displaystyle': self.displaystyle or 'standard',
- 'gracewindowminutes': int(self.gracewindowminutes or 0)
+ 'gracewindowminutes': int(self.gracewindowminutes or 0),
+ 'boardcategory': self.boardcategory or ''
}
diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py
index 8a86632..5acbd2e 100644
--- a/tests/test_plugin_migrations.py
+++ b/tests/test_plugin_migrations.py
@@ -64,8 +64,9 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
# the per-transaction revision column.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
-# notifications indexes businessunitid, then adds the per-type grace window.
-EXPECTED_HEAD_REVISION['notifications'] = 'notifications0003grace'
+# notifications indexes businessunitid, then adds the per-type grace window and
+# the shared board category.
+EXPECTED_HEAD_REVISION['notifications'] = 'notifications0004category'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.
diff --git a/tests/test_plugins/test_board_category.py b/tests/test_plugins/test_board_category.py
new file mode 100644
index 0000000..160f28c
--- /dev/null
+++ b/tests/test_plugins/test_board_category.py
@@ -0,0 +1,117 @@
+"""Tests for notificationtypes.boardcategory (shared row on the shopfloor board).
+
+The board grouped cards by display style alone, so every type set to grid landed
+in the Recertification row and every carousel type in Recognition's, under a
+heading naming someone else's type. Each type now gets a row of its own, and
+this column is the opt-in for the other direction: Change, Awareness and
+Incident share one row under a category name.
+
+The grouping itself is frontend (ShopfloorDashboard.vue); what is pinned here is
+the contract it groups on - the feed must carry boardcategory per card, and the
+type API must round-trip it.
+"""
+
+from plugins.notifications.models import Notification, NotificationType
+
+
+def _make_type(db, typename, displaystyle='grid', boardcategory=None):
+ t = NotificationType(typename=typename, typecolor='#17a2b8', isactive=True,
+ displaystyle=displaystyle, boardcategory=boardcategory)
+ db.session.add(t)
+ db.session.commit()
+ return t
+
+
+def _make_note(db, ntype, text):
+ n = Notification(notificationtypeid=ntype.notificationtypeid,
+ notification=text, businessunitid=None,
+ isactive=True, isshopfloor=True)
+ db.session.add(n)
+ db.session.commit()
+ return n
+
+
+def _current(client):
+ resp = client.get('/api/notifications/shopfloor')
+ assert resp.status_code == 200, resp.get_json()
+ return resp.get_json()['data']['current']
+
+
+def test_feed_carries_the_category_per_card(client, db):
+ """The dashboard groups on this field, so it has to reach the card."""
+ ntype = _make_type(db, 'Awareness', boardcategory='Alerts')
+ _make_note(db, ntype, 'watch your step')
+
+ card = _current(client)[0]
+ assert card['boardcategory'] == 'Alerts'
+ assert card['typename'] == 'Awareness'
+ assert card['displaystyle'] == 'grid'
+
+
+def test_uncategorised_type_reports_an_empty_category(client, db):
+ """Blank, not null: the dashboard falls back to the type name for a row."""
+ ntype = _make_type(db, 'Recertification')
+ _make_note(db, ntype, 'forklift cert due')
+
+ assert _current(client)[0]['boardcategory'] == ''
+
+
+def test_types_sharing_a_category_keep_their_own_names(client, db):
+ """Grouping is the dashboard's job; the feed must not flatten the types."""
+ change = _make_type(db, 'Change', boardcategory='Alerts')
+ incident = _make_type(db, 'Incident', boardcategory='Alerts')
+ _make_note(db, change, 'line 3 retooling')
+ _make_note(db, incident, 'press 12 down')
+
+ cards = _current(client)
+ assert {c['typename'] for c in cards} == {'Change', 'Incident'}
+ assert {c['boardcategory'] for c in cards} == {'Alerts'}
+
+
+def test_api_round_trips_the_category(client, db, auth_headers):
+ create = client.post('/api/notifications/types',
+ json={'typename': 'Awareness', 'displaystyle': 'grid',
+ 'boardcategory': 'Alerts'},
+ headers=auth_headers)
+ assert create.status_code in (200, 201), create.get_json()
+ typeid = create.get_json()['data']['notificationtypeid']
+ assert create.get_json()['data']['boardcategory'] == 'Alerts'
+
+ listed = client.get('/api/notifications/types', headers=auth_headers)
+ row = next(t for t in listed.get_json()['data']
+ if t['notificationtypeid'] == typeid)
+ assert row['boardcategory'] == 'Alerts'
+
+
+def test_blank_category_stores_as_null_not_an_empty_group(client, db, auth_headers):
+ """Every uncategorised type sharing the empty string would be one big row."""
+ create = client.post('/api/notifications/types',
+ json={'typename': 'Solo', 'boardcategory': ' '},
+ headers=auth_headers)
+ assert create.status_code in (200, 201), create.get_json()
+ typeid = create.get_json()['data']['notificationtypeid']
+
+ stored = db.session.get(NotificationType, typeid)
+ assert stored.boardcategory is None
+ assert create.get_json()['data']['boardcategory'] == ''
+
+
+def test_api_rejects_an_overlong_category(client, db, auth_headers):
+ resp = client.post('/api/notifications/types',
+ json={'typename': 'Bad', 'boardcategory': 'x' * 51},
+ headers=auth_headers)
+
+ assert resp.status_code == 400
+ assert 'boardcategory' in resp.get_data(as_text=True)
+
+
+def test_category_change_moves_the_config_version(client, db, auth_headers):
+ """Open kiosks reload on a layout change; a re-grouped board is one."""
+ ntype = _make_type(db, 'Awareness')
+ before = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
+
+ client.put(f'/api/notifications/types/{ntype.notificationtypeid}',
+ json={'boardcategory': 'Alerts'}, headers=auth_headers)
+
+ after = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
+ assert before != after