Files
shopdb-flask/plugins/notifications/frontend/views/NotificationsList.vue
cproudlock ea6fae91c3 notifications: correct timezone handling + configurable site timezone
Notification start/end times displayed and stored wrong by the tz offset
(a 2:34 PM entry showed 6:34 PM). Two stacked bugs: to_dict emitted stored
UTC as naive ISO (no offset) so the browser read it as local, and the form
filled the datetime-local input from toISOString() (UTC).

Fix and generalize to a configurable site timezone (multi-site):
- New setting site_timezone (default America/New_York), public, editable in
  Settings > Site > Localization (common-zone dropdown).
- Backend tags datetimes UTC (_utc_iso); parse normalizes to naive UTC
  (_parse_utc); daily-reset expiry uses the site zone (_next_site_time);
  calendar allDay events key off the site-local day (_site_date).
- Shared frontend util datetime.js (Intl-based, DST-safe) converts between a
  UTC instant and a site-zone wall clock. Notification form, list, and
  calendar all render/enter in the site zone.
2026-07-30 15:08:59 -04:00

186 lines
5.6 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="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, 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)
}
}
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>