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