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 ──────────────────────────────────────────────────────── */} +
+
+

System Alerts & Activity

+
+

Notifications

+ {counts.unread > 0 && ( + + + {counts.unread} New + + )} +
+

+ Live clinical reminders, vaccine alerts, and system notices. +

+
+ +
+ {actionFeedback && ( +
+ + {actionFeedback} +
+ )} + + + + +
+
+ + {/* ── Control Bar: Tabs, Module Pills, Search ──────────────────────────── */} +
+ {/* Tabs */} +
+ + + + + + + +
+ + {/* Search & Module filter row */} +
+ {/* Module Pills */} +
+ + {availableModules.map((mod) => { + const meta = getModuleMeta(mod) + const Icon = meta.icon + return ( + + ) + })} +
+ + {/* Search box */} +
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+
+
+ + {/* ── Error Banner ─────────────────────────────────────────────────────── */} + {error && ( +
+ +
+ Unable to load notifications +

{error}

+
+ +
+ )} + + {/* ── 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 && ( + + )} +
+ ) : ( +
+ {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 && ( + + )} +
+ +
+ + + +
+
+
+
+ ) + })} +
+ )} +
+
+ ) +} 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}
+ +
+ )} + {/* ── Main Two-Panel Layout ────────────────────────────────────────────── */}
@@ -340,7 +476,7 @@ export default function Records() {
- + setSearchQuery(e.target.value)} /> + {(searchQuery || selectedTravellerId) && ( + + )} +
+ +
+
@@ -419,6 +586,16 @@ export default function Records() { EMR #{selectedTraveller.emrID} )} +
@@ -445,28 +622,65 @@ export default function Records() { Add Vaccine - + {!isEditingDossier ? ( + + ) : ( +
+ + + {isDirty && ( + + )} +
+ )}
@@ -516,75 +730,109 @@ export default function Records() { )} - + {isEditingDossier && ( + + )} {/* Dose Cards Grid */}
- {(track.doses || []).map((dose, doseIdx) => ( -
-
-
)} + + {/* ── Import Patient Modal ─────────────────────────────────────────────── */} + {showImportModal && ( +
setShowImportModal(false)}> +
e.stopPropagation()} style={{ maxWidth: '460px' }}> +
+ + + Import Patient from EMR + + +
+ +
+
+ +
+ { + setImportForm((prev) => ({ ...prev, emrID: e.target.value })) + setEmrLookupError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleLookupJuvonno() + } + }} + disabled={emrSearching} + required + /> + +
+
+ + {/* Juvonno Lookup Error */} + {emrLookupError && ( +
+ + {emrLookupError} +
+ )} + + {/* Juvonno Patient Resolved Banner */} + {juvonnoPatientInfo && ( +
+ + + Found: {juvonnoPatientInfo.fullName} + {juvonnoPatientInfo.dob ? ` (DOB: ${juvonnoPatientInfo.dob})` : ''} + +
+ )} + + {/* Patient Already Exists Locally Notice */} + {juvonnoPatientInfo?.isAlreadySaved && ( +
+ + + This traveller already exists in your local records. Submitting will select their existing record. + +
+ )} + + {/* Patient Name Field */} +
+ + setImportForm((prev) => ({ ...prev, name: e.target.value }))} + required + /> +
+ +
+ + +
+
+
+
+ )} ) } 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 (