The time fields were hidden for the employee types (recognition, recert); show them for every type. Backend already honors start/end on create and update and only auto-fills the end when left blank (recognition = next 8 AM, recert = two weeks), so nothing server-side changes.
645 lines
18 KiB
Vue
645 lines
18 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</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 } from '@/api'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
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,
|
|
isshopfloor: false,
|
|
employeesso: ''
|
|
})
|
|
|
|
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 in parallel
|
|
const [typesRes, buRes, appsRes] = await Promise.all([
|
|
notificationsApi.types.list(),
|
|
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
|
|
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } }))
|
|
])
|
|
|
|
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
|
|
}
|
|
})
|
|
|
|
function formatDateForInput(dateStr) {
|
|
if (!dateStr) return ''
|
|
const date = new Date(dateStr)
|
|
return date.toISOString().slice(0, 16)
|
|
}
|
|
|
|
function setNow(field) {
|
|
form.value[field] = formatDateForInput(new Date().toISOString())
|
|
}
|
|
|
|
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 = formatDateForInput(new Date().toISOString())
|
|
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: form.value.starttime ? new Date(form.value.starttime).toISOString() : null,
|
|
endtime: form.value.endtime ? new Date(form.value.endtime).toISOString() : null,
|
|
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>
|