Split Travellers data into Trips; Added 'Risk Audit' screen; Added follow-up checks; connected 'Trips' to Juvonno.

This commit is contained in:
2026-09-03 15:18:32 -04:00
parent 7085a478fb
commit 8e14bb438d
21 changed files with 3958 additions and 226 deletions
+55
View File
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import { import {
fetchEntries, fetchEntries,
fetchTravellers,
createEntry, createEntry,
updateEntry, updateEntry,
deleteEntry, deleteEntry,
@@ -11,6 +12,7 @@ import {
saveVaccineRecord, saveVaccineRecord,
updateVaccineRecord, updateVaccineRecord,
} from './pocketbase.js' } from './pocketbase.js'
import { JUVONNO_API_URL, JUVONNO_API_KEY } from './juvonno-config.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -99,6 +101,9 @@ ipcMain.handle('app:get-version', () => app.getVersion())
ipcMain.handle('pb:fetch-entries', async () => { ipcMain.handle('pb:fetch-entries', async () => {
return await fetchEntries() return await fetchEntries()
}) })
ipcMain.handle('pb:fetch-travellers', async () => {
return await fetchTravellers()
})
ipcMain.handle('pb:create-entry', async (_event, trip) => { ipcMain.handle('pb:create-entry', async (_event, trip) => {
return await createEntry(trip) return await createEntry(trip)
}) })
@@ -125,4 +130,54 @@ ipcMain.handle('pb:update-vaccine-record', async (_event, id, recordData) => {
return await updateVaccineRecord(id, recordData) return await updateVaccineRecord(id, recordData)
}) })
// ─── 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'
const AUDIT_API_KEY = '63350077-643e-40d8-8d34-378495c81203'
const response = await fetch(AUDIT_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': AUDIT_API_KEY,
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(`Audit webhook returned HTTP ${response.status} ${response.statusText}`)
}
return await response.json()
})
// ─── Juvonno EMR IPC Handler (Node-side to avoid CORS) ──────────────────────
ipcMain.handle('juvonno:fetch-chart', async (_event, emrId) => {
if (!emrId) {
throw new Error('EMR ID is required.')
}
const url = `${JUVONNO_API_URL}/customers/chart/${encodeURIComponent(emrId)}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-API-Key': JUVONNO_API_KEY,
},
})
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Customer chart with EMR ID "${emrId}" not found in Juvonno.`)
}
if (response.status === 403 || response.status === 401) {
throw new Error(`Juvonno authentication failed (HTTP ${response.status}). Please verify the X-API-Key in juvonno-config.js.`)
}
throw new Error(`Juvonno API error: HTTP ${response.status} ${response.statusText}`)
}
return await response.json()
})
+6
View File
@@ -0,0 +1,6 @@
/**
* Configuration for Juvonno EMR Integration.
* Replace JUVONNO_API_KEY with the actual API key provided by Juvonno.
*/
export const JUVONNO_API_URL = 'https://canadatravelmed.juvonno.com/api'
export const JUVONNO_API_KEY = '4f664679e83c89ac76b2db0a1855804cbe38d7cf'
+222 -58
View File
@@ -1,10 +1,25 @@
import PocketBase from 'pocketbase' import PocketBase from 'pocketbase'
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js' import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
const COLLECTION = 'travellers' const TRAVELLERS_COLLECTION = 'travellers'
const TRIPS_COLLECTION = 'trips'
const VACCINE_RECORDS_COLLECTION = 'records' const VACCINE_RECORDS_COLLECTION = 'records'
const VACCINES_COLLECTION = 'vaccines' const VACCINES_COLLECTION = 'vaccines'
let pb = null
let authPromise = null
function getClient() {
if (!pb) {
pb = new PocketBase(POCKETBASE_URL)
// Main process may receive overlapping IPC calls (e.g. React Strict Mode).
// Default auto-cancel would abort duplicate in-flight PocketBase requests.
pb.autoCancellation(false)
}
return pb
}
function parseVaccineRecord(r) { function parseVaccineRecord(r) {
return { return {
id: r.id, id: r.id,
@@ -48,16 +63,18 @@ export async function fetchVaccineRecords() {
export async function saveVaccineRecord(data) { export async function saveVaccineRecord(data) {
await ensureAuth() await ensureAuth()
const client = getClient() const client = getClient()
const travellerId = data.traveller_id const travellerId = data.traveller_id || data.travellerId || data.traveller
let existing = null let existing = null
try { try {
if (travellerId) {
const list = await client.collection(VACCINE_RECORDS_COLLECTION).getList(1, 50, { const list = await client.collection(VACCINE_RECORDS_COLLECTION).getList(1, 50, {
filter: `traveller_id = "${travellerId}"` filter: `traveller_id = "${travellerId}"`
}) })
if (list.items && list.items.length > 0) { if (list.items && list.items.length > 0) {
existing = list.items[0] existing = list.items[0]
} }
}
} catch (e) { } catch (e) {
// ignore filter error // ignore filter error
} }
@@ -69,52 +86,76 @@ export async function saveVaccineRecord(data) {
timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [], timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [],
} }
let record
if (existing) { if (existing) {
const record = await client.collection(VACCINE_RECORDS_COLLECTION).update(existing.id, payload) record = await client.collection(VACCINE_RECORDS_COLLECTION).update(existing.id, payload)
return parseVaccineRecord(record)
} else { } else {
const record = await client.collection(VACCINE_RECORDS_COLLECTION).create(payload) record = await client.collection(VACCINE_RECORDS_COLLECTION).create(payload)
return parseVaccineRecord(record)
} }
// Also link record.id into travellers collection record_id
if (travellerId && record?.id) {
try {
await client.collection(TRAVELLERS_COLLECTION).update(travellerId, {
record_id: record.id,
})
} catch (e) {
console.warn(`[pocketbase] Could not link record_id to traveller ${travellerId}:`, e.message)
}
}
return parseVaccineRecord(record)
} }
export async function updateVaccineRecord(id, data) { export async function updateVaccineRecord(id, data) {
await ensureAuth() await ensureAuth()
const travellerId = data.traveller_id const client = getClient()
const travellerId = data.traveller_id || data.travellerId || data.traveller
const payload = { const payload = {
traveller_id: travellerId, traveller_id: travellerId,
traveller_name: data.travellerName || data.traveller_name || '', traveller_name: data.travellerName || data.traveller_name || '',
emr_id: data.emrID || data.emr_id || null, emr_id: data.emrID || data.emr_id || null,
timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [], timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [],
} }
const record = await getClient().collection(VACCINE_RECORDS_COLLECTION).update(id, payload) const record = await client.collection(VACCINE_RECORDS_COLLECTION).update(id, payload)
if (travellerId && record?.id) {
try {
await client.collection(TRAVELLERS_COLLECTION).update(travellerId, {
record_id: record.id,
})
} catch (e) {
console.warn(`[pocketbase] Could not link record_id to traveller ${travellerId}:`, e.message)
}
}
return parseVaccineRecord(record) return parseVaccineRecord(record)
} }
let pb = null
let authPromise = null
function getClient() {
if (!pb) {
pb = new PocketBase(POCKETBASE_URL)
// Main process may receive overlapping IPC calls (e.g. React Strict Mode).
// Default auto-cancel would abort duplicate in-flight PocketBase requests.
pb.autoCancellation(false)
}
return pb
}
function parseEntry(r) { function parseEntry(r) {
const traveller = r.expand?.traveller_id
? (Array.isArray(r.expand.traveller_id) ? r.expand.traveller_id[0] : r.expand.traveller_id)
: null
const travellerId = typeof r.traveller_id === 'string'
? r.traveller_id
: (Array.isArray(r.traveller_id) ? r.traveller_id[0] : (traveller?.id || ''))
return { return {
id: r.id, id: r.id,
recordID: r.record_id ?? r.recordID ?? '', tripId: r.id,
emrID: r.emr_id ?? '', travellerId: travellerId,
name: r.name ?? '', name: traveller?.name ?? r.name ?? '',
location: r.location ?? '', emrID: traveller?.emr_id ?? r.emr_id ?? '',
recordID: traveller?.record_id ?? r.record_id ?? r.recordID ?? '',
location: r.locations ?? r.location ?? '',
locations: r.locations ?? r.location ?? '',
countryISO: r.country_iso ?? '', countryISO: r.country_iso ?? '',
countriesISO: r.countries_iso ?? [], countriesISO: r.countries_iso ?? [],
status: r.status ?? '', followUpStatus: r.follow_up_status ?? [],
isAtRisk: r.is_at_risk ?? false,
archived: r.archived ?? false,
departure: r.departure ?? '', departure: r.departure ?? '',
arrival: r.arrival ?? '', arrival: r.arrival ?? '',
created: r.created, created: r.created,
@@ -145,61 +186,184 @@ export async function ensureAuth() {
export async function fetchEntries() { export async function fetchEntries() {
await ensureAuth() await ensureAuth()
const list = await getClient().collection(COLLECTION).getFullList({ sort: '-created' }) const list = await getClient().collection(TRIPS_COLLECTION).getFullList({
sort: '-created',
filter: 'archived != true',
expand: 'traveller_id',
})
return list.map(parseEntry) return list.map(parseEntry)
} }
function parseTraveller(r) {
return {
id: r.id,
travellerId: r.id,
name: r.name ?? '',
emrID: r.emr_id ?? '',
recordID: r.record_id ?? '',
created: r.created,
updated: r.updated,
}
}
export async function fetchTravellers() {
await ensureAuth()
const list = await getClient().collection(TRAVELLERS_COLLECTION).getFullList({
sort: 'name',
})
return list.map(parseTraveller)
}
export async function createEntry(trip) { export async function createEntry(trip) {
await ensureAuth() await ensureAuth()
const payload = { const client = getClient()
name: trip.name,
location: trip.location, let traveller = null
countries_iso: trip.countriesISO,
departure: trip.departure, // 1. Validate mandatory emrID and search existing traveller by emr_id (string)
arrival: trip.arrival, const emrStr = trip.emrID !== undefined && trip.emrID !== null ? String(trip.emrID).trim() : ''
if (!emrStr) {
throw new Error('EMR ID is required to create a trip.')
} }
if (trip.status !== undefined) {
payload.status = trip.status 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) {
traveller = list.items[0]
} }
if (trip.recordID || trip.record_id) { } catch (e) {
payload.record_id = trip.recordID || trip.record_id console.warn(`[pocketbase] Failed looking up traveller with emr_id "${emrStr}":`, e.message)
} }
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
const emrNum = Number(trip.emrID) // 2. If no traveller found with this emr_id, create a new record in travellers collection
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID if (!traveller) {
const travellerPayload = {
emr_id: emrStr,
name: trip.name ? trip.name.trim() : '',
} }
const record = await getClient().collection(COLLECTION).create(payload) traveller = await client.collection(TRAVELLERS_COLLECTION).create(travellerPayload)
}
// 3. Create the trip record in trips collection linked to the traveller
const tripPayload = {
traveller_id: traveller.id,
locations: trip.location ?? trip.locations ?? '',
countries_iso: trip.countriesISO ?? trip.countries_iso ?? [],
departure: trip.departure ?? '',
arrival: trip.arrival ?? '',
follow_up_status: trip.followUpStatus ?? trip.follow_up_status ?? [],
is_at_risk: trip.isAtRisk ?? trip.is_at_risk ?? false,
}
const record = await client.collection(TRIPS_COLLECTION).create(tripPayload, {
expand: 'traveller_id',
})
// Ensure expand is attached for parseEntry
if (!record.expand || !record.expand.traveller_id) {
record.expand = { ...record.expand, traveller_id: traveller }
}
return parseEntry(record) return parseEntry(record)
} }
export async function updateEntry(id, trip) { export async function updateEntry(id, trip) {
await ensureAuth() await ensureAuth()
const payload = { const client = getClient()
name: trip.name,
location: trip.location, // 1. Fetch current trip to identify the linked traveller
countries_iso: trip.countriesISO, let existingTrip = null
departure: trip.departure, try {
arrival: trip.arrival, existingTrip = await client.collection(TRIPS_COLLECTION).getOne(id, {
expand: 'traveller_id',
})
} catch (e) {
// ignore
} }
if (trip.status !== undefined) {
payload.status = trip.status // 2. Update trip in trips collection
const tripPayload = {}
if (trip.location !== undefined || trip.locations !== undefined) {
tripPayload.locations = trip.locations ?? trip.location ?? ''
}
if (trip.countriesISO !== undefined || trip.countries_iso !== undefined) {
tripPayload.countries_iso = trip.countriesISO ?? trip.countries_iso ?? []
}
if (trip.departure !== undefined) {
tripPayload.departure = trip.departure
}
if (trip.arrival !== undefined) {
tripPayload.arrival = trip.arrival
}
if (trip.followUpStatus !== undefined || trip.follow_up_status !== undefined) {
tripPayload.follow_up_status = trip.followUpStatus ?? trip.follow_up_status ?? []
}
if (trip.isAtRisk !== undefined || trip.is_at_risk !== undefined) {
tripPayload.is_at_risk = trip.isAtRisk ?? trip.is_at_risk ?? false
}
const updatedTrip = await client.collection(TRIPS_COLLECTION).update(id, tripPayload)
// 3. Update associated traveller record if traveller fields changed
const emrStr = trip.emrID !== undefined && trip.emrID !== null ? String(trip.emrID).trim() : ''
let travellerId = trip.travellerId || trip.traveller_id || existingTrip?.traveller_id || existingTrip?.expand?.traveller_id?.id
let updatedTraveller = existingTrip?.expand?.traveller_id || null
// If emrID was provided, check if it matches an existing traveller to re-link
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) {
const matched = list.items[0]
if (matched.id !== travellerId) {
travellerId = matched.id
updatedTraveller = matched
await client.collection(TRIPS_COLLECTION).update(id, { traveller_id: travellerId })
}
}
} catch (e) {
console.warn(`[pocketbase] Failed looking up traveller with emr_id "${emrStr}" in updateEntry:`, e.message)
}
}
if (travellerId) {
const travellerPayload = {}
if (trip.name !== undefined) {
travellerPayload.name = typeof trip.name === 'string' ? trip.name.trim() : trip.name
}
if (emrStr) {
travellerPayload.emr_id = emrStr
} }
if (trip.recordID !== undefined || trip.record_id !== undefined) { if (trip.recordID !== undefined || trip.record_id !== undefined) {
payload.record_id = trip.recordID || trip.record_id || null travellerPayload.record_id = trip.recordID || trip.record_id || null
} }
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
const emrNum = Number(trip.emrID) if (Object.keys(travellerPayload).length > 0) {
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID try {
} else if (trip.emrID === '') { updatedTraveller = await client.collection(TRAVELLERS_COLLECTION).update(travellerId, travellerPayload)
payload.emr_id = null } catch (e) {
console.warn(`[pocketbase] Could not update traveller ${travellerId}:`, e.message)
} }
const record = await getClient().collection(COLLECTION).update(id, payload) }
return parseEntry(record) }
updatedTrip.expand = { ...updatedTrip.expand, traveller_id: updatedTraveller }
return parseEntry(updatedTrip)
} }
export async function deleteEntry(id) { export async function deleteEntry(id) {
await ensureAuth() await ensureAuth()
await getClient().collection(COLLECTION).delete(id) return await getClient().collection(TRIPS_COLLECTION).update(id, {
archived: true,
})
} }
+21
View File
@@ -16,6 +16,7 @@ const ALLOWED_SEND_CHANNELS = [
'schedule:fetch', 'schedule:fetch',
'app:ready', 'app:ready',
'pb:fetch-entries', 'pb:fetch-entries',
'pb:fetch-travellers',
'pb:create-entry', 'pb:create-entry',
'pb:update-entry', 'pb:update-entry',
'pb:delete-entry', 'pb:delete-entry',
@@ -23,6 +24,8 @@ const ALLOWED_SEND_CHANNELS = [
'pb:fetch-vaccine-records', 'pb:fetch-vaccine-records',
'pb:save-vaccine-record', 'pb:save-vaccine-record',
'pb:update-vaccine-record', 'pb:update-vaccine-record',
'audit:run-health-audit',
'juvonno:fetch-chart',
] ]
const ALLOWED_RECEIVE_CHANNELS = [ const ALLOWED_RECEIVE_CHANNELS = [
@@ -31,6 +34,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'schedule:response', 'schedule:response',
'app:get-version', 'app:get-version',
'pb:fetch-entries', 'pb:fetch-entries',
'pb:fetch-travellers',
'pb:create-entry', 'pb:create-entry',
'pb:update-entry', 'pb:update-entry',
'pb:delete-entry', 'pb:delete-entry',
@@ -38,6 +42,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
'pb:fetch-vaccine-records', 'pb:fetch-vaccine-records',
'pb:save-vaccine-record', 'pb:save-vaccine-record',
'pb:update-vaccine-record', 'pb:update-vaccine-record',
'audit:run-health-audit',
'juvonno:fetch-chart',
] ]
// ─── Exposed API ────────────────────────────────────────────────────────── // ─── Exposed API ──────────────────────────────────────────────────────────
@@ -47,6 +53,7 @@ contextBridge.exposeInMainWorld('api', {
*/ */
pb: { pb: {
fetchEntries: () => ipcRenderer.invoke('pb:fetch-entries'), fetchEntries: () => ipcRenderer.invoke('pb:fetch-entries'),
fetchTravellers: () => ipcRenderer.invoke('pb:fetch-travellers'),
createEntry: (trip) => ipcRenderer.invoke('pb:create-entry', trip), createEntry: (trip) => ipcRenderer.invoke('pb:create-entry', trip),
updateEntry: (id, trip) => ipcRenderer.invoke('pb:update-entry', id, trip), updateEntry: (id, trip) => ipcRenderer.invoke('pb:update-entry', id, trip),
deleteEntry: (id) => ipcRenderer.invoke('pb:delete-entry', id), deleteEntry: (id) => ipcRenderer.invoke('pb:delete-entry', id),
@@ -56,6 +63,20 @@ contextBridge.exposeInMainWorld('api', {
updateVaccineRecord: (id, data) => ipcRenderer.invoke('pb:update-vaccine-record', id, data), updateVaccineRecord: (id, data) => ipcRenderer.invoke('pb:update-vaccine-record', id, data),
}, },
/**
* Health Risk Audit helper methods
*/
audit: {
runHealthAudit: (payload) => ipcRenderer.invoke('audit:run-health-audit', payload),
},
/**
* Juvonno EMR helper methods
*/
juvonno: {
fetchChart: (emrId) => ipcRenderer.invoke('juvonno:fetch-chart', emrId),
},
/** /**
* Send a one-way message to the main process. * Send a one-way message to the main process.
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS * @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

+2 -3
View File
@@ -2,7 +2,6 @@
"name": "ctm-concierge", "name": "ctm-concierge",
"version": "1.0.0", "version": "1.0.0",
"description": "CTM Concierge - Real-time patient journey tracker for CTM", "description": "CTM Concierge - Real-time patient journey tracker for CTM",
"homepage": "https://canadatravelmed.ca",
"author": { "author": {
"name": "Wirediv", "name": "Wirediv",
"email": "info@wirediv.com" "email": "info@wirediv.com"
@@ -52,11 +51,11 @@
}, },
"win": { "win": {
"target": "nsis", "target": "nsis",
"icon": "public/icon.ico" "icon": "icons/icon.ico"
}, },
"mac": { "mac": {
"target": "dmg", "target": "dmg",
"icon": "public/icon.icns" "icon": "icons/icon.icns"
} }
} }
} }
+2
View File
@@ -4,6 +4,7 @@ import Dashboard from './components/Dashboard.jsx'
import LiveTracking from './components/LiveTracking.jsx' import LiveTracking from './components/LiveTracking.jsx'
import Trips from './components/Trips.jsx' import Trips from './components/Trips.jsx'
import Records from './components/Records.jsx' import Records from './components/Records.jsx'
import RiskAudit from './components/RiskAudit.jsx'
import PreTravel from './components/PreTravel.jsx' import PreTravel from './components/PreTravel.jsx'
/** /**
* App — root layout component. * App — root layout component.
@@ -18,6 +19,7 @@ export default function App() {
case 'live-tracking': return <LiveTracking /> case 'live-tracking': return <LiveTracking />
case 'trips': return <Trips /> case 'trips': return <Trips />
case 'records': return <Records /> case 'records': return <Records />
case 'risk-audit': return <RiskAudit />
case 'pre-travel': return <PreTravel /> case 'pre-travel': return <PreTravel />
default: return <Dashboard /> default: return <Dashboard />
} }
+107
View File
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
id="svg2"
sodipodi:docname="_svgclean2.svg"
viewBox="0 0 1000 500"
version="1.1"
inkscape:version="0.48.3.1 r9886"
>
<sodipodi:namedview
id="namedview12"
bordercolor="#666666"
inkscape:pageshadow="2"
guidetolerance="10"
pagecolor="#ffffff"
gridtolerance="10"
inkscape:window-maximized="0"
inkscape:zoom="0.22425739"
objecttolerance="10"
borderopacity="1"
inkscape:current-layer="svg2"
inkscape:cx="372.04724"
inkscape:cy="-26.181091"
inkscape:window-y="0"
inkscape:window-x="0"
inkscape:window-width="640"
showgrid="false"
inkscape:pageopacity="0"
inkscape:window-height="480"
/>
<rect
id="rect4"
style="fill:#ff0000"
height="500"
width="1e3"
y="0"
x="0"
/>
<rect
id="rect6"
style="fill:#ffffff"
height="500"
width="500"
y="0"
x="250"
/>
<path
id="path8"
style="fill:#ff0000"
inkscape:connector-curvature="0"
d="m499.99 46.875-34.113 63.625c-3.8709 6.915-10.806 6.2736-17.742 2.4114l-24.697-12.789 18.407 97.727c3.8709 17.854-8.5486 17.854-14.678 10.134l-43.101-48.251-6.9974 24.503c-0.80692 3.2178-4.3548 6.5974-9.6775 5.7926l-54.502-11.459 14.315 52.045c3.0645 11.581 5.4549 16.375-3.0938 19.43l-19.426 9.1302 93.821 76.208c3.7135 2.8815 5.5897 8.067 4.2677 12.762l-8.2114 26.947c32.304-3.7237 61.249-9.3259 93.569-12.776 2.8532-0.30459 7.6299 4.4041 7.6103 7.7106l-4.2802 98.723h15.706l-2.4724-98.512c-0.0197-3.3065 4.3137-8.2269 7.167-7.9223 32.32 3.4503 61.265 9.0525 93.569 12.776l-8.2113-26.947c-1.322-4.6951 0.55417-9.8806 4.2677-12.762l93.821-76.208-19.426-9.1302c-8.5487-3.0543-6.1583-7.849-3.0938-19.43l14.315-52.045-54.502 11.459c-5.3227 0.80483-8.8706-2.5748-9.6775-5.7926l-6.9974-24.503-43.101 48.251c-6.1291 7.7198-18.549 7.7198-14.678-10.134l18.407-97.727-24.697 12.789c-6.9356 3.8622-13.871 4.5036-17.742-2.4114"
/>
<metadata
id="metadata10"
>
<rdf:RDF
>
<cc:Work
>
<dc:format
>image/svg+xml</dc:format
>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage"
/>
<cc:license
rdf:resource="http://creativecommons.org/licenses/publicdomain/"
/>
<dc:publisher
>
<cc:Agent
rdf:about="http://openclipart.org/"
>
<dc:title
>Openclipart</dc:title
>
</cc:Agent
>
</dc:publisher
>
</cc:Work
>
<cc:License
rdf:about="http://creativecommons.org/licenses/publicdomain/"
>
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction"
/>
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution"
/>
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
/>
</cc:License
>
</rdf:RDF
>
</metadata
>
</svg
>

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because it is too large Load Diff
+55 -4
View File
@@ -19,6 +19,21 @@ const STATUS_META = {
'followed-up': { label: 'Followed Up', className: 'status-followed-up' }, 'followed-up': { label: 'Followed Up', className: 'status-followed-up' },
} }
const FOLLOW_UP_META = {
'in-transit': {
label: 'In-Transit',
color: '#60a5fa',
bg: 'rgba(59, 130, 246, 0.12)',
border: '1px solid rgba(59, 130, 246, 0.3)',
},
'post-travel': {
label: 'Post-Travel',
color: '#22d3ee',
bg: 'rgba(6, 182, 212, 0.12)',
border: '1px solid rgba(6, 182, 212, 0.3)',
},
}
function getGreeting() { function getGreeting() {
const h = new Date().getHours() const h = new Date().getHours()
if (h < 12) return 'Good morning' if (h < 12) return 'Good morning'
@@ -223,18 +238,19 @@ export default function Dashboard() {
<th scope="col">Departure</th> <th scope="col">Departure</th>
<th scope="col">Arrival</th> <th scope="col">Arrival</th>
<th scope="col">Status</th> <th scope="col">Status</th>
<th scope='col'>Follow-up</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{travellers.length === 0 && !loading ? ( {travellers.length === 0 && !loading ? (
<tr> <tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '24px', color: 'var(--color-text-muted)' }}> <td colSpan={7} style={{ textAlign: 'center', padding: '24px', color: 'var(--color-text-muted)' }}>
No travellers found in PocketBase database. No travellers found in PocketBase database.
</td> </td>
</tr> </tr>
) : filteredTravellers.length === 0 ? ( ) : filteredTravellers.length === 0 ? (
<tr> <tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '32px 24px', color: 'var(--color-text-muted)' }}> <td colSpan={7} style={{ textAlign: 'center', padding: '32px 24px', color: 'var(--color-text-muted)' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<Filter size={24} style={{ color: 'var(--color-text-muted)', opacity: 0.5 }} /> <Filter size={24} style={{ color: 'var(--color-text-muted)', opacity: 0.5 }} />
<span>No patients found matching status "<strong>{STATUS_META[selectedStatus]?.label || selectedStatus}</strong>".</span> <span>No patients found matching status "<strong>{STATUS_META[selectedStatus]?.label || selectedStatus}</strong>".</span>
@@ -264,13 +280,48 @@ export default function Dashboard() {
<td>{p.emrID}</td> <td>{p.emrID}</td>
<td className="patient-name">{p.name}</td> <td className="patient-name">{p.name}</td>
<td>{p.location || 'N/A'}</td> <td>{p.location || 'N/A'}</td>
<td>{new Date(p.departure).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}</td> <td>{new Date(p.departure).toLocaleDateString('en-CA', { timeZone: 'UTC', month: 'short', day: 'numeric', year: 'numeric', })}</td>
<td>{new Date(p.arrival).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}</td> <td>{new Date(p.arrival).toLocaleDateString('en-CA', { timeZone: 'UTC', month: 'short', day: 'numeric', year: 'numeric', })}</td>
<td> <td>
<span className={`status-pill ${meta.className}`}> <span className={`status-pill ${meta.className}`}>
{meta.label} {meta.label}
</span> </span>
</td> </td>
<td>
{Array.isArray(p.followUpStatus) && p.followUpStatus.length > 0 ? (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{p.followUpStatus.map((st) => {
const fuMeta = FOLLOW_UP_META[st] || {
label: st,
color: 'var(--color-text-secondary)',
bg: 'var(--color-bg-subtle)',
border: '1px solid var(--color-border)',
}
return (
<span
key={st}
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 7px',
borderRadius: 'var(--radius-sm, 4px)',
background: fuMeta.bg,
color: fuMeta.color,
border: fuMeta.border,
display: 'inline-flex',
alignItems: 'center',
lineHeight: 1.2,
}}
>
{fuMeta.label}
</span>
)
})}
</div>
) : (
<span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs, 12px)' }}></span>
)}
</td>
</tr> </tr>
) )
}) })
+2 -3
View File
@@ -36,8 +36,7 @@ const STATUS_META = {
'pre-travel': { label: 'Pre-Travel', colorVar: '--color-accent-violet', cssClass: 'status-pre-travel', markerClass: 'marker-violet' }, 'pre-travel': { label: 'Pre-Travel', colorVar: '--color-accent-violet', cssClass: 'status-pre-travel', markerClass: 'marker-violet' },
'in-transit': { label: 'In Transit', colorVar: '--color-primary', cssClass: 'status-in-transit', markerClass: 'marker-blue' }, 'in-transit': { label: 'In Transit', colorVar: '--color-primary', cssClass: 'status-in-transit', markerClass: 'marker-blue' },
'at-risk': { label: 'At Risk', colorVar: '--color-accent-amber', cssClass: 'status-at-risk', markerClass: 'marker-amber' }, 'at-risk': { label: 'At Risk', colorVar: '--color-accent-amber', cssClass: 'status-at-risk', markerClass: 'marker-amber' },
'post-travel': { label: 'Post-Travel', colorVar: '--color-accent-emerald', cssClass: 'status-post-travel', markerClass: 'marker-emerald' }, 'post-travel': { label: 'Post-Travel', colorVar: '--color-accent-emerald', cssClass: 'status-post-travel', markerClass: 'marker-emerald' }
'followed-up': { label: 'Followed Up', colorVar: '--color-primary', cssClass: 'status-followed-up', markerClass: 'marker-blue' },
} }
/** /**
@@ -699,7 +698,7 @@ export default function LiveTracking() {
<div className="log-detail-item"> <div className="log-detail-item">
<span className="log-detail-label">Dates</span> <span className="log-detail-label">Dates</span>
<span className="log-detail-val"> <span className="log-detail-val">
{p.departure ? new Date(p.departure).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A'} {p.arrival ? new Date(p.arrival).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A'} {p.departure ? new Date(p.departure).toLocaleDateString('en-CA', { timeZone: 'UTC', month: 'short', day: 'numeric' }) : 'N/A'} {p.arrival ? new Date(p.arrival).toLocaleDateString('en-CA', { timeZone: 'UTC', month: 'short', day: 'numeric' }) : 'N/A'}
</span> </span>
</div> </div>
</div> </div>
+3 -2
View File
@@ -452,9 +452,10 @@ export default function PreTravel() {
}), }),
})) }))
const targetTravellerId = selectedTrip.travellerId || selectedTrip.traveller_id || selectedTrip.id
const savedRec = await saveVaccineRecord({ const savedRec = await saveVaccineRecord({
traveller_id: selectedTrip.id, traveller_id: targetTravellerId,
traveller: selectedTrip.id, traveller: targetTravellerId,
traveller_name: selectedTrip.name, traveller_name: selectedTrip.name,
emr_id: selectedTrip.emrID, emr_id: selectedTrip.emrID,
timelines: vaccineTimelines, timelines: vaccineTimelines,
+14 -17
View File
@@ -5,19 +5,15 @@ import {
Search, Search,
Plus, Plus,
Save, Save,
CheckCircle,
Calendar,
AlertTriangle, AlertTriangle,
Trash2, Trash2,
X, X,
RotateCw, RotateCw,
Check, Check,
FileText,
ShieldCheck, ShieldCheck,
CalendarDays,
} from 'lucide-react' } from 'lucide-react'
import { import {
fetchEntries, fetchTravellers,
fetchVaccineRecords, fetchVaccineRecords,
fetchVaccines, fetchVaccines,
saveVaccineRecord, saveVaccineRecord,
@@ -51,7 +47,7 @@ export default function Records() {
setError(null) setError(null)
try { try {
const [travellerData, recordData, catalogData] = await Promise.all([ const [travellerData, recordData, catalogData] = await Promise.all([
fetchEntries(), fetchTravellers(),
fetchVaccineRecords(), fetchVaccineRecords(),
fetchVaccines(), fetchVaccines(),
]) ])
@@ -82,12 +78,14 @@ export default function Records() {
const existingRecord = useMemo(() => { const existingRecord = useMemo(() => {
if (!selectedTravellerId) return null if (!selectedTravellerId) return null
const targetTid = selectedTraveller?.travellerId || selectedTraveller?.traveller_id || selectedTraveller?.id
return ( return (
vaccineRecords.find( vaccineRecords.find(
(r) => (r) =>
(selectedTraveller?.recordID && r.id === selectedTraveller.recordID) || (selectedTraveller?.recordID && r.id === selectedTraveller.recordID) ||
(selectedTraveller?.record_id && r.id === selectedTraveller.record_id) || (selectedTraveller?.record_id && r.id === selectedTraveller.record_id) ||
(r.travellerId || r.traveller) === selectedTravellerId (r.travellerId && (r.travellerId === targetTid || r.travellerId === selectedTravellerId)) ||
(r.traveller && (r.traveller === targetTid || r.traveller === selectedTravellerId))
) || null ) || null
) )
}, [vaccineRecords, selectedTravellerId, selectedTraveller]) }, [vaccineRecords, selectedTravellerId, selectedTraveller])
@@ -213,16 +211,21 @@ export default function Records() {
setSaving(true) setSaving(true)
setSaveSuccess(null) setSaveSuccess(null)
try { try {
const targetTravellerId = selectedTraveller.travellerId || selectedTraveller.traveller_id || selectedTraveller.id
await saveVaccineRecord({ await saveVaccineRecord({
traveller_id: selectedTraveller.id, traveller_id: targetTravellerId,
traveller_name: selectedTraveller.name, traveller_name: selectedTraveller.name,
emr_id: selectedTraveller.emrID, emr_id: selectedTraveller.emrID,
timelines: editableTimelines, timelines: editableTimelines,
}) })
// Reload vaccine records // Reload vaccine records and travellers to sync record_id status
const recordData = await fetchVaccineRecords() const [recordData, travellerData] = await Promise.all([
fetchVaccineRecords(),
fetchTravellers(),
])
setVaccineRecords(recordData || []) setVaccineRecords(recordData || [])
setTravellers(travellerData || [])
setIsDirty(false) setIsDirty(false)
setSaveSuccess(`Vaccine record for ${selectedTraveller.name} updated successfully!`) setSaveSuccess(`Vaccine record for ${selectedTraveller.name} updated successfully!`)
setTimeout(() => setSaveSuccess(null), 5000) setTimeout(() => setSaveSuccess(null), 5000)
@@ -361,7 +364,7 @@ export default function Records() {
) : ( ) : (
filteredTravellers.map((traveller) => { filteredTravellers.map((traveller) => {
const isSelected = traveller.id === selectedTravellerId const isSelected = traveller.id === selectedTravellerId
const summary = travellerRecordSummaries[traveller.id] const summary = travellerRecordSummaries[traveller.travellerId] || travellerRecordSummaries[traveller.id]
let badgeClass = 'rec-badge-none' let badgeClass = 'rec-badge-none'
let badgeText = 'No Record' let badgeText = 'No Record'
@@ -417,12 +420,6 @@ export default function Records() {
</span> </span>
)} )}
</div> </div>
<div className="rec-dossier-meta">
{selectedTraveller.location && (
<span>Destination: {selectedTraveller.location}</span>
)}
<span>Departure: {selectedTraveller.departure ? formatDate(localDate(selectedTraveller.departure)) : 'N/A'}</span>
</div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
+753
View File
@@ -0,0 +1,753 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'
import {
ShieldAlert,
AlertTriangle,
Globe,
Flag,
Calendar,
Compass,
ExternalLink,
RotateCw,
Search,
CheckCircle2,
AlertCircle,
Activity,
ChevronDown,
X,
Clock,
Sparkles,
MapPin,
Flame,
ShieldCheck,
ArrowRight,
Stethoscope,
Landmark,
Biohazard,
} from 'lucide-react'
import COUNTRIES from '../assets/country-options.json'
import CanadaFlag from '../assets/Flag_of_Canada.svg'
const TRAVEL_STYLES = [
{ value: 'Backpacking / Budget', label: 'Backpacking / Budget', desc: 'Hostels, local transit, street food exposure' },
{ value: 'Mid-range / Adventure', label: 'Mid-range / Adventure', desc: 'Hotels, excursions, varied terrain' },
{ value: '5-Star Resort / Luxury', label: '5-Star Resort / Luxury', desc: 'Resort grounds, controlled environments' },
{ value: 'Business / Urban', label: 'Business / Urban', desc: 'City centres, conference facilities, standard dining' },
{ value: 'Humanitarian / Long-term', label: 'Humanitarian / Long-term', desc: 'Remote communities, extended deployment' },
]
function getScoreTheme(score) {
if (score >= 8) {
return {
tier: 'High Risk',
color: '#f43f5e',
bg: 'rgba(244, 63, 94, 0.12)',
border: 'rgba(244, 63, 94, 0.35)',
glow: 'rgba(244, 63, 94, 0.25)',
icon: Flame,
}
}
if (score >= 4) {
return {
tier: 'Moderate Risk',
color: '#f59e0b',
bg: 'rgba(245, 158, 11, 0.12)',
border: 'rgba(245, 158, 11, 0.35)',
glow: 'rgba(245, 158, 11, 0.25)',
icon: AlertTriangle,
}
}
return {
tier: 'Low Risk',
color: '#10b981',
bg: 'rgba(16, 185, 129, 0.12)',
border: 'rgba(16, 185, 129, 0.35)',
glow: 'rgba(16, 185, 129, 0.25)',
icon: ShieldCheck,
}
}
export default function RiskAudit() {
// Form state
const [selectedCountry, setSelectedCountry] = useState(null)
const [travelStyle, setTravelStyle] = useState('Backpacking / Budget')
const [travelDate, setTravelDate] = useState('')
// Country dropdown search state
const [countrySearch, setCountrySearch] = useState('')
const [isCountryDropdownOpen, setIsCountryDropdownOpen] = useState(false)
const countryDropdownRef = useRef(null)
// API Request state
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [auditData, setAuditData] = useState(null)
const [lastAuditMeta, setLastAuditMeta] = useState(null)
// Filter countries for combobox
const filteredCountries = useMemo(() => {
const q = countrySearch.toLowerCase().trim()
if (!q) return COUNTRIES
return COUNTRIES.filter(
(c) =>
c.label.toLowerCase().includes(q) ||
c.iso.toLowerCase().includes(q) ||
(c.cdcCountry && c.cdcCountry.toLowerCase().includes(q))
)
}, [countrySearch])
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(e) {
if (countryDropdownRef.current && !countryDropdownRef.current.contains(e.target)) {
setIsCountryDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Handle Form Submit
async function handleAuditSubmit(e) {
if (e) e.preventDefault()
if (!selectedCountry || !travelDate) return
setLoading(true)
setError(null)
const payload = {
destination: {
label: selectedCountry.label,
iso: selectedCountry.iso,
cdcCountry: selectedCountry.cdcCountry || selectedCountry.label,
},
travelStyle: travelStyle,
travelDate: travelDate,
}
try {
let rawData
// Use Electron Main process IPC bridge to bypass browser CORS
if (window.api?.audit?.runHealthAudit) {
rawData = await window.api.audit.runHealthAudit(payload)
} else if (window.api?.invoke) {
rawData = await window.api.invoke('audit:run-health-audit', payload)
}
// API can return an array [ { ... } ] or direct object
const result = Array.isArray(rawData) ? rawData[0] : rawData
if (!result) {
throw new Error('Received empty response from the risk assessment engine.')
}
setAuditData(result)
setLastAuditMeta({
country: selectedCountry,
travelStyle,
travelDate,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
})
} catch (err) {
setError(err.message || 'Unable to reach the risk audit service. Please check network connection.')
setAuditData(null)
} finally {
setLoading(false)
}
}
function handleReset() {
setSelectedCountry(null)
setCountrySearch('')
setTravelStyle('Backpacking / Budget')
setTravelDate('')
setError(null)
setAuditData(null)
setLastAuditMeta(null)
}
const scoreTheme = auditData ? getScoreTheme(auditData.riskScore || 0) : null
const ScoreIcon = scoreTheme ? scoreTheme.icon : ShieldAlert
return (
<section className="risk-audit" aria-label="Travel Health Risk Audit">
{/* ── Header ──────────────────────────────────────────────────────────── */}
<header className="ra-page-header">
<div>
<div className="ra-page-tag">
<Stethoscope size={13} />
<span>Clinical Consultation Engine</span>
</div>
<h1 className="dashboard-title" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
Travel Health Risk Audit
</h1>
<p className="dashboard-subtitle">
Aggregate real-time travel health notices, disease outbreaks, and security advisories from GAC, CDC, and WHO.
</p>
</div>
{(auditData || error) && (
<button
type="button"
className="ra-reset-btn"
onClick={handleReset}
disabled={loading}
>
<RotateCw size={13} />
<span>New Audit</span>
</button>
)}
</header>
{/* ── Main 2-Column Layout ────────────────────────────────────────────── */}
<div className="ra-layout">
{/* ── Left Column: Configuration Form ───────────────────────────────── */}
<aside className="ra-form-panel">
<div className="ra-panel-card">
<div className="ra-panel-header">
<Compass size={16} className="ra-header-icon" />
<h2 className="ra-panel-title">Audit Parameters</h2>
</div>
<form onSubmit={handleAuditSubmit} className="ra-form">
{/* Destination Searchable Dropdown */}
<div className="ra-field" ref={countryDropdownRef}>
<label htmlFor="country-search-input" className="ra-label">
Destination Country <span className="ra-required">*</span>
</label>
<div className="ra-combobox-wrap">
<div
className={`ra-combobox-input-box ${isCountryDropdownOpen ? 'focused' : ''} ${selectedCountry ? 'has-val' : ''}`}
onClick={() => setIsCountryDropdownOpen(true)}
>
<Search size={14} className="ra-input-icon" />
<input
id="country-search-input"
type="text"
className="ra-combobox-input"
placeholder={selectedCountry ? selectedCountry.label : 'Select destination country…'}
value={isCountryDropdownOpen ? countrySearch : selectedCountry ? selectedCountry.label : ''}
onChange={(e) => {
setCountrySearch(e.target.value)
setIsCountryDropdownOpen(true)
}}
onFocus={() => {
setIsCountryDropdownOpen(true)
setCountrySearch('')
}}
autoComplete="off"
/>
{selectedCountry && !isCountryDropdownOpen && (
<span className="ra-iso-pill">{selectedCountry.iso}</span>
)}
{selectedCountry && (
<button
type="button"
className="ra-clear-input-btn"
onClick={(e) => {
e.stopPropagation()
setSelectedCountry(null)
setCountrySearch('')
}}
aria-label="Clear destination"
>
<X size={13} />
</button>
)}
<ChevronDown size={14} className={`ra-chevron ${isCountryDropdownOpen ? 'rotated' : ''}`} />
</div>
{isCountryDropdownOpen && (
<div className="ra-dropdown-menu">
<div className="ra-dropdown-count">
{filteredCountries.length} countries found
</div>
<ul className="ra-dropdown-list" role="listbox">
{filteredCountries.map((country) => (
<li key={country.iso}>
<button
type="button"
className={`ra-dropdown-item ${selectedCountry?.iso === country.iso ? 'selected' : ''}`}
onClick={() => {
setSelectedCountry(country)
setIsCountryDropdownOpen(false)
setCountrySearch('')
}}
>
<span className="ra-dropdown-country-name">{country.label}</span>
<span className="ra-dropdown-country-iso">{country.iso}</span>
</button>
</li>
))}
{filteredCountries.length === 0 && (
<li className="ra-dropdown-empty">
No country found matching "{countrySearch}"
</li>
)}
</ul>
</div>
)}
</div>
</div>
{/* Travel Style Select */}
<div className="ra-field">
<label htmlFor="ra-travel-style" className="ra-label">
Travel Style
</label>
<div className="ra-select-wrap">
<select
id="ra-travel-style"
className="ra-select"
value={travelStyle}
onChange={(e) => setTravelStyle(e.target.value)}
>
{TRAVEL_STYLES.map((style) => (
<option key={style.value} value={style.value}>
{style.label}
</option>
))}
</select>
<ChevronDown size={14} className="ra-select-chevron" />
</div>
<div className="ra-field-hint">
{TRAVEL_STYLES.find((s) => s.value === travelStyle)?.desc}
</div>
</div>
{/* Departure Date */}
<div className="ra-field">
<label htmlFor="ra-travel-date" className="ra-label">
Departure Date <span className="ra-required">*</span>
</label>
<div className="ra-date-wrap">
<input
id="ra-travel-date"
type="date"
className="ra-date-input"
value={travelDate}
onChange={(e) => setTravelDate(e.target.value)}
required
/>
</div>
</div>
{/* Action Buttons */}
<div className="ra-form-actions">
<button
type="submit"
id="ra-submit-btn"
className="ra-submit-btn"
disabled={!selectedCountry || !travelDate || loading}
>
{loading ? (
<>
<RotateCw size={15} className="ra-spinner" />
<span>Running Clinical Audit</span>
</>
) : (
<>
<ShieldAlert size={15} />
<span>Run Health Audit</span>
<ArrowRight size={14} />
</>
)}
</button>
</div>
</form>
</div>
</aside>
{/* ── Right Column: Audit Results & Intelligence Display ────────────── */}
<main className="ra-results-panel" aria-live="polite">
{/* Error Banner */}
{error && (
<div className="ra-error-banner" role="alert">
<AlertCircle size={20} className="ra-error-icon" />
<div className="ra-error-content">
<strong>Audit Service Connection Error</strong>
<p>{error}</p>
</div>
<button
type="button"
className="ra-retry-btn"
onClick={() => handleAuditSubmit()}
>
<RotateCw size={13} />
<span>Retry</span>
</button>
</div>
)}
{/* Loading Skeleton */}
{loading && (
<div className="ra-loading-card">
<div className="ra-loading-spinner-wrap">
<RotateCw size={36} className="ra-spinner-large" />
<div className="ra-pulse-halo" />
</div>
<h3 className="ra-loading-title">Aggregating Global Health Data</h3>
<p className="ra-loading-sub">
Cross-referencing destination advisories with Canadian consular alerts, CDC travel notices, and WHO disease outbreaks
</p>
<div className="ra-loading-steps">
<div className="ra-loading-step active">
<span className="ra-step-num">1</span>
<span>Fetching GAC Risk Level</span>
</div>
<div className="ra-loading-step active">
<span className="ra-step-num">2</span>
<span>Checking CDC Health Notices</span>
</div>
<div className="ra-loading-step active">
<span className="ra-step-num">3</span>
<span>Analyzing WHO DON Outbreaks</span>
</div>
</div>
</div>
)}
{/* Empty State when no audit has been run */}
{!loading && !auditData && !error && (
<div className="ra-empty-card">
<div className="ra-empty-icon-wrap">
<ShieldAlert size={36} style={{ color: 'var(--color-primary)' }} />
</div>
<h2 className="ra-empty-title">Ready for Consultation Audit</h2>
<p className="ra-empty-sub">
Select a destination country, travel style, and departure date on the left panel to trigger an automated clinical health risk assessment.
</p>
<div className="ra-empty-features">
<div className="ra-empty-feature">
<Landmark size={30} style={{ color: 'var(--color-accent-rose)' }} />
<div>
<strong>Gov. of Canada Advisories</strong>
<p>Security alerts, regional warnings, and health risks</p>
</div>
</div>
<div className="ra-empty-feature">
<Biohazard size={30} style={{ color: 'var(--color-accent-amber)' }} />
<div>
<strong>CDC Travel Health Notices</strong>
<p>Level 14 health notices, vector-borne spikes</p>
</div>
</div>
<div className="ra-empty-feature">
<Globe size={30} style={{ color: 'var(--color-accent-teal)' }} />
<div>
<strong>WHO Outbreak Bulletins</strong>
<p>Real-time epidemic tracking and global DON items</p>
</div>
</div>
</div>
</div>
)}
{/* Audit Results Content */}
{!loading && auditData && (
<div className="ra-report-wrap" id="printable-risk-report">
{/* Report Header Card */}
<div className="ra-report-header" style={{ borderColor: scoreTheme.border }}>
<div className="ra-header-main">
<h2 className="ra-report-title">
Clinical Travel Health Profile
</h2>
<p className="ra-report-subtitle">
Multi-agency assessment for:
</p>
<div className="ra-header-meta-row">
<span className="ra-badge-dest">
<MapPin size={13} />
{auditData.destination || lastAuditMeta?.country?.label}
{lastAuditMeta?.country?.iso && ` (${lastAuditMeta.country.iso})`}
</span>
<span className="ra-badge-meta">
<Compass size={12} />
{lastAuditMeta?.travelStyle}
</span>
<span className="ra-badge-meta">
<Calendar size={12} />
{lastAuditMeta?.travelDate}
</span>
{lastAuditMeta?.timestamp && (
<span className="ra-badge-time">
<Clock size={11} />
Checked {lastAuditMeta.timestamp}
</span>
)}
</div>
</div>
{/* Score Gauge Badge */}
<div
className="ra-score-card"
style={{
background: scoreTheme.bg,
borderColor: scoreTheme.border,
boxShadow: `0 0 20px ${scoreTheme.glow}`,
}}
>
<div className="ra-score-top">
<ScoreIcon size={16} style={{ color: scoreTheme.color }} />
<span className="ra-score-tier" style={{ color: scoreTheme.color }}>
{scoreTheme.tier}
</span>
</div>
<div className="ra-score-val" style={{ color: scoreTheme.color }}>
{auditData.riskScore ?? '—'}
<span className="ra-score-max">/10</span>
</div>
<div className="ra-score-label">Composite Risk Index</div>
</div>
</div>
{/* Priority Red Flags (if any) */}
{auditData.redFlags && auditData.redFlags.length > 0 && (
<div className="ra-section-card">
<div className="ra-section-header">
<AlertTriangle size={16} style={{ color: '#f59e0b' }} />
<h3 className="ra-section-title">Priority Red Flags & Active Notices</h3>
<span className="ra-section-count">{auditData.redFlags.length}</span>
</div>
<div className="ra-flags-grid">
{auditData.redFlags.map((flag, idx) => {
const isStable = flag.toLowerCase().includes('stable') || flag.toLowerCase().includes('low risk')
return (
<div
key={idx}
className={`ra-flag-card ${isStable ? 'stable' : 'warning'}`}
>
<div className={`ra-flag-indicator ${isStable ? 'stable' : 'warning'}`} />
<span className="ra-flag-text">{flag}</span>
</div>
)
})}
</div>
</div>
)}
{/* Clinical Summary Block */}
{auditData.clinicalSummary && (
<div className="ra-section-card clinical-summary-card">
<div className="ra-section-header">
<Sparkles size={16} style={{ color: 'var(--color-primary)' }} />
<h3 className="ra-section-title">Executive Clinical Summary</h3>
</div>
<p className="ra-clinical-text">{auditData.clinicalSummary}</p>
</div>
)}
{/* 3-Way Agency Breakdown Grid */}
<div className="ra-agency-grid">
{/* ── 1. Canadian Context (GAC) ─────────────────────────────── */}
<div className="ra-agency-card gac-card">
<div className="ra-agency-header">
<div className="ra-agency-badge gac">
<img src={CanadaFlag} width="40px" />
<span>Government of Canada (GAC)</span>
</div>
</div>
<div className="ra-agency-body">
{auditData.canadianContext?.riskLevel && (
<div className="ra-gac-level-box">
<span className="ra-sub-label">Official Advisory Level</span>
<div className="ra-gac-level-text">
<ShieldAlert size={14} style={{ color: '#f59e0b' }} />
<strong>{auditData.canadianContext.riskLevel}</strong>
</div>
{auditData.canadianContext?.advisoryText && (
<p className="ra-gac-subtext">{auditData.canadianContext.advisoryText}</p>
)}
</div>
)}
{auditData.gacRiskText && (
<div className="ra-agency-item">
<span className="ra-sub-label">Consular & Security Note</span>
<p className="ra-agency-desc">{auditData.gacRiskText}</p>
</div>
)}
{auditData.canadianContext?.recentUpdates && (
<div className="ra-agency-item">
<span className="ra-sub-label">Latest Advisory Update</span>
<span className="ra-update-pill">{auditData.canadianContext.recentUpdates}</span>
</div>
)}
{/* Source Links */}
<div className="ra-agency-links">
{auditData.travelRiskUrl && (
<a
href={auditData.travelRiskUrl}
target="_blank"
rel="noopener noreferrer"
className="ra-ext-link"
>
<span>GAC Travel Advisory</span>
<ExternalLink size={12} />
</a>
)}
{auditData.gcHealthRiskUrl && (
<a
href={auditData.gcHealthRiskUrl}
target="_blank"
rel="noopener noreferrer"
className="ra-ext-link"
>
<span>GAC Health Advisory</span>
<ExternalLink size={12} />
</a>
)}
</div>
</div>
</div>
{/* ── 2. CDC Context ─────────────────────────────────────────── */}
<div className="ra-agency-card cdc-card">
<div className="ra-agency-header">
<div className="ra-agency-badge cdc">
<Biohazard size={16} style={{ color: 'var(--color-accent-amber)' }} />
<span>Centers for Disease Control (CDC)</span>
</div>
</div>
<div className="ra-agency-body">
{auditData.cdcContext?.cdcNotices && auditData.cdcContext.cdcNotices.length > 0 ? (
<div className="ra-cdc-notices-list">
{auditData.cdcContext.cdcNotices.map((notice, idx) => (
<div key={idx} className="ra-cdc-notice-item">
<div className="ra-cdc-notice-top">
<span className="ra-cdc-level-badge">{notice.level || 'Health Notice'}</span>
{notice.pubDate && (
<span className="ra-cdc-date">
{new Date(notice.pubDate).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
)}
</div>
<h4 className="ra-cdc-notice-title">{notice.title}</h4>
{notice.summary && (
<p className="ra-cdc-summary-text">{notice.summary}</p>
)}
{notice.link && (
<a
href={notice.link}
target="_blank"
rel="noopener noreferrer"
className="ra-ext-link small"
>
<span>Read Full CDC Notice</span>
<ExternalLink size={11} />
</a>
)}
</div>
))}
</div>
) : (
<div className="ra-agency-item">
<span className="ra-sub-label">CDC Notice Status</span>
<div className="ra-empty-agency-note">
<CheckCircle2 size={15} style={{ color: '#10b981' }} />
<span>No active high-severity CDC Travel Health Notices found for this destination.</span>
</div>
</div>
)}
{auditData.cdcHealthNoticeUrl && (
<div className="ra-agency-links">
<a
href={auditData.cdcHealthNoticeUrl}
target="_blank"
rel="noopener noreferrer"
className="ra-ext-link"
>
<span>CDC Traveler Health Destination Guide</span>
<ExternalLink size={12} />
</a>
</div>
)}
</div>
</div>
{/* ── 3. WHO Context ─────────────────────────────────────────── */}
<div className="ra-agency-card who-card">
<div className="ra-agency-header">
<div className="ra-agency-badge who">
<Globe size={16} style={{ color: 'var(--color-accent-teal)' }} />
<span>WHO Disease Outbreak News (DONs)</span>
</div>
</div>
<div className="ra-agency-body">
{auditData.whoContext?.whoNotices && auditData.whoContext.whoNotices.length > 0 ? (
<div className="ra-who-list">
{auditData.whoContext.whoNotices.map((notice, idx) => (
<a
key={notice.id || idx}
href={notice.url}
target="_blank"
rel="noopener noreferrer"
className="ra-who-item"
>
<div className="ra-who-dot" />
<div className="ra-who-item-content">
<span className="ra-who-item-title">{notice.title}</span>
<span className="ra-who-item-date">{notice.date}</span>
</div>
<ExternalLink size={13} className="ra-who-link-icon" />
</a>
))}
</div>
) : auditData.donItems && auditData.donItems.length > 0 ? (
<div className="ra-who-list">
{auditData.donItems.map((item, idx) => {
const url = auditData.donUrls?.[idx]
return url ? (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="ra-who-item"
>
<div className="ra-who-dot" />
<div className="ra-who-item-content">
<span className="ra-who-item-title">{item}</span>
</div>
<ExternalLink size={13} className="ra-who-link-icon" />
</a>
) : (
<div key={idx} className="ra-who-item">
<div className="ra-who-dot" />
<div className="ra-who-item-content">
<span className="ra-who-item-title">{item}</span>
</div>
</div>
)
})}
</div>
) : (
<div className="ra-empty-agency-note">
<CheckCircle2 size={15} style={{ color: '#10b981' }} />
<span>
{auditData.whoContext?.whoMessage ||
auditData.donMessage ||
'No recent disease outbreak notices recorded for this destination.'}
</span>
</div>
)}
</div>
</div>
</div>
</div>
)}
</main>
</div>
</section>
)
}
+32 -3
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { import {
Syringe, Syringe,
LayoutDashboard, LayoutDashboard,
@@ -8,6 +8,7 @@ import {
Settings, Settings,
MapPin, MapPin,
Briefcase, Briefcase,
ShieldAlert
} from 'lucide-react' } from 'lucide-react'
import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg' import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
import { fetchEntries } from '../lib/pocketbase.js' import { fetchEntries } from '../lib/pocketbase.js'
@@ -18,10 +19,14 @@ const NAV_ITEMS = [
{ id: 'trips', label: 'Trips', icon: Plane, badge: null, disabled: false }, { id: 'trips', label: 'Trips', icon: Plane, badge: null, disabled: false },
{ id: 'pre-travel', label: 'Pre-Travel', icon: Briefcase, badge: null, disabled: false }, { id: 'pre-travel', label: 'Pre-Travel', icon: Briefcase, badge: null, disabled: false },
{ id: 'records', label: 'Records', icon: Syringe, badge: null, disabled: false }, { id: 'records', label: 'Records', icon: Syringe, badge: null, disabled: false },
]
const TOOLS_ITEMS = [
{ id: 'risk-audit', label: 'Risk Audit', icon: ShieldAlert, badge: null, disabled: false },
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true }, { id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
] ]
const BOTTOM_ITEMS = [ const SYSTEM_ITEMS = [
{ id: 'notifications', label: 'Notifications', icon: Bell, badge: null, disabled: true }, { id: 'notifications', label: 'Notifications', icon: Bell, badge: null, disabled: true },
{ id: 'settings', label: 'Settings', icon: Settings, badge: null, disabled: true }, { id: 'settings', label: 'Settings', icon: Settings, badge: null, disabled: true },
] ]
@@ -96,8 +101,32 @@ export default function Sidebar({ activePage, onNavigate }) {
) )
})} })}
<span className="sidebar-section-label" style={{ marginTop: 'var(--space-4)' }}>Tools</span>
{TOOLS_ITEMS.map((item) => {
const Icon = item.icon
return (
<button
key={item.id}
id={`nav-${item.id}`}
className={`nav-item${activePage === item.id ? ' active' : ''}`}
onClick={() => onNavigate(item.id)}
aria-current={activePage === item.id ? 'page' : undefined}
disabled={item.disabled}
style={item.disabled ? { opacity: 0.6, cursor: 'not-allowed' } : {}}
>
<Icon className="nav-item-icon" aria-hidden="true" size={18} />
{item.label}
{item.badge && (
<span className="nav-item-badge" aria-label={`${item.badge} alerts`}>
{item.badge}
</span>
)}
</button>
)
})}
<span className="sidebar-section-label" style={{ marginTop: 'var(--space-4)' }}>System</span> <span className="sidebar-section-label" style={{ marginTop: 'var(--space-4)' }}>System</span>
{BOTTOM_ITEMS.map((item) => { {SYSTEM_ITEMS.map((item) => {
const Icon = item.icon const Icon = item.icon
return ( return (
<button <button
+299 -50
View File
@@ -10,8 +10,10 @@ import {
Trash2, Trash2,
Calendar, Calendar,
MapPin, MapPin,
Search,
} from 'lucide-react' } from 'lucide-react'
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js' import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
import { fetchCustomerChart } from '../lib/juvonno.js'
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js' import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
import CountrySelect from './CountrySelect.jsx' import CountrySelect from './CountrySelect.jsx'
@@ -20,7 +22,6 @@ const STATUS_META = {
'in-transit': { label: 'In Transit', className: 'status-in-transit' }, 'in-transit': { label: 'In Transit', className: 'status-in-transit' },
'at-risk': { label: 'At Risk', className: 'status-at-risk' }, 'at-risk': { label: 'At Risk', className: 'status-at-risk' },
'post-travel': { label: 'Post-Travel', className: 'status-post-travel' }, 'post-travel': { label: 'Post-Travel', className: 'status-post-travel' },
'followed-up': { label: 'Followed Up', className: 'status-followed-up' },
} }
const INITIAL_NEW_FORM = { const INITIAL_NEW_FORM = {
@@ -30,7 +31,7 @@ const INITIAL_NEW_FORM = {
countriesISO: [], countriesISO: [],
departure: '', departure: '',
arrival: '', arrival: '',
status: '', followUpStatus: [],
} }
export default function Trips() { export default function Trips() {
@@ -42,6 +43,9 @@ export default function Trips() {
// New Trip form state (Left Panel) // New Trip form state (Left Panel)
const [newTripForm, setNewTripForm] = useState(INITIAL_NEW_FORM) const [newTripForm, setNewTripForm] = useState(INITIAL_NEW_FORM)
const [emrSearching, setEmrSearching] = useState(false)
const [emrLookupError, setEmrLookupError] = useState(null)
const [juvonnoPatient, setJuvonnoPatient] = useState(null)
// Edit Trip state (Right Panel when editing) // Edit Trip state (Right Panel when editing)
const [editingTrip, setEditingTrip] = useState(null) const [editingTrip, setEditingTrip] = useState(null)
@@ -72,9 +76,62 @@ export default function Trips() {
} }
}, [successMsg]) }, [successMsg])
// Lookup customer chart from Juvonno EMR API
async function handleLookupJuvonno() {
const rawId = Number(newTripForm.emrID)
if (!rawId) {
setEmrLookupError('Please enter an EMR ID first.')
return
}
setEmrSearching(true)
setEmrLookupError(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
setJuvonnoPatient({
id: data.id,
firstName,
lastName,
fullName,
dob
})
setNewTripForm((prev) => ({
...prev,
emrID: rawId,
name: fullName,
}))
} catch (err) {
setJuvonnoPatient(null)
setEmrLookupError(err.message || `Failed to fetch customer chart for EMR ID "${rawId}".`)
} finally {
setEmrSearching(false)
}
}
function handleResetJuvonno() {
setJuvonnoPatient(null)
setEmrLookupError(null)
setNewTripForm((prev) => ({
...prev,
emrID: '',
name: '',
}))
}
// Handle New Trip Form submission (Left Form) // Handle New Trip Form submission (Left Form)
async function handleCreateTrip(e) { async function handleCreateTrip(e) {
e.preventDefault() e.preventDefault()
if (!newTripForm.emrID || String(newTripForm.emrID).trim() === '') {
setError('Please enter an EMR ID.')
return
}
if (!newTripForm.name.trim()) { if (!newTripForm.name.trim()) {
setError('Please enter a patient / traveller name.') setError('Please enter a patient / traveller name.')
return return
@@ -89,17 +146,20 @@ export default function Trips() {
: newTripForm.countriesISO : newTripForm.countriesISO
const payload = { const payload = {
emrID: newTripForm.emrID ? Number(newTripForm.emrID) : null, emrID: newTripForm.emrID ? String(newTripForm.emrID).trim() : '',
name: newTripForm.name.trim(), name: newTripForm.name.trim(),
location: newTripForm.location.trim(), locations: newTripForm.location.trim(),
countriesISO: isoArray, countriesISO: isoArray,
departure: newTripForm.departure, departure: newTripForm.departure,
arrival: newTripForm.arrival, arrival: newTripForm.arrival,
followUpStatus: newTripForm.followUpStatus || [],
} }
try { try {
await createEntry(payload) await createEntry(payload)
setNewTripForm(INITIAL_NEW_FORM) setNewTripForm(INITIAL_NEW_FORM)
setJuvonnoPatient(null)
setEmrLookupError(null)
setSuccessMsg('New trip created successfully!') setSuccessMsg('New trip created successfully!')
await loadData() await loadData()
} catch (err) { } catch (err) {
@@ -114,6 +174,12 @@ export default function Trips() {
setEditingTrip(trip) setEditingTrip(trip)
const isos = getPatientISOs(trip) const isos = getPatientISOs(trip)
const followUps = Array.isArray(trip.followUpStatus)
? trip.followUpStatus
: Array.isArray(trip.followUpStatus)
? trip.followUpStatus
: []
setEditTripForm({ setEditTripForm({
emrID: trip.emrID !== undefined && trip.emrID !== null ? trip.emrID : '', emrID: trip.emrID !== undefined && trip.emrID !== null ? trip.emrID : '',
name: trip.name || '', name: trip.name || '',
@@ -121,7 +187,7 @@ export default function Trips() {
countriesISO: Array.isArray(isos) ? isos : typeof isos === 'string' && isos ? isos.split(',').map((s) => s.trim().toUpperCase()) : [], countriesISO: Array.isArray(isos) ? isos : typeof isos === 'string' && isos ? isos.split(',').map((s) => s.trim().toUpperCase()) : [],
departure: trip.departure ? trip.departure.slice(0, 10) : '', departure: trip.departure ? trip.departure.slice(0, 10) : '',
arrival: trip.arrival ? trip.arrival.slice(0, 10) : '', arrival: trip.arrival ? trip.arrival.slice(0, 10) : '',
status: trip.status || '', followUpStatus: followUps,
}) })
// Scroll top container into view smoothly // Scroll top container into view smoothly
@@ -133,10 +199,26 @@ export default function Trips() {
setEditTripForm(INITIAL_NEW_FORM) setEditTripForm(INITIAL_NEW_FORM)
} }
// Toggle follow-up status values ('in-transit', 'post-travel')
function handleFollowUpToggle(value) {
const current = editTripForm.followUpStatus || []
const next = current.includes(value)
? current.filter((v) => v !== value)
: [...current, value]
setEditTripForm({
...editTripForm,
followUpStatus: next,
})
}
// Handle Save Changes submission (Right Edit Form) // Handle Save Changes submission (Right Edit Form)
async function handleUpdateTrip(e) { async function handleUpdateTrip(e) {
e.preventDefault() e.preventDefault()
if (!editingTrip) return if (!editingTrip) return
if (!editTripForm.emrID || String(editTripForm.emrID).trim() === '') {
setError('EMR ID cannot be empty.')
return
}
if (!editTripForm.name.trim()) { if (!editTripForm.name.trim()) {
setError('Patient name cannot be empty.') setError('Patient name cannot be empty.')
return return
@@ -150,13 +232,13 @@ export default function Trips() {
: editTripForm.countriesISO : editTripForm.countriesISO
const payload = { const payload = {
emrID: editTripForm.emrID ? Number(editTripForm.emrID) : null, emrID: editTripForm.emrID ? String(editTripForm.emrID).trim() : '',
name: editTripForm.name.trim(), name: editTripForm.name.trim(),
location: editTripForm.location.trim(), location: editTripForm.location.trim(),
countriesISO: isoArray, countriesISO: isoArray,
departure: editTripForm.departure, departure: editTripForm.departure,
arrival: editTripForm.arrival, arrival: editTripForm.arrival,
status: editTripForm.status || '', followUpStatus: editTripForm.followUpStatus || [],
} }
try { try {
@@ -194,7 +276,7 @@ export default function Trips() {
if (!dateStr) return 'N/A' if (!dateStr) return 'N/A'
const date = new Date(dateStr) const date = new Date(dateStr)
if (isNaN(date.getTime())) return 'N/A' if (isNaN(date.getTime())) return 'N/A'
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) return date.toLocaleDateString('en-CA', { timeZone: 'UTC', month: 'short', day: 'numeric', year: 'numeric' })
} }
// Compute live preview state for New Form draft // Compute live preview state for New Form draft
@@ -209,6 +291,7 @@ export default function Trips() {
: [], : [],
departure: newTripForm.departure, departure: newTripForm.departure,
arrival: newTripForm.arrival, arrival: newTripForm.arrival,
followUpStatus: newTripForm.followUpStatus || [],
} }
const newDraftStatusKey = calculatePatientStatus(newDraftPatient) const newDraftStatusKey = calculatePatientStatus(newDraftPatient)
const newDraftMeta = STATUS_META[newDraftStatusKey] || STATUS_META['pre-travel'] const newDraftMeta = STATUS_META[newDraftStatusKey] || STATUS_META['pre-travel']
@@ -227,16 +310,11 @@ export default function Trips() {
: [], : [],
departure: editTripForm.departure, departure: editTripForm.departure,
arrival: editTripForm.arrival, arrival: editTripForm.arrival,
status: editTripForm.status, followUpStatus: editTripForm.followUpStatus || [],
} }
const editDraftStatusKey = calculatePatientStatus(editDraftPatient) const editDraftStatusKey = calculatePatientStatus(editDraftPatient)
const editDraftMeta = STATUS_META[editDraftStatusKey] || STATUS_META['pre-travel'] const editDraftMeta = STATUS_META[editDraftStatusKey] || STATUS_META['pre-travel']
const editDraftISOs = getPatientISOs(editDraftPatient) const editDraftISOs = getPatientISOs(editDraftPatient)
const isPostTravel =
calculatePatientStatus({
departure: editTripForm.departure,
arrival: editTripForm.arrival,
}) === 'post-travel' || editTripForm.status === 'followed-up'
const isEditingMode = editingTrip !== null const isEditingMode = editingTrip !== null
@@ -308,32 +386,151 @@ export default function Trips() {
)} )}
<form onSubmit={handleCreateTrip} className="trips-form"> <form onSubmit={handleCreateTrip} className="trips-form">
<div className="trips-form-row"> {/* Phase 1: EMR ID Lookup */}
<div className="trips-form-field"> <div className="trips-form-field">
<label className="trips-form-label" htmlFor="new-emr-id">EMR ID</label> <label className="trips-form-label" htmlFor="new-emr-id">
EMR ID <span style={{ color: 'var(--color-danger, #ef4444)' }}>*</span>
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input <input
id="new-emr-id" id="new-emr-id"
type="number" type="number"
min="1"
className="trips-form-input" className="trips-form-input"
placeholder="e.g. 1042" placeholder="EMR ID (e.g. 1042)"
value={newTripForm.emrID} value={newTripForm.emrID}
onChange={(e) => setNewTripForm({ ...newTripForm, emrID: e.target.value })} onChange={(e) => {
disabled={isEditingMode || submitting} setNewTripForm({ ...newTripForm, emrID: e.target.value })
/> if (emrLookupError) setEmrLookupError(null)
</div> if (juvonnoPatient) {
setJuvonnoPatient(null)
<div className="trips-form-field"> setNewTripForm((prev) => ({ ...prev, emrID: e.target.value, name: '' }))
<label className="trips-form-label" htmlFor="new-patient-name">Traveller Name</label> }
<input }}
id="new-patient-name" onKeyDown={(e) => {
type="text" if (['e', 'E', '+', '-', '.'].includes(e.key)) {
className="trips-form-input" e.preventDefault()
placeholder="e.g. John Doe" }
value={newTripForm.name} if (e.key === 'Enter' && !juvonnoPatient) {
onChange={(e) => setNewTripForm({ ...newTripForm, name: e.target.value })} e.preventDefault()
disabled={isEditingMode || submitting} handleLookupJuvonno()
}
}}
disabled={isEditingMode || submitting || emrSearching || Boolean(juvonnoPatient)}
required required
/> />
{!juvonnoPatient ? (
<button
type="button"
className="btn-secondary"
onClick={handleLookupJuvonno}
disabled={isEditingMode || submitting || emrSearching || !String(newTripForm.emrID || '').trim()}
style={{
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '0 16px',
flexShrink: 0,
}}
title="Fetch patient chart from Juvonno"
>
{emrSearching ? (
<RotateCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<Search size={14} />
)}
{emrSearching ? 'Searching...' : 'Lookup'}
</button>
) : (
<button
type="button"
className="btn-secondary"
onClick={handleResetJuvonno}
disabled={submitting}
style={{
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '0 12px',
flexShrink: 0,
color: 'var(--color-text-muted)',
}}
title="Clear and lookup another EMR"
>
<X size={14} />
Change
</button>
)}
</div>
</div>
{/* Error message if Juvonno lookup fails */}
{emrLookupError && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 14px',
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)',
lineHeight: 1.4,
}}
>
<AlertTriangle size={15} style={{ flexShrink: 0 }} />
<span style={{ flex: 1 }}>{emrLookupError}</span>
</div>
)}
{/* Phase 2: Patient Resolved - Show Traveller Name & Reveal Remaining Fields */}
{juvonnoPatient && (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: 'rgba(16, 185, 129, 0.08)',
border: '1px solid rgba(16, 185, 129, 0.28)',
borderRadius: 'var(--radius-sm)',
}}
>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: 'rgba(16, 185, 129, 0.2)',
color: '#10b981',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Check size={16} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '11px', textTransform: 'uppercase', color: 'var(--color-text-muted)', fontWeight: 600, letterSpacing: '0.04em' }}>
Traveller Name (from Juvonno)
</div>
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--color-text-primary)', marginTop: '2px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{juvonnoPatient.fullName}
</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '11px', textTransform: 'uppercase', color: 'var(--color-text-muted)', fontWeight: 600, letterSpacing: '0.04em' }}>
Date of Birth (from Juvonno)
</div>
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--color-text-primary)', marginTop: '2px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{juvonnoPatient.dob}
</div>
</div> </div>
</div> </div>
@@ -389,6 +586,8 @@ export default function Trips() {
{submitting ? 'Creating...' : 'Create Trip'} {submitting ? 'Creating...' : 'Create Trip'}
</button> </button>
</div> </div>
</>
)}
</form> </form>
</section> </section>
@@ -491,15 +690,18 @@ export default function Trips() {
<div className="trips-form" style={{ marginTop: '8px' }}> <div className="trips-form" style={{ marginTop: '8px' }}>
<div className="trips-form-row"> <div className="trips-form-row">
<div className="trips-form-field"> <div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-emr-id">EMR ID</label> <label className="trips-form-label" htmlFor="edit-emr-id">
EMR ID <span style={{ color: 'var(--color-danger, #ef4444)' }}>*</span>
</label>
<input <input
id="edit-emr-id" id="edit-emr-id"
type="number" type="text"
className="trips-form-input" className="trips-form-input"
placeholder="e.g. 1042" placeholder="e.g. 1042"
value={editTripForm.emrID} value={editTripForm.emrID}
onChange={(e) => setEditTripForm({ ...editTripForm, emrID: e.target.value })} onChange={(e) => setEditTripForm({ ...editTripForm, emrID: e.target.value })}
disabled={submitting} disabled={submitting}
required
/> />
</div> </div>
@@ -572,7 +774,7 @@ export default function Trips() {
</div> </div>
</div> </div>
{/* Live chips preview and Follow-up Email checkbox for edit form */} {/* Live chips preview and Follow-up status checkboxes for edit form */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px', marginTop: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px', marginTop: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>ISO Preview:</span> <span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>ISO Preview:</span>
@@ -587,9 +789,10 @@ export default function Trips() {
)} )}
</div> </div>
{isPostTravel && ( <div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>Follow-up:</span>
<label <label
htmlFor="edit-followup-email" htmlFor="edit-followup-in-transit"
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
@@ -597,31 +800,55 @@ export default function Trips() {
cursor: 'pointer', cursor: 'pointer',
fontSize: '12px', fontSize: '12px',
fontWeight: 600, fontWeight: 600,
color: editTripForm.status === 'followed-up' ? '#06b6d4' : 'var(--color-text-secondary)', color: editTripForm.followUpStatus?.includes('in-transit') ? '#3b82f6' : 'var(--color-text-secondary)',
background: editTripForm.status === 'followed-up' ? 'rgba(6, 182, 212, 0.12)' : 'rgba(255, 255, 255, 0.04)', background: editTripForm.followUpStatus?.includes('in-transit') ? 'rgba(59, 130, 246, 0.12)' : 'rgba(255, 255, 255, 0.04)',
padding: '4px 10px', padding: '4px 10px',
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
border: editTripForm.status === 'followed-up' ? '1px solid rgba(6, 182, 212, 0.35)' : '1px solid var(--color-border)', border: editTripForm.followUpStatus?.includes('in-transit') ? '1px solid rgba(59, 130, 246, 0.35)' : '1px solid var(--color-border)',
transition: 'all var(--transition-fast)', transition: 'all var(--transition-fast)',
userSelect: 'none', userSelect: 'none',
}} }}
> >
<input <input
id="edit-followup-email" id="edit-followup-in-transit"
type="checkbox" type="checkbox"
checked={editTripForm.status === 'followed-up'} checked={Boolean(editTripForm.followUpStatus?.includes('in-transit'))}
onChange={(e) => onChange={() => handleFollowUpToggle('in-transit')}
setEditTripForm({ disabled={submitting}
...editTripForm, style={{ cursor: 'pointer', accentColor: '#3b82f6' }}
status: e.target.checked ? 'followed-up' : '', />
}) <span>In-Transit</span>
} </label>
<label
htmlFor="edit-followup-post-travel"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 600,
color: editTripForm.follow_up_sfollowUpStatustatus?.includes('post-travel') ? '#06b6d4' : 'var(--color-text-secondary)',
background: editTripForm.followUpStatus?.includes('post-travel') ? 'rgba(6, 182, 212, 0.12)' : 'rgba(255, 255, 255, 0.04)',
padding: '4px 10px',
borderRadius: 'var(--radius-md)',
border: editTripForm.followUpStatus?.includes('post-travel') ? '1px solid rgba(6, 182, 212, 0.35)' : '1px solid var(--color-border)',
transition: 'all var(--transition-fast)',
userSelect: 'none',
}}
>
<input
id="edit-followup-post-travel"
type="checkbox"
checked={Boolean(editTripForm.followUpStatus?.includes('post-travel'))}
onChange={() => handleFollowUpToggle('post-travel')}
disabled={submitting} disabled={submitting}
style={{ cursor: 'pointer', accentColor: '#06b6d4' }} style={{ cursor: 'pointer', accentColor: '#06b6d4' }}
/> />
<span>Follow-up Email</span> <span>Post-Travel</span>
</label> </label>
)} </div>
</div> </div>
</div> </div>
@@ -727,9 +954,31 @@ export default function Trips() {
<td>{formatDate(p.departure)}</td> <td>{formatDate(p.departure)}</td>
<td>{formatDate(p.arrival)}</td> <td>{formatDate(p.arrival)}</td>
<td> <td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
<span className={`status-pill ${meta.className}`}> <span className={`status-pill ${meta.className}`}>
{meta.label} {meta.label}
</span> </span>
{((Array.isArray(p.followUpStatus) && p.followUpStatus.length > 0) || (Array.isArray(p.followUpStatus) && p.followUpStatus.length > 0)) && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(p.followUpStatus).map((st) => (
<span
key={st}
style={{
fontSize: '10px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: '4px',
background: st === 'in-transit' ? 'rgba(59, 130, 246, 0.15)' : 'rgba(6, 182, 212, 0.15)',
color: st === 'in-transit' ? '#60a5fa' : '#22d3ee',
border: st === 'in-transit' ? '1px solid rgba(59, 130, 246, 0.3)' : '1px solid rgba(6, 182, 212, 0.3)',
}}
>
</span>
))}
</div>
)}
</div>
</td> </td>
<td style={{ textAlign: 'right' }}> <td style={{ textAlign: 'right' }}>
<div className="trips-list-actions" style={{ justifyContent: 'flex-end' }}> <div className="trips-list-actions" style={{ justifyContent: 'flex-end' }}>
+1148 -14
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
/**
* Client helper for fetching customer chart from Juvonno EMR via Electron IPC bridge.
*/
function getJuvonnoApi() {
if (!window.api?.juvonno) {
throw new Error('Juvonno IPC bridge unavailable. Please launch inside CTM Concierge desktop app.')
}
return window.api.juvonno
}
export async function fetchCustomerChart(emrId) {
return await getJuvonnoApi().fetchChart(emrId)
}
+8 -12
View File
@@ -6,33 +6,25 @@
* Calculates the dynamic travel status of a patient based on departure and arrival dates. * Calculates the dynamic travel status of a patient based on departure and arrival dates.
* *
* Rules: * Rules:
* 1. If patient status is explicitly 'at-risk', preserve 'at-risk'. * 1. If patient is marked as at-risk (is_at_risk / isAtRisk), return 'at-risk'.
* 2. If departure or arrival date is missing, fallback to existing status or 'pre-travel'. * 2. If departure or arrival date is missing, fallback to 'pre-travel'.
* 3. Day-based comparison against current date: * 3. Day-based comparison against current date:
* - Before departure date -> 'pre-travel' * - Before departure date -> 'pre-travel'
* - Between departure and arrival date (inclusive) -> 'in-transit' * - Between departure and arrival date (inclusive) -> 'in-transit'
* - After arrival date -> 'post-travel' * - After arrival date -> 'post-travel'
* *
* @param {Object} patient - Patient record containing departure, arrival, and optional status * @param {Object} patient - Patient record containing departure, arrival, and optional at-risk flags
* @param {Date} [refDate=new Date()] - Reference date for comparison * @param {Date} [refDate=new Date()] - Reference date for comparison
* @returns {string} Calculated status ('at-risk' | 'pre-travel' | 'in-transit' | 'post-travel') * @returns {string} Calculated status ('at-risk' | 'pre-travel' | 'in-transit' | 'post-travel')
*/ */
export function calculatePatientStatus(patient, refDate = new Date()) { export function calculatePatientStatus(patient, refDate = new Date()) {
if (!patient) return 'pre-travel' if (!patient) return 'pre-travel'
// Priority override: operational 'at-risk' and 'followed-up' flags
if (patient.status === 'at-risk') {
return 'at-risk'
}
if (patient.status === 'followed-up') {
return 'followed-up'
}
const departureStr = (patient.departure || '').slice(0, 10) const departureStr = (patient.departure || '').slice(0, 10)
const arrivalStr = (patient.arrival || '').slice(0, 10) const arrivalStr = (patient.arrival || '').slice(0, 10)
if (!departureStr || !arrivalStr) { if (!departureStr || !arrivalStr) {
return patient.status || 'pre-travel' return 'pre-travel'
} }
// Format reference date to YYYY-MM-DD in local time // Format reference date to YYYY-MM-DD in local time
@@ -45,6 +37,10 @@ export function calculatePatientStatus(patient, refDate = new Date()) {
return 'pre-travel' return 'pre-travel'
} }
if (todayStr >= departureStr && todayStr <= arrivalStr) { if (todayStr >= departureStr && todayStr <= arrivalStr) {
// Priority override: operational 'at-risk' flag if in transit
if (patient.isAtRisk) {
return 'at-risk'
}
return 'in-transit' return 'in-transit'
} }
return 'post-travel' return 'post-travel'
+4
View File
@@ -12,6 +12,10 @@ export async function fetchEntries() {
return processPatientRecords(data) return processPatientRecords(data)
} }
export async function fetchTravellers() {
return getPbApi().fetchTravellers()
}
export async function createEntry(tripData) { export async function createEntry(tripData) {
const payload = tripData?.trip ? tripData.trip : tripData const payload = tripData?.trip ? tripData.trip : tripData
return getPbApi().createEntry(payload) return getPbApi().createEntry(payload)