Added Notifications screen and logic for Records.
This commit is contained in:
1 parent
72ec7472c5
commit
697d72f379
13 files changed
+2190
-80
No files matched your search
@@ -4,6 +4,7 @@ import path from 'node:path'
|
||||
import {
|
||||
fetchEntries,
|
||||
fetchTravellers,
|
||||
createTraveller,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
@@ -11,6 +12,11 @@ import {
|
||||
fetchVaccineRecords,
|
||||
saveVaccineRecord,
|
||||
updateVaccineRecord,
|
||||
fetchNotifications,
|
||||
updateNotification,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
archiveNotification,
|
||||
} from './pocketbase.js'
|
||||
import { JUVONNO_API_URL, JUVONNO_API_KEY } from './juvonno-config.js'
|
||||
|
||||
@@ -104,6 +110,9 @@ ipcMain.handle('pb:fetch-entries', async () => {
|
||||
ipcMain.handle('pb:fetch-travellers', async () => {
|
||||
return await fetchTravellers()
|
||||
})
|
||||
ipcMain.handle('pb:create-traveller', async (_event, data) => {
|
||||
return await createTraveller(data)
|
||||
})
|
||||
ipcMain.handle('pb:create-entry', async (_event, trip) => {
|
||||
return await createEntry(trip)
|
||||
})
|
||||
@@ -130,6 +139,26 @@ ipcMain.handle('pb:update-vaccine-record', async (_event, id, recordData) => {
|
||||
return await updateVaccineRecord(id, recordData)
|
||||
})
|
||||
|
||||
ipcMain.handle('pb:fetch-notifications', async (_event, options) => {
|
||||
return await fetchNotifications(options)
|
||||
})
|
||||
|
||||
ipcMain.handle('pb:update-notification', async (_event, id, data) => {
|
||||
return await updateNotification(id, data)
|
||||
})
|
||||
|
||||
ipcMain.handle('pb:mark-notification-read', async (_event, id, isRead) => {
|
||||
return await markNotificationRead(id, isRead)
|
||||
})
|
||||
|
||||
ipcMain.handle('pb:mark-all-notifications-read', async () => {
|
||||
return await markAllNotificationsRead()
|
||||
})
|
||||
|
||||
ipcMain.handle('pb:archive-notification', async (_event, id, isArchived) => {
|
||||
return await archiveNotification(id, isArchived)
|
||||
})
|
||||
|
||||
// ─── Health Risk Audit IPC Handler (Node-side to avoid CORS) ────────────────
|
||||
ipcMain.handle('audit:run-health-audit', async (_event, payload) => {
|
||||
const AUDIT_WEBHOOK_URL = 'https://n8n.wirediv.dev/webhook/travel-health-audit'
|
||||
|
||||
+126
-2
@@ -5,6 +5,7 @@ const TRAVELLERS_COLLECTION = 'travellers'
|
||||
const TRIPS_COLLECTION = 'trips'
|
||||
const VACCINE_RECORDS_COLLECTION = 'records'
|
||||
const VACCINES_COLLECTION = 'vaccines'
|
||||
const NOTIFICATIONS_COLLECTION = 'notifications'
|
||||
|
||||
|
||||
let pb = null
|
||||
@@ -46,8 +47,6 @@ export async function fetchVaccines() {
|
||||
total_doses: r.total_doses ?? 1,
|
||||
dose_intervals: r.dose_intervals ?? [],
|
||||
min_days_before_travel: r.min_days_before_travel ?? 0,
|
||||
allows_grace_period: r.allows_grace_period ?? false,
|
||||
has_accelerated_schedule: r.has_accelerated_schedule ?? false,
|
||||
requires_icvp_certificate: r.requires_icvp_certificate ?? false,
|
||||
created: r.created,
|
||||
updated: r.updated,
|
||||
@@ -224,6 +223,47 @@ export async function fetchTravellers() {
|
||||
return list.map(parseTraveller)
|
||||
}
|
||||
|
||||
export async function createTraveller(data) {
|
||||
await ensureAuth()
|
||||
const client = getClient()
|
||||
|
||||
const emrStr = data.emrID !== undefined && data.emrID !== null ? String(data.emrID).trim() : ''
|
||||
const nameStr = data.name ? String(data.name).trim() : ''
|
||||
|
||||
if (!nameStr) {
|
||||
throw new Error('Traveller name is required.')
|
||||
}
|
||||
|
||||
// If already exists with this emr_id, return existing
|
||||
if (emrStr) {
|
||||
try {
|
||||
const filter = client.filter
|
||||
? client.filter('emr_id = {:emrId}', { emrId: emrStr })
|
||||
: `emr_id = "${emrStr.replace(/"/g, '\\"')}"`
|
||||
|
||||
const list = await client.collection(TRAVELLERS_COLLECTION).getList(1, 1, { filter })
|
||||
if (list.items && list.items.length > 0) {
|
||||
return {
|
||||
...parseTraveller(list.items[0]),
|
||||
isExisting: true,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[pocketbase] Lookup existing traveller with emr_id "${emrStr}":`, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const travellerPayload = {
|
||||
emr_id: emrStr,
|
||||
name: nameStr,
|
||||
}
|
||||
const record = await client.collection(TRAVELLERS_COLLECTION).create(travellerPayload)
|
||||
return {
|
||||
...parseTraveller(record),
|
||||
isExisting: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEntry(trip) {
|
||||
await ensureAuth()
|
||||
const client = getClient()
|
||||
@@ -376,6 +416,90 @@ export async function deleteEntry(id) {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Notifications Helpers ──────────────────────────────────────────────────
|
||||
|
||||
function parseNotification(r) {
|
||||
let message = r.message ?? ''
|
||||
if (typeof message === 'string') {
|
||||
const trimmed = message.trim()
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
try {
|
||||
message = JSON.parse(trimmed)
|
||||
} catch {
|
||||
message = trimmed.slice(1, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
message,
|
||||
module: r.module ?? 'records',
|
||||
is_read: Boolean(r.is_read),
|
||||
is_archived: Boolean(r.is_archived),
|
||||
meta: r.meta ?? null,
|
||||
application: r.application ?? '',
|
||||
created: r.created,
|
||||
updated: r.updated,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all notifications, sorted by created desc by default.
|
||||
* Optional query options: { filter, sort }
|
||||
*/
|
||||
export async function fetchNotifications(options = {}) {
|
||||
await ensureAuth()
|
||||
const client = getClient()
|
||||
const queryParams = {
|
||||
sort: options.sort || '-created',
|
||||
requestKey: null,
|
||||
}
|
||||
if (options.filter) {
|
||||
queryParams.filter = options.filter
|
||||
}
|
||||
const list = await client.collection(NOTIFICATIONS_COLLECTION).getFullList(queryParams)
|
||||
return list.map(parseNotification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a notification record by id (e.g. { is_read: true }, { is_archived: true }).
|
||||
*/
|
||||
export async function updateNotification(id, data) {
|
||||
await ensureAuth()
|
||||
const client = getClient()
|
||||
const record = await client.collection(NOTIFICATIONS_COLLECTION).update(id, data)
|
||||
return parseNotification(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to mark a notification as read or unread.
|
||||
*/
|
||||
export async function markNotificationRead(id, isRead = true) {
|
||||
return await updateNotification(id, { is_read: isRead })
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all unread notifications as read.
|
||||
*/
|
||||
export async function markAllNotificationsRead() {
|
||||
await ensureAuth()
|
||||
const client = getClient()
|
||||
const unreadList = await client.collection(NOTIFICATIONS_COLLECTION).getFullList({
|
||||
filter: 'is_read = false',
|
||||
requestKey: null,
|
||||
})
|
||||
const updated = await Promise.all(
|
||||
unreadList.map((item) =>
|
||||
client.collection(NOTIFICATIONS_COLLECTION).update(item.id, { is_read: true })
|
||||
)
|
||||
)
|
||||
return updated.map(parseNotification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives or unarchives a notification.
|
||||
*/
|
||||
export async function archiveNotification(id, isArchived = true) {
|
||||
return await updateNotification(id, { is_archived: isArchived })
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'app:ready',
|
||||
'pb:fetch-entries',
|
||||
'pb:fetch-travellers',
|
||||
'pb:create-traveller',
|
||||
'pb:create-entry',
|
||||
'pb:update-entry',
|
||||
'pb:delete-entry',
|
||||
@@ -24,6 +25,11 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'pb:fetch-vaccine-records',
|
||||
'pb:save-vaccine-record',
|
||||
'pb:update-vaccine-record',
|
||||
'pb:fetch-notifications',
|
||||
'pb:update-notification',
|
||||
'pb:mark-notification-read',
|
||||
'pb:mark-all-notifications-read',
|
||||
'pb:archive-notification',
|
||||
'audit:run-health-audit',
|
||||
'juvonno:fetch-chart',
|
||||
]
|
||||
@@ -35,6 +41,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'app:get-version',
|
||||
'pb:fetch-entries',
|
||||
'pb:fetch-travellers',
|
||||
'pb:create-traveller',
|
||||
'pb:create-entry',
|
||||
'pb:update-entry',
|
||||
'pb:delete-entry',
|
||||
@@ -42,6 +49,11 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'pb:fetch-vaccine-records',
|
||||
'pb:save-vaccine-record',
|
||||
'pb:update-vaccine-record',
|
||||
'pb:fetch-notifications',
|
||||
'pb:update-notification',
|
||||
'pb:mark-notification-read',
|
||||
'pb:mark-all-notifications-read',
|
||||
'pb:archive-notification',
|
||||
'audit:run-health-audit',
|
||||
'juvonno:fetch-chart',
|
||||
]
|
||||
@@ -54,6 +66,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
pb: {
|
||||
fetchEntries: () => ipcRenderer.invoke('pb:fetch-entries'),
|
||||
fetchTravellers: () => ipcRenderer.invoke('pb:fetch-travellers'),
|
||||
createTraveller: (data) => ipcRenderer.invoke('pb:create-traveller', data),
|
||||
createEntry: (trip) => ipcRenderer.invoke('pb:create-entry', trip),
|
||||
updateEntry: (id, trip) => ipcRenderer.invoke('pb:update-entry', id, trip),
|
||||
deleteEntry: (id) => ipcRenderer.invoke('pb:delete-entry', id),
|
||||
@@ -61,6 +74,11 @@ contextBridge.exposeInMainWorld('api', {
|
||||
fetchVaccineRecords: () => ipcRenderer.invoke('pb:fetch-vaccine-records'),
|
||||
saveVaccineRecord: (data) => ipcRenderer.invoke('pb:save-vaccine-record', data),
|
||||
updateVaccineRecord: (id, data) => ipcRenderer.invoke('pb:update-vaccine-record', id, data),
|
||||
fetchNotifications: (options) => ipcRenderer.invoke('pb:fetch-notifications', options),
|
||||
updateNotification: (id, data) => ipcRenderer.invoke('pb:update-notification', id, data),
|
||||
markNotificationRead: (id, isRead) => ipcRenderer.invoke('pb:mark-notification-read', id, isRead),
|
||||
markAllNotificationsRead: () => ipcRenderer.invoke('pb:mark-all-notifications-read'),
|
||||
archiveNotification: (id, isArchived) => ipcRenderer.invoke('pb:archive-notification', id, isArchived),
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Generated
+3
-3
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "ctm-concierge",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ctm-concierge",
|
||||
"version": "0.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.3.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ctm-concierge",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "CTM Concierge - Real-time patient journey tracker for CTM",
|
||||
"author": {
|
||||
"name": "Wirediv",
|
||||
|
||||
+31
-2
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Sidebar from './components/Sidebar.jsx'
|
||||
import Dashboard from './components/Dashboard.jsx'
|
||||
import LiveTracking from './components/LiveTracking.jsx'
|
||||
@@ -6,6 +6,9 @@ import Trips from './components/Trips.jsx'
|
||||
import Records from './components/Records.jsx'
|
||||
import RiskAudit from './components/RiskAudit.jsx'
|
||||
import PreTravel from './components/PreTravel.jsx'
|
||||
import Notifications from './components/Notifications.jsx'
|
||||
import { fetchNotifications } from './lib/pocketbase.js'
|
||||
|
||||
/**
|
||||
* App — root layout component.
|
||||
* Implements a two-panel CSS Grid: fixed sidebar + scrollable main viewport.
|
||||
@@ -13,6 +16,21 @@ import PreTravel from './components/PreTravel.jsx'
|
||||
*/
|
||||
export default function App() {
|
||||
const [activePage, setActivePage] = useState('dashboard')
|
||||
const [unreadNotifCount, setUnreadNotifCount] = useState(0)
|
||||
|
||||
const refreshUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const notifs = await fetchNotifications()
|
||||
const unread = (notifs || []).filter((n) => !n.is_read && !n.is_archived).length
|
||||
setUnreadNotifCount(unread)
|
||||
} catch {
|
||||
// Fallback for bridge unready or offline state
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshUnreadCount()
|
||||
}, [refreshUnreadCount, activePage])
|
||||
|
||||
function renderPage() {
|
||||
switch (activePage) {
|
||||
@@ -21,13 +39,24 @@ export default function App() {
|
||||
case 'records': return <Records />
|
||||
case 'risk-audit': return <RiskAudit />
|
||||
case 'pre-travel': return <PreTravel />
|
||||
case 'notifications':
|
||||
return (
|
||||
<Notifications
|
||||
onNavigate={setActivePage}
|
||||
onUnreadCountChange={setUnreadNotifCount}
|
||||
/>
|
||||
)
|
||||
default: return <Dashboard />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar activePage={activePage} onNavigate={setActivePage} />
|
||||
<Sidebar
|
||||
activePage={activePage}
|
||||
onNavigate={setActivePage}
|
||||
unreadNotifCount={unreadNotifCount}
|
||||
/>
|
||||
<main className="main-viewport">
|
||||
{renderPage()}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Archive,
|
||||
RotateCw,
|
||||
Search,
|
||||
Syringe,
|
||||
Plane,
|
||||
Briefcase,
|
||||
MapPin,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
Inbox,
|
||||
AlertCircle,
|
||||
EyeOff,
|
||||
X,
|
||||
Layers,
|
||||
Sparkles,
|
||||
ArchiveRestore,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchNotifications,
|
||||
updateNotification,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
archiveNotification,
|
||||
} from '../lib/pocketbase.js'
|
||||
|
||||
// ── Module Configuration ──────────────────────────────────────────────────────
|
||||
const MODULE_CONFIG = {
|
||||
records: {
|
||||
label: 'Records',
|
||||
icon: Syringe,
|
||||
color: '#38bdf8', // sky blue
|
||||
bg: 'rgba(56, 189, 248, 0.12)',
|
||||
border: 'rgba(56, 189, 248, 0.28)',
|
||||
route: 'records',
|
||||
actionText: 'Open Records',
|
||||
},
|
||||
trips: {
|
||||
label: 'Trips',
|
||||
icon: Plane,
|
||||
color: '#34d399', // emerald
|
||||
bg: 'rgba(52, 211, 153, 0.12)',
|
||||
border: 'rgba(52, 211, 153, 0.28)',
|
||||
route: 'trips',
|
||||
actionText: 'Open Trips',
|
||||
},
|
||||
'pre-travel': {
|
||||
label: 'Pre-Travel',
|
||||
icon: Briefcase,
|
||||
color: '#fbbf24', // amber
|
||||
bg: 'rgba(251, 191, 36, 0.12)',
|
||||
border: 'rgba(251, 191, 36, 0.28)',
|
||||
route: 'pre-travel',
|
||||
actionText: 'Open Pre-Travel',
|
||||
},
|
||||
'live-tracking': {
|
||||
label: 'Live Tracking',
|
||||
icon: MapPin,
|
||||
color: '#aa61f8ff', // violet
|
||||
bg: 'rgba(139, 92, 246, 0.12)',
|
||||
border: 'rgba(156, 117, 248, 0.28)',
|
||||
route: 'live-tracking',
|
||||
actionText: 'Open Live Tracking',
|
||||
},
|
||||
}
|
||||
|
||||
function getModuleMeta(moduleName) {
|
||||
const key = (moduleName || '').toLowerCase().trim()
|
||||
return (
|
||||
MODULE_CONFIG[key] || {
|
||||
label: key ? key.charAt(0).toUpperCase() + key.slice(1) : 'System',
|
||||
icon: Bell,
|
||||
color: '#94a3b8',
|
||||
bg: 'rgba(148, 163, 184, 0.12)',
|
||||
border: 'rgba(148, 163, 184, 0.25)',
|
||||
route: null,
|
||||
actionText: null,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString) {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString.replace(' ', 'T'))
|
||||
if (isNaN(date.getTime())) return dateString
|
||||
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
|
||||
if (diffSec < 60) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHour < 24) return `${diffHour}h ago`
|
||||
if (diffDay === 1) return 'Yesterday'
|
||||
if (diffDay < 7) return `${diffDay}d ago`
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatFullDateTime(dateString) {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString.replace(' ', 'T'))
|
||||
if (isNaN(date.getTime())) return dateString
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function cleanMessage(rawMessage) {
|
||||
if (typeof rawMessage !== 'string') return ''
|
||||
let trimmed = rawMessage.trim()
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
try {
|
||||
trimmed = JSON.parse(trimmed)
|
||||
} catch {
|
||||
trimmed = trimmed.slice(1, -1)
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export default function Notifications({ onNavigate, onUnreadCountChange }) {
|
||||
const [notifications, setNotifications] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [actionFeedback, setActionFeedback] = useState(null)
|
||||
|
||||
// Filters
|
||||
const [statusTab, setStatusTab] = useState('inbox') // 'inbox' | 'unread' | 'archived' | 'all'
|
||||
const [selectedModule, setSelectedModule] = useState('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// ─── Fetch notifications ──────────────────────────────────────────────────
|
||||
const loadNotifications = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoading(true)
|
||||
else setRefreshing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const data = await fetchNotifications({ sort: '-created' })
|
||||
setNotifications(data || [])
|
||||
} catch (err) {
|
||||
console.error('[Notifications] fetch error:', err)
|
||||
setError(err.message || 'Failed to fetch notifications.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications()
|
||||
}, [loadNotifications])
|
||||
|
||||
// Temporary feedback toast
|
||||
const showFeedback = (text) => {
|
||||
setActionFeedback(text)
|
||||
setTimeout(() => {
|
||||
setActionFeedback((curr) => (curr === text ? null : curr))
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// ─── Item Actions ─────────────────────────────────────────────────────────
|
||||
const handleToggleRead = async (notif, e) => {
|
||||
e.stopPropagation()
|
||||
const targetState = !notif.is_read
|
||||
// Optimistic UI update
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === notif.id ? { ...n, is_read: targetState } : n))
|
||||
)
|
||||
try {
|
||||
await markNotificationRead(notif.id, targetState)
|
||||
showFeedback(targetState ? 'Marked as read' : 'Marked as unread')
|
||||
} catch (err) {
|
||||
console.error('[Notifications] failed to update read status:', err)
|
||||
// Rollback
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === notif.id ? { ...n, is_read: notif.is_read } : n))
|
||||
)
|
||||
showFeedback('Failed to update status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleArchive = async (notif, e) => {
|
||||
e.stopPropagation()
|
||||
const targetState = !notif.is_archived
|
||||
// Optimistic UI update
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === notif.id ? { ...n, is_archived: targetState } : n))
|
||||
)
|
||||
try {
|
||||
await archiveNotification(notif.id, targetState)
|
||||
showFeedback(targetState ? 'Notification archived' : 'Notification restored')
|
||||
} catch (err) {
|
||||
console.error('[Notifications] failed to archive notification:', err)
|
||||
// Rollback
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === notif.id ? { ...n, is_archived: notif.is_archived } : n))
|
||||
)
|
||||
showFeedback('Failed to update archive status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
const unreadCount = notifications.filter((n) => !n.is_read && !n.is_archived).length
|
||||
if (unreadCount === 0) return
|
||||
|
||||
// Optimistic
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (!n.is_archived ? { ...n, is_read: true } : n))
|
||||
)
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
showFeedback(`Marked ${unreadCount} notification${unreadCount === 1 ? '' : 's'} as read`)
|
||||
} catch (err) {
|
||||
console.error('[Notifications] failed to mark all as read:', err)
|
||||
loadNotifications(true)
|
||||
showFeedback('Failed to mark all as read')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Filter & Search ──────────────────────────────────────────────────────
|
||||
const availableModules = useMemo(() => {
|
||||
const set = new Set()
|
||||
notifications.forEach((n) => {
|
||||
if (n.module) set.add(n.module.toLowerCase())
|
||||
})
|
||||
return Array.from(set)
|
||||
}, [notifications])
|
||||
|
||||
const filteredNotifications = useMemo(() => {
|
||||
return notifications.filter((n) => {
|
||||
// Tab filter
|
||||
if (statusTab === 'inbox' && n.is_archived) return false
|
||||
if (statusTab === 'unread' && (n.is_read || n.is_archived)) return false
|
||||
if (statusTab === 'archived' && !n.is_archived) return false
|
||||
|
||||
// Module filter
|
||||
if (selectedModule !== 'all' && (n.module || '').toLowerCase() !== selectedModule) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Text search
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const msg = cleanMessage(n.message).toLowerCase()
|
||||
const mod = (n.module || '').toLowerCase()
|
||||
const metaStr = n.meta ? JSON.stringify(n.meta).toLowerCase() : ''
|
||||
if (!msg.includes(query) && !mod.includes(query) && !metaStr.includes(query)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [notifications, statusTab, selectedModule, searchQuery])
|
||||
|
||||
// Counts
|
||||
const counts = useMemo(() => {
|
||||
const total = notifications.length
|
||||
const unread = notifications.filter((n) => !n.is_read && !n.is_archived).length
|
||||
const inbox = notifications.filter((n) => !n.is_archived).length
|
||||
const archived = notifications.filter((n) => n.is_archived).length
|
||||
return { total, unread, inbox, archived }
|
||||
}, [notifications])
|
||||
|
||||
// Sync unread count to parent (App.jsx)
|
||||
useEffect(() => {
|
||||
if (typeof onUnreadCountChange === 'function') {
|
||||
onUnreadCountChange(counts.unread)
|
||||
}
|
||||
}, [counts.unread, onUnreadCountChange])
|
||||
|
||||
return (
|
||||
<div className="notifications-page" id="notifications-page">
|
||||
{/* ── Page Header ──────────────────────────────────────────────────────── */}
|
||||
<header className="notifications-header">
|
||||
<div className="notifications-header-left">
|
||||
<p className="dashboard-greeting">System Alerts & Activity</p>
|
||||
<div className="notifications-title-row">
|
||||
<h1 className="dashboard-title">Notifications</h1>
|
||||
{counts.unread > 0 && (
|
||||
<span className="notifications-unread-pill">
|
||||
<span className="notifications-pulse-dot" />
|
||||
{counts.unread} New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="dashboard-subtitle">
|
||||
Live clinical reminders, vaccine alerts, and system notices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="notifications-header-actions">
|
||||
{actionFeedback && (
|
||||
<div className="notifications-feedback-toast" role="status">
|
||||
<Check size={14} />
|
||||
<span>{actionFeedback}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="notifications-mark-all-btn"
|
||||
className="notifications-action-btn secondary"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={counts.unread === 0 || loading}
|
||||
title="Mark all active notifications as read"
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
<span>Mark all read</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="notifications-refresh-btn"
|
||||
className="notifications-action-btn primary"
|
||||
onClick={() => loadNotifications(true)}
|
||||
disabled={loading || refreshing}
|
||||
title="Refresh notifications"
|
||||
>
|
||||
<RotateCw
|
||||
size={16}
|
||||
className={loading || refreshing ? 'notifications-spin' : ''}
|
||||
/>
|
||||
<span>{refreshing ? 'Updating…' : 'Refresh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Control Bar: Tabs, Module Pills, Search ──────────────────────────── */}
|
||||
<div className="notifications-control-bar">
|
||||
{/* Tabs */}
|
||||
<div className="notifications-tabs" role="tablist">
|
||||
<button
|
||||
className={`notif-tab ${statusTab === 'inbox' ? 'active' : ''}`}
|
||||
onClick={() => setStatusTab('inbox')}
|
||||
role="tab"
|
||||
aria-selected={statusTab === 'inbox'}
|
||||
>
|
||||
<Inbox size={15} />
|
||||
<span>Inbox</span>
|
||||
<span className="notif-tab-badge">{counts.inbox}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`notif-tab ${statusTab === 'unread' ? 'active' : ''}`}
|
||||
onClick={() => setStatusTab('unread')}
|
||||
role="tab"
|
||||
aria-selected={statusTab === 'unread'}
|
||||
>
|
||||
<Bell size={15} />
|
||||
<span>Unread</span>
|
||||
{counts.unread > 0 && (
|
||||
<span className="notif-tab-badge highlight">{counts.unread}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`notif-tab ${statusTab === 'archived' ? 'active' : ''}`}
|
||||
onClick={() => setStatusTab('archived')}
|
||||
role="tab"
|
||||
aria-selected={statusTab === 'archived'}
|
||||
>
|
||||
<Archive size={15} />
|
||||
<span>Archived</span>
|
||||
<span className="notif-tab-badge">{counts.archived}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`notif-tab ${statusTab === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setStatusTab('all')}
|
||||
role="tab"
|
||||
aria-selected={statusTab === 'all'}
|
||||
>
|
||||
<Layers size={15} />
|
||||
<span>All</span>
|
||||
<span className="notif-tab-badge">{counts.total}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search & Module filter row */}
|
||||
<div className="notifications-filters-row">
|
||||
{/* Module Pills */}
|
||||
<div className="notif-module-pills" role="radiogroup" aria-label="Filter by module">
|
||||
<button
|
||||
className={`notif-module-pill ${selectedModule === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedModule('all')}
|
||||
>
|
||||
All Modules
|
||||
</button>
|
||||
{availableModules.map((mod) => {
|
||||
const meta = getModuleMeta(mod)
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<button
|
||||
key={mod}
|
||||
className={`notif-module-pill ${selectedModule === mod ? 'active' : ''}`}
|
||||
onClick={() => setSelectedModule(mod)}
|
||||
style={
|
||||
selectedModule === mod
|
||||
? { backgroundColor: meta.bg, borderColor: meta.border, color: meta.color }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Icon size={13} style={{ color: meta.color }} />
|
||||
<span>{meta.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search box */}
|
||||
<div className="notif-search-box">
|
||||
<Search size={15} className="notif-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
id="notifications-search-input"
|
||||
className="notif-search-input"
|
||||
placeholder="Search notifications..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="notif-search-clear"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Error Banner ─────────────────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="notif-error-banner" role="alert">
|
||||
<AlertCircle size={18} />
|
||||
<div className="notif-error-content">
|
||||
<strong>Unable to load notifications</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<button className="notif-retry-btn" onClick={() => loadNotifications()}>
|
||||
<RotateCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Notifications List ───────────────────────────────────────────────── */}
|
||||
<div className="notifications-content-area">
|
||||
{loading ? (
|
||||
<div className="notif-loading-container">
|
||||
<div className="notif-loading-skeleton" />
|
||||
<div className="notif-loading-skeleton" />
|
||||
<div className="notif-loading-skeleton" />
|
||||
</div>
|
||||
) : filteredNotifications.length === 0 ? (
|
||||
<div className="notif-empty-state">
|
||||
<div className="notif-empty-icon-wrap">
|
||||
{searchQuery ? (
|
||||
<Search size={32} />
|
||||
) : statusTab === 'unread' ? (
|
||||
<Sparkles size={32} />
|
||||
) : statusTab === 'archived' ? (
|
||||
<Archive size={32} />
|
||||
) : (
|
||||
<Inbox size={32} />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="notif-empty-title">
|
||||
{searchQuery
|
||||
? 'No matching notifications'
|
||||
: statusTab === 'unread'
|
||||
? 'All caught up!'
|
||||
: statusTab === 'archived'
|
||||
? 'No archived notifications'
|
||||
: 'No notifications in inbox'}
|
||||
</h3>
|
||||
<p className="notif-empty-desc">
|
||||
{searchQuery
|
||||
? `No notifications found matching "${searchQuery}". Try a different keyword.`
|
||||
: statusTab === 'unread'
|
||||
? 'You have addressed all active notifications. New clinical reminders will appear here.'
|
||||
: statusTab === 'archived'
|
||||
? 'Notifications you archive for record keeping will be stored here.'
|
||||
: 'When new patient or system alerts are triggered, they will show up here.'}
|
||||
</p>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="notif-empty-action-btn"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setSelectedModule('all')
|
||||
}}
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="notifications-card-list">
|
||||
{filteredNotifications.map((notif) => {
|
||||
const moduleMeta = getModuleMeta(notif.module)
|
||||
const ModuleIcon = moduleMeta.icon
|
||||
const message = cleanMessage(notif.message)
|
||||
const relativeTime = formatRelativeTime(notif.created)
|
||||
const fullTime = formatFullDateTime(notif.created)
|
||||
|
||||
return (
|
||||
<article
|
||||
key={notif.id}
|
||||
id={`notif-card-${notif.id}`}
|
||||
className={`notif-card ${!notif.is_read ? 'is-unread' : ''} ${notif.is_archived ? 'is-archived' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!notif.is_read) {
|
||||
markNotificationRead(notif.id, true)
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === notif.id ? { ...n, is_read: true } : n))
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Left status accent strip & unread dot */}
|
||||
<div className="notif-card-accent-indicator">
|
||||
{!notif.is_read && <span className="notif-card-glow-dot" title="Unread" />}
|
||||
</div>
|
||||
|
||||
<div className="notif-card-main">
|
||||
{/* Header: Module pill, timestamp, status */}
|
||||
<div className="notif-card-header">
|
||||
<div className="notif-card-meta-left">
|
||||
<span
|
||||
className="notif-module-badge"
|
||||
style={{
|
||||
backgroundColor: moduleMeta.bg,
|
||||
borderColor: moduleMeta.border,
|
||||
color: moduleMeta.color,
|
||||
}}
|
||||
>
|
||||
<ModuleIcon size={13} style={{ color: moduleMeta.color }} />
|
||||
<span>{moduleMeta.label}</span>
|
||||
</span>
|
||||
|
||||
{!notif.is_read && (
|
||||
<span className="notif-unread-tag">Unread</span>
|
||||
)}
|
||||
|
||||
{notif.is_archived && (
|
||||
<span className="notif-archived-tag">Archived</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notif-card-meta-right" title={`Created: ${fullTime}`}>
|
||||
<Clock size={13} />
|
||||
<span>{relativeTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Body */}
|
||||
<div className="notif-card-body">
|
||||
<p className="notif-card-message">{message}</p>
|
||||
|
||||
{/* Metadata Inspector (if meta exists and is not null) */}
|
||||
{notif.meta && typeof notif.meta === 'object' && Object.keys(notif.meta).length > 0 && (
|
||||
<div className="notif-meta-preview">
|
||||
{Object.entries(notif.meta).map(([key, val]) => (
|
||||
<span key={key} className="notif-meta-chip">
|
||||
<span className="notif-meta-key">{key}:</span>
|
||||
<span className="notif-meta-val">
|
||||
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="notif-card-footer">
|
||||
<div className="notif-card-actions-left">
|
||||
{/* Module navigation shortcut */}
|
||||
{moduleMeta.route && onNavigate && (
|
||||
<button
|
||||
className="notif-link-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onNavigate(moduleMeta.route)
|
||||
}}
|
||||
title={`Navigate to ${moduleMeta.label}`}
|
||||
>
|
||||
<span>{moduleMeta.actionText || `Open ${moduleMeta.label}`}</span>
|
||||
<ArrowRight size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notif-card-actions-right">
|
||||
<button
|
||||
className={`notif-tool-btn ${notif.is_read ? 'read-active' : ''}`}
|
||||
onClick={(e) => handleToggleRead(notif, e)}
|
||||
title={notif.is_read ? 'Mark as unread' : 'Mark as read'}
|
||||
aria-label={notif.is_read ? 'Mark as unread' : 'Mark as read'}
|
||||
>
|
||||
{notif.is_read ? (
|
||||
<>
|
||||
<EyeOff size={14} />
|
||||
<span className="notif-tool-label">Mark unread</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check size={14} />
|
||||
<span className="notif-tool-label">Mark read</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`notif-tool-btn ${notif.is_archived ? 'archived-active' : ''}`}
|
||||
onClick={(e) => handleToggleArchive(notif, e)}
|
||||
title={notif.is_archived ? 'Unarchive' : 'Archive'}
|
||||
aria-label={notif.is_archived ? 'Unarchive' : 'Archive'}
|
||||
>
|
||||
{notif.is_archived ? (
|
||||
<>
|
||||
<ArchiveRestore size={14} />
|
||||
<span className="notif-tool-label">Restore</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive size={14} />
|
||||
<span className="notif-tool-label">Archive</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -157,11 +157,6 @@ function VaccineSearchPanel({
|
||||
<span className="pt-vaccine-dropdown-name">{v.name}</span>
|
||||
<span className="pt-vaccine-dropdown-meta">
|
||||
{v.total_doses} dose{v.total_doses !== 1 ? 's' : ''}
|
||||
{v.has_accelerated_schedule && (
|
||||
<span className="pt-badge-accel" title="Accelerated schedule available">
|
||||
<Zap size={9} /> Accel
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -446,7 +441,6 @@ export default function PreTravel() {
|
||||
label: d.label,
|
||||
suggested_date: dateStr,
|
||||
administered_date: d.isInPast ? dateStr : null,
|
||||
completed: d.isInPast,
|
||||
notes: '',
|
||||
}
|
||||
}),
|
||||
|
||||
+461
-44
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Syringe,
|
||||
User,
|
||||
UserPlus,
|
||||
Search,
|
||||
Plus,
|
||||
Save,
|
||||
Edit2,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -14,10 +16,12 @@ import {
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchTravellers,
|
||||
createTraveller,
|
||||
fetchVaccineRecords,
|
||||
fetchVaccines,
|
||||
saveVaccineRecord,
|
||||
} from '../lib/pocketbase.js'
|
||||
import { fetchCustomerChart } from '../lib/juvonno.js'
|
||||
import { getVaccineColor, formatDate, localDate, addDays } from '../lib/vaccineScheduleUtils.js'
|
||||
|
||||
export default function Records() {
|
||||
@@ -33,6 +37,7 @@ export default function Records() {
|
||||
|
||||
const [editableTimelines, setEditableTimelines] = useState([])
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [isEditingDossier, setIsEditingDossier] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveSuccess, setSaveSuccess] = useState(null)
|
||||
|
||||
@@ -41,6 +46,15 @@ export default function Records() {
|
||||
const [addVaccineId, setAddVaccineId] = useState('')
|
||||
const [addFirstDoseDate, setAddFirstDoseDate] = useState('')
|
||||
|
||||
// Modal state for importing traveller from EMR
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importForm, setImportForm] = useState({ emrID: '', name: '' })
|
||||
const [emrSearching, setEmrSearching] = useState(false)
|
||||
const [emrLookupError, setEmrLookupError] = useState(null)
|
||||
const [juvonnoPatientInfo, setJuvonnoPatientInfo] = useState(null)
|
||||
const [importSubmitting, setImportSubmitting] = useState(false)
|
||||
const [successMsg, setSuccessMsg] = useState(null)
|
||||
|
||||
// ── Load All Data ──────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
setLoading(true)
|
||||
@@ -56,10 +70,6 @@ export default function Records() {
|
||||
setVaccineRecords(recordData || [])
|
||||
setVaccineCatalog(catalogData || [])
|
||||
|
||||
// Auto-select first traveller if none selected
|
||||
if (!selectedTravellerId && (travellerData || []).length > 0) {
|
||||
setSelectedTravellerId(travellerData[0].id)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load vaccine records.')
|
||||
} finally {
|
||||
@@ -71,6 +81,14 @@ export default function Records() {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// Auto-dismiss success notification
|
||||
useEffect(() => {
|
||||
if (successMsg) {
|
||||
const timer = setTimeout(() => setSuccessMsg(null), 4500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [successMsg])
|
||||
|
||||
// ── Sync Editable State when Selected Traveller changes ────────────────────
|
||||
const selectedTraveller = useMemo(() => {
|
||||
return travellers.find((t) => t.id === selectedTravellerId) || null
|
||||
@@ -98,6 +116,7 @@ export default function Records() {
|
||||
setEditableTimelines([])
|
||||
}
|
||||
setIsDirty(false)
|
||||
setIsEditingDossier(false)
|
||||
setSaveSuccess(null)
|
||||
}, [existingRecord, selectedTravellerId])
|
||||
|
||||
@@ -111,7 +130,7 @@ export default function Records() {
|
||||
for (const t of timelines) {
|
||||
const doses = t.doses || []
|
||||
totalDoses += doses.length
|
||||
completedDoses += doses.filter((d) => d.completed).length
|
||||
completedDoses += doses.filter((d) => Boolean(d.administered_date && String(d.administered_date).trim())).length
|
||||
}
|
||||
const key = rec.travellerId || rec.traveller
|
||||
if (key) {
|
||||
@@ -146,7 +165,8 @@ export default function Records() {
|
||||
let trackComplete = true
|
||||
for (const dose of track.doses || []) {
|
||||
totalDoses++
|
||||
if (dose.completed) {
|
||||
const isAdministered = Boolean(dose.administered_date && String(dose.administered_date).trim())
|
||||
if (isAdministered) {
|
||||
completedDoses++
|
||||
} else {
|
||||
trackComplete = false
|
||||
@@ -162,30 +182,10 @@ export default function Records() {
|
||||
}, [editableTimelines])
|
||||
|
||||
// ── Dose Edit Handlers ─────────────────────────────────────────────────────
|
||||
function handleToggleDose(trackIndex, doseIndex) {
|
||||
setEditableTimelines((prev) => {
|
||||
const next = JSON.parse(JSON.stringify(prev))
|
||||
const dose = next[trackIndex].doses[doseIndex]
|
||||
dose.completed = !dose.completed
|
||||
|
||||
// Auto-set administered_date when checking completed
|
||||
if (dose.completed && !(dose.administered_date)) {
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const targetDate = dose.suggested_date || todayStr
|
||||
dose.administered_date = targetDate
|
||||
}
|
||||
return next
|
||||
})
|
||||
setIsDirty(true)
|
||||
}
|
||||
|
||||
function handleDoseDateChange(trackIndex, doseIndex, field, value) {
|
||||
setEditableTimelines((prev) => {
|
||||
const next = JSON.parse(JSON.stringify(prev))
|
||||
next[trackIndex].doses[doseIndex][field] = value
|
||||
if (field === 'administered_date') {
|
||||
next[trackIndex].doses[doseIndex].administered_date = value
|
||||
}
|
||||
return next
|
||||
})
|
||||
setIsDirty(true)
|
||||
@@ -227,6 +227,7 @@ export default function Records() {
|
||||
setVaccineRecords(recordData || [])
|
||||
setTravellers(travellerData || [])
|
||||
setIsDirty(false)
|
||||
setIsEditingDossier(false)
|
||||
setSaveSuccess(`Vaccine record for ${selectedTraveller.name} updated successfully!`)
|
||||
setTimeout(() => setSaveSuccess(null), 5000)
|
||||
} catch (err) {
|
||||
@@ -236,6 +237,17 @@ export default function Records() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
const timelines = existingRecord?.timelines || existingRecord?.vaccineTimelines
|
||||
if (existingRecord && timelines) {
|
||||
setEditableTimelines(JSON.parse(JSON.stringify(timelines)))
|
||||
} else {
|
||||
setEditableTimelines([])
|
||||
}
|
||||
setIsDirty(false)
|
||||
setIsEditingDossier(false)
|
||||
}
|
||||
|
||||
// ── Add Vaccine to Dossier Modal ───────────────────────────────────────────
|
||||
function handleAddVaccineSubmit(e) {
|
||||
e.preventDefault()
|
||||
@@ -267,7 +279,6 @@ export default function Records() {
|
||||
label: totalDoses === 1 ? 'Single Dose' : `Dose ${d}`,
|
||||
suggested_date: dateStr,
|
||||
administered_date: null,
|
||||
completed: false,
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
@@ -282,11 +293,112 @@ export default function Records() {
|
||||
|
||||
setEditableTimelines((prev) => [...prev, newTrack])
|
||||
setIsDirty(true)
|
||||
setIsEditingDossier(true)
|
||||
setShowAddModal(false)
|
||||
setAddVaccineId('')
|
||||
setAddFirstDoseDate('')
|
||||
}
|
||||
|
||||
// ── Import Traveller Handlers ──────────────────────────────────────────────
|
||||
async function handleLookupJuvonno() {
|
||||
const rawId = Number(importForm.emrID)
|
||||
if (!rawId) {
|
||||
setEmrLookupError('Please enter a valid numeric EMR ID first.')
|
||||
return
|
||||
}
|
||||
|
||||
setEmrSearching(true)
|
||||
setEmrLookupError(null)
|
||||
setJuvonnoPatientInfo(null)
|
||||
|
||||
try {
|
||||
const data = await fetchCustomerChart(rawId)
|
||||
const firstName = (data?.customer?.first_name || '').trim()
|
||||
const lastName = (data?.customer?.last_name || '').trim()
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ')
|
||||
const dob = data?.customer?.date_of_birth
|
||||
|
||||
// Check if patient already exists in local database
|
||||
const alreadySaved = travellers.find((t) => String(t.emrID) === String(rawId))
|
||||
|
||||
setJuvonnoPatientInfo({
|
||||
firstName,
|
||||
lastName,
|
||||
fullName,
|
||||
dob,
|
||||
isAlreadySaved: Boolean(alreadySaved),
|
||||
existingName: alreadySaved?.name,
|
||||
})
|
||||
|
||||
setImportForm((prev) => ({
|
||||
...prev,
|
||||
emrID: rawId,
|
||||
name: fullName || alreadySaved?.name || prev.name,
|
||||
}))
|
||||
} catch (err) {
|
||||
// Check if traveller is already present locally even if Juvonno chart fetch failed
|
||||
const alreadySaved = travellers.find((t) => String(t.emrID) === String(rawId))
|
||||
if (alreadySaved) {
|
||||
setJuvonnoPatientInfo({
|
||||
fullName: alreadySaved.name,
|
||||
isAlreadySaved: true,
|
||||
existingName: alreadySaved.name,
|
||||
})
|
||||
setImportForm((prev) => ({
|
||||
...prev,
|
||||
emrID: rawId,
|
||||
name: alreadySaved.name,
|
||||
}))
|
||||
setEmrLookupError(null)
|
||||
} else {
|
||||
setJuvonnoPatientInfo(null)
|
||||
setEmrLookupError(err.message || `Failed to fetch customer chart for EMR ID "${rawId}".`)
|
||||
}
|
||||
} finally {
|
||||
setEmrSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateTravellerSubmit(e) {
|
||||
e.preventDefault()
|
||||
const name = (importForm.name || '').trim()
|
||||
const emrID = String(importForm.emrID || '').trim()
|
||||
|
||||
if (!name) {
|
||||
setEmrLookupError('Please enter the traveller’s name.')
|
||||
return
|
||||
}
|
||||
|
||||
setImportSubmitting(true)
|
||||
setEmrLookupError(null)
|
||||
|
||||
try {
|
||||
const res = await createTraveller({
|
||||
emrID: emrID || undefined,
|
||||
name,
|
||||
})
|
||||
|
||||
await loadData()
|
||||
if (res?.id) {
|
||||
setSelectedTravellerId(res.id)
|
||||
}
|
||||
|
||||
setShowImportModal(false)
|
||||
setImportForm({ emrID: '', name: '' })
|
||||
setJuvonnoPatientInfo(null)
|
||||
|
||||
if (res?.isExisting) {
|
||||
setSuccessMsg(`Patient already exists in records. Selected ${res.name} (EMR #${res.emrID || emrID}).`)
|
||||
} else {
|
||||
setSuccessMsg(`Successfully created new traveller "${res.name}" (EMR #${res.emrID || emrID})!`)
|
||||
}
|
||||
} catch (err) {
|
||||
setEmrLookupError(err.message || 'Failed to create traveller record.')
|
||||
} finally {
|
||||
setImportSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render Component ───────────────────────────────────────────────────────
|
||||
return (
|
||||
<section className="records" aria-label="Vaccine Records Management">
|
||||
@@ -328,6 +440,30 @@ export default function Records() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Success Banner ─────────────────────────────────────────────────── */}
|
||||
{successMsg && (
|
||||
<div style={{
|
||||
background: 'rgba(16, 185, 129, 0.12)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.35)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '14px 18px',
|
||||
marginBottom: '20px',
|
||||
color: '#34d399',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<Check size={20} style={{ color: '#10b981', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 'var(--font-size-sm)', fontWeight: 600 }}>{successMsg}</div>
|
||||
<button
|
||||
onClick={() => setSuccessMsg(null)}
|
||||
style={{ background: 'transparent', border: 'none', color: '#34d399', cursor: 'pointer', fontSize: '18px' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main Two-Panel Layout ────────────────────────────────────────────── */}
|
||||
<div className="rec-layout">
|
||||
|
||||
@@ -340,7 +476,7 @@ export default function Records() {
|
||||
</div>
|
||||
|
||||
<div className="rec-search-wrap">
|
||||
<Search size={14} style={{ color: 'var(--color-text-muted)' }} />
|
||||
<Search size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
className="rec-search-input"
|
||||
@@ -348,6 +484,37 @@ export default function Records() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{(searchQuery || selectedTravellerId) && (
|
||||
<button
|
||||
type="button"
|
||||
id="rec-search-clear-btn"
|
||||
className="rec-search-clear-btn"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setSelectedTravellerId(null)
|
||||
}}
|
||||
title="Clear search and selection"
|
||||
aria-label="Clear search and selection"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rec-import-trigger-wrap">
|
||||
<button
|
||||
type="button"
|
||||
id="rec-import-traveller-btn"
|
||||
className="rec-import-btn"
|
||||
onClick={() => {
|
||||
setEmrLookupError(null)
|
||||
setJuvonnoPatientInfo(null)
|
||||
setShowImportModal(true)
|
||||
}}
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>Import Patient</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rec-traveller-list">
|
||||
@@ -419,6 +586,16 @@ export default function Records() {
|
||||
EMR #{selectedTraveller.emrID}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
id="rec-clear-selected-traveller-btn"
|
||||
className="rec-dossier-close-btn"
|
||||
onClick={() => setSelectedTravellerId(null)}
|
||||
title="Close dossier (clear selection)"
|
||||
aria-label="Close dossier"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -445,14 +622,48 @@ export default function Records() {
|
||||
Add Vaccine
|
||||
</button>
|
||||
|
||||
{!isEditingDossier ? (
|
||||
<button
|
||||
type="button"
|
||||
id="rec-edit-dossier-btn"
|
||||
className="btn-secondary"
|
||||
onClick={() => setIsEditingDossier(true)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '7px 14px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title="Edit doses and vaccination timelines"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
id="rec-cancel-edit-btn"
|
||||
className="pt-refresh-btn"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={saving}
|
||||
style={{ background: 'var(--color-bg-subtle)' }}
|
||||
title={isDirty ? 'Cancel unsaved changes' : 'Finish editing'}
|
||||
>
|
||||
{isDirty ? 'Cancel' : 'Done'}
|
||||
</button>
|
||||
|
||||
{isDirty && (
|
||||
<button
|
||||
id="rec-save-dossier-btn"
|
||||
className="pt-save-plan-btn"
|
||||
onClick={handleSaveDossier}
|
||||
disabled={saving || !isDirty}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: isDirty ? 'var(--color-accent-emerald, #10b981)' : 'var(--color-bg-subtle)',
|
||||
color: isDirty ? '#fff' : 'var(--color-text-muted)',
|
||||
background: 'var(--color-accent-emerald, #10b981)',
|
||||
color: '#fff',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{saving ? (
|
||||
@@ -463,10 +674,13 @@ export default function Records() {
|
||||
) : (
|
||||
<>
|
||||
<Save size={14} />
|
||||
{isDirty ? 'Save Changes' : 'Saved'}
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -516,6 +730,7 @@ export default function Records() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditingDossier && (
|
||||
<button
|
||||
className="pt-vaccine-chip-remove"
|
||||
onClick={() => handleRemoveVaccineTrack(trackIdx)}
|
||||
@@ -524,34 +739,39 @@ export default function Records() {
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dose Cards Grid */}
|
||||
<div className="rec-dose-grid">
|
||||
{(track.doses || []).map((dose, doseIdx) => (
|
||||
{(track.doses || []).map((dose, doseIdx) => {
|
||||
const isAdministered = Boolean(dose.administered_date && String(dose.administered_date).trim())
|
||||
return (
|
||||
<div
|
||||
key={dose.dose_number || doseIdx}
|
||||
className={`rec-dose-card${dose.completed ? ' rec-dose-card--completed' : ''}`}
|
||||
className={`rec-dose-card${isAdministered ? ' rec-dose-card--administered' : ''}`}
|
||||
>
|
||||
<div className="rec-dose-card-top">
|
||||
<label className="rec-dose-toggle">
|
||||
<div className="rec-dose-status-indicator">
|
||||
<div
|
||||
className={`rec-checkbox${dose.completed ? ' rec-checkbox--checked' : ''}`}
|
||||
onClick={() => handleToggleDose(trackIdx, doseIdx)}
|
||||
className={`rec-checkbox${isAdministered ? ' rec-checkbox--checked' : ''}`}
|
||||
title={isAdministered ? 'Administered' : 'Pending administered date'}
|
||||
>
|
||||
{dose.completed && <Check size={13} />}
|
||||
{isAdministered && <Check size={13} />}
|
||||
</div>
|
||||
<span style={{ color: dose.completed ? '#34d399' : 'var(--color-text-primary)' }}>
|
||||
<span style={{ color: isAdministered ? '#34d399' : 'var(--color-text-primary)' }}>
|
||||
{dose.label || `Dose ${dose.dose_number}`}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<span className={`rec-traveller-badge ${dose.completed ? 'rec-badge-complete' : 'rec-badge-none'}`}>
|
||||
{dose.completed ? 'Administered' : 'Pending'}
|
||||
<span className={`rec-traveller-badge ${isAdministered ? 'rec-badge-complete' : 'rec-badge-none'}`}>
|
||||
{isAdministered ? 'Administered' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rec-dose-fields">
|
||||
{isEditingDossier ? (
|
||||
<>
|
||||
<div className="rec-field-group">
|
||||
<span className="rec-field-label">Suggested Date</span>
|
||||
<input
|
||||
@@ -582,9 +802,37 @@ export default function Records() {
|
||||
onChange={(e) => handleDoseNotesChange(trackIdx, doseIdx, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rec-dose-view-fields">
|
||||
<div className="rec-view-row">
|
||||
<span className="rec-view-label">Suggested</span>
|
||||
<span className="rec-view-val">
|
||||
{formatDate(dose.suggested_date) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rec-view-row">
|
||||
<span className="rec-view-label">Administered</span>
|
||||
<span className={`rec-view-val ${isAdministered ? 'rec-view-val--date' : 'rec-view-val--muted'}`}>
|
||||
{isAdministered ? formatDate(dose.administered_date) : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{dose.notes ? (
|
||||
<div className="rec-view-row">
|
||||
<span className="rec-view-label">Notes/Lot</span>
|
||||
<span className="rec-view-val rec-view-val--note" title={dose.notes}>
|
||||
{dose.notes}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -659,6 +907,175 @@ export default function Records() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Import Patient Modal ─────────────────────────────────────────────── */}
|
||||
{showImportModal && (
|
||||
<div className="rec-modal-overlay" onClick={() => setShowImportModal(false)}>
|
||||
<div className="rec-modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '460px' }}>
|
||||
<div className="rec-modal-title">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<UserPlus size={18} style={{ color: 'var(--color-primary)' }} />
|
||||
Import Patient from EMR
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
style={{ background: 'none', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }}
|
||||
onClick={() => setShowImportModal(false)}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateTravellerSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="rec-field-group">
|
||||
<label className="rec-field-label">EMR ID (Juvonno)</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="number"
|
||||
className="rec-date-input"
|
||||
style={{ flex: 1, padding: '8px 12px' }}
|
||||
placeholder="e.g. 1042"
|
||||
value={importForm.emrID}
|
||||
onChange={(e) => {
|
||||
setImportForm((prev) => ({ ...prev, emrID: e.target.value }))
|
||||
setEmrLookupError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleLookupJuvonno()
|
||||
}
|
||||
}}
|
||||
disabled={emrSearching}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={handleLookupJuvonno}
|
||||
disabled={emrSearching || !String(importForm.emrID || '').trim()}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '0 14px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title="Fetch patient chart from Juvonno EMR"
|
||||
>
|
||||
{emrSearching ? (
|
||||
<RotateCw size={13} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
) : (
|
||||
<Search size={13} />
|
||||
)}
|
||||
<span>{emrSearching ? 'Searching...' : 'Lookup'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Juvonno Lookup Error */}
|
||||
{emrLookupError && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: '#f87171',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={14} style={{ flexShrink: 0 }} />
|
||||
<span style={{ flex: 1 }}>{emrLookupError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Juvonno Patient Resolved Banner */}
|
||||
{juvonnoPatientInfo && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(16, 185, 129, 0.08)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.28)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: '#34d399',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
}}
|
||||
>
|
||||
<Check size={14} style={{ flexShrink: 0 }} />
|
||||
<span>
|
||||
Found: <strong>{juvonnoPatientInfo.fullName}</strong>
|
||||
{juvonnoPatientInfo.dob ? ` (DOB: ${juvonnoPatientInfo.dob})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Patient Already Exists Locally Notice */}
|
||||
{juvonnoPatientInfo?.isAlreadySaved && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--color-primary)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
}}
|
||||
>
|
||||
<User size={14} style={{ flexShrink: 0 }} />
|
||||
<span>
|
||||
This traveller already exists in your local records. Submitting will select their existing record.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Patient Name Field */}
|
||||
<div className="rec-field-group">
|
||||
<label className="rec-field-label">Traveller / Patient Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="rec-date-input"
|
||||
style={{ width: '100%', padding: '8px 12px' }}
|
||||
placeholder="Full Name"
|
||||
value={importForm.name}
|
||||
onChange={(e) => setImportForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rec-modal-actions" style={{ marginTop: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pt-refresh-btn"
|
||||
onClick={() => setShowImportModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="pt-save-plan-btn"
|
||||
disabled={importSubmitting || !importForm.name.trim()}
|
||||
>
|
||||
{importSubmitting
|
||||
? 'Processing…'
|
||||
: juvonnoPatientInfo?.isAlreadySaved
|
||||
? 'Select Existing Record'
|
||||
: 'Create Traveller'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const TOOLS_ITEMS = [
|
||||
]
|
||||
|
||||
const SYSTEM_ITEMS = [
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, badge: null, disabled: true },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, badge: null, disabled: false },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, badge: null, disabled: true },
|
||||
]
|
||||
|
||||
@@ -35,8 +35,9 @@ const SYSTEM_ITEMS = [
|
||||
* Sidebar — primary navigation for CTM Concierge.
|
||||
* @param {string} activePage — the currently active page id (owned by App)
|
||||
* @param {function} onNavigate — callback to change the active page
|
||||
* @param {number} unreadNotifCount — unread notifications count (owned by App)
|
||||
*/
|
||||
export default function Sidebar({ activePage, onNavigate }) {
|
||||
export default function Sidebar({ activePage, onNavigate, unreadNotifCount = 0 }) {
|
||||
const [postTravelCount, setPostTravelCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,6 +63,16 @@ export default function Sidebar({ activePage, onNavigate }) {
|
||||
return item
|
||||
})
|
||||
|
||||
const systemItems = SYSTEM_ITEMS.map((item) => {
|
||||
if (item.id === 'notifications') {
|
||||
return {
|
||||
...item,
|
||||
badge: unreadNotifCount > 0 ? String(unreadNotifCount) : null,
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
{/* Logo / App identity */}
|
||||
@@ -126,7 +137,7 @@ export default function Sidebar({ activePage, onNavigate }) {
|
||||
})}
|
||||
|
||||
<span className="sidebar-section-label" style={{ marginTop: 'var(--space-4)' }}>System</span>
|
||||
{SYSTEM_ITEMS.map((item) => {
|
||||
{systemItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
|
||||
+802
-15
@@ -2428,18 +2428,6 @@ button {
|
||||
}
|
||||
|
||||
/* ── Badges ───────────────────────────────────────────────────────────────── */
|
||||
.pt-badge-accel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pt-badge-icvp {
|
||||
display: inline-flex;
|
||||
padding: 1px 5px;
|
||||
@@ -2771,7 +2759,7 @@ button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: var(--space-6);
|
||||
padding: var(--space-8);
|
||||
gap: var(--space-5);
|
||||
overflow-y: auto;
|
||||
background-color: var(--color-bg-base);
|
||||
@@ -2831,6 +2819,54 @@ button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rec-search-clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rec-search-clear-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.rec-import-trigger-wrap {
|
||||
padding: 12px 12px 18px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.rec-import-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
border: 1px solid rgba(14, 165, 233, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.rec-import-btn:hover {
|
||||
background: rgba(14, 165, 233, 0.16);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rec-traveller-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2939,6 +2975,27 @@ button {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.rec-dossier-close-btn {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.rec-dossier-close-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.rec-compliance-bar-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3014,6 +3071,7 @@ button {
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.rec-dose-card--administered,
|
||||
.rec-dose-card--completed {
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
@@ -3025,11 +3083,11 @@ button {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.rec-dose-toggle {
|
||||
.rec-dose-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
user-select: none;
|
||||
@@ -3044,6 +3102,7 @@ button {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rec-checkbox--checked {
|
||||
@@ -3083,6 +3142,7 @@ button {
|
||||
font-size: var(--font-size-xs);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.rec-date-input:focus,
|
||||
@@ -3090,6 +3150,62 @@ button {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Read-only dose fields in View Mode */
|
||||
.rec-dose-view-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.rec-view-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.rec-view-label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rec-view-val {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.rec-view-val--date {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.rec-view-val--muted {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rec-view-val--note {
|
||||
color: var(--color-text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 0.74rem;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
/* Add Vaccine Modal */
|
||||
.rec-modal-overlay {
|
||||
position: fixed;
|
||||
@@ -4272,3 +4388,674 @@ button {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
Notifications Module
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.notifications-page {
|
||||
padding: var(--space-6) var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
max-width: 1300px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
.notifications-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notifications-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.notifications-unread-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
color: #38bdf8;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9999px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.notifications-pulse-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background-color: #38bdf8;
|
||||
box-shadow: 0 0 8px #38bdf8;
|
||||
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
.notifications-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.notifications-feedback-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
color: #34d399;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.notifications-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.notifications-action-btn.secondary {
|
||||
background: var(--color-bg-elevated);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notifications-action-btn.secondary:hover:not(:disabled) {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-primary);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.notifications-action-btn.primary {
|
||||
background: rgba(59, 158, 255, 0.12);
|
||||
border-color: rgba(59, 158, 255, 0.35);
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.notifications-action-btn.primary:hover:not(:disabled) {
|
||||
background: rgba(59, 158, 255, 0.22);
|
||||
border-color: rgba(59, 158, 255, 0.6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.notifications-action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.notifications-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Control Bar ──────────────────────────────────────────────────────────── */
|
||||
.notifications-control-bar {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.notifications-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--space-3);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.notif-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notif-tab:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notif-tab.active {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-primary-hover);
|
||||
border: 1px solid rgba(59, 158, 255, 0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notif-tab-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notif-tab-badge.highlight {
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.notifications-filters-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notif-module-pills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notif-module-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.notif-module-pill:hover {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notif-module-pill.active {
|
||||
background: rgba(59, 158, 255, 0.15);
|
||||
border-color: rgba(59, 158, 255, 0.4);
|
||||
color: var(--color-primary-hover);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notif-search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 260px;
|
||||
flex-grow: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.notif-search-icon {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notif-search-input {
|
||||
width: 100%;
|
||||
padding: 7px 32px 7px 34px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: inherit;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.notif-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-active);
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.notif-search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.notif-search-clear:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* ── Error Banner ────────────────────────────────────────────────────────── */
|
||||
.notif-error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.notif-error-content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.notif-error-content strong {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notif-error-content p {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notif-retry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(244, 63, 94, 0.2);
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notif-retry-btn:hover {
|
||||
background: rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
/* ── Content Area & Cards ─────────────────────────────────────────────────── */
|
||||
.notifications-content-area {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.notifications-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.notif-card {
|
||||
display: flex;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notif-card:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.notif-card.is-unread {
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0.05) 0%, var(--color-bg-surface) 35%);
|
||||
border-color: rgba(56, 189, 248, 0.3);
|
||||
box-shadow: 0 2px 10px rgba(56, 189, 248, 0.06);
|
||||
}
|
||||
|
||||
.notif-card.is-unread:hover {
|
||||
border-color: rgba(56, 189, 248, 0.5);
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0.08) 0%, var(--color-bg-elevated) 35%);
|
||||
}
|
||||
|
||||
.notif-card.is-archived {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.notif-card.is-archived:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.notif-card-accent-indicator {
|
||||
width: 4px;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notif-card.is-unread .notif-card-accent-indicator {
|
||||
background: #38bdf8;
|
||||
box-shadow: 0 0 8px rgba(56, 189, 248, 0.8);
|
||||
}
|
||||
|
||||
.notif-card-glow-dot {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 12px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #38bdf8;
|
||||
box-shadow: 0 0 6px #38bdf8;
|
||||
}
|
||||
|
||||
.notif-card-main {
|
||||
flex-grow: 1;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notif-card-meta-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.notif-module-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.notif-unread-tag {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.notif-archived-tag {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.notif-card-meta-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.notif-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.notif-card-message {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.notif-card.is-unread .notif-card-message {
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.notif-meta-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.notif-meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.notif-meta-key {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notif-meta-val {
|
||||
color: var(--color-primary-hover);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.notif-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.notif-link-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.notif-link-action-btn:hover {
|
||||
color: var(--color-primary-hover);
|
||||
background: rgba(59, 158, 255, 0.1);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.notif-card-actions-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.notif-tool-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.notif-tool-btn:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-primary);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.notif-tool-btn.read-active {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.notif-tool-btn.archived-active {
|
||||
color: #38bdf8;
|
||||
border-color: rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
|
||||
/* ── Empty & Loading States ──────────────────────────────────────────────── */
|
||||
.notif-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.notif-empty-icon-wrap {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.notif-empty-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.notif-empty-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 440px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.notif-empty-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.notif-empty-action-btn:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.notif-loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.notif-loading-skeleton {
|
||||
height: 110px;
|
||||
background: linear-gradient(90deg, var(--color-bg-surface) 25%, var(--color-bg-elevated) 50%, var(--color-bg-surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
animation: shimmer 1.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export async function fetchTravellers() {
|
||||
return getPbApi().fetchTravellers()
|
||||
}
|
||||
|
||||
export async function createTraveller(data) {
|
||||
return getPbApi().createTraveller(data)
|
||||
}
|
||||
|
||||
export async function createEntry(tripData) {
|
||||
const payload = tripData?.trip ? tripData.trip : tripData
|
||||
return getPbApi().createEntry(payload)
|
||||
@@ -45,4 +49,24 @@ export async function updateVaccineRecord(id, recordData) {
|
||||
return getPbApi().updateVaccineRecord(id, recordData)
|
||||
}
|
||||
|
||||
export async function fetchNotifications(options) {
|
||||
return getPbApi().fetchNotifications(options)
|
||||
}
|
||||
|
||||
export async function updateNotification(id, data) {
|
||||
return getPbApi().updateNotification(id, data)
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id, isRead = true) {
|
||||
return getPbApi().markNotificationRead(id, isRead)
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead() {
|
||||
return getPbApi().markAllNotificationsRead()
|
||||
}
|
||||
|
||||
export async function archiveNotification(id, isArchived = true) {
|
||||
return getPbApi().archiveNotification(id, isArchived)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,28 @@ export function localDate(str) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Date as a human-readable short string: "Aug 15, 2026"
|
||||
* @param {Date} date
|
||||
* Formats a Date or date string as a human-readable short string: "Aug 15, 2026"
|
||||
* Accepts Date objects, ISO strings, or YYYY-MM-DD strings.
|
||||
* Gracefully handles null, undefined, or invalid dates.
|
||||
* @param {Date|string|null|undefined} date
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDate(date) {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
if (!date) return ''
|
||||
let d = date
|
||||
if (typeof d === 'string') {
|
||||
const trimmed = d.trim().slice(0, 10)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
const [year, month, day] = trimmed.split('-').map(Number)
|
||||
d = new Date(year, month - 1, day)
|
||||
} else {
|
||||
d = new Date(date)
|
||||
}
|
||||
}
|
||||
if (!(d instanceof Date) || isNaN(d.getTime())) {
|
||||
return ''
|
||||
}
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +110,6 @@ export function computeIdealSchedule(vaccine, departureDate, today = new Date(),
|
||||
total_doses: totalDoses,
|
||||
dose_intervals: intervals = [],
|
||||
min_days_before_travel: minDaysBefore = 0,
|
||||
has_accelerated_schedule: hasAccelerated = false,
|
||||
} = vaccine
|
||||
|
||||
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
|
||||
Reference in new issue
Block a user