diff --git a/frontend/src/views/ShopfloorDashboard.vue b/frontend/src/views/ShopfloorDashboard.vue index f98521f6..c6035552 100644 --- a/frontend/src/views/ShopfloorDashboard.vue +++ b/frontend/src/views/ShopfloorDashboard.vue @@ -252,9 +252,10 @@ const recertDescriptions = computed(() => { return out }) -// Recertification shows as a single rotating row: one page of tiles at a time -// so it stays compact on any screen, cycling through everyone due. -const RECERT_PAGE_SIZE = 8 +// Recertification shows a rotating 2x2 block: one page of tiles at a time so it +// stays compact on any screen, cycling through everyone due. Eight across left +// each tile too narrow to read at floor distance. +const RECERT_PAGE_SIZE = 4 const currentRecertPage = ref(0) const recertPageCount = computed(() => Math.max(1, Math.ceil(recertifications.value.length / RECERT_PAGE_SIZE)) @@ -577,26 +578,29 @@ function handlePhotoError(e) { margin: -4px 0 14px; line-height: 1.35; } -/* Single row that cycles through pages of employees */ +/* 2x2 block that cycles through pages of employees */ .recert-row { display: grid; - grid-template-columns: repeat(8, 1fr); + grid-template-columns: repeat(2, 1fr); gap: 14px; } .recert-tile { background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%); border: 2px solid #0d6efd; border-radius: 10px; - padding: 14px 10px; + padding: 14px 18px; + /* Photo beside the name rather than above it: two rows of stacked tiles + would double the section's height, and the whole board is scaled to fit + one screen, so height here shrinks everything else. */ display: flex; - flex-direction: column; align-items: center; - gap: 10px; - text-align: center; + gap: 18px; + text-align: left; } .recert-photo { - width: 90px; - height: 90px; + width: 120px; + height: 120px; + flex: none; border-radius: 50%; object-fit: cover; border: 3px solid #0d6efd; @@ -608,7 +612,9 @@ function handlePhotoError(e) { background: #fff; } .recert-name { - font-size: 20px; + flex: 1; + min-width: 0; + font-size: 26px; font-weight: 700; line-height: 1.15; /* Always reserve two lines so a name that wraps does not make its tile diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index da8d187d..844652e6 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -140,6 +140,10 @@ def _apply_expiry_fields(t, data): _DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner') +# Ceiling on the per-type post-expiry tail. A day is already far longer than +# "recently ended"; anything more is an end time that should have been later. +_MAX_GRACE_MINUTES = 1440 + def _apply_display_fields(t, data): """Set shopfloor display-behavior columns on a NotificationType from request @@ -153,6 +157,17 @@ 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 'gracewindowminutes' in data: + raw = data.get('gracewindowminutes') + raw = 0 if raw in (None, '') else raw + try: + minutes = int(raw) + except (TypeError, ValueError): + return "gracewindowminutes must be a whole number of minutes" + if minutes < 0 or minutes > _MAX_GRACE_MINUTES: + return ("gracewindowminutes must be between 0 and %d" + % _MAX_GRACE_MINUTES) + t.gracewindowminutes = minutes return None @@ -163,10 +178,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" % ( + "%s|%s|%s|%d|%d|%s|%s|%s|%d|%d" % ( 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), ) for t in types ] @@ -698,8 +714,15 @@ def get_shopfloor_notifications(): # All units: only show notifications with NULL businessunitid base_query = base_query.filter(Notification.businessunitid.is_(None)) - # Current notifications (active now or ended within 30 minutes) - thirty_min_ago = now - timedelta(minutes=30) + # Current notifications: showing now, plus anything whose type asks to keep + # ended cards up for a while (gracewindowminutes, 0 by default - an end time + # means the card leaves the board then). The window is per type, so the SQL + # only widens to the largest configured tail and each row is then held to + # its own; that keeps this one portable query instead of a per-type interval + # expression, and with every type at 0 it collapses to "still showing". + widest_grace = db.session.query( + db.func.max(NotificationType.gracewindowminutes)).scalar() or 0 + widest_grace_start = now - timedelta(minutes=int(widest_grace)) current_query = base_query.filter( db.or_( # Active and currently showing @@ -708,16 +731,23 @@ def get_shopfloor_notifications(): db.or_(Notification.starttime.is_(None), Notification.starttime <= now), db.or_(Notification.endtime.is_(None), Notification.endtime >= now) ), - # Recently ended (within 30 min) - show as resolved + # Ended, but possibly inside its type's tail - narrowed below db.and_( Notification.endtime.isnot(None), - Notification.endtime >= thirty_min_ago, + Notification.endtime >= widest_grace_start, Notification.endtime < now ) ) ).order_by(Notification.notificationid.desc()) - current_notifications = current_query.all() + def _within_grace(n): + """True unless the row ended outside its own type's tail.""" + if n.endtime is None or n.endtime >= now: + return True + grace = (n.notificationtype.gracewindowminutes or 0) if n.notificationtype else 0 + return n.endtime >= now - timedelta(minutes=int(grace)) + + current_notifications = [n for n in current_query.all() if _within_grace(n)] # Upcoming notifications (starts within next 5 days) five_days = now + timedelta(days=5) @@ -731,7 +761,9 @@ def get_shopfloor_notifications(): def notification_to_shopfloor(n, employee_override=None): """Convert notification to shopfloor format.""" - is_resolved = n.endtime and n.endtime < now + # bool, not the datetime-or-None the and-chain yields: a card with no + # end time was serializing resolved as null. + is_resolved = bool(n.endtime and n.endtime < now) ntype = n.notificationtype show_photo = bool(ntype and ntype.showemployeephoto) diff --git a/plugins/notifications/frontend/views/NotificationTypesList.vue b/plugins/notifications/frontend/views/NotificationTypesList.vue index 6642158e..13030475 100644 --- a/plugins/notifications/frontend/views/NotificationTypesList.vue +++ b/plugins/notifications/frontend/views/NotificationTypesList.vue @@ -87,6 +87,18 @@ + +