Files
shopdb-flask/plugins/notifications/frontend/views/NotificationsList.vue
cproudlock 85ff25462e
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Reset to page one when a filter changes, and let the catalog carry a real type
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.
2026-08-05 11:26:23 -04:00

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>