Two unrelated things found while looking at blank printer types. Selecting a filter while past page one returned an empty list. The filter asked the server for page 5 of a result set that now had one page, and the screen said nothing matched. useListQuery already resets the page - setSearch and setExtra both do - but the filter dropdowns bypassed it and called the loader directly. Nine list pages now route through applyFilter, which calls setPage(1) when it needs to and loads directly when already on page one, so the composable's URL watcher does not also fire and fetch twice. scripts/retype_models.py addresses why printer types cannot be derived. The catalog types every printer model "Printer": true, and useless, since it does not say whether the product is a laser, a plotter or a label printer. That answer is a property of the model - every VersaLink C405 is a laser MFP - but nothing recorded it, so nothing could derive it. Recording it on the MODEL means the existing backfill fills every printer by exact name match, and a printer added later inherits the right type the moment its model is chosen. It exports the models needing a decision to CSV with a type suggested from the model number, a person corrects the column, and applying it is a dry run unless given --commit. A suggested type is refused unless it already exists in that asset class's own vocabulary, which is what keeps the later name match working. The suggestion order matters and got this wrong first time: a generic plotter pattern matched "Zebra ZT411" and filed a label printer as a plotter. Brands now come before generic patterns, and the review step exists precisely because a confident wrong guess would type every asset using that model. Verified on the development database: 24 printer models need a decision, 22 got a sensible suggestion, applying them let all 42 printers match a printertype by name, and the transaction rolled back cleanly.
199 lines
6.0 KiB
Vue
199 lines
6.0 KiB
Vue
<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="applyFilter">
|
|
<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="applyFilter">
|
|
<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, settingsApi } from '@/api'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
|
|
|
|
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)
|
|
const siteTimezone = ref(DEFAULT_TZ)
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const tzRes = await settingsApi.get('site_timezone')
|
|
const tzValue = tzRes?.data?.data?.value
|
|
if (tzValue) siteTimezone.value = tzValue
|
|
} catch (e) { /* keep default tz */ }
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
|
// asked the server for page 5 of a result set that now has one page, and the
|
|
// list came back empty as though the filter matched nothing.
|
|
//
|
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
|
// answers with onChange - so calling the loader as well would fetch twice.
|
|
// Load directly only when already on page 1, where nothing changes and the
|
|
// watcher stays silent.
|
|
function applyFilter() {
|
|
if (page.value > 1) setPage(1)
|
|
else loadNotifications()
|
|
}
|
|
|
|
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 ''
|
|
// startdate/enddate are UTC; show the site-zone calendar date.
|
|
return formatInZone(dateStr, siteTimezone.value, {
|
|
year: 'numeric', month: 'numeric', day: 'numeric', hour: undefined, minute: undefined
|
|
})
|
|
}
|
|
</script>
|