ADR-013 Phase 4: relocate the remaining 9 plugin frontends (all 13 done)
Relocate warranty, measuringtools, network, printers, usb, notifications, computers, and slides into plugins/<name>/frontend/. Each plugin's views are pulled from wherever they lived (own dir, plus the shared views/settings/, views/reports/, views/print/ dirs, and top-level views) into the plugin's frontend/views/, and its route file becomes the self-contained routes.js. Handled the messy cases: - computers: name mismatch (its views live in views/pcs/) - moved by following the route file's own imports, so the dir name did not matter. Its OS/access- protocol/PC-type settings views move with it (only computers.js routed them). - network: NetworkHub's sibling sub-views (NetworkDevicesList, SubnetsBrowse, not directly routed) moved too so its `./` imports resolve. - printers: the qrLogo helper is SHARED with core AssetLabel, so it stays in views/print/ and PrinterQR imports it via @/views/print/qrLogo. - slides: route file is toplevel-only (TVDashboard); SlideManager stays core (core.js routes /settings/slides). frontend/src/views/ now holds only core views; frontend/src/router/routes/ holds only core.js. All 13 plugins are self-contained under plugins/<name>/frontend/. Verified live: Network (hub + moved sub-views), Computers (name mismatch), GE-Enforce (helper), printedparts all render from their staged frontends. Build + 58 vitest + naming green.
This commit is contained in:
41
plugins/notifications/frontend/routes.js
Normal file
41
plugins/notifications/frontend/routes.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Notifications plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('./views/NotificationsList.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/new',
|
||||
name: 'notification-new',
|
||||
component: () => import('./views/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'settings/notificationtypes',
|
||||
name: 'notification-types',
|
||||
component: () => import('./views/NotificationTypesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id',
|
||||
name: 'notification-detail',
|
||||
component: () => import('./views/NotificationForm.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id/edit',
|
||||
name: 'notification-edit',
|
||||
component: () => import('./views/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('./views/CalendarView.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
}
|
||||
]
|
||||
380
plugins/notifications/frontend/views/CalendarView.vue
Normal file
380
plugins/notifications/frontend/views/CalendarView.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Calendar</h1>
|
||||
</div>
|
||||
|
||||
<div class="calendar-container card">
|
||||
<FullCalendar :options="calendarOptions" />
|
||||
</div>
|
||||
|
||||
<!-- More events tooltip -->
|
||||
<div
|
||||
v-if="moreTooltipData.length"
|
||||
ref="moreTooltip"
|
||||
class="fc-more-tooltip"
|
||||
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
|
||||
@mouseleave="hideMoreTooltip"
|
||||
>
|
||||
<div
|
||||
v-for="(evt, idx) in moreTooltipData"
|
||||
:key="idx"
|
||||
class="fc-more-tooltip-event"
|
||||
:style="{ borderLeftColor: evt.color }"
|
||||
@click="openEventFromTooltip(evt)"
|
||||
>
|
||||
{{ evt.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event details modal -->
|
||||
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
|
||||
<div class="modal">
|
||||
<!-- Employee-photo event (recognition/recertification) with highlight -->
|
||||
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
|
||||
<div class="recognition-badge">
|
||||
<span class="recognition-icon"><Trophy :size="24" /></span>
|
||||
</div>
|
||||
<div class="recognition-info">
|
||||
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
|
||||
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
|
||||
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
|
||||
<span class="employee-icon"><User :size="16" /></span>
|
||||
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular event header -->
|
||||
<h2 v-else>{{ selectedEvent.title }}</h2>
|
||||
|
||||
<div class="event-details">
|
||||
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
|
||||
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
|
||||
</p>
|
||||
<p v-if="selectedEvent.end">
|
||||
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
|
||||
</p>
|
||||
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
|
||||
<strong>Details:</strong>
|
||||
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
|
||||
</p>
|
||||
<p v-if="selectedEvent.extendedProps?.ticketnumber">
|
||||
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
|
||||
</p>
|
||||
<p v-if="selectedEvent.extendedProps?.linkurl">
|
||||
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
|
||||
More Info
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<router-link
|
||||
v-if="selectedEvent.extendedProps?.notificationid"
|
||||
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
View Notification
|
||||
</router-link>
|
||||
<button class="btn btn-primary" @click="closeEventModal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import { Trophy, User } from 'lucide-vue-next'
|
||||
import { notificationsApi } from '@/api'
|
||||
|
||||
const events = ref([])
|
||||
const selectedEvent = ref(null)
|
||||
const calendarRef = ref(null)
|
||||
const moreTooltip = ref(null)
|
||||
const moreTooltipData = ref([])
|
||||
const tooltipPosition = ref({ left: 0, top: 0 })
|
||||
|
||||
// Store events by date for hover lookup
|
||||
const eventsByDate = ref({})
|
||||
|
||||
const calendarOptions = ref({
|
||||
plugins: [dayGridPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
events: [],
|
||||
eventClick: (info) => {
|
||||
selectedEvent.value = info.event
|
||||
},
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,dayGridWeek'
|
||||
},
|
||||
height: 'auto',
|
||||
dayMaxEvents: 3,
|
||||
moreLinkClick: () => 'none' // Disable click, we use hover
|
||||
})
|
||||
|
||||
function hideMoreTooltip() {
|
||||
moreTooltipData.value = []
|
||||
}
|
||||
|
||||
function handleMoreLinkHover(e) {
|
||||
const moreLink = e.target.closest('.fc-daygrid-more-link')
|
||||
if (!moreLink) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the day cell and get its date
|
||||
const dayCell = moreLink.closest('.fc-daygrid-day')
|
||||
if (!dayCell) return
|
||||
|
||||
const dateStr = dayCell.getAttribute('data-date')
|
||||
if (!dateStr || !eventsByDate.value[dateStr]) return
|
||||
|
||||
// Get events for this date that would be hidden (after first 3)
|
||||
const dayEvents = eventsByDate.value[dateStr]
|
||||
const hiddenEvents = dayEvents.slice(3).map(evt => ({
|
||||
title: evt.title,
|
||||
color: evt.backgroundColor || '#14abef',
|
||||
// Include full event data for modal
|
||||
start: evt.start,
|
||||
end: evt.end,
|
||||
extendedProps: evt.extendedProps || {}
|
||||
}))
|
||||
|
||||
if (hiddenEvents.length === 0) return
|
||||
|
||||
moreTooltipData.value = hiddenEvents
|
||||
|
||||
// Position tooltip
|
||||
const rect = moreLink.getBoundingClientRect()
|
||||
tooltipPosition.value = {
|
||||
left: rect.left,
|
||||
top: rect.bottom + 5
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoreLinkLeave(e) {
|
||||
const related = e.relatedTarget
|
||||
// Don't hide if moving to the tooltip itself
|
||||
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
|
||||
return
|
||||
}
|
||||
hideMoreTooltip()
|
||||
}
|
||||
|
||||
function buildEventsByDate() {
|
||||
const byDate = {}
|
||||
for (const evt of events.value) {
|
||||
const dateStr = evt.start ? evt.start.split('T')[0] : null
|
||||
if (dateStr) {
|
||||
if (!byDate[dateStr]) byDate[dateStr] = []
|
||||
byDate[dateStr].push(evt)
|
||||
}
|
||||
}
|
||||
eventsByDate.value = byDate
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEvents()
|
||||
|
||||
// Add event delegation for more links
|
||||
await nextTick()
|
||||
const container = document.querySelector('.calendar-container')
|
||||
if (container) {
|
||||
container.addEventListener('mouseenter', handleMoreLinkHover, true)
|
||||
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const container = document.querySelector('.calendar-container')
|
||||
if (container) {
|
||||
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
|
||||
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEvents()
|
||||
})
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const response = await notificationsApi.getCalendar()
|
||||
events.value = response.data.data
|
||||
calendarOptions.value.events = events.value
|
||||
buildEventsByDate()
|
||||
} catch (error) {
|
||||
console.error('Error loading calendar events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function closeEventModal() {
|
||||
selectedEvent.value = null
|
||||
}
|
||||
|
||||
function openEventFromTooltip(evt) {
|
||||
// Create an event-like object that matches FullCalendar's event structure
|
||||
selectedEvent.value = {
|
||||
title: evt.title,
|
||||
start: evt.start,
|
||||
end: evt.end,
|
||||
extendedProps: evt.extendedProps
|
||||
}
|
||||
hideMoreTooltip()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-container {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.event-details {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.event-details p {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.event-details p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-details strong {
|
||||
color: var(--text-light);
|
||||
display: inline-block;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.message-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.message-block strong {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 0.25rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Recognition event styling */
|
||||
.recognition-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.recognition-badge {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
.recognition-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.recognition-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recognition-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.recognition-title {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 1.4;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.recognition-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(65, 129, 255, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.employee-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.employee-name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
642
plugins/notifications/frontend/views/NotificationForm.vue
Normal file
642
plugins/notifications/frontend/views/NotificationForm.vue
Normal file
@@ -0,0 +1,642 @@
|
||||
<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 - Hidden for Recognition (auto-set) -->
|
||||
<div v-if="!isEmployeeType" 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>
|
||||
350
plugins/notifications/frontend/views/NotificationTypesList.vue
Normal file
350
plugins/notifications/frontend/views/NotificationTypesList.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Notification Types</h1>
|
||||
<div class="actions">
|
||||
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
||||
<button class="btn btn-primary" @click="openNew">New Type</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Color</th>
|
||||
<th>Display style</th>
|
||||
<th>Employee</th>
|
||||
<th>Auto-expiry</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in types" :key="t.notificationtypeid">
|
||||
<td>
|
||||
<strong>{{ t.typename }}</strong>
|
||||
<div v-if="t.typedescription" class="muted">{{ t.typedescription }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
|
||||
<span class="mono">{{ t.typecolor }}</span>
|
||||
</td>
|
||||
<td><span class="badge">{{ t.displaystyle || 'standard' }}</span></td>
|
||||
<td>
|
||||
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
|
||||
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
|
||||
<span v-if="!t.splitperemployee && !t.showemployeephoto" class="muted">-</span>
|
||||
</td>
|
||||
<td>{{ expiryLabel(t) }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="t.isactive ? 'badge-success' : 'badge-secondary'">
|
||||
{{ t.isactive ? 'yes' : 'no' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm btn-secondary" @click="openEdit(t)">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !types.length">
|
||||
<td colspan="7" class="muted" style="text-align:center;">No notification types.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-if="editing" class="modal-overlay" @click.self="close">
|
||||
<div class="modal-panel">
|
||||
<h2>{{ form.notificationtypeid ? 'Edit' : 'New' }} Notification Type</h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input v-model="form.typename" type="text" maxlength="50" placeholder="e.g. Safety Alert" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Description</span>
|
||||
<input v-model="form.typedescription" type="text" placeholder="Shown to editors" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Color</span>
|
||||
<ColorSwatchPicker v-model="form.typecolor" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Display style</span>
|
||||
<select v-model="form.displaystyle">
|
||||
<option value="standard">Standard rows</option>
|
||||
<option value="carousel">Carousel (rotating photo card)</option>
|
||||
<option value="grid">Grid (cycling row of tiles)</option>
|
||||
<option value="banner">Banner (full-width strip)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field checkbox">
|
||||
<input v-model="form.splitperemployee" type="checkbox" />
|
||||
<span>Split one card per employee</span>
|
||||
</label>
|
||||
|
||||
<label class="field checkbox">
|
||||
<input v-model="form.showemployeephoto" type="checkbox" />
|
||||
<span>Show employee photo + name (HR lookup)</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Auto-expiry</span>
|
||||
<select v-model="form.expirymode">
|
||||
<option value="none">None (stays until end time / indefinite)</option>
|
||||
<option value="duration">Duration (N days after posting)</option>
|
||||
<option value="dailytime">Daily reset (clears at an hour, Eastern)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label v-if="form.expirymode === 'duration'" class="field">
|
||||
<span>Days</span>
|
||||
<input v-model.number="form.expirydays" type="number" min="1" />
|
||||
</label>
|
||||
|
||||
<label v-if="form.expirymode === 'dailytime'" class="field">
|
||||
<span>Hour (0-23, Eastern)</span>
|
||||
<input v-model.number="form.expiryhour" type="number" min="0" max="23" />
|
||||
</label>
|
||||
|
||||
<label class="field checkbox">
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
<span>Active</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button class="btn btn-primary" :disabled="saving || !form.typename" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notificationsApi } from '@/api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
// Keyword typecolors the shopfloor board maps to fixed accent colors; anything
|
||||
// else is a literal hex.
|
||||
const KEYWORD_COLORS = {
|
||||
recognition: '#ffc107',
|
||||
recertification: '#0d6efd',
|
||||
training: '#17a2b8',
|
||||
success: '#04b962',
|
||||
warning: '#ff8800',
|
||||
danger: '#f5365c',
|
||||
info: '#14abef',
|
||||
primary: '#7934f3'
|
||||
}
|
||||
|
||||
function isHex(c) {
|
||||
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
|
||||
}
|
||||
function swatchColor(c) {
|
||||
if (!c) return '#888'
|
||||
return KEYWORD_COLORS[c] || c
|
||||
}
|
||||
|
||||
function expiryLabel(t) {
|
||||
if (t.expirymode === 'duration' && t.expirydays) return `${t.expirydays} day(s)`
|
||||
if (t.expirymode === 'dailytime') return `daily @ ${String(t.expiryhour ?? 8).padStart(2, '0')}:00 ET`
|
||||
return 'none'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await notificationsApi.types.list()
|
||||
types.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading notification types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
error.value = ''
|
||||
form.value = {
|
||||
typename: '',
|
||||
typedescription: '',
|
||||
typecolor: '#17a2b8',
|
||||
displaystyle: 'standard',
|
||||
splitperemployee: false,
|
||||
showemployeephoto: false,
|
||||
expirymode: 'none',
|
||||
expirydays: null,
|
||||
expiryhour: null,
|
||||
isactive: true
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function openEdit(t) {
|
||||
error.value = ''
|
||||
form.value = {
|
||||
notificationtypeid: t.notificationtypeid,
|
||||
typename: t.typename || '',
|
||||
typedescription: t.typedescription || '',
|
||||
typecolor: t.typecolor || '#17a2b8',
|
||||
displaystyle: t.displaystyle || 'standard',
|
||||
splitperemployee: !!t.splitperemployee,
|
||||
showemployeephoto: !!t.showemployeephoto,
|
||||
expirymode: t.expirymode || 'none',
|
||||
expirydays: t.expirydays ?? null,
|
||||
expiryhour: t.expiryhour ?? null,
|
||||
isactive: t.isactive !== false
|
||||
}
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
const payload = { ...form.value }
|
||||
try {
|
||||
if (payload.notificationtypeid) {
|
||||
await notificationsApi.types.update(payload.notificationtypeid, payload)
|
||||
} else {
|
||||
await notificationsApi.types.create(payload)
|
||||
}
|
||||
editing.value = false
|
||||
await load()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Save failed.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.muted {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.swatch.lg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 40px 16px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-panel {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.modal-panel h2 {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.field > span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.field input[type="text"],
|
||||
.field input[type="number"],
|
||||
.field select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.field.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.field.checkbox > span {
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.color-row input[type="text"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
</style>
|
||||
175
plugins/notifications/frontend/views/NotificationsList.vue
Normal file
175
plugins/notifications/frontend/views/NotificationsList.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Notifications</h1>
|
||||
<div class="actions">
|
||||
<router-link to="/settings/notificationtypes" class="btn btn-secondary">Manage Types</router-link>
|
||||
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search notifications..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="selectedType" class="form-control" @change="loadNotifications">
|
||||
<option value="">All Types</option>
|
||||
<option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid">
|
||||
{{ type.typename }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="currentFilter" class="form-control" @change="loadNotifications">
|
||||
<option value="">All</option>
|
||||
<option value="current">Current Only</option>
|
||||
<option value="pinned">Pinned Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="notifications.length === 0" class="empty">
|
||||
No notifications found.
|
||||
</div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Start Date</th>
|
||||
<th>End Date</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="notification in notifications" :key="notification.notificationid">
|
||||
<td>
|
||||
<router-link :to="`/notifications/${notification.notificationid}/edit`">
|
||||
{{ notification.title }}
|
||||
</router-link>
|
||||
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="{ backgroundColor: notification.typecolor }">
|
||||
{{ notification.typename }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(notification.startdate) }}</td>
|
||||
<td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']">
|
||||
{{ notification.iscurrent ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small">
|
||||
Edit
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notificationsApi } from '@/api'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const notifications = ref([])
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search: searchQuery, setPage, setSearch } = useListQuery({ onChange: loadNotifications })
|
||||
const selectedType = ref('')
|
||||
const currentFilter = ref('')
|
||||
const perPage = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = ref(1)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTypes()
|
||||
await loadNotifications()
|
||||
})
|
||||
|
||||
async function loadTypes() {
|
||||
try {
|
||||
const response = await notificationsApi.types.list()
|
||||
types.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
params.search = searchQuery.value
|
||||
}
|
||||
if (selectedType.value) {
|
||||
params.typeid = selectedType.value
|
||||
}
|
||||
if (currentFilter.value === 'current') {
|
||||
params.current = 'true'
|
||||
} else if (currentFilter.value === 'pinned') {
|
||||
params.pinned = 'true'
|
||||
}
|
||||
|
||||
const response = await notificationsApi.list(params)
|
||||
notifications.value = response.data.data
|
||||
total.value = response.data.meta?.pagination?.total || notifications.value.length
|
||||
totalPages.value = Math.ceil(total.value / perPage.value)
|
||||
} catch (error) {
|
||||
console.error('Error loading notifications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(searchQuery.value)
|
||||
loadNotifications()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user