diff --git a/electron/main/index.js b/electron/main/index.js
index 2404203..4b40c90 100644
--- a/electron/main/index.js
+++ b/electron/main/index.js
@@ -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'
diff --git a/electron/main/pocketbase.js b/electron/main/pocketbase.js
index 38a418e..a2f63ee 100644
--- a/electron/main/pocketbase.js
+++ b/electron/main/pocketbase.js
@@ -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 })
+}
diff --git a/electron/preload/index.js b/electron/preload/index.js
index 7b266e2..cf22d97 100644
--- a/electron/preload/index.js
+++ b/electron/preload/index.js
@@ -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),
},
/**
diff --git a/package-lock.json b/package-lock.json
index cf8656b..3f0484f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 933fdd7..f9590f7 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.jsx b/src/App.jsx
index 191253d..8dc1d44 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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
case 'risk-audit': return
case 'pre-travel': return
+ case 'notifications':
+ return (
+
+ )
default: return
}
}
return (
-
+
{renderPage()}
diff --git a/src/components/Notifications.jsx b/src/components/Notifications.jsx
new file mode 100644
index 0000000..4bbd4f9
--- /dev/null
+++ b/src/components/Notifications.jsx
@@ -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 (
+
+ {/* ── Page Header ──────────────────────────────────────────────────────── */}
+
+
+ {/* ── Control Bar: Tabs, Module Pills, Search ──────────────────────────── */}
+
+ {/* Tabs */}
+
+
setStatusTab('inbox')}
+ role="tab"
+ aria-selected={statusTab === 'inbox'}
+ >
+
+ Inbox
+ {counts.inbox}
+
+
+
setStatusTab('unread')}
+ role="tab"
+ aria-selected={statusTab === 'unread'}
+ >
+
+ Unread
+ {counts.unread > 0 && (
+ {counts.unread}
+ )}
+
+
+
setStatusTab('archived')}
+ role="tab"
+ aria-selected={statusTab === 'archived'}
+ >
+
+ Archived
+ {counts.archived}
+
+
+
setStatusTab('all')}
+ role="tab"
+ aria-selected={statusTab === 'all'}
+ >
+
+ All
+ {counts.total}
+
+
+
+ {/* Search & Module filter row */}
+
+ {/* Module Pills */}
+
+ setSelectedModule('all')}
+ >
+ All Modules
+
+ {availableModules.map((mod) => {
+ const meta = getModuleMeta(mod)
+ const Icon = meta.icon
+ return (
+ setSelectedModule(mod)}
+ style={
+ selectedModule === mod
+ ? { backgroundColor: meta.bg, borderColor: meta.border, color: meta.color }
+ : {}
+ }
+ >
+
+ {meta.label}
+
+ )
+ })}
+
+
+ {/* Search box */}
+
+
+ setSearchQuery(e.target.value)}
+ />
+ {searchQuery && (
+ setSearchQuery('')}
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+
+
+
+ {/* ── Error Banner ─────────────────────────────────────────────────────── */}
+ {error && (
+
+
+
+
Unable to load notifications
+
{error}
+
+
loadNotifications()}>
+
+ Retry
+
+
+ )}
+
+ {/* ── Notifications List ───────────────────────────────────────────────── */}
+
+ {loading ? (
+
+ ) : filteredNotifications.length === 0 ? (
+
+
+ {searchQuery ? (
+
+ ) : statusTab === 'unread' ? (
+
+ ) : statusTab === 'archived' ? (
+
+ ) : (
+
+ )}
+
+
+ {searchQuery
+ ? 'No matching notifications'
+ : statusTab === 'unread'
+ ? 'All caught up!'
+ : statusTab === 'archived'
+ ? 'No archived notifications'
+ : 'No notifications in inbox'}
+
+
+ {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.'}
+
+ {searchQuery && (
+
{
+ setSearchQuery('')
+ setSelectedModule('all')
+ }}
+ >
+ Clear all filters
+
+ )}
+
+ ) : (
+
+ {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 (
+
{
+ 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 */}
+
+ {!notif.is_read && }
+
+
+
+ {/* Header: Module pill, timestamp, status */}
+
+
+
+
+ {moduleMeta.label}
+
+
+ {!notif.is_read && (
+ Unread
+ )}
+
+ {notif.is_archived && (
+ Archived
+ )}
+
+
+
+
+ {relativeTime}
+
+
+
+ {/* Message Body */}
+
+
{message}
+
+ {/* Metadata Inspector (if meta exists and is not null) */}
+ {notif.meta && typeof notif.meta === 'object' && Object.keys(notif.meta).length > 0 && (
+
+ {Object.entries(notif.meta).map(([key, val]) => (
+
+ {key}:
+
+ {typeof val === 'object' ? JSON.stringify(val) : String(val)}
+
+
+ ))}
+
+ )}
+
+
+ {/* Footer Actions */}
+
+
+ {/* Module navigation shortcut */}
+ {moduleMeta.route && onNavigate && (
+
{
+ e.stopPropagation()
+ onNavigate(moduleMeta.route)
+ }}
+ title={`Navigate to ${moduleMeta.label}`}
+ >
+ {moduleMeta.actionText || `Open ${moduleMeta.label}`}
+
+
+ )}
+
+
+
+
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 ? (
+ <>
+
+ Mark unread
+ >
+ ) : (
+ <>
+
+ Mark read
+ >
+ )}
+
+
+
handleToggleArchive(notif, e)}
+ title={notif.is_archived ? 'Unarchive' : 'Archive'}
+ aria-label={notif.is_archived ? 'Unarchive' : 'Archive'}
+ >
+ {notif.is_archived ? (
+ <>
+
+ Restore
+ >
+ ) : (
+ <>
+
+ Archive
+ >
+ )}
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/PreTravel.jsx b/src/components/PreTravel.jsx
index 7aa34ce..9327abe 100644
--- a/src/components/PreTravel.jsx
+++ b/src/components/PreTravel.jsx
@@ -157,11 +157,6 @@ function VaccineSearchPanel({
{v.name}
{v.total_doses} dose{v.total_doses !== 1 ? 's' : ''}
- {v.has_accelerated_schedule && (
-
- Accel
-
- )}
@@ -446,7 +441,6 @@ export default function PreTravel() {
label: d.label,
suggested_date: dateStr,
administered_date: d.isInPast ? dateStr : null,
- completed: d.isInPast,
notes: '',
}
}),
diff --git a/src/components/Records.jsx b/src/components/Records.jsx
index 4f8e86f..4fda3ab 100644
--- a/src/components/Records.jsx
+++ b/src/components/Records.jsx
@@ -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 (
@@ -328,6 +440,30 @@ export default function Records() {
)}
+ {/* ── Success Banner ─────────────────────────────────────────────────── */}
+ {successMsg && (
+
+
+
{successMsg}
+
setSuccessMsg(null)}
+ style={{ background: 'transparent', border: 'none', color: '#34d399', cursor: 'pointer', fontSize: '18px' }}
+ >
+ ×
+
+
+ )}
+
{/* ── Main Two-Panel Layout ────────────────────────────────────────────── */}
@@ -340,7 +476,7 @@ export default function Records() {
-
+
setSearchQuery(e.target.value)}
/>
+ {(searchQuery || selectedTravellerId) && (
+ {
+ setSearchQuery('')
+ setSelectedTravellerId(null)
+ }}
+ title="Clear search and selection"
+ aria-label="Clear search and selection"
+ >
+
+
+ )}
+
+
+
+ {
+ setEmrLookupError(null)
+ setJuvonnoPatientInfo(null)
+ setShowImportModal(true)
+ }}
+ >
+
+ Import Patient
+
@@ -419,6 +586,16 @@ export default function Records() {
EMR #{selectedTraveller.emrID}
)}
+ setSelectedTravellerId(null)}
+ title="Close dossier (clear selection)"
+ aria-label="Close dossier"
+ >
+
+
@@ -445,28 +622,65 @@ export default function Records() {
Add Vaccine
-
- {saving ? (
- <>
-
- Saving…
- >
- ) : (
- <>
-
- {isDirty ? 'Save Changes' : 'Saved'}
- >
- )}
-
+ {!isEditingDossier ? (
+ setIsEditingDossier(true)}
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '6px',
+ padding: '7px 14px',
+ whiteSpace: 'nowrap',
+ }}
+ title="Edit doses and vaccination timelines"
+ >
+
+ Edit
+
+ ) : (
+
+
+ {isDirty ? 'Cancel' : 'Done'}
+
+
+ {isDirty && (
+
+ {saving ? (
+ <>
+
+ Saving…
+ >
+ ) : (
+ <>
+
+ Save Changes
+ >
+ )}
+
+ )}
+
+ )}
@@ -516,75 +730,109 @@ export default function Records() {
)}
- handleRemoveVaccineTrack(trackIdx)}
- title="Remove vaccine course from dossier"
- style={{ color: 'var(--color-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
- >
-
-
+ {isEditingDossier && (
+ handleRemoveVaccineTrack(trackIdx)}
+ title="Remove vaccine course from dossier"
+ style={{ color: 'var(--color-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
+ >
+
+
+ )}
{/* Dose Cards Grid */}
- {(track.doses || []).map((dose, doseIdx) => (
-
-
-
- handleToggleDose(trackIdx, doseIdx)}
- >
- {dose.completed &&
}
+ {(track.doses || []).map((dose, doseIdx) => {
+ const isAdministered = Boolean(dose.administered_date && String(dose.administered_date).trim())
+ return (
+
+
+
+
+ {isAdministered && }
+
+
+ {dose.label || `Dose ${dose.dose_number}`}
+
-
- {dose.label || `Dose ${dose.dose_number}`}
+
+
+ {isAdministered ? 'Administered' : 'Pending'}
-
-
-
- {dose.completed ? 'Administered' : 'Pending'}
-
-
-
-
-
- Suggested Date
- handleDoseDateChange(trackIdx, doseIdx, 'suggested_date', e.target.value)}
- />
-
- Administered Date
- handleDoseDateChange(trackIdx, doseIdx, 'administered_date', e.target.value)}
- />
-
+
+ {isEditingDossier ? (
+ <>
+
+ Suggested Date
+ handleDoseDateChange(trackIdx, doseIdx, 'suggested_date', e.target.value)}
+ />
+
-
-
Notes / Clinic / Lot #
-
handleDoseNotesChange(trackIdx, doseIdx, e.target.value)}
- />
+
+ Administered Date
+ handleDoseDateChange(trackIdx, doseIdx, 'administered_date', e.target.value)}
+ />
+
+
+
+ Notes / Clinic / Lot #
+ handleDoseNotesChange(trackIdx, doseIdx, e.target.value)}
+ />
+
+ >
+ ) : (
+
+
+ Suggested
+
+ {formatDate(dose.suggested_date) || '—'}
+
+
+
+
+ Administered
+
+ {isAdministered ? formatDate(dose.administered_date) : 'Pending'}
+
+
+
+ {dose.notes ? (
+
+ Notes/Lot
+
+ {dose.notes}
+
+
+ ) : null}
+
+ )}
-
- ))}
+ )
+ })}
)
@@ -659,6 +907,175 @@ export default function Records() {
)}
+
+ {/* ── Import Patient Modal ─────────────────────────────────────────────── */}
+ {showImportModal && (
+
setShowImportModal(false)}>
+
e.stopPropagation()} style={{ maxWidth: '460px' }}>
+
+
+
+ Import Patient from EMR
+
+ setShowImportModal(false)}
+ >
+
+
+
+
+
+
+
+ )}
)
}
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
index cf33779..9fa0b9a 100644
--- a/src/components/Sidebar.jsx
+++ b/src/components/Sidebar.jsx
@@ -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 (
{/* Logo / App identity */}
@@ -126,7 +137,7 @@ export default function Sidebar({ activePage, onNavigate }) {
})}
System
- {SYSTEM_ITEMS.map((item) => {
+ {systemItems.map((item) => {
const Icon = item.icon
return (