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.
662 lines
20 KiB
Vue
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)">×</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>
|