Per-type board rows with shared categories, a network device IP field, and two dark-mode dialogs
This commit is contained in:
@@ -96,6 +96,22 @@ Use `PrintersList.vue` as the reference for new list pages. It has NO scoped sty
|
|||||||
|
|
||||||
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
|
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
|
||||||
|
|
||||||
|
## Data Entry: Page or Modal
|
||||||
|
|
||||||
|
Both patterns are in use on purpose. Pick by what the record IS, not by how big the form feels today.
|
||||||
|
|
||||||
|
**Routed form page** (`views/<Thing>Form.vue` + a route) when the record has its own detail page and its own URL: machines, PCs, printers, network devices, measuring tools, USB devices, applications, knowledge-base articles, notifications, printed items. These forms are long, often carry an image upload, a map-position picker or relationship editing, and someone will want to link straight to one.
|
||||||
|
|
||||||
|
**Modal over its list** when the record is reference data that only exists inside the list it belongs to: every `*TypesList`, plus locations, vendors, models, VLANs, subnets, support teams, custom fields, API tokens, printer drivers, dashboard defaults. The form is a handful of fields, and keeping the list visible behind the dialog is the point.
|
||||||
|
|
||||||
|
Rule of thumb: **a thing with a detail page gets a form page; a lookup row gets a modal.**
|
||||||
|
|
||||||
|
Modal rules (from the overlay-close fix, commit d8fe0a4):
|
||||||
|
|
||||||
|
- A modal holding typed input must NOT close on overlay click or Escape. Losing a part-filled form to a stray click is not an acceptable failure.
|
||||||
|
- A confirmation dialog MAY close on overlay click (`@click.self`), since it holds nothing to lose.
|
||||||
|
- The panel background is `var(--bg-card-solid)`, never `var(--bg-card)`: the card variable is translucent in dark mode, which leaves a dialog see-through over the overlay. The shared `.modal` in `style.css` already does this; hand-rolled `.modal-panel` rules must too.
|
||||||
|
|
||||||
## Component Organization
|
## Component Organization
|
||||||
|
|
||||||
- **Global styles**: `src/assets/style.css`
|
- **Global styles**: `src/assets/style.css`
|
||||||
|
|||||||
@@ -23,90 +23,107 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="dashboard-content">
|
<main class="dashboard-content">
|
||||||
<!-- Banner - single prominent full-width message -->
|
<!--
|
||||||
<section v-if="banners.length" class="banner-section">
|
One section per TYPE, in style order (banner, carousel, grid). Grouping
|
||||||
<div
|
by display style alone merged every grid type into Recertification's
|
||||||
v-for="n in banners"
|
row and every carousel type into Recognition's, under that type's
|
||||||
:key="n.notificationid"
|
heading. Each type now carries its own row titled by its own name.
|
||||||
class="banner-strip"
|
-->
|
||||||
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
|
<template v-for="group in styledGroups" :key="group.key">
|
||||||
>
|
|
||||||
{{ n.notification }}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Recognition Carousel -->
|
<!-- Banner - full-width strips, one per message -->
|
||||||
<section v-if="recognitions.length" class="recognition-section">
|
<section v-if="group.displaystyle === 'banner'" class="banner-section">
|
||||||
<div class="section-title recognition">Employee Recognition</div>
|
|
||||||
<div class="recognition-carousel">
|
|
||||||
<div
|
<div
|
||||||
v-for="(rec, idx) in recognitions"
|
v-for="n in group.items"
|
||||||
:key="`${rec.notificationid}-${rec.employeesso}`"
|
:key="n.notificationid"
|
||||||
class="recognition-card"
|
class="banner-strip"
|
||||||
:class="{ active: idx === currentRecognition }"
|
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
|
||||||
>
|
>
|
||||||
<div class="recognition-photo-container">
|
{{ n.notification }}
|
||||||
<img
|
</div>
|
||||||
v-if="rec.employeepicture"
|
</section>
|
||||||
:src="rec.employeepicture"
|
|
||||||
:alt="rec.employeename"
|
<!-- Carousel - one card at a time, rotating -->
|
||||||
class="recognition-photo"
|
<section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
|
||||||
@error="handlePhotoError"
|
<div class="section-title recognition">{{ group.typename }}</div>
|
||||||
/>
|
<div class="recognition-carousel">
|
||||||
<img
|
<div
|
||||||
v-else
|
v-for="(rec, idx) in group.items"
|
||||||
:src="employeePhotoFallback"
|
:key="`${rec.notificationid}-${rec.employeesso}`"
|
||||||
alt="No photo"
|
class="recognition-card"
|
||||||
class="recognition-photo ge-logo-fallback"
|
:class="{ active: idx === carouselIndex(group) }"
|
||||||
/>
|
>
|
||||||
</div>
|
<!-- A type that names no employee is a message, not a person:
|
||||||
<div class="recognition-content">
|
no photo frame, no empty name line. -->
|
||||||
<div class="recognition-header">
|
<div v-if="hasEmployee(rec)" class="recognition-photo-container">
|
||||||
<div class="recognition-name">{{ rec.employeename }}</div>
|
<img
|
||||||
|
v-if="rec.employeepicture"
|
||||||
|
:src="rec.employeepicture"
|
||||||
|
:alt="rec.employeename"
|
||||||
|
class="recognition-photo"
|
||||||
|
@error="handlePhotoError"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="employeePhotoFallback"
|
||||||
|
alt="No photo"
|
||||||
|
class="recognition-photo ge-logo-fallback"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="recognition-content">
|
||||||
|
<div v-if="hasEmployee(rec)" class="recognition-header">
|
||||||
|
<div class="recognition-name">{{ rec.employeename }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recognition-message">{{ rec.notification }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recognition-message">{{ rec.notification }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Recertification grid - everyone due shown at once, so nobody has to
|
<!-- Grid - a row of tiles, cycling a page at a time -->
|
||||||
wait for a carousel to rotate to their name -->
|
<section v-else class="recert-section">
|
||||||
<section v-if="recertifications.length" class="recert-section">
|
<div class="section-title recert-title">
|
||||||
<div class="section-title recert-title">
|
<span>{{ group.typename }} ({{ group.items.length }})</span>
|
||||||
<span>Recertification Required ({{ recertifications.length }})</span>
|
<span v-if="gridRangeLabel(group)" class="recert-range">{{ gridRangeLabel(group) }}</span>
|
||||||
<span v-if="recertRangeLabel" class="recert-range">{{ recertRangeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="msg in recertDescriptions"
|
|
||||||
:key="msg"
|
|
||||||
class="recert-description"
|
|
||||||
>
|
|
||||||
{{ msg }}
|
|
||||||
</div>
|
|
||||||
<div class="recert-row">
|
|
||||||
<div
|
|
||||||
v-for="rec in recertPage"
|
|
||||||
:key="`recert-${rec.notificationid}-${rec.employeesso}`"
|
|
||||||
class="recert-tile"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="rec.employeepicture"
|
|
||||||
:src="rec.employeepicture"
|
|
||||||
:alt="rec.employeename"
|
|
||||||
class="recert-photo"
|
|
||||||
@error="handlePhotoError"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
v-else
|
|
||||||
:src="employeePhotoFallback"
|
|
||||||
alt="No photo"
|
|
||||||
class="recert-photo ge-logo-fallback"
|
|
||||||
/>
|
|
||||||
<div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
</section>
|
v-for="msg in groupDescriptions(group)"
|
||||||
|
:key="msg"
|
||||||
|
class="recert-description"
|
||||||
|
>
|
||||||
|
{{ msg }}
|
||||||
|
</div>
|
||||||
|
<div class="recert-row">
|
||||||
|
<div
|
||||||
|
v-for="rec in gridPageItems(group)"
|
||||||
|
:key="`grid-${rec.notificationid}-${rec.employeesso}`"
|
||||||
|
class="recert-tile"
|
||||||
|
:class="{ 'message-tile': !hasEmployee(rec) }"
|
||||||
|
>
|
||||||
|
<template v-if="hasEmployee(rec)">
|
||||||
|
<img
|
||||||
|
v-if="rec.employeepicture"
|
||||||
|
:src="rec.employeepicture"
|
||||||
|
:alt="rec.employeename"
|
||||||
|
class="recert-photo"
|
||||||
|
@error="handlePhotoError"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="employeePhotoFallback"
|
||||||
|
alt="No photo"
|
||||||
|
class="recert-photo ge-logo-fallback"
|
||||||
|
/>
|
||||||
|
<div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
|
||||||
|
</template>
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Current Notifications -->
|
<!-- Current Notifications -->
|
||||||
<section v-if="currentNotifications.length" class="notifications-section">
|
<section v-if="currentNotifications.length" class="notifications-section">
|
||||||
@@ -170,7 +187,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- No notifications -->
|
<!-- No notifications -->
|
||||||
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length && !recertifications.length && !banners.length" class="no-events">
|
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !styledGroups.length" class="no-events">
|
||||||
No active notifications
|
No active notifications
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -202,7 +219,6 @@ const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' })
|
|||||||
const businessUnit = ref('')
|
const businessUnit = ref('')
|
||||||
const businessUnits = ref([])
|
const businessUnits = ref([])
|
||||||
const notifications = ref({ current: [], upcoming: [] })
|
const notifications = ref({ current: [], upcoming: [] })
|
||||||
const currentRecognition = ref(0)
|
|
||||||
// Layout-config fingerprint from the feed; when it changes the kiosk reloads.
|
// Layout-config fingerprint from the feed; when it changes the kiosk reloads.
|
||||||
const loadedConfigVersion = ref(null)
|
const loadedConfigVersion = ref(null)
|
||||||
|
|
||||||
@@ -228,49 +244,81 @@ function fitToScreen() {
|
|||||||
// type set to carousel/grid/banner renders that way - not just the built-ins.
|
// type set to carousel/grid/banner renders that way - not just the built-ins.
|
||||||
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
|
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
|
||||||
|
|
||||||
const recognitions = computed(() =>
|
// Sections render in style order, so the board keeps its shape as types come
|
||||||
notifications.value.current.filter(n => n.displaystyle === 'carousel')
|
// and go: strips at the top, then rotating cards, then tile rows.
|
||||||
)
|
const STYLE_ORDER = { banner: 0, carousel: 1, grid: 2 }
|
||||||
|
|
||||||
const recertifications = computed(() =>
|
// One row per TYPE, unless types opt into a shared boardcategory - Change,
|
||||||
notifications.value.current.filter(n => n.displaystyle === 'grid')
|
// Awareness and Incident under one "Alerts" heading, say, while Recognition and
|
||||||
)
|
// Recertification keep rows of their own. Grouping by display style alone
|
||||||
|
// folded every grid type into one row under someone else's name.
|
||||||
|
// The style is part of the key: a category cannot merge a banner with a tile
|
||||||
|
// 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) {
|
||||||
|
if (!SPECIAL_STYLES.includes(n.displaystyle)) continue
|
||||||
|
const heading = (n.boardcategory || '').trim() || n.typename || n.displaystyle
|
||||||
|
const key = `${n.displaystyle}|${heading}`
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, {
|
||||||
|
key,
|
||||||
|
typename: heading,
|
||||||
|
displaystyle: n.displaystyle,
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
groups.get(key).items.push(n)
|
||||||
|
}
|
||||||
|
return [...groups.values()].sort((a, b) =>
|
||||||
|
(STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
|
||||||
|
a.typename.localeCompare(b.typename)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const banners = computed(() =>
|
// A card belongs to a person only if it names one; a plain message type set to
|
||||||
notifications.value.current.filter(n => n.displaystyle === 'banner')
|
// carousel or grid has no SSO and must not render a placeholder face.
|
||||||
)
|
function hasEmployee(n) {
|
||||||
|
return !!(n.employeesso || n.employeename || n.employeepicture)
|
||||||
|
}
|
||||||
|
|
||||||
// Distinct training descriptions shown once above the grid (the per-person
|
// Distinct messages shown once above a grid row (the per-person tiles carry
|
||||||
// tiles only carry photo + name).
|
// only photo + name, so the message would otherwise have nowhere to go).
|
||||||
const recertDescriptions = computed(() => {
|
function groupDescriptions(group) {
|
||||||
|
if (!group.items.some(hasEmployee)) return []
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
const out = []
|
const out = []
|
||||||
for (const r of recertifications.value) {
|
for (const item of group.items) {
|
||||||
const msg = (r.notification || '').trim()
|
const msg = (item.notification || '').trim()
|
||||||
if (msg && !seen.has(msg)) { seen.add(msg); out.push(msg) }
|
if (msg && !seen.has(msg)) { seen.add(msg); out.push(msg) }
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
})
|
}
|
||||||
|
|
||||||
// Recertification shows as a single rotating row: one page of tiles at a time
|
// Rotation state is per group, keyed by type: two grid rows page independently
|
||||||
// so it stays compact on any screen, cycling through everyone due.
|
// instead of sharing one index.
|
||||||
const RECERT_PAGE_SIZE = 8
|
const GRID_PAGE_SIZE = 8
|
||||||
const currentRecertPage = ref(0)
|
const carouselIndexes = ref({})
|
||||||
const recertPageCount = computed(() =>
|
const gridPages = ref({})
|
||||||
Math.max(1, Math.ceil(recertifications.value.length / RECERT_PAGE_SIZE))
|
|
||||||
)
|
function carouselIndex(group) {
|
||||||
const recertPage = computed(() => {
|
return (carouselIndexes.value[group.key] || 0) % Math.max(1, group.items.length)
|
||||||
const page = currentRecertPage.value % recertPageCount.value
|
}
|
||||||
const start = page * RECERT_PAGE_SIZE
|
function gridPageCount(group) {
|
||||||
return recertifications.value.slice(start, start + RECERT_PAGE_SIZE)
|
return Math.max(1, Math.ceil(group.items.length / GRID_PAGE_SIZE))
|
||||||
})
|
}
|
||||||
const recertRangeLabel = computed(() => {
|
function gridPageItems(group) {
|
||||||
if (recertPageCount.value <= 1) return ''
|
const page = (gridPages.value[group.key] || 0) % gridPageCount(group)
|
||||||
const page = currentRecertPage.value % recertPageCount.value
|
const start = page * GRID_PAGE_SIZE
|
||||||
const start = page * RECERT_PAGE_SIZE
|
return group.items.slice(start, start + GRID_PAGE_SIZE)
|
||||||
const end = Math.min(start + RECERT_PAGE_SIZE, recertifications.value.length)
|
}
|
||||||
return `${start + 1}-${end} of ${recertifications.value.length}`
|
function gridRangeLabel(group) {
|
||||||
})
|
if (gridPageCount(group) <= 1) return ''
|
||||||
|
const page = (gridPages.value[group.key] || 0) % gridPageCount(group)
|
||||||
|
const start = page * GRID_PAGE_SIZE
|
||||||
|
const end = Math.min(start + GRID_PAGE_SIZE, group.items.length)
|
||||||
|
return `${start + 1}-${end} of ${group.items.length}`
|
||||||
|
}
|
||||||
|
|
||||||
const currentNotifications = computed(() =>
|
const currentNotifications = computed(() =>
|
||||||
notifications.value.current.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
|
notifications.value.current.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
|
||||||
@@ -325,17 +373,23 @@ onMounted(async () => {
|
|||||||
// Auto-refresh every 30 seconds
|
// Auto-refresh every 30 seconds
|
||||||
refreshInterval = setInterval(loadData, 30000)
|
refreshInterval = setInterval(loadData, 30000)
|
||||||
|
|
||||||
// Rotate recognition carousel every 8 seconds
|
// Advance every carousel row every 8 seconds. Each row keeps its own index,
|
||||||
|
// so a row of three cards does not drag a row of ten along with it.
|
||||||
recognitionInterval = setInterval(() => {
|
recognitionInterval = setInterval(() => {
|
||||||
if (recognitions.value.length > 1) {
|
for (const group of styledGroups.value) {
|
||||||
currentRecognition.value = (currentRecognition.value + 1) % recognitions.value.length
|
if (group.displaystyle !== 'carousel' || group.items.length < 2) continue
|
||||||
|
const next = (carouselIndexes.value[group.key] || 0) + 1
|
||||||
|
carouselIndexes.value[group.key] = next % group.items.length
|
||||||
}
|
}
|
||||||
}, 8000)
|
}, 8000)
|
||||||
|
|
||||||
// Cycle the recertification row through pages of employees every 7 seconds.
|
// Page every grid row every 7 seconds, each on its own page count.
|
||||||
recertPageInterval = setInterval(() => {
|
recertPageInterval = setInterval(() => {
|
||||||
if (recertPageCount.value > 1) {
|
for (const group of styledGroups.value) {
|
||||||
currentRecertPage.value = (currentRecertPage.value + 1) % recertPageCount.value
|
if (group.displaystyle !== 'grid') continue
|
||||||
|
const pages = gridPageCount(group)
|
||||||
|
if (pages < 2) continue
|
||||||
|
gridPages.value[group.key] = ((gridPages.value[group.key] || 0) + 1) % pages
|
||||||
}
|
}
|
||||||
}, 7000)
|
}, 7000)
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,9 @@ onMounted(load)
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
.modal-panel {
|
.modal-panel {
|
||||||
background: var(--bg-card);
|
/* Solid: var(--bg-card) is translucent in dark mode, which made this
|
||||||
|
dialog see-through. */
|
||||||
|
background: var(--bg-card-solid);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ def _upsert_primary_ip(asset, ip):
|
|||||||
"""Create/update/clear the asset's primary IP Communication from an
|
"""Create/update/clear the asset's primary IP Communication from an
|
||||||
ipaddress string. No-op if the IP CommunicationType is not seeded."""
|
ipaddress string. No-op if the IP CommunicationType is not seeded."""
|
||||||
ip = (ip or '').strip()
|
ip = (ip or '').strip()
|
||||||
if not ip:
|
|
||||||
return
|
|
||||||
comm = Communication.query.filter_by(assetid=asset.assetid, isprimary=True).first()
|
comm = Communication.query.filter_by(assetid=asset.assetid, isprimary=True).first()
|
||||||
|
if not ip:
|
||||||
|
# Clearing the field clears the row's IP - it used to return early, so
|
||||||
|
# an address could be typed but never taken back out.
|
||||||
|
if comm:
|
||||||
|
comm.ipaddress = None
|
||||||
|
return
|
||||||
if comm:
|
if comm:
|
||||||
comm.ipaddress = ip
|
comm.ipaddress = ip
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -112,6 +112,16 @@
|
|||||||
placeholder="e.g., sw-bldg1-floor2"
|
placeholder="e.g., sw-bldg1-floor2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ipaddress">IP Address</label>
|
||||||
|
<input
|
||||||
|
id="ipaddress"
|
||||||
|
v-model="form.ipaddress"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="e.g., 192.168.1.10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="networkdevicetypeid">Device Type</label>
|
<label for="networkdevicetypeid">Device Type</label>
|
||||||
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
|
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
|
||||||
@@ -318,6 +328,7 @@ const form = ref({
|
|||||||
locationid: '',
|
locationid: '',
|
||||||
businessunitid: '',
|
businessunitid: '',
|
||||||
hostname: '',
|
hostname: '',
|
||||||
|
ipaddress: '',
|
||||||
networkdevicetypeid: '',
|
networkdevicetypeid: '',
|
||||||
vendorid: '',
|
vendorid: '',
|
||||||
modelnumberid: '',
|
modelnumberid: '',
|
||||||
@@ -419,6 +430,9 @@ async function loadDevice() {
|
|||||||
form.value.mapx = data.mapx
|
form.value.mapx = data.mapx
|
||||||
form.value.mapy = data.mapy
|
form.value.mapy = data.mapy
|
||||||
form.value.notes = data.notes || ''
|
form.value.notes = data.notes || ''
|
||||||
|
// The IP lives in a Communication row, not on the extension table; the API
|
||||||
|
// flattens it onto the response as ipaddress.
|
||||||
|
form.value.ipaddress = data.ipaddress || ''
|
||||||
|
|
||||||
// Network device specific
|
// Network device specific
|
||||||
if (data.networkdevice) {
|
if (data.networkdevice) {
|
||||||
@@ -453,6 +467,7 @@ async function submitForm() {
|
|||||||
locationid: form.value.locationid || null,
|
locationid: form.value.locationid || null,
|
||||||
businessunitid: form.value.businessunitid || null,
|
businessunitid: form.value.businessunitid || null,
|
||||||
hostname: form.value.hostname || null,
|
hostname: form.value.hostname || null,
|
||||||
|
ipaddress: form.value.ipaddress || null,
|
||||||
networkdevicetypeid: form.value.networkdevicetypeid || null,
|
networkdevicetypeid: form.value.networkdevicetypeid || null,
|
||||||
vendorid: form.value.vendorid || null,
|
vendorid: form.value.vendorid || null,
|
||||||
modelnumberid: form.value.modelnumberid || null,
|
modelnumberid: form.value.modelnumberid || null,
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ def _apply_display_fields(t, data):
|
|||||||
if ds not in _DISPLAY_STYLES:
|
if ds not in _DISPLAY_STYLES:
|
||||||
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
||||||
t.displaystyle = ds
|
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:
|
if 'gracewindowminutes' in data:
|
||||||
raw = data.get('gracewindowminutes')
|
raw = data.get('gracewindowminutes')
|
||||||
raw = 0 if raw in (None, '') else raw
|
raw = 0 if raw in (None, '') else raw
|
||||||
@@ -178,11 +185,11 @@ def _config_version():
|
|||||||
reach pages that are already open."""
|
reach pages that are already open."""
|
||||||
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
||||||
parts = [
|
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,
|
t.notificationtypeid, t.typecolor, t.displaystyle,
|
||||||
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
||||||
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
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
|
for t in types
|
||||||
]
|
]
|
||||||
@@ -779,8 +786,11 @@ def get_shopfloor_notifications():
|
|||||||
'resolved': is_resolved,
|
'resolved': is_resolved,
|
||||||
'typename': ntype.typename if ntype else None,
|
'typename': ntype.typename if ntype else None,
|
||||||
'typecolor': ntype.typecolor 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',
|
'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)
|
# Employee info (photo only when the type wants it)
|
||||||
|
|||||||
@@ -32,7 +32,10 @@
|
|||||||
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
|
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
|
||||||
<span class="mono">{{ t.typecolor }}</span>
|
<span class="mono">{{ t.typecolor }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge">{{ t.displaystyle || 'standard' }}</span></td>
|
<td>
|
||||||
|
<span class="badge">{{ t.displaystyle || 'standard' }}</span>
|
||||||
|
<span v-if="t.boardcategory" class="badge badge-secondary">{{ t.boardcategory }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
|
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
|
||||||
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
|
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
|
||||||
@@ -87,6 +90,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Board category (optional)</span>
|
||||||
|
<input v-model="form.boardcategory" type="text" maxlength="50"
|
||||||
|
placeholder="its own row" />
|
||||||
|
<small class="muted">
|
||||||
|
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.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Keep showing after end (minutes)</span>
|
<span>Keep showing after end (minutes)</span>
|
||||||
<input v-model.number="form.gracewindowminutes" type="number"
|
<input v-model.number="form.gracewindowminutes" type="number"
|
||||||
@@ -215,6 +229,7 @@ function openNew() {
|
|||||||
expirydays: null,
|
expirydays: null,
|
||||||
expiryhour: null,
|
expiryhour: null,
|
||||||
gracewindowminutes: 0,
|
gracewindowminutes: 0,
|
||||||
|
boardcategory: '',
|
||||||
isactive: true
|
isactive: true
|
||||||
}
|
}
|
||||||
editing.value = true
|
editing.value = true
|
||||||
@@ -234,6 +249,7 @@ function openEdit(t) {
|
|||||||
expirydays: t.expirydays ?? null,
|
expirydays: t.expirydays ?? null,
|
||||||
expiryhour: t.expiryhour ?? null,
|
expiryhour: t.expiryhour ?? null,
|
||||||
gracewindowminutes: t.gracewindowminutes ?? 0,
|
gracewindowminutes: t.gracewindowminutes ?? 0,
|
||||||
|
boardcategory: t.boardcategory || '',
|
||||||
isactive: t.isactive !== false
|
isactive: t.isactive !== false
|
||||||
}
|
}
|
||||||
editing.value = true
|
editing.value = true
|
||||||
@@ -300,7 +316,10 @@ onMounted(load)
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
.modal-panel {
|
.modal-panel {
|
||||||
background: var(--bg-card);
|
/* Solid, not var(--bg-card): the card variable is translucent in dark mode
|
||||||
|
(rgba(0,0,61,0.4)) so cards glass over the page, which left this dialog
|
||||||
|
see-through with the table legible behind it. */
|
||||||
|
background: var(--bg-card-solid);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Add notificationtypes.boardcategory (shared heading on the shopfloor board).
|
||||||
|
|
||||||
|
The board grouped cards by display style alone, so every type set to grid landed
|
||||||
|
inside the Recertification row and every carousel type inside Recognition's,
|
||||||
|
under a heading that named someone else's type. Each type now gets a row of its
|
||||||
|
own; this column is the opt-in for the other direction - several types that
|
||||||
|
belong together (Change, Awareness, Incident) share one row under a category
|
||||||
|
name instead of taking three.
|
||||||
|
|
||||||
|
Idempotent; downgrade drops the column.
|
||||||
|
|
||||||
|
Revision ID: notifications0004category
|
||||||
|
Revises: notifications0003grace
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'notifications0004category'
|
||||||
|
down_revision = 'notifications0003grace'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLE = 'notificationtypes'
|
||||||
|
_COLUMN = 'boardcategory'
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(insp, table):
|
||||||
|
return {c['name'] for c in insp.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if _TABLE not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
if _COLUMN not in _column_names(insp, _TABLE):
|
||||||
|
op.add_column(_TABLE, sa.Column(_COLUMN, sa.String(50), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if _TABLE not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
if _COLUMN in _column_names(insp, _TABLE):
|
||||||
|
op.drop_column(_TABLE, _COLUMN)
|
||||||
@@ -83,6 +83,12 @@ class NotificationType(db.Model):
|
|||||||
gracewindowminutes = db.Column(db.Integer, nullable=False,
|
gracewindowminutes = db.Column(db.Integer, nullable=False,
|
||||||
server_default='0', default=0)
|
server_default='0', default=0)
|
||||||
|
|
||||||
|
# Optional shared heading on the shopfloor board. Blank (the default) gives
|
||||||
|
# the type a row of its own under its own name; types sharing a category
|
||||||
|
# share one row under that category name, provided they also share a
|
||||||
|
# displaystyle - a banner and a tile row cannot be the same row.
|
||||||
|
boardcategory = db.Column(db.String(50), nullable=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<NotificationType {self.typename}>"
|
return f"<NotificationType {self.typename}>"
|
||||||
|
|
||||||
@@ -100,7 +106,8 @@ class NotificationType(db.Model):
|
|||||||
'splitperemployee': bool(self.splitperemployee),
|
'splitperemployee': bool(self.splitperemployee),
|
||||||
'showemployeephoto': bool(self.showemployeephoto),
|
'showemployeephoto': bool(self.showemployeephoto),
|
||||||
'displaystyle': self.displaystyle or 'standard',
|
'displaystyle': self.displaystyle or 'standard',
|
||||||
'gracewindowminutes': int(self.gracewindowminutes or 0)
|
'gracewindowminutes': int(self.gracewindowminutes or 0),
|
||||||
|
'boardcategory': self.boardcategory or ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
|
|||||||
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||||
# the per-transaction revision column.
|
# the per-transaction revision column.
|
||||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||||
# notifications indexes businessunitid, then adds the per-type grace window.
|
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0003grace'
|
# the shared board category.
|
||||||
|
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0004category'
|
||||||
|
|
||||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
|
|||||||
117
tests/test_plugins/test_board_category.py
Normal file
117
tests/test_plugins/test_board_category.py
Normal file
@@ -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
|
||||||
@@ -57,6 +57,39 @@ def test_update_network_device_ipaddress_upserts(client, db, auth_headers, netwo
|
|||||||
assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9'
|
assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9'
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_the_ip_takes_it_back_out(client, db, auth_headers, network_setup):
|
||||||
|
"""Blanking the form field must clear the stored IP.
|
||||||
|
|
||||||
|
The upsert helper returned early on a blank value, so an address could be
|
||||||
|
typed and never removed - invisible until the form grew an IP field.
|
||||||
|
"""
|
||||||
|
from shopdb.core.models import Communication
|
||||||
|
created = client.post('/api/network', json={
|
||||||
|
'assetnumber': 'SW-09', 'name': 'Switch', 'ipaddress': '10.0.0.5',
|
||||||
|
}, headers=auth_headers).get_json()['data']
|
||||||
|
devid = created['networkdevice']['networkdeviceid']
|
||||||
|
|
||||||
|
cleared = client.put(f'/api/network/{devid}', json={'ipaddress': ''},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert cleared.status_code == 200, cleared.get_json()
|
||||||
|
assert not cleared.get_json()['data']['ipaddress']
|
||||||
|
|
||||||
|
comms = Communication.query.filter_by(assetid=created['assetid']).all()
|
||||||
|
assert len(comms) == 1 and comms[0].ipaddress is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_omitting_ipaddress_leaves_it_alone(client, db, auth_headers, network_setup):
|
||||||
|
"""A PUT that never mentions the field must not wipe the stored IP."""
|
||||||
|
created = client.post('/api/network', json={
|
||||||
|
'assetnumber': 'SW-10', 'name': 'Switch', 'ipaddress': '10.0.0.7',
|
||||||
|
}, headers=auth_headers).get_json()['data']
|
||||||
|
devid = created['networkdevice']['networkdeviceid']
|
||||||
|
|
||||||
|
updated = client.put(f'/api/network/{devid}', json={'hostname': 'sw-10'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert updated.get_json()['data']['ipaddress'] == '10.0.0.7'
|
||||||
|
|
||||||
|
|
||||||
def test_seed_reference_data_creates_ip_commtype(app, db):
|
def test_seed_reference_data_creates_ip_commtype(app, db):
|
||||||
"""The reference-data seed now creates the IP communication type the asset
|
"""The reference-data seed now creates the IP communication type the asset
|
||||||
import depends on."""
|
import depends on."""
|
||||||
|
|||||||
Reference in New Issue
Block a user