Add USB, Notifications, Network plugins and reusable EmployeeSearch component
New Plugins: - USB plugin: Device checkout/checkin with employee lookup, checkout history - Notifications plugin: Announcements with types, scheduling, shopfloor display - Network plugin: Network device management with subnets and VLANs - Equipment and Computers plugins: Asset type separation Frontend: - EmployeeSearch component: Reusable employee lookup with autocomplete - USB views: List, detail, checkout/checkin modals - Notifications views: List, form with recognition mode - Network views: Device list, detail, form - Calendar view with FullCalendar integration - Shopfloor and TV dashboard views - Reports index page - Map editor for asset positioning - Light/dark mode fixes for map tooltips Backend: - Employee search API with external lookup service - Collector API for PowerShell data collection - Reports API endpoints - Slides API for TV dashboard - Fixed AppVersion model (removed BaseModel inheritance) - Added checkout_name column to usbcheckouts table Styling: - Unified detail page styles - Improved pagination (page numbers instead of prev/next) - Dark/light mode theme improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
601
frontend/src/views/notifications/NotificationForm.vue
Normal file
601
frontend/src/views/notifications/NotificationForm.vue
Normal file
@@ -0,0 +1,601 @@
|
||||
<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">{{ isRecognition ? 'Recognition Message' : 'Notification' }} *</label>
|
||||
<textarea
|
||||
id="notification"
|
||||
v-model="form.notification"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
required
|
||||
:disabled="isDetail"
|
||||
:placeholder="isRecognition ? 'Enter the recognition message...' : 'Enter the notification message...'"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Employee Search - Only for Recognition -->
|
||||
<div v-if="isRecognition" 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 for employees or press Enter to add a custom name</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 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 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="!isRecognition" 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="!isRecognition" 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'
|
||||
|
||||
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: ''
|
||||
})
|
||||
|
||||
// Check if selected type is Recognition
|
||||
const isRecognition = computed(() => {
|
||||
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
|
||||
return selectedType?.typename?.toLowerCase() === 'recognition'
|
||||
})
|
||||
|
||||
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({ per_page: 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
|
||||
if (n.employeesso) {
|
||||
const ssos = n.employeesso.split(',')
|
||||
for (const sso of ssos) {
|
||||
if (sso.trim()) {
|
||||
// Try to look up the employee name
|
||||
const name = n.employeename || sso.trim()
|
||||
selectedEmployees.value.push({ sso: sso.trim(), name })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Set default start date to now
|
||||
form.value.starttime = formatDateForInput(new Date().toISOString())
|
||||
}
|
||||
} 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() {
|
||||
// If switching to Recognition, auto-set times
|
||||
if (isRecognition.value) {
|
||||
const now = new Date()
|
||||
form.value.starttime = formatDateForInput(now.toISOString())
|
||||
// End time = now + 30 days
|
||||
const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
form.value.endtime = formatDateForInput(endDate.toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
// 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 res = await employeesApi.search(query)
|
||||
employeeResults.value = res.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()
|
||||
}
|
||||
|
||||
function addCustomEmployee() {
|
||||
const name = employeeSearch.value.trim()
|
||||
if (!name) return
|
||||
|
||||
// Add as custom name (no SSO)
|
||||
selectedEmployees.value.push({
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
})
|
||||
|
||||
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 = ''
|
||||
|
||||
// Validation for Recognition type
|
||||
if (isRecognition.value && selectedEmployees.value.length === 0) {
|
||||
error.value = 'Please select at least one employee for 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 recognition, also send employeename
|
||||
if (isRecognition.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 = err.response?.data?.message || '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>
|
||||
168
frontend/src/views/notifications/NotificationsList.vue
Normal file
168
frontend/src/views/notifications/NotificationsList.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Notifications</h1>
|
||||
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
|
||||
</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}`">
|
||||
{{ 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 -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button
|
||||
v-for="p in totalPages"
|
||||
:key="p"
|
||||
:class="{ active: p === page }"
|
||||
@click="goToPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notificationsApi } from '@/api'
|
||||
|
||||
const notifications = ref([])
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const selectedType = ref('')
|
||||
const currentFilter = ref('')
|
||||
const page = ref(1)
|
||||
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,
|
||||
per_page: perPage.value
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
params.search = searchQuery.value
|
||||
}
|
||||
if (selectedType.value) {
|
||||
params.type_id = 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(() => {
|
||||
page.value = 1
|
||||
loadNotifications()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
page.value = p
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user