Files
shopdb-flask/plugins/notifications/frontend/views/NotificationForm.vue
Proudlock 381cc6c845 Add fleet alert notifications for shop floor PCs
Fleet alerts push server notifications to endpoint PCs via the command pipe.
Operators acknowledge alerts via toast UI in the tray. Backend tracks
acknowledgements per asset to prevent re-showing dismissed alerts.

Backend: alertfleet/alertscope/alerttimeout columns, GET/POST /api/endpoints/alerts,
NotificationAcknowledgement model with unique constraint, hostname validation.

Frontend: Alert Fleet checkbox in notification form.

Agent: FleetAlertClient polls alerts, FleetAlertStore tracks acknowledged IDs,
FleetAlertForm displays toast (no auto-dismiss), FleetAlertWorker 60s polling.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-08 16:19:14 -04:00

708 lines
22 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Notification' : (isDetail ? 'Notification Details' : 'New Notification') }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveNotification">
<div class="form-group">
<label for="notificationtypeid">Type *</label>
<select
id="notificationtypeid"
v-model="form.notificationtypeid"
class="form-control"
required
:disabled="isDetail"
@change="onTypeChange"
>
<option value="">-- Select Type --</option>
<option
v-for="type in types"
:key="type.notificationtypeid"
:value="type.notificationtypeid"
>
{{ type.typename }}
</option>
</select>
<small class="form-hint">Classification type for this notification</small>
</div>
<div class="form-group">
<label for="notification">{{ messageLabel }} *</label>
<textarea
id="notification"
v-model="form.notification"
class="form-control"
rows="4"
required
:disabled="isDetail"
:placeholder="messagePlaceholder"
></textarea>
</div>
<!-- Employee Search - for Recognition and Recertification -->
<div v-if="isEmployeeType" class="form-group">
<label>Employee(s) *</label>
<div class="employee-search-container">
<input
v-model="employeeSearch"
type="text"
class="form-control"
placeholder="Search by name..."
:disabled="isDetail"
@input="searchEmployees"
@keydown.enter.prevent="addCustomEmployee"
/>
<div v-if="employeeResults.length" class="employee-dropdown">
<div
v-for="emp in employeeResults"
:key="emp.SSO"
class="employee-option"
@click="selectEmployee(emp)"
>
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
<span class="emp-sso">{{ emp.SSO }}</span>
</div>
</div>
</div>
<small class="form-hint">Search and pick, or paste multiple SSOs / names separated by commas and press Enter</small>
<!-- Selected Employees -->
<div v-if="selectedEmployees.length" class="selected-employees">
<div
v-for="(emp, idx) in selectedEmployees"
:key="idx"
class="selected-employee"
>
<span>{{ emp.name }}</span>
<button v-if="!isDetail" type="button" class="btn-remove" @click="removeEmployee(idx)">&times;</button>
</div>
</div>
</div>
<div v-if="!isEmployeeType" class="form-group">
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
v-model="form.businessunitid"
class="form-control"
:disabled="isDetail"
>
<option value="">-- All Business Units --</option>
<option
v-for="bu in businessUnits"
:key="bu.businessunitid"
:value="bu.businessunitid"
>
{{ bu.businessunit }}
</option>
</select>
<small class="form-hint">Leave blank to apply to all</small>
</div>
<div v-if="!isEmployeeType" class="form-group">
<label for="appid">Related Application</label>
<select
id="appid"
v-model="form.appid"
class="form-control"
:disabled="isDetail"
>
<option value="">-- No Application --</option>
<option
v-for="app in applications"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
<small class="form-hint">Link to a specific application (e.g., for software updates)</small>
</div>
<div v-if="!isEmployeeType" class="form-group">
<label for="ticketnumber">Ticket Number</label>
<input
id="ticketnumber"
v-model="form.ticketnumber"
type="text"
class="form-control"
maxlength="50"
:disabled="isDetail"
placeholder="GEINC123456 or GECHG123456"
/>
<small class="form-hint">Optional ServiceNow ticket number</small>
</div>
<div class="form-group">
<label for="link">More Info URL</label>
<input
id="link"
v-model="form.link"
type="url"
class="form-control"
maxlength="500"
:disabled="isDetail"
placeholder="https://..."
/>
</div>
<!-- Time fields - available for every type. Left blank, the end auto-
fills to the type's window (recognition = next 8 AM reset,
recertification = two weeks); set them to override. -->
<div class="form-row">
<div class="form-group">
<label for="starttime">Start Time *</label>
<div class="input-group">
<input
id="starttime"
v-model="form.starttime"
type="datetime-local"
class="form-control"
required
:disabled="isDetail"
/>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('starttime')">
Now
</button>
</div>
<small class="form-hint">When notification becomes visible ({{ siteTimezone }})</small>
</div>
<div class="form-group">
<label for="endtime">End Time</label>
<div class="input-group">
<input
id="endtime"
v-model="form.endtime"
type="datetime-local"
class="form-control"
:disabled="isDetail"
/>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('endtime')">
Now
</button>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="form.endtime = ''">
Clear
</button>
</div>
<small class="form-hint">Leave blank for indefinite</small>
</div>
</div>
<div class="form-row checkbox-row">
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.isactive"
:disabled="isDetail"
/>
Active
</label>
<small class="form-hint">Uncheck to save as draft</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.isshopfloor"
:disabled="isDetail"
/>
Show on Shopfloor Dashboard
</label>
<small class="form-hint">Display on TV dashboard</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.alertfleet"
:disabled="isDetail || !form.isactive"
/>
Alert Fleet
</label>
<small class="form-hint">Push this notification to shop floor PCs</small>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-actions">
<template v-if="isDetail">
<router-link :to="`/notifications/${route.params.id}/edit`" class="btn btn-primary">
Edit
</router-link>
<router-link to="/notifications" class="btn btn-secondary">Back</router-link>
</template>
<template v-else>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update' : 'Create') }}
</button>
<router-link to="/notifications" class="btn btn-secondary">Cancel</router-link>
</template>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi, settingsApi } from '@/api'
import { resolveEmployeeName, fullname } from '@/composables/employeeResolve'
import { apiError } from '@/utils/apiError'
import { zonedInputFromUtc, utcFromZonedInput, DEFAULT_TZ } from '@/utils/datetime'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => route.name === 'notification-edit')
const isDetail = computed(() => route.name === 'notification-detail')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const types = ref([])
const businessUnits = ref([])
const applications = ref([])
// Employee search
const employeeSearch = ref('')
const employeeResults = ref([])
const selectedEmployees = ref([])
const form = ref({
notification: '',
notificationtypeid: '',
businessunitid: '',
appid: '',
ticketnumber: '',
link: '',
starttime: '',
endtime: '',
isactive: true,
// Ticked for a NEW notification: the shopfloor board is where these are
// meant to be seen, and leaving it off by default meant most were written
// and then never appeared there. Editing an EXISTING notification still
// loads its own stored value (see loadNotification), so this never flips a
// setting somebody deliberately turned off.
isshopfloor: true,
employeesso: '',
alertfleet: false,
alertscope: null,
alerttimeout: 0
})
// Site timezone (settings key site_timezone) so form times show/enter in the
// site's wall clock, not the viewer's browser zone. Loaded in onMounted.
const siteTimezone = ref(DEFAULT_TZ)
function selectedTypeName() {
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
return selectedType?.typename?.toLowerCase() || ''
}
// Recognition and recertification are the employee-photo types: both show the
// employee picker, hide the time/BU/app fields, and get a server-computed
// display window.
const isRecognition = computed(() => selectedTypeName() === 'recognition')
const isRecertification = computed(() => selectedTypeName() === 'recertification')
const isEmployeeType = computed(() => isRecognition.value || isRecertification.value)
const messageLabel = computed(() =>
isRecertification.value ? 'Recertification Message'
: isRecognition.value ? 'Recognition Message'
: 'Notification'
)
const messagePlaceholder = computed(() =>
isRecertification.value ? 'Enter the recertification message...'
: isRecognition.value ? 'Enter the recognition message...'
: 'Enter the notification message...'
)
onMounted(async () => {
try {
// Load dropdown data + the site timezone in parallel. Resolve the tz FIRST
// (before any formatDateForInput) so times render in the site's zone.
const [typesRes, buRes, appsRes, tzRes] = await Promise.all([
notificationsApi.types.list(),
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
// listAll: perpage is clamped to 100 server-side, and the catalogue is
// already past that, so this dropdown was missing its tail.
applicationsApi.listAll().then(items => ({ data: { data: items } }))
.catch(() => ({ data: { data: [] } })),
settingsApi.get('site_timezone').catch(() => null)
])
const tzValue = tzRes?.data?.data?.value
if (tzValue) siteTimezone.value = tzValue
types.value = typesRes.data.data || []
businessUnits.value = buRes.data?.data || []
applications.value = appsRes.data?.data || []
// Load notification if editing or viewing
if (route.params.id) {
const response = await notificationsApi.get(route.params.id)
const n = response.data.data
form.value = {
notification: n.notification || '',
notificationtypeid: n.notificationtypeid || '',
businessunitid: n.businessunitid || '',
appid: n.appid || '',
ticketnumber: n.ticketnumber || '',
link: n.link || '',
starttime: formatDateForInput(n.starttime),
endtime: formatDateForInput(n.endtime),
isactive: n.isactive !== false,
isshopfloor: n.isshopfloor || false,
employeesso: n.employeesso || '',
alertfleet: n.alertfleet || false,
alertscope: n.alertscope || null,
alerttimeout: n.alerttimeout !== undefined ? n.alerttimeout : 0
}
// Parse existing employee data: SSOs are comma-joined and names are
// ", "-joined, so pair them up by index (one chip per person). Falling
// back to the SSO when a name is missing.
if (n.employeesso) {
const ssos = n.employeesso.split(',')
const names = (n.employeename || '').split(',')
ssos.forEach((rawSso, i) => {
const sso = rawSso.trim()
if (!sso) return
const name = (names[i] || '').trim() || sso
selectedEmployees.value.push({ sso, name })
})
}
} else {
// Set default start date to now
form.value.starttime = formatDateForInput(new Date().toISOString())
// Default to Recognition type
const recognitionType = types.value.find(t => t.typename?.toLowerCase() === 'recognition')
if (recognitionType) {
form.value.notificationtypeid = recognitionType.notificationtypeid
onTypeChange()
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
// UTC value from the API -> a datetime-local string in the SITE timezone.
function formatDateForInput(dateStr) {
return zonedInputFromUtc(dateStr, siteTimezone.value)
}
function setNow(field) {
form.value[field] = zonedInputFromUtc(new Date(), siteTimezone.value)
}
function onTypeChange() {
// Recognition and recertification get a server-computed display window
// (recognition clears at 8 AM Eastern; recertification runs two weeks). Set
// start to now and leave end blank so the backend applies the per-type rule.
if (isEmployeeType.value) {
form.value.starttime = zonedInputFromUtc(new Date(), siteTimezone.value)
form.value.endtime = ''
}
}
// Employee search
let searchTimeout = null
async function searchEmployees() {
if (searchTimeout) clearTimeout(searchTimeout)
const query = employeeSearch.value.trim()
if (query.length < 2) {
employeeResults.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const response = await employeesApi.search(query)
employeeResults.value = response.data.data || []
} catch (err) {
console.error('Employee search error:', err)
employeeResults.value = []
}
}, 300)
}
function selectEmployee(emp) {
// Check if already selected
if (selectedEmployees.value.some(e => e.sso === String(emp.SSO))) {
return
}
selectedEmployees.value.push({
sso: String(emp.SSO),
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
})
employeeSearch.value = ''
employeeResults.value = []
updateEmployeeSso()
}
// Add one or many at once. The input may hold a single value or a
// comma-separated list of SSOs and/or names. Numeric tokens are treated as
// SSOs and resolved to a real name; everything else is a custom name.
async function addCustomEmployee() {
const raw = employeeSearch.value.trim()
if (!raw) return
const tokens = raw.split(',').map(t => t.trim()).filter(Boolean)
for (const token of tokens) {
if (/^\d{4,}$/.test(token)) {
if (selectedEmployees.value.some(e => e.sso === token)) continue
let name = token
try {
const emp = (await employeesApi.lookup(token)).data.data
if (emp) name = `${emp.First_Name} ${emp.Last_Name}`.trim() || token
} catch (err) {
// SSO not found - keep the number as the label
}
selectedEmployees.value.push({ sso: token, name })
} else {
// Look the name up before treating it as free text. Typing a name in
// full and pressing Enter used to produce `NAME:<text>`, linked to
// nothing - no photo, no directory record - while picking the same
// person off the suggestion list produced a real SSO. The chip looked
// identical either way, which is why it went unnoticed.
const { match, matches, failed } = await resolveEmployeeName(token)
if (match) {
const sso = String(match.SSO)
if (selectedEmployees.value.some(e => e.sso === sso)) continue
selectedEmployees.value.push({ sso, name: fullname(match) || token })
continue
}
if (matches && matches.length) {
// Several people match. Guessing is exactly wrong here, so show the
// list and let the operator pick.
employeeResults.value = matches
error.value = `More than one employee matches "${token}" - pick one.`
return
}
if (failed) {
error.value = 'Could not reach the employee directory. Try again, or '
+ 'pick from the list.'
return
}
const key = `NAME:${token}`
if (selectedEmployees.value.some(e => e.sso === key)) continue
selectedEmployees.value.push({ sso: key, name: token })
}
}
employeeSearch.value = ''
employeeResults.value = []
updateEmployeeSso()
}
function removeEmployee(idx) {
selectedEmployees.value.splice(idx, 1)
updateEmployeeSso()
}
function updateEmployeeSso() {
form.value.employeesso = selectedEmployees.value.map(e => e.sso).join(',')
}
async function saveNotification() {
error.value = ''
// Employee-photo types require at least one employee
if (isEmployeeType.value && selectedEmployees.value.length === 0) {
error.value = `Please select at least one employee for ${isRecertification.value ? 'recertification' : 'recognition'}`
return
}
saving.value = true
try {
const data = {
notification: form.value.notification,
notificationtypeid: parseInt(form.value.notificationtypeid) || null,
businessunitid: parseInt(form.value.businessunitid) || null,
appid: parseInt(form.value.appid) || null,
ticketnumber: form.value.ticketnumber || null,
link: form.value.link || null,
starttime: utcFromZonedInput(form.value.starttime, siteTimezone.value),
endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value),
isactive: form.value.isactive,
isshopfloor: form.value.isshopfloor,
employeesso: form.value.employeesso || null,
alertfleet: form.value.alertfleet || false,
alertscope: form.value.alertscope || null,
alerttimeout: form.value.alerttimeout !== undefined ? form.value.alerttimeout : 0
}
// For employee-photo types, also send employeename
if (isEmployeeType.value && selectedEmployees.value.length > 0) {
data.employeename = selectedEmployees.value.map(e => e.name).join(', ')
}
if (isEdit.value) {
await notificationsApi.update(route.params.id, data)
} else {
await notificationsApi.create(data)
}
router.push('/notifications')
} catch (err) {
console.error('Error saving notification:', err)
error.value = apiError(err, 'Failed to save notification')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.input-group {
display: flex;
gap: 0.25rem;
}
.input-group .form-control {
flex: 1;
}
.input-group .btn {
flex-shrink: 0;
}
.checkbox-row {
display: flex;
gap: 2rem;
margin-top: 1rem;
}
.checkbox-row .form-group {
flex: none;
}
.checkbox-row label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
/* Employee search styles */
.employee-search-container {
position: relative;
}
.employee-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid, #1a1a1a);
border: 1px solid var(--border);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.employee-option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.employee-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emp-name {
font-weight: 500;
}
.emp-sso {
font-size: 0.85rem;
color: var(--text-light);
}
.selected-employees {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.selected-employee {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
.btn-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
opacity: 0.7;
}
.btn-remove:hover {
opacity: 1;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
.checkbox-row {
flex-direction: column;
gap: 0.5rem;
}
}
</style>