Added Notifications screen and logic for Records.

This commit is contained in:
Bruno Silva committed 2026-09-28 16:41:51 -04:00
1 parent 72ec7472c5
commit 697d72f379
13 files changed
+2255 -145

No files matched your search

+29
View File
@@ -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
View File
@@ -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 })
}
+18
View File
@@ -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),
},
/**
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+662
View File
@@ -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>
)
}
-6
View File
@@ -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: '',
}
}),
+526 -109
View File
@@ -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,28 +622,65 @@ export default function Records() {
Add Vaccine
</button>
<button
id="rec-save-dossier-btn"
className="pt-save-plan-btn"
onClick={handleSaveDossier}
disabled={saving || !isDirty}
style={{
background: isDirty ? 'var(--color-accent-emerald, #10b981)' : 'var(--color-bg-subtle)',
color: isDirty ? '#fff' : 'var(--color-text-muted)',
}}
>
{saving ? (
<>
<RotateCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
Saving…
</>
) : (
<>
<Save size={14} />
{isDirty ? 'Save Changes' : 'Saved'}
</>
)}
</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}
style={{
background: 'var(--color-accent-emerald, #10b981)',
color: '#fff',
whiteSpace: 'nowrap',
}}
>
{saving ? (
<>
<RotateCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
Saving…
</>
) : (
<>
<Save size={14} />
Save Changes
</>
)}
</button>
)}
</div>
)}
</div>
</div>
@@ -516,75 +730,109 @@ export default function Records() {
)}
</div>
<button
className="pt-vaccine-chip-remove"
onClick={() => handleRemoveVaccineTrack(trackIdx)}
title="Remove vaccine course from dossier"
style={{ color: 'var(--color-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Trash2 size={15} />
</button>
{isEditingDossier && (
<button
className="pt-vaccine-chip-remove"
onClick={() => handleRemoveVaccineTrack(trackIdx)}
title="Remove vaccine course from dossier"
style={{ color: 'var(--color-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Trash2 size={15} />
</button>
)}
</div>
{/* Dose Cards Grid */}
<div className="rec-dose-grid">
{(track.doses || []).map((dose, doseIdx) => (
<div
key={dose.dose_number || doseIdx}
className={`rec-dose-card${dose.completed ? ' rec-dose-card--completed' : ''}`}
>
<div className="rec-dose-card-top">
<label className="rec-dose-toggle">
<div
className={`rec-checkbox${dose.completed ? ' rec-checkbox--checked' : ''}`}
onClick={() => handleToggleDose(trackIdx, doseIdx)}
>
{dose.completed && <Check size={13} />}
{(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${isAdministered ? ' rec-dose-card--administered' : ''}`}
>
<div className="rec-dose-card-top">
<div className="rec-dose-status-indicator">
<div
className={`rec-checkbox${isAdministered ? ' rec-checkbox--checked' : ''}`}
title={isAdministered ? 'Administered' : 'Pending administered date'}
>
{isAdministered && <Check size={13} />}
</div>
<span style={{ color: isAdministered ? '#34d399' : 'var(--color-text-primary)' }}>
{dose.label || `Dose ${dose.dose_number}`}
</span>
</div>
<span style={{ color: dose.completed ? '#34d399' : 'var(--color-text-primary)' }}>
{dose.label || `Dose ${dose.dose_number}`}
<span className={`rec-traveller-badge ${isAdministered ? 'rec-badge-complete' : 'rec-badge-none'}`}>
{isAdministered ? 'Administered' : 'Pending'}
</span>
</label>
<span className={`rec-traveller-badge ${dose.completed ? 'rec-badge-complete' : 'rec-badge-none'}`}>
{dose.completed ? 'Administered' : 'Pending'}
</span>
</div>
<div className="rec-dose-fields">
<div className="rec-field-group">
<span className="rec-field-label">Suggested Date</span>
<input
type="date"
className="rec-date-input"
value={dose.suggested_date || ''}
onChange={(e) => handleDoseDateChange(trackIdx, doseIdx, 'suggested_date', e.target.value)}
/>
</div>
<div className="rec-field-group">
<span className="rec-field-label">Administered Date</span>
<input
type="date"
className="rec-date-input"
value={dose.administered_date || ''}
onChange={(e) => handleDoseDateChange(trackIdx, doseIdx, 'administered_date', e.target.value)}
/>
</div>
<div className="rec-dose-fields">
{isEditingDossier ? (
<>
<div className="rec-field-group">
<span className="rec-field-label">Suggested Date</span>
<input
type="date"
className="rec-date-input"
value={dose.suggested_date || ''}
onChange={(e) => handleDoseDateChange(trackIdx, doseIdx, 'suggested_date', e.target.value)}
/>
</div>
<div className="rec-field-group">
<span className="rec-field-label">Notes / Clinic / Lot #</span>
<input
type="text"
className="rec-text-input"
placeholder="e.g. Lot #A1290"
value={dose.notes || ''}
onChange={(e) => handleDoseNotesChange(trackIdx, doseIdx, e.target.value)}
/>
<div className="rec-field-group">
<span className="rec-field-label">Administered Date</span>
<input
type="date"
className="rec-date-input"
value={dose.administered_date || ''}
onChange={(e) => handleDoseDateChange(trackIdx, doseIdx, 'administered_date', e.target.value)}
/>
</div>
<div className="rec-field-group">
<span className="rec-field-label">Notes / Clinic / Lot #</span>
<input
type="text"
className="rec-text-input"
placeholder="e.g. Lot #A1290"
value={dose.notes || ''}
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>
</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>
)
}
+14 -3
View File
@@ -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
View File
@@ -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;
@@ -4271,4 +4387,675 @@ button {
color: #34d399;
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;
}
}
+24
View File
@@ -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)
}
+19 -4
View File
@@ -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())