Files
shopdb-flask/plugins/notifications/frontend/views/NotificationForm.vue
cproudlock 9c1c6c5729
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s
fix: page past the 100-row cap in application pickers
get_pagination_params clamps perpage to MAX_PAGE_SIZE (100) and reports
nothing about having done so, so a caller asking for perpage: 1000 gets the
first 100 rows and a success response. Every picker built that way looked
complete and was not.

Found on a live site with 126 active applications: the 26 sorting last were
absent from the knowledge-base topic dropdown, so an article could not be
filed against them. Nothing was wrong with those application records, and
editing them could never have helped.

Adds fetchAllPages() to the api module, generalizing the one call site that
already handled this correctly (modelsApi.listAll), and points the four
application pickers at a new applicationsApi.listAll(): the KB article form,
the KB list's topic filter, the notification form, and the report filter
builder.

Lists that render a page at a time are untouched - they page for a reason.
Other callers still asking for more than 100 rows of vendors, locations,
models, subnets and the rest are latent: correct only while those tables stay
under 100, and silent on the day they do not.
2026-08-17 14:06:35 -04:00

662 lines
20 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>
<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 { 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: ''
})
// 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 || ''
}
// 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 {
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
}
// 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>