Files
shopdb-flask/plugins/notifications/frontend/views/NotificationForm.vue
cproudlock 658e5c2224 Give the calendar room, and put new notifications on the shopfloor board
The calendar was set to height 'auto', which sizes each week row to its own
content, so a month of mostly empty days collapsed into thin strips. It now
takes a viewport-relative height and expandRows shares that evenly across the
weeks, with a floor under each day cell so a short window squeezes the grid back
down rather than the rows vanishing.

New notifications now have "Show on Shopfloor Dashboard" ticked. The board is
where these are meant to be read, and starting unticked meant most were written
and then never appeared on it.

Only the default for a NEW notification. Editing an existing one still loads its
stored value, so nothing that was deliberately turned off gets flipped back on,
and the column default is left alone so an API or import caller that omits the
field keeps the behaviour it has today.
2026-08-05 15:37:46 -04:00

659 lines
19 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: [] } })),
applicationsApi.list({ perpage: 500 }).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>