shopfloor dashboard: show upcoming cards, state chips, mixed-row headings
Scheduled notifications with a special display style rendered nowhere at all. styledGroups only read notifications.current, so anything upcoming fell through to the standard Upcoming list, which drops banner/carousel/grid styles. A scheduled banner was therefore invisible until the moment it went live. Upcoming items now join the styled groups, tagged so they can be told apart, and each row sorts live cards ahead of not-yet-started ones. State chips name what a card is doing: UPCOMING with its start time, or RESOLVED for the tail a type's grace window buys it. Pending cards also dim to 0.72 so a glance never mistakes one for live. The dim rule is scoped to .recognition-card.active.pending on purpose: inactive carousel cards sit at opacity 0, and an unscoped .pending outranks that, which stacks the whole carousel back into view at once. Rows keyed by board category can hold more than one type, and the heading then speaks for none of them. Such rows keep a neutral heading instead of wearing the first type's colour, and each card carries its own type chip. The chip is named rather than only coloured, since colour alone does not survive a colourblind reader at board distance. Carousel rows gain one pip per card, so a reader can see how many are in the rotation and how long until theirs returns.
This commit is contained in:
@@ -37,8 +37,10 @@
|
||||
v-for="n in group.items"
|
||||
:key="n.notificationid"
|
||||
class="banner-strip"
|
||||
:class="{ pending: n.upcoming || n.resolved }"
|
||||
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
|
||||
>
|
||||
<span v-if="stateLabel(n)" class="state-chip">{{ stateLabel(n) }}</span>
|
||||
{{ n.notification }}
|
||||
</div>
|
||||
</section>
|
||||
@@ -47,14 +49,15 @@
|
||||
<section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
|
||||
<div
|
||||
class="section-title recognition"
|
||||
:style="{ backgroundColor: getTypeColor(group.typecolor), color: textOn(group.typecolor) }"
|
||||
:class="{ mixed: isMixed(group) }"
|
||||
:style="headingStyle(group)"
|
||||
>{{ group.typename }}</div>
|
||||
<div class="recognition-carousel">
|
||||
<div
|
||||
v-for="(rec, idx) in group.items"
|
||||
:key="`${rec.notificationid}-${rec.employeesso}`"
|
||||
class="recognition-card"
|
||||
:class="{ active: idx === carouselIndex(group) }"
|
||||
:class="{ active: idx === carouselIndex(group), pending: rec.upcoming || rec.resolved }"
|
||||
:style="{ '--accent': getTypeColor(rec.typecolor) }"
|
||||
>
|
||||
<!-- A type that names no employee is a message, not a person:
|
||||
@@ -78,17 +81,41 @@
|
||||
<div v-if="hasEmployee(rec)" class="recognition-header">
|
||||
<div class="recognition-name">{{ rec.employeename }}</div>
|
||||
</div>
|
||||
<!-- On a shared category row the heading names the category, so
|
||||
the card has to say which type it is. Named, not just
|
||||
coloured: colour alone is no use to a colourblind reader
|
||||
across a 1920 board. -->
|
||||
<div class="chip-row">
|
||||
<span
|
||||
v-if="isMixed(group)"
|
||||
class="type-chip"
|
||||
:style="{ backgroundColor: getTypeColor(rec.typecolor), color: textOn(rec.typecolor) }"
|
||||
>{{ rec.typename }}</span>
|
||||
<span v-if="stateLabel(rec)" class="state-chip">{{ stateLabel(rec) }}</span>
|
||||
</div>
|
||||
<div class="recognition-message">{{ rec.notification }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- One pip per card, so a reader knows how many are in the rotation
|
||||
and how long until theirs comes back round. -->
|
||||
<div v-if="group.items.length > 1" class="carousel-pips">
|
||||
<span
|
||||
v-for="(rec, idx) in group.items"
|
||||
:key="`pip-${rec.notificationid}-${rec.employeesso}`"
|
||||
class="pip"
|
||||
:class="{ on: idx === carouselIndex(group) }"
|
||||
:style="{ backgroundColor: getTypeColor(rec.typecolor) }"
|
||||
></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Grid - a row of tiles, cycling a page at a time -->
|
||||
<section v-else class="recert-section">
|
||||
<div
|
||||
class="section-title recert-title"
|
||||
:style="{ backgroundColor: getTypeColor(group.typecolor), color: textOn(group.typecolor) }"
|
||||
:class="{ mixed: isMixed(group) }"
|
||||
:style="headingStyle(group)"
|
||||
>
|
||||
<span>{{ group.typename }} ({{ group.items.length }})</span>
|
||||
<span v-if="gridRangeLabel(group)" class="recert-range">{{ gridRangeLabel(group) }}</span>
|
||||
@@ -105,7 +132,7 @@
|
||||
v-for="rec in gridPageItems(group)"
|
||||
:key="`grid-${rec.notificationid}-${rec.employeesso}`"
|
||||
class="recert-tile"
|
||||
:class="{ 'message-tile': !hasEmployee(rec) }"
|
||||
:class="{ 'message-tile': !hasEmployee(rec), pending: rec.upcoming || rec.resolved }"
|
||||
:style="{ '--accent': getTypeColor(rec.typecolor) }"
|
||||
>
|
||||
<template v-if="hasEmployee(rec)">
|
||||
@@ -127,6 +154,7 @@
|
||||
<!-- No employee: the message IS the tile, so a grid type like
|
||||
Awareness does not render rows of blank placeholder faces. -->
|
||||
<div v-else class="recert-message">{{ rec.notification }}</div>
|
||||
<span v-if="stateLabel(rec)" class="state-chip tile-state">{{ stateLabel(rec) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -264,7 +292,14 @@ const STYLE_ORDER = { banner: 0, carousel: 1, grid: 2 }
|
||||
// row, so same-category types of different styles still render separately.
|
||||
const styledGroups = computed(() => {
|
||||
const groups = new Map()
|
||||
for (const n of notifications.value.current) {
|
||||
// Upcoming cards belong here too. They used to be filtered into the standard
|
||||
// Upcoming list, which drops anything with a special style - so a scheduled
|
||||
// banner or carousel card rendered nowhere at all until it went live.
|
||||
const tagged = [
|
||||
...notifications.value.current.map(n => ({ ...n, upcoming: false })),
|
||||
...notifications.value.upcoming.map(n => ({ ...n, upcoming: true }))
|
||||
]
|
||||
for (const n of tagged) {
|
||||
if (!SPECIAL_STYLES.includes(n.displaystyle)) continue
|
||||
const heading = (n.boardcategory || '').trim() || n.typename || n.displaystyle
|
||||
const key = `${n.displaystyle}|${heading}`
|
||||
@@ -282,6 +317,10 @@ const styledGroups = computed(() => {
|
||||
}
|
||||
groups.get(key).items.push(n)
|
||||
}
|
||||
// Live cards lead each row; what has not started yet follows.
|
||||
for (const group of groups.values()) {
|
||||
group.items.sort((a, b) => Number(a.upcoming) - Number(b.upcoming))
|
||||
}
|
||||
return [...groups.values()].sort((a, b) =>
|
||||
(STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
|
||||
a.typename.localeCompare(b.typename)
|
||||
@@ -465,6 +504,29 @@ function getTypeColor(typecolor) {
|
||||
return aliases[typecolor] || typecolor || '#14abef'
|
||||
}
|
||||
|
||||
// What a card's state chip says, or '' while it is simply live. Resolved is the
|
||||
// tail a type's grace window buys it; upcoming is scheduled and not yet showing.
|
||||
function stateLabel(item) {
|
||||
if (item.upcoming) return `UPCOMING ${formatDateTime(item.starttime)}`
|
||||
return item.resolved ? 'RESOLVED' : ''
|
||||
}
|
||||
|
||||
// A row holding more than one type: the heading names a category, so it cannot
|
||||
// speak for any single type's colour or name.
|
||||
function isMixed(group) {
|
||||
return new Set(group.items.map(item => item.typename)).size > 1
|
||||
}
|
||||
|
||||
// A single-type row wears that type's colour. A mixed one keeps the neutral
|
||||
// heading, since picking the first type's colour just mislabels the rest.
|
||||
function headingStyle(group) {
|
||||
if (isMixed(group)) return {}
|
||||
return {
|
||||
backgroundColor: getTypeColor(group.typecolor),
|
||||
color: textOn(group.typecolor)
|
||||
}
|
||||
}
|
||||
|
||||
// Text colour for a heading painted an arbitrary type colour. Gold needs dark
|
||||
// text, navy needs white, and a site picks its own hexes - so this is computed
|
||||
// rather than a per-colour rule.
|
||||
@@ -701,6 +763,14 @@ function handlePhotoError(e) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* A row of several types speaks for none of them, so it keeps the neutral
|
||||
heading rather than wearing the first type's colour (or, via the per-style
|
||||
fallbacks above, Recognition's gold and Recertification's blue). */
|
||||
.section-title.mixed {
|
||||
background: #4181ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.section-title.danger {
|
||||
background: #f5365c;
|
||||
}
|
||||
@@ -732,9 +802,75 @@ function handlePhotoError(e) {
|
||||
|
||||
.recognition-carousel {
|
||||
position: relative;
|
||||
/* Enough for a photo card; a message-only card is shorter and leaves the
|
||||
rest as breathing room rather than resizing the board every 8 seconds. */
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
.carousel-pips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.pip {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
.pip.on {
|
||||
opacity: 1;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Scheduled or just-ended cards stay legible but visibly quieter than live
|
||||
ones, so a glance at the board never mistakes one for the other. Carousel
|
||||
cards are dimmed only while ACTIVE: the inactive ones are held at opacity 0
|
||||
by .recognition-card, and a plain .pending rule outranks it, which brought
|
||||
the whole stack back into view on top of each other. */
|
||||
.recognition-card.active.pending,
|
||||
.recert-tile.pending,
|
||||
.banner-strip.pending {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 2px solid rgba(255, 255, 255, 0.45);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.state-chip.tile-state {
|
||||
font-size: 14px;
|
||||
padding: 2px 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.type-chip {
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recognition-card {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -774,6 +910,8 @@ function handlePhotoError(e) {
|
||||
}
|
||||
|
||||
.recognition-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user