main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
762 lines
23 KiB
Vue
762 lines
23 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 class="form-group">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
v-model="form.alertfleet"
|
|
:disabled="isDetail || !form.isactive"
|
|
/>
|
|
Alert Fleet
|
|
</label>
|
|
<small class="form-hint">Push this notification to shop floor PCs</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="form.alertfleet" class="fleet-alert-options">
|
|
<div class="form-group">
|
|
<label for="alertscope">Alert Scope (JSON)</label>
|
|
<textarea
|
|
id="alertscope"
|
|
v-model="alertScopeText"
|
|
class="form-control"
|
|
rows="3"
|
|
:disabled="isDetail"
|
|
placeholder='{"assettype": "computer", "location": "Building A"}'
|
|
></textarea>
|
|
<small class="form-hint">Optional: Filter alerts by asset type, location, or business unit (JSON format)</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="alerttimeout">Auto-dismiss Timeout (minutes)</label>
|
|
<input
|
|
id="alerttimeout"
|
|
v-model.number="form.alerttimeout"
|
|
type="number"
|
|
class="form-control"
|
|
min="0"
|
|
:disabled="isDetail"
|
|
/>
|
|
<small class="form-hint">Minutes until auto-dismiss (0 = manual acknowledgment only)</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 { resolveEmployeeName, fullname } from '@/composables/employeeResolve'
|
|
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: '',
|
|
alertfleet: false,
|
|
alertscope: null,
|
|
alerttimeout: 0
|
|
})
|
|
|
|
// 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)
|
|
|
|
// Alert scope as text for editing (converts to/from JSON)
|
|
const alertScopeText = ref('')
|
|
|
|
watch(() => form.value.alertscope, (newVal) => {
|
|
if (newVal && typeof newVal === 'object') {
|
|
alertScopeText.value = JSON.stringify(newVal, null, 2)
|
|
} else if (newVal && typeof newVal === 'string') {
|
|
alertScopeText.value = newVal
|
|
} else {
|
|
alertScopeText.value = ''
|
|
}
|
|
}, { immediate: true })
|
|
|
|
watch(alertScopeText, (newVal) => {
|
|
if (!newVal.trim()) {
|
|
form.value.alertscope = null
|
|
return
|
|
}
|
|
try {
|
|
form.value.alertscope = JSON.parse(newVal)
|
|
} catch (e) {
|
|
// Keep the text as-is until it's valid JSON
|
|
form.value.alertscope = newVal
|
|
}
|
|
})
|
|
|
|
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 || '',
|
|
alertfleet: n.alertfleet || false,
|
|
alertscope: n.alertscope || null,
|
|
alerttimeout: n.alerttimeout !== undefined ? n.alerttimeout : 0
|
|
}
|
|
|
|
// 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 {
|
|
// Look the name up before treating it as free text. Typing a name in
|
|
// full and pressing Enter used to produce `NAME:<text>`, linked to
|
|
// nothing - no photo, no directory record - while picking the same
|
|
// person off the suggestion list produced a real SSO. The chip looked
|
|
// identical either way, which is why it went unnoticed.
|
|
const { match, matches, failed } = await resolveEmployeeName(token)
|
|
if (match) {
|
|
const sso = String(match.SSO)
|
|
if (selectedEmployees.value.some(e => e.sso === sso)) continue
|
|
selectedEmployees.value.push({ sso, name: fullname(match) || token })
|
|
continue
|
|
}
|
|
if (matches && matches.length) {
|
|
// Several people match. Guessing is exactly wrong here, so show the
|
|
// list and let the operator pick.
|
|
employeeResults.value = matches
|
|
error.value = `More than one employee matches "${token}" - pick one.`
|
|
return
|
|
}
|
|
if (failed) {
|
|
error.value = 'Could not reach the employee directory. Try again, or '
|
|
+ 'pick from the list.'
|
|
return
|
|
}
|
|
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,
|
|
alertfleet: form.value.alertfleet || false,
|
|
alertscope: form.value.alertscope || null,
|
|
alerttimeout: form.value.alerttimeout !== undefined ? form.value.alerttimeout : 0
|
|
}
|
|
|
|
// 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>
|