Per-type board rows with shared categories, a network device IP field, and two dark-mode dialogs
This commit is contained in:
@@ -192,7 +192,9 @@ onMounted(load)
|
||||
z-index: 1000;
|
||||
}
|
||||
.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);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -24,9 +24,13 @@ def _upsert_primary_ip(asset, ip):
|
||||
"""Create/update/clear the asset's primary IP Communication from an
|
||||
ipaddress string. No-op if the IP CommunicationType is not seeded."""
|
||||
ip = (ip or '').strip()
|
||||
if not ip:
|
||||
return
|
||||
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:
|
||||
comm.ipaddress = ip
|
||||
return
|
||||
|
||||
@@ -112,6 +112,16 @@
|
||||
placeholder="e.g., sw-bldg1-floor2"
|
||||
/>
|
||||
</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">
|
||||
<label for="networkdevicetypeid">Device Type</label>
|
||||
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
|
||||
@@ -318,6 +328,7 @@ const form = ref({
|
||||
locationid: '',
|
||||
businessunitid: '',
|
||||
hostname: '',
|
||||
ipaddress: '',
|
||||
networkdevicetypeid: '',
|
||||
vendorid: '',
|
||||
modelnumberid: '',
|
||||
@@ -419,6 +430,9 @@ async function loadDevice() {
|
||||
form.value.mapx = data.mapx
|
||||
form.value.mapy = data.mapy
|
||||
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
|
||||
if (data.networkdevice) {
|
||||
@@ -453,6 +467,7 @@ async function submitForm() {
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
hostname: form.value.hostname || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
networkdevicetypeid: form.value.networkdevicetypeid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
|
||||
<span class="mono">{{ t.typecolor }}</span>
|
||||
</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>
|
||||
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
|
||||
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
|
||||
@@ -87,6 +90,17 @@
|
||||
</select>
|
||||
</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">
|
||||
<span>Keep showing after end (minutes)</span>
|
||||
<input v-model.number="form.gracewindowminutes" type="number"
|
||||
@@ -215,6 +229,7 @@ function openNew() {
|
||||
expirydays: null,
|
||||
expiryhour: null,
|
||||
gracewindowminutes: 0,
|
||||
boardcategory: '',
|
||||
isactive: true
|
||||
}
|
||||
editing.value = true
|
||||
@@ -234,6 +249,7 @@ function openEdit(t) {
|
||||
expirydays: t.expirydays ?? null,
|
||||
expiryhour: t.expiryhour ?? null,
|
||||
gracewindowminutes: t.gracewindowminutes ?? 0,
|
||||
boardcategory: t.boardcategory || '',
|
||||
isactive: t.isactive !== false
|
||||
}
|
||||
editing.value = true
|
||||
@@ -300,7 +316,10 @@ onMounted(load)
|
||||
z-index: 1000;
|
||||
}
|
||||
.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);
|
||||
border: 1px solid var(--border);
|
||||
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,
|
||||
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):
|
||||
return f"<NotificationType {self.typename}>"
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user