Split Travellers data into Trips; Added 'Risk Audit' screen; Added follow-up checks; connected 'Trips' to Juvonno.
This commit is contained in:
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
fetchEntries,
|
||||
fetchTravellers,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
saveVaccineRecord,
|
||||
updateVaccineRecord,
|
||||
} from './pocketbase.js'
|
||||
import { JUVONNO_API_URL, JUVONNO_API_KEY } from './juvonno-config.js'
|
||||
|
||||
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 () => {
|
||||
return await fetchEntries()
|
||||
})
|
||||
ipcMain.handle('pb:fetch-travellers', async () => {
|
||||
return await fetchTravellers()
|
||||
})
|
||||
ipcMain.handle('pb:create-entry', async (_event, trip) => {
|
||||
return await createEntry(trip)
|
||||
})
|
||||
@@ -125,4 +130,54 @@ ipcMain.handle('pb:update-vaccine-record', async (_event, 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()
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
+228
-64
@@ -1,10 +1,25 @@
|
||||
import PocketBase from 'pocketbase'
|
||||
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 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) {
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -48,15 +63,17 @@ export async function fetchVaccineRecords() {
|
||||
export async function saveVaccineRecord(data) {
|
||||
await ensureAuth()
|
||||
const client = getClient()
|
||||
const travellerId = data.traveller_id
|
||||
const travellerId = data.traveller_id || data.travellerId || data.traveller
|
||||
|
||||
let existing = null
|
||||
try {
|
||||
const list = await client.collection(VACCINE_RECORDS_COLLECTION).getList(1, 50, {
|
||||
filter: `traveller_id = "${travellerId}"`
|
||||
})
|
||||
if (list.items && list.items.length > 0) {
|
||||
existing = list.items[0]
|
||||
if (travellerId) {
|
||||
const list = await client.collection(VACCINE_RECORDS_COLLECTION).getList(1, 50, {
|
||||
filter: `traveller_id = "${travellerId}"`
|
||||
})
|
||||
if (list.items && list.items.length > 0) {
|
||||
existing = list.items[0]
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore filter error
|
||||
@@ -69,52 +86,76 @@ export async function saveVaccineRecord(data) {
|
||||
timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [],
|
||||
}
|
||||
|
||||
let record
|
||||
if (existing) {
|
||||
const record = await client.collection(VACCINE_RECORDS_COLLECTION).update(existing.id, payload)
|
||||
return parseVaccineRecord(record)
|
||||
record = await client.collection(VACCINE_RECORDS_COLLECTION).update(existing.id, payload)
|
||||
} else {
|
||||
const record = await client.collection(VACCINE_RECORDS_COLLECTION).create(payload)
|
||||
return parseVaccineRecord(record)
|
||||
record = await client.collection(VACCINE_RECORDS_COLLECTION).create(payload)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
await ensureAuth()
|
||||
const travellerId = data.traveller_id
|
||||
const client = getClient()
|
||||
const travellerId = data.traveller_id || data.travellerId || data.traveller
|
||||
const payload = {
|
||||
traveller_id: travellerId,
|
||||
traveller_name: data.travellerName || data.traveller_name || '',
|
||||
emr_id: data.emrID || data.emr_id || null,
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
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 {
|
||||
id: r.id,
|
||||
recordID: r.record_id ?? r.recordID ?? '',
|
||||
emrID: r.emr_id ?? '',
|
||||
name: r.name ?? '',
|
||||
location: r.location ?? '',
|
||||
tripId: r.id,
|
||||
travellerId: travellerId,
|
||||
name: traveller?.name ?? r.name ?? '',
|
||||
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 ?? '',
|
||||
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 ?? '',
|
||||
arrival: r.arrival ?? '',
|
||||
created: r.created,
|
||||
@@ -145,61 +186,184 @@ export async function ensureAuth() {
|
||||
|
||||
export async function fetchEntries() {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
await ensureAuth()
|
||||
const payload = {
|
||||
name: trip.name,
|
||||
location: trip.location,
|
||||
countries_iso: trip.countriesISO,
|
||||
departure: trip.departure,
|
||||
arrival: trip.arrival,
|
||||
const client = getClient()
|
||||
|
||||
let traveller = null
|
||||
|
||||
// 1. Validate mandatory emrID and search existing traveller by emr_id (string)
|
||||
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]
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[pocketbase] Failed looking up traveller with emr_id "${emrStr}":`, e.message)
|
||||
}
|
||||
if (trip.recordID || trip.record_id) {
|
||||
payload.record_id = trip.recordID || trip.record_id
|
||||
|
||||
// 2. If no traveller found with this emr_id, create a new record in travellers collection
|
||||
if (!traveller) {
|
||||
const travellerPayload = {
|
||||
emr_id: emrStr,
|
||||
name: trip.name ? trip.name.trim() : '',
|
||||
}
|
||||
traveller = await client.collection(TRAVELLERS_COLLECTION).create(travellerPayload)
|
||||
}
|
||||
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
|
||||
const emrNum = Number(trip.emrID)
|
||||
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
|
||||
|
||||
// 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 getClient().collection(COLLECTION).create(payload)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export async function updateEntry(id, trip) {
|
||||
await ensureAuth()
|
||||
const payload = {
|
||||
name: trip.name,
|
||||
location: trip.location,
|
||||
countries_iso: trip.countriesISO,
|
||||
departure: trip.departure,
|
||||
arrival: trip.arrival,
|
||||
const client = getClient()
|
||||
|
||||
// 1. Fetch current trip to identify the linked traveller
|
||||
let existingTrip = null
|
||||
try {
|
||||
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.recordID !== undefined || trip.record_id !== undefined) {
|
||||
payload.record_id = trip.recordID || trip.record_id || null
|
||||
if (trip.countriesISO !== undefined || trip.countries_iso !== undefined) {
|
||||
tripPayload.countries_iso = trip.countriesISO ?? trip.countries_iso ?? []
|
||||
}
|
||||
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
|
||||
const emrNum = Number(trip.emrID)
|
||||
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
|
||||
} else if (trip.emrID === '') {
|
||||
payload.emr_id = null
|
||||
if (trip.departure !== undefined) {
|
||||
tripPayload.departure = trip.departure
|
||||
}
|
||||
const record = await getClient().collection(COLLECTION).update(id, payload)
|
||||
return parseEntry(record)
|
||||
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) {
|
||||
travellerPayload.record_id = trip.recordID || trip.record_id || null
|
||||
}
|
||||
|
||||
if (Object.keys(travellerPayload).length > 0) {
|
||||
try {
|
||||
updatedTraveller = await client.collection(TRAVELLERS_COLLECTION).update(travellerId, travellerPayload)
|
||||
} catch (e) {
|
||||
console.warn(`[pocketbase] Could not update traveller ${travellerId}:`, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedTrip.expand = { ...updatedTrip.expand, traveller_id: updatedTraveller }
|
||||
return parseEntry(updatedTrip)
|
||||
}
|
||||
|
||||
export async function deleteEntry(id) {
|
||||
await ensureAuth()
|
||||
await getClient().collection(COLLECTION).delete(id)
|
||||
return await getClient().collection(TRIPS_COLLECTION).update(id, {
|
||||
archived: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'schedule:fetch',
|
||||
'app:ready',
|
||||
'pb:fetch-entries',
|
||||
'pb:fetch-travellers',
|
||||
'pb:create-entry',
|
||||
'pb:update-entry',
|
||||
'pb:delete-entry',
|
||||
@@ -23,6 +24,8 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'pb:fetch-vaccine-records',
|
||||
'pb:save-vaccine-record',
|
||||
'pb:update-vaccine-record',
|
||||
'audit:run-health-audit',
|
||||
'juvonno:fetch-chart',
|
||||
]
|
||||
|
||||
const ALLOWED_RECEIVE_CHANNELS = [
|
||||
@@ -31,6 +34,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'schedule:response',
|
||||
'app:get-version',
|
||||
'pb:fetch-entries',
|
||||
'pb:fetch-travellers',
|
||||
'pb:create-entry',
|
||||
'pb:update-entry',
|
||||
'pb:delete-entry',
|
||||
@@ -38,6 +42,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'pb:fetch-vaccine-records',
|
||||
'pb:save-vaccine-record',
|
||||
'pb:update-vaccine-record',
|
||||
'audit:run-health-audit',
|
||||
'juvonno:fetch-chart',
|
||||
]
|
||||
|
||||
// ─── Exposed API ──────────────────────────────────────────────────────────
|
||||
@@ -47,6 +53,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
*/
|
||||
pb: {
|
||||
fetchEntries: () => ipcRenderer.invoke('pb:fetch-entries'),
|
||||
fetchTravellers: () => ipcRenderer.invoke('pb:fetch-travellers'),
|
||||
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),
|
||||
@@ -56,6 +63,20 @@ contextBridge.exposeInMainWorld('api', {
|
||||
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.
|
||||
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
+2
-3
@@ -2,7 +2,6 @@
|
||||
"name": "ctm-concierge",
|
||||
"version": "1.0.0",
|
||||
"description": "CTM Concierge - Real-time patient journey tracker for CTM",
|
||||
"homepage": "https://canadatravelmed.ca",
|
||||
"author": {
|
||||
"name": "Wirediv",
|
||||
"email": "info@wirediv.com"
|
||||
@@ -52,11 +51,11 @@
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "public/icon.ico"
|
||||
"icon": "icons/icon.ico"
|
||||
},
|
||||
"mac": {
|
||||
"target": "dmg",
|
||||
"icon": "public/icon.icns"
|
||||
"icon": "icons/icon.icns"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Dashboard from './components/Dashboard.jsx'
|
||||
import LiveTracking from './components/LiveTracking.jsx'
|
||||
import Trips from './components/Trips.jsx'
|
||||
import Records from './components/Records.jsx'
|
||||
import RiskAudit from './components/RiskAudit.jsx'
|
||||
import PreTravel from './components/PreTravel.jsx'
|
||||
/**
|
||||
* App — root layout component.
|
||||
@@ -18,6 +19,7 @@ export default function App() {
|
||||
case 'live-tracking': return <LiveTracking />
|
||||
case 'trips': return <Trips />
|
||||
case 'records': return <Records />
|
||||
case 'risk-audit': return <RiskAudit />
|
||||
case 'pre-travel': return <PreTravel />
|
||||
default: return <Dashboard />
|
||||
}
|
||||
|
||||
@@ -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
@@ -19,6 +19,21 @@ const STATUS_META = {
|
||||
'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() {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return 'Good morning'
|
||||
@@ -223,18 +238,19 @@ export default function Dashboard() {
|
||||
<th scope="col">Departure</th>
|
||||
<th scope="col">Arrival</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope='col'>Follow-up</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{travellers.length === 0 && !loading ? (
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredTravellers.length === 0 ? (
|
||||
<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' }}>
|
||||
<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>
|
||||
@@ -264,13 +280,48 @@ export default function Dashboard() {
|
||||
<td>{p.emrID}</td>
|
||||
<td className="patient-name">{p.name}</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.arrival).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-CA', { timeZone: 'UTC', month: 'short', day: 'numeric', year: 'numeric', })}</td>
|
||||
<td>
|
||||
<span className={`status-pill ${meta.className}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -36,8 +36,7 @@ const STATUS_META = {
|
||||
'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' },
|
||||
'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' },
|
||||
'followed-up': { label: 'Followed Up', colorVar: '--color-primary', cssClass: 'status-followed-up', markerClass: 'marker-blue' },
|
||||
'post-travel': { label: 'Post-Travel', colorVar: '--color-accent-emerald', cssClass: 'status-post-travel', markerClass: 'marker-emerald' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -699,7 +698,7 @@ export default function LiveTracking() {
|
||||
<div className="log-detail-item">
|
||||
<span className="log-detail-label">Dates</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -452,9 +452,10 @@ export default function PreTravel() {
|
||||
}),
|
||||
}))
|
||||
|
||||
const targetTravellerId = selectedTrip.travellerId || selectedTrip.traveller_id || selectedTrip.id
|
||||
const savedRec = await saveVaccineRecord({
|
||||
traveller_id: selectedTrip.id,
|
||||
traveller: selectedTrip.id,
|
||||
traveller_id: targetTravellerId,
|
||||
traveller: targetTravellerId,
|
||||
traveller_name: selectedTrip.name,
|
||||
emr_id: selectedTrip.emrID,
|
||||
timelines: vaccineTimelines,
|
||||
|
||||
+14
-17
@@ -5,19 +5,15 @@ import {
|
||||
Search,
|
||||
Plus,
|
||||
Save,
|
||||
CheckCircle,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
X,
|
||||
RotateCw,
|
||||
Check,
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
CalendarDays,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchEntries,
|
||||
fetchTravellers,
|
||||
fetchVaccineRecords,
|
||||
fetchVaccines,
|
||||
saveVaccineRecord,
|
||||
@@ -51,7 +47,7 @@ export default function Records() {
|
||||
setError(null)
|
||||
try {
|
||||
const [travellerData, recordData, catalogData] = await Promise.all([
|
||||
fetchEntries(),
|
||||
fetchTravellers(),
|
||||
fetchVaccineRecords(),
|
||||
fetchVaccines(),
|
||||
])
|
||||
@@ -82,12 +78,14 @@ export default function Records() {
|
||||
|
||||
const existingRecord = useMemo(() => {
|
||||
if (!selectedTravellerId) return null
|
||||
const targetTid = selectedTraveller?.travellerId || selectedTraveller?.traveller_id || selectedTraveller?.id
|
||||
return (
|
||||
vaccineRecords.find(
|
||||
(r) =>
|
||||
(selectedTraveller?.recordID && r.id === selectedTraveller.recordID) ||
|
||||
(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
|
||||
)
|
||||
}, [vaccineRecords, selectedTravellerId, selectedTraveller])
|
||||
@@ -213,16 +211,21 @@ export default function Records() {
|
||||
setSaving(true)
|
||||
setSaveSuccess(null)
|
||||
try {
|
||||
const targetTravellerId = selectedTraveller.travellerId || selectedTraveller.traveller_id || selectedTraveller.id
|
||||
await saveVaccineRecord({
|
||||
traveller_id: selectedTraveller.id,
|
||||
traveller_id: targetTravellerId,
|
||||
traveller_name: selectedTraveller.name,
|
||||
emr_id: selectedTraveller.emrID,
|
||||
timelines: editableTimelines,
|
||||
})
|
||||
|
||||
// Reload vaccine records
|
||||
const recordData = await fetchVaccineRecords()
|
||||
// Reload vaccine records and travellers to sync record_id status
|
||||
const [recordData, travellerData] = await Promise.all([
|
||||
fetchVaccineRecords(),
|
||||
fetchTravellers(),
|
||||
])
|
||||
setVaccineRecords(recordData || [])
|
||||
setTravellers(travellerData || [])
|
||||
setIsDirty(false)
|
||||
setSaveSuccess(`Vaccine record for ${selectedTraveller.name} updated successfully!`)
|
||||
setTimeout(() => setSaveSuccess(null), 5000)
|
||||
@@ -361,7 +364,7 @@ export default function Records() {
|
||||
) : (
|
||||
filteredTravellers.map((traveller) => {
|
||||
const isSelected = traveller.id === selectedTravellerId
|
||||
const summary = travellerRecordSummaries[traveller.id]
|
||||
const summary = travellerRecordSummaries[traveller.travellerId] || travellerRecordSummaries[traveller.id]
|
||||
|
||||
let badgeClass = 'rec-badge-none'
|
||||
let badgeText = 'No Record'
|
||||
@@ -417,12 +420,6 @@ export default function Records() {
|
||||
</span>
|
||||
)}
|
||||
</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 style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
|
||||
@@ -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 1–4 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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Syringe,
|
||||
LayoutDashboard,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Settings,
|
||||
MapPin,
|
||||
Briefcase,
|
||||
ShieldAlert
|
||||
} from 'lucide-react'
|
||||
import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
|
||||
import { fetchEntries } from '../lib/pocketbase.js'
|
||||
@@ -18,10 +19,14 @@ const NAV_ITEMS = [
|
||||
{ id: 'trips', label: 'Trips', icon: Plane, 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 },
|
||||
]
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
const BOTTOM_ITEMS = [
|
||||
const SYSTEM_ITEMS = [
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, 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>
|
||||
{BOTTOM_ITEMS.map((item) => {
|
||||
{SYSTEM_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
|
||||
+353
-104
@@ -10,8 +10,10 @@ import {
|
||||
Trash2,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
|
||||
import { fetchCustomerChart } from '../lib/juvonno.js'
|
||||
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
|
||||
import CountrySelect from './CountrySelect.jsx'
|
||||
|
||||
@@ -20,7 +22,6 @@ const STATUS_META = {
|
||||
'in-transit': { label: 'In Transit', className: 'status-in-transit' },
|
||||
'at-risk': { label: 'At Risk', className: 'status-at-risk' },
|
||||
'post-travel': { label: 'Post-Travel', className: 'status-post-travel' },
|
||||
'followed-up': { label: 'Followed Up', className: 'status-followed-up' },
|
||||
}
|
||||
|
||||
const INITIAL_NEW_FORM = {
|
||||
@@ -30,7 +31,7 @@ const INITIAL_NEW_FORM = {
|
||||
countriesISO: [],
|
||||
departure: '',
|
||||
arrival: '',
|
||||
status: '',
|
||||
followUpStatus: [],
|
||||
}
|
||||
|
||||
export default function Trips() {
|
||||
@@ -42,6 +43,9 @@ export default function Trips() {
|
||||
|
||||
// New Trip form state (Left Panel)
|
||||
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)
|
||||
const [editingTrip, setEditingTrip] = useState(null)
|
||||
@@ -72,9 +76,62 @@ export default function Trips() {
|
||||
}
|
||||
}, [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)
|
||||
async function handleCreateTrip(e) {
|
||||
e.preventDefault()
|
||||
if (!newTripForm.emrID || String(newTripForm.emrID).trim() === '') {
|
||||
setError('Please enter an EMR ID.')
|
||||
return
|
||||
}
|
||||
if (!newTripForm.name.trim()) {
|
||||
setError('Please enter a patient / traveller name.')
|
||||
return
|
||||
@@ -89,17 +146,20 @@ export default function Trips() {
|
||||
: newTripForm.countriesISO
|
||||
|
||||
const payload = {
|
||||
emrID: newTripForm.emrID ? Number(newTripForm.emrID) : null,
|
||||
emrID: newTripForm.emrID ? String(newTripForm.emrID).trim() : '',
|
||||
name: newTripForm.name.trim(),
|
||||
location: newTripForm.location.trim(),
|
||||
locations: newTripForm.location.trim(),
|
||||
countriesISO: isoArray,
|
||||
departure: newTripForm.departure,
|
||||
arrival: newTripForm.arrival,
|
||||
followUpStatus: newTripForm.followUpStatus || [],
|
||||
}
|
||||
|
||||
try {
|
||||
await createEntry(payload)
|
||||
setNewTripForm(INITIAL_NEW_FORM)
|
||||
setJuvonnoPatient(null)
|
||||
setEmrLookupError(null)
|
||||
setSuccessMsg('New trip created successfully!')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
@@ -114,6 +174,12 @@ export default function Trips() {
|
||||
setEditingTrip(trip)
|
||||
|
||||
const isos = getPatientISOs(trip)
|
||||
const followUps = Array.isArray(trip.followUpStatus)
|
||||
? trip.followUpStatus
|
||||
: Array.isArray(trip.followUpStatus)
|
||||
? trip.followUpStatus
|
||||
: []
|
||||
|
||||
setEditTripForm({
|
||||
emrID: trip.emrID !== undefined && trip.emrID !== null ? trip.emrID : '',
|
||||
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()) : [],
|
||||
departure: trip.departure ? trip.departure.slice(0, 10) : '',
|
||||
arrival: trip.arrival ? trip.arrival.slice(0, 10) : '',
|
||||
status: trip.status || '',
|
||||
followUpStatus: followUps,
|
||||
})
|
||||
|
||||
// Scroll top container into view smoothly
|
||||
@@ -133,10 +199,26 @@ export default function Trips() {
|
||||
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)
|
||||
async function handleUpdateTrip(e) {
|
||||
e.preventDefault()
|
||||
if (!editingTrip) return
|
||||
if (!editTripForm.emrID || String(editTripForm.emrID).trim() === '') {
|
||||
setError('EMR ID cannot be empty.')
|
||||
return
|
||||
}
|
||||
if (!editTripForm.name.trim()) {
|
||||
setError('Patient name cannot be empty.')
|
||||
return
|
||||
@@ -150,13 +232,13 @@ export default function Trips() {
|
||||
: editTripForm.countriesISO
|
||||
|
||||
const payload = {
|
||||
emrID: editTripForm.emrID ? Number(editTripForm.emrID) : null,
|
||||
emrID: editTripForm.emrID ? String(editTripForm.emrID).trim() : '',
|
||||
name: editTripForm.name.trim(),
|
||||
location: editTripForm.location.trim(),
|
||||
countriesISO: isoArray,
|
||||
departure: editTripForm.departure,
|
||||
arrival: editTripForm.arrival,
|
||||
status: editTripForm.status || '',
|
||||
followUpStatus: editTripForm.followUpStatus || [],
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -194,7 +276,7 @@ export default function Trips() {
|
||||
if (!dateStr) return 'N/A'
|
||||
const date = new Date(dateStr)
|
||||
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
|
||||
@@ -209,6 +291,7 @@ export default function Trips() {
|
||||
: [],
|
||||
departure: newTripForm.departure,
|
||||
arrival: newTripForm.arrival,
|
||||
followUpStatus: newTripForm.followUpStatus || [],
|
||||
}
|
||||
const newDraftStatusKey = calculatePatientStatus(newDraftPatient)
|
||||
const newDraftMeta = STATUS_META[newDraftStatusKey] || STATUS_META['pre-travel']
|
||||
@@ -227,16 +310,11 @@ export default function Trips() {
|
||||
: [],
|
||||
departure: editTripForm.departure,
|
||||
arrival: editTripForm.arrival,
|
||||
status: editTripForm.status,
|
||||
followUpStatus: editTripForm.followUpStatus || [],
|
||||
}
|
||||
const editDraftStatusKey = calculatePatientStatus(editDraftPatient)
|
||||
const editDraftMeta = STATUS_META[editDraftStatusKey] || STATUS_META['pre-travel']
|
||||
const editDraftISOs = getPatientISOs(editDraftPatient)
|
||||
const isPostTravel =
|
||||
calculatePatientStatus({
|
||||
departure: editTripForm.departure,
|
||||
arrival: editTripForm.arrival,
|
||||
}) === 'post-travel' || editTripForm.status === 'followed-up'
|
||||
|
||||
const isEditingMode = editingTrip !== null
|
||||
|
||||
@@ -308,87 +386,208 @@ export default function Trips() {
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreateTrip} className="trips-form">
|
||||
<div className="trips-form-row">
|
||||
<div className="trips-form-field">
|
||||
<label className="trips-form-label" htmlFor="new-emr-id">EMR ID</label>
|
||||
{/* Phase 1: EMR ID Lookup */}
|
||||
<div className="trips-form-field">
|
||||
<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
|
||||
id="new-emr-id"
|
||||
type="number"
|
||||
min="1"
|
||||
className="trips-form-input"
|
||||
placeholder="e.g. 1042"
|
||||
placeholder="EMR ID (e.g. 1042)"
|
||||
value={newTripForm.emrID}
|
||||
onChange={(e) => setNewTripForm({ ...newTripForm, emrID: e.target.value })}
|
||||
disabled={isEditingMode || submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="trips-form-field">
|
||||
<label className="trips-form-label" htmlFor="new-patient-name">Traveller Name</label>
|
||||
<input
|
||||
id="new-patient-name"
|
||||
type="text"
|
||||
className="trips-form-input"
|
||||
placeholder="e.g. John Doe"
|
||||
value={newTripForm.name}
|
||||
onChange={(e) => setNewTripForm({ ...newTripForm, name: e.target.value })}
|
||||
disabled={isEditingMode || submitting}
|
||||
onChange={(e) => {
|
||||
setNewTripForm({ ...newTripForm, emrID: e.target.value })
|
||||
if (emrLookupError) setEmrLookupError(null)
|
||||
if (juvonnoPatient) {
|
||||
setJuvonnoPatient(null)
|
||||
setNewTripForm((prev) => ({ ...prev, emrID: e.target.value, name: '' }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (['e', 'E', '+', '-', '.'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'Enter' && !juvonnoPatient) {
|
||||
e.preventDefault()
|
||||
handleLookupJuvonno()
|
||||
}
|
||||
}}
|
||||
disabled={isEditingMode || submitting || emrSearching || Boolean(juvonnoPatient)}
|
||||
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>
|
||||
|
||||
<div className="trips-form-field">
|
||||
<label className="trips-form-label">Destination Country(ies)</label>
|
||||
<CountrySelect
|
||||
selectedISOs={newTripForm.countriesISO}
|
||||
onChange={(updatedISOs, updatedLocationStr) => {
|
||||
setNewTripForm({
|
||||
...newTripForm,
|
||||
countriesISO: updatedISOs,
|
||||
location: updatedLocationStr,
|
||||
})
|
||||
{/* 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,
|
||||
}}
|
||||
disabled={isEditingMode || submitting}
|
||||
placeholder="Search country (e.g. Spain, France)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="trips-form-row">
|
||||
<div className="trips-form-field">
|
||||
<label className="trips-form-label" htmlFor="new-departure">Departure Date</label>
|
||||
<input
|
||||
id="new-departure"
|
||||
type="date"
|
||||
className="trips-form-input"
|
||||
value={newTripForm.departure}
|
||||
onChange={(e) => setNewTripForm({ ...newTripForm, departure: e.target.value })}
|
||||
disabled={isEditingMode || submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="trips-form-field">
|
||||
<label className="trips-form-label" htmlFor="new-arrival">Return / Arrival Date</label>
|
||||
<input
|
||||
id="new-arrival"
|
||||
type="date"
|
||||
className="trips-form-input"
|
||||
value={newTripForm.arrival}
|
||||
onChange={(e) => setNewTripForm({ ...newTripForm, arrival: e.target.value })}
|
||||
disabled={isEditingMode || submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="trips-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isEditingMode || submitting}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
{submitting ? 'Creating...' : 'Create Trip'}
|
||||
</button>
|
||||
</div>
|
||||
<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 className="trips-form-field">
|
||||
<label className="trips-form-label">Destination Country(ies)</label>
|
||||
<CountrySelect
|
||||
selectedISOs={newTripForm.countriesISO}
|
||||
onChange={(updatedISOs, updatedLocationStr) => {
|
||||
setNewTripForm({
|
||||
...newTripForm,
|
||||
countriesISO: updatedISOs,
|
||||
location: updatedLocationStr,
|
||||
})
|
||||
}}
|
||||
disabled={isEditingMode || submitting}
|
||||
placeholder="Search country (e.g. Spain, France)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="trips-form-row">
|
||||
<div className="trips-form-field">
|
||||
<label className="trips-form-label" htmlFor="new-departure">Departure Date</label>
|
||||
<input
|
||||
id="new-departure"
|
||||
type="date"
|
||||
className="trips-form-input"
|
||||
value={newTripForm.departure}
|
||||
onChange={(e) => setNewTripForm({ ...newTripForm, departure: e.target.value })}
|
||||
disabled={isEditingMode || submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="trips-form-field">
|
||||
<label className="trips-form-label" htmlFor="new-arrival">Return / Arrival Date</label>
|
||||
<input
|
||||
id="new-arrival"
|
||||
type="date"
|
||||
className="trips-form-input"
|
||||
value={newTripForm.arrival}
|
||||
onChange={(e) => setNewTripForm({ ...newTripForm, arrival: e.target.value })}
|
||||
disabled={isEditingMode || submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="trips-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isEditingMode || submitting}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
{submitting ? 'Creating...' : 'Create Trip'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -491,15 +690,18 @@ export default function Trips() {
|
||||
<div className="trips-form" style={{ marginTop: '8px' }}>
|
||||
<div className="trips-form-row">
|
||||
<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
|
||||
id="edit-emr-id"
|
||||
type="number"
|
||||
type="text"
|
||||
className="trips-form-input"
|
||||
placeholder="e.g. 1042"
|
||||
value={editTripForm.emrID}
|
||||
onChange={(e) => setEditTripForm({ ...editTripForm, emrID: e.target.value })}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -572,7 +774,7 @@ export default function Trips() {
|
||||
</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', gap: '6px', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>ISO Preview:</span>
|
||||
@@ -587,9 +789,10 @@ export default function Trips() {
|
||||
)}
|
||||
</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
|
||||
htmlFor="edit-followup-email"
|
||||
htmlFor="edit-followup-in-transit"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -597,31 +800,55 @@ export default function Trips() {
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: editTripForm.status === 'followed-up' ? '#06b6d4' : 'var(--color-text-secondary)',
|
||||
background: editTripForm.status === 'followed-up' ? 'rgba(6, 182, 212, 0.12)' : 'rgba(255, 255, 255, 0.04)',
|
||||
color: editTripForm.followUpStatus?.includes('in-transit') ? '#3b82f6' : 'var(--color-text-secondary)',
|
||||
background: editTripForm.followUpStatus?.includes('in-transit') ? 'rgba(59, 130, 246, 0.12)' : 'rgba(255, 255, 255, 0.04)',
|
||||
padding: '4px 10px',
|
||||
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)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="edit-followup-email"
|
||||
id="edit-followup-in-transit"
|
||||
type="checkbox"
|
||||
checked={editTripForm.status === 'followed-up'}
|
||||
onChange={(e) =>
|
||||
setEditTripForm({
|
||||
...editTripForm,
|
||||
status: e.target.checked ? 'followed-up' : '',
|
||||
})
|
||||
}
|
||||
checked={Boolean(editTripForm.followUpStatus?.includes('in-transit'))}
|
||||
onChange={() => handleFollowUpToggle('in-transit')}
|
||||
disabled={submitting}
|
||||
style={{ cursor: 'pointer', accentColor: '#3b82f6' }}
|
||||
/>
|
||||
<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}
|
||||
style={{ cursor: 'pointer', accentColor: '#06b6d4' }}
|
||||
/>
|
||||
<span>Follow-up Email</span>
|
||||
<span>Post-Travel</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -727,9 +954,31 @@ export default function Trips() {
|
||||
<td>{formatDate(p.departure)}</td>
|
||||
<td>{formatDate(p.arrival)}</td>
|
||||
<td>
|
||||
<span className={`status-pill ${meta.className}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
|
||||
<span className={`status-pill ${meta.className}`}>
|
||||
{meta.label}
|
||||
</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 style={{ textAlign: 'right' }}>
|
||||
<div className="trips-list-actions" style={{ justifyContent: 'flex-end' }}>
|
||||
|
||||
+1148
-14
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -6,33 +6,25 @@
|
||||
* Calculates the dynamic travel status of a patient based on departure and arrival dates.
|
||||
*
|
||||
* Rules:
|
||||
* 1. If patient status is explicitly 'at-risk', preserve 'at-risk'.
|
||||
* 2. If departure or arrival date is missing, fallback to existing status or 'pre-travel'.
|
||||
* 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 'pre-travel'.
|
||||
* 3. Day-based comparison against current date:
|
||||
* - Before departure date -> 'pre-travel'
|
||||
* - Between departure and arrival date (inclusive) -> 'in-transit'
|
||||
* - 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
|
||||
* @returns {string} Calculated status ('at-risk' | 'pre-travel' | 'in-transit' | 'post-travel')
|
||||
*/
|
||||
export function calculatePatientStatus(patient, refDate = new Date()) {
|
||||
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 arrivalStr = (patient.arrival || '').slice(0, 10)
|
||||
|
||||
if (!departureStr || !arrivalStr) {
|
||||
return patient.status || 'pre-travel'
|
||||
return 'pre-travel'
|
||||
}
|
||||
|
||||
// Format reference date to YYYY-MM-DD in local time
|
||||
@@ -45,6 +37,10 @@ export function calculatePatientStatus(patient, refDate = new Date()) {
|
||||
return 'pre-travel'
|
||||
}
|
||||
if (todayStr >= departureStr && todayStr <= arrivalStr) {
|
||||
// Priority override: operational 'at-risk' flag if in transit
|
||||
if (patient.isAtRisk) {
|
||||
return 'at-risk'
|
||||
}
|
||||
return 'in-transit'
|
||||
}
|
||||
return 'post-travel'
|
||||
|
||||
@@ -12,6 +12,10 @@ export async function fetchEntries() {
|
||||
return processPatientRecords(data)
|
||||
}
|
||||
|
||||
export async function fetchTravellers() {
|
||||
return getPbApi().fetchTravellers()
|
||||
}
|
||||
|
||||
export async function createEntry(tripData) {
|
||||
const payload = tripData?.trip ? tripData.trip : tripData
|
||||
return getPbApi().createEntry(payload)
|
||||
|
||||
Reference in New Issue
Block a user