diff --git a/electron/main/index.js b/electron/main/index.js index 7d1a341..2404203 100644 --- a/electron/main/index.js +++ b/electron/main/index.js @@ -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() +}) + + diff --git a/electron/main/juvonno-config.js b/electron/main/juvonno-config.js new file mode 100644 index 0000000..7bfc88d --- /dev/null +++ b/electron/main/juvonno-config.js @@ -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' diff --git a/electron/main/pocketbase.js b/electron/main/pocketbase.js index f2db24f..f3cd4f6 100644 --- a/electron/main/pocketbase.js +++ b/electron/main/pocketbase.js @@ -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, + }) } diff --git a/electron/preload/index.js b/electron/preload/index.js index ea5688d..7b266e2 100644 --- a/electron/preload/index.js +++ b/electron/preload/index.js @@ -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 diff --git a/icons/icon.icns b/icons/icon.icns new file mode 100644 index 0000000..b7ebc75 Binary files /dev/null and b/icons/icon.icns differ diff --git a/icons/icon.ico b/icons/icon.ico new file mode 100644 index 0000000..2286be4 Binary files /dev/null and b/icons/icon.ico differ diff --git a/package.json b/package.json index 8f0c33c..a17f3ff 100644 --- a/package.json +++ b/package.json @@ -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" } } } \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index f262b14..191253d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 case 'trips': return case 'records': return + case 'risk-audit': return case 'pre-travel': return default: return } diff --git a/src/assets/Flag_of_Canada.svg b/src/assets/Flag_of_Canada.svg new file mode 100644 index 0000000..1612ef1 --- /dev/null +++ b/src/assets/Flag_of_Canada.svg @@ -0,0 +1,107 @@ + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + + + + + + + + + diff --git a/src/assets/country-options.json b/src/assets/country-options.json new file mode 100644 index 0000000..5db3843 --- /dev/null +++ b/src/assets/country-options.json @@ -0,0 +1,1152 @@ +[ + { + "label": "Afghanistan", + "iso": "AF", + "cdcCountry": "Afghanistan" + }, + { + "label": "Albania", + "iso": "AL", + "cdcCountry": "Albania" + }, + { + "label": "Algeria", + "iso": "DZ", + "cdcCountry": "Algeria" + }, + { + "label": "American Samoa", + "iso": "AS", + "cdcCountry": "American Samoa" + }, + { + "label": "Andorra", + "iso": "AD", + "cdcCountry": "Andorra" + }, + { + "label": "Angola", + "iso": "AO", + "cdcCountry": "Angola" + }, + { + "label": "Anguilla", + "iso": "AI", + "cdcCountry": "Anguilla" + }, + { + "label": "Antarctica", + "iso": "AQ", + "cdcCountry": "Antarctica" + }, + { + "label": "Antigua and Barbuda", + "iso": "AG", + "cdcCountry": "Antigua and Barbuda" + }, + { + "label": "Argentina", + "iso": "AR", + "cdcCountry": "Argentina" + }, + { + "label": "Armenia", + "iso": "AM", + "cdcCountry": "Armenia" + }, + { + "label": "Aruba", + "iso": "AW", + "cdcCountry": "Aruba" + }, + { + "label": "Australia", + "iso": "AU", + "cdcCountry": "Australia" + }, + { + "label": "Austria", + "iso": "AT", + "cdcCountry": "Austria" + }, + { + "label": "Azerbaijan", + "iso": "AZ", + "cdcCountry": "Azerbaijan" + }, + { + "label": "Azores", + "iso": "PT-20", + "cdcCountry": "Azores" + }, + { + "label": "Bahamas", + "iso": "BS", + "cdcCountry": "Bahamas" + }, + { + "label": "Bahrain", + "iso": "BH", + "cdcCountry": "Bahrain" + }, + { + "label": "Bangladesh", + "iso": "BD", + "cdcCountry": "Bangladesh" + }, + { + "label": "Barbados", + "iso": "BB", + "cdcCountry": "Barbados" + }, + { + "label": "Belarus", + "iso": "BY", + "cdcCountry": "Belarus" + }, + { + "label": "Belgium", + "iso": "BE", + "cdcCountry": "Belgium" + }, + { + "label": "Belize", + "iso": "BZ", + "cdcCountry": "Belize" + }, + { + "label": "Benin", + "iso": "BJ", + "cdcCountry": "Benin" + }, + { + "label": "Bermuda", + "iso": "BM", + "cdcCountry": "Bermuda" + }, + { + "label": "Bhutan", + "iso": "BT", + "cdcCountry": "Bhutan" + }, + { + "label": "Bolivia", + "iso": "BO", + "cdcCountry": "Bolivia" + }, + { + "label": "Bonaire", + "iso": "BQ", + "cdcCountry": "Bonaire" + }, + { + "label": "Bosnia and Herzegovina", + "iso": "BA", + "cdcCountry": "Bosnia and Herzegovina" + }, + { + "label": "Botswana", + "iso": "BW", + "cdcCountry": "Botswana" + }, + { + "label": "Brazil", + "iso": "BR", + "cdcCountry": "Brazil" + }, + { + "label": "British Virgin Islands", + "iso": "VG", + "cdcCountry": "British Virgin Islands" + }, + { + "label": "Brunei", + "iso": "BN", + "cdcCountry": "Brunei" + }, + { + "label": "Bulgaria", + "iso": "BG", + "cdcCountry": "Bulgaria" + }, + { + "label": "Burkina Faso", + "iso": "BF", + "cdcCountry": "Burkina Faso" + }, + { + "label": "Burundi", + "iso": "BI", + "cdcCountry": "Burundi" + }, + { + "label": "Cabo Verde", + "iso": "CV", + "cdcCountry": "Cape Verde" + }, + { + "label": "Cambodia", + "iso": "KH", + "cdcCountry": "Cambodia" + }, + { + "label": "Cameroon", + "iso": "CM", + "cdcCountry": "Cameroon" + }, + { + "label": "Canary Islands", + "iso": "IC", + "cdcCountry": "Canary Islands" + }, + { + "label": "Cayman Islands", + "iso": "KY", + "cdcCountry": "Cayman Islands" + }, + { + "label": "Central African Republic", + "iso": "CF", + "cdcCountry": "Central African Republic" + }, + { + "label": "Chad", + "iso": "TD", + "cdcCountry": "Chad" + }, + { + "label": "Chile", + "iso": "CL", + "cdcCountry": "Chile" + }, + { + "label": "China", + "iso": "CN", + "cdcCountry": "China" + }, + { + "label": "Colombia", + "iso": "CO", + "cdcCountry": "Colombia" + }, + { + "label": "Comoros", + "iso": "KM", + "cdcCountry": "Comoros" + }, + { + "label": "Cook Islands", + "iso": "CK", + "cdcCountry": "Cook Islands" + }, + { + "label": "Costa Rica", + "iso": "CR", + "cdcCountry": "Costa Rica" + }, + { + "label": "Croatia", + "iso": "HR", + "cdcCountry": "Croatia" + }, + { + "label": "Cuba", + "iso": "CU", + "cdcCountry": "Cuba" + }, + { + "label": "Curaçao", + "iso": "CW", + "cdcCountry": "Curaçao" + }, + { + "label": "Cyprus", + "iso": "CY", + "cdcCountry": "Cyprus" + }, + { + "label": "Czechia", + "iso": "CZ", + "cdcCountry": "Czechia" + }, + { + "label": "Democratic Republic of Congo", + "iso": "CD", + "cdcCountry": "Democratic Republic of the Congo" + }, + { + "label": "Denmark", + "iso": "DK", + "cdcCountry": "Denmark" + }, + { + "label": "Djibouti", + "iso": "DJ", + "cdcCountry": "Djibouti" + }, + { + "label": "Dominica", + "iso": "DM", + "cdcCountry": "Dominica" + }, + { + "label": "Dominican Republic", + "iso": "DO", + "cdcCountry": "Dominican Republic" + }, + { + "label": "Ecuador", + "iso": "EC", + "cdcCountry": "Ecuador" + }, + { + "label": "Egypt", + "iso": "EG", + "cdcCountry": "Egypt" + }, + { + "label": "El Salvador", + "iso": "SV", + "cdcCountry": "El Salvador" + }, + { + "label": "Equatorial Guinea", + "iso": "GQ", + "cdcCountry": "Equatorial Guinea" + }, + { + "label": "Eritrea", + "iso": "ER", + "cdcCountry": "Eritrea" + }, + { + "label": "Estonia", + "iso": "EE", + "cdcCountry": "Estonia" + }, + { + "label": "Ethiopia", + "iso": "ET", + "cdcCountry": "Ethiopia" + }, + { + "label": "Falkland Islands", + "iso": "FK", + "cdcCountry": "Falkland Islands" + }, + { + "label": "Fiji", + "iso": "FJ", + "cdcCountry": "Fiji" + }, + { + "label": "Finland", + "iso": "FI", + "cdcCountry": "Finland" + }, + { + "label": "France", + "iso": "FR", + "cdcCountry": "France" + }, + { + "label": "French Guiana", + "iso": "GF", + "cdcCountry": "French Guiana" + }, + { + "label": "French Polynesia", + "iso": "PF", + "cdcCountry": "French Polynesia" + }, + { + "label": "Gabon", + "iso": "GA", + "cdcCountry": "Gabon" + }, + { + "label": "Gambia, The", + "iso": "GM", + "cdcCountry": "Gambia, The" + }, + { + "label": "Georgia", + "iso": "GE", + "cdcCountry": "Georgia" + }, + { + "label": "Germany", + "iso": "DE", + "cdcCountry": "Germany" + }, + { + "label": "Ghana", + "iso": "GH", + "cdcCountry": "Ghana" + }, + { + "label": "Gibraltar", + "iso": "GI", + "cdcCountry": "Gibraltar" + }, + { + "label": "Greece", + "iso": "GR", + "cdcCountry": "Greece" + }, + { + "label": "Greenland", + "iso": "GL", + "cdcCountry": "Greenland" + }, + { + "label": "Grenada", + "iso": "GD", + "cdcCountry": "Grenada" + }, + { + "label": "Guadeloupe", + "iso": "GP", + "cdcCountry": "Guadeloupe" + }, + { + "label": "Guam", + "iso": "GU", + "cdcCountry": "Guam" + }, + { + "label": "Guatemala", + "iso": "GT", + "cdcCountry": "Guatemala" + }, + { + "label": "Guinea", + "iso": "GN", + "cdcCountry": "Guinea" + }, + { + "label": "Guinea-Bissau", + "iso": "GW", + "cdcCountry": "Guinea-Bissau" + }, + { + "label": "Guyana", + "iso": "GY", + "cdcCountry": "Guyana" + }, + { + "label": "Haiti", + "iso": "HT", + "cdcCountry": "Haiti" + }, + { + "label": "Honduras", + "iso": "HN", + "cdcCountry": "Honduras" + }, + { + "label": "Hong Kong", + "iso": "HK", + "cdcCountry": "Hong Kong" + }, + { + "label": "Hungary", + "iso": "HU", + "cdcCountry": "Hungary" + }, + { + "label": "Iceland", + "iso": "IS", + "cdcCountry": "Iceland" + }, + { + "label": "India", + "iso": "IN", + "cdcCountry": "India" + }, + { + "label": "Indonesia", + "iso": "ID", + "cdcCountry": "Indonesia" + }, + { + "label": "Iran", + "iso": "IR", + "cdcCountry": "Iran" + }, + { + "label": "Iraq", + "iso": "IQ", + "cdcCountry": "Iraq" + }, + { + "label": "Ireland", + "iso": "IE", + "cdcCountry": "Ireland" + }, + { + "label": "Israel", + "iso": "IL", + "cdcCountry": "Israel" + }, + { + "label": "Italy", + "iso": "IT", + "cdcCountry": "Italy" + }, + { + "label": "Ivory Coast", + "iso": "CI", + "cdcCountry": "Côte d'Ivoire (Ivory Coast)" + }, + { + "label": "Jamaica", + "iso": "JM", + "cdcCountry": "Jamaica" + }, + { + "label": "Japan", + "iso": "JP", + "cdcCountry": "Japan" + }, + { + "label": "Jordan", + "iso": "JO", + "cdcCountry": "Jordan" + }, + { + "label": "Kazakhstan", + "iso": "KZ", + "cdcCountry": "Kazakhstan" + }, + { + "label": "Kenya", + "iso": "KE", + "cdcCountry": "Kenya" + }, + { + "label": "Kiribati", + "iso": "KI", + "cdcCountry": "Kiribati" + }, + { + "label": "Kosovo", + "iso": "XK", + "cdcCountry": "Kosovo" + }, + { + "label": "Kuwait", + "iso": "KW", + "cdcCountry": "Kuwait" + }, + { + "label": "Kyrgyzstan", + "iso": "KG", + "cdcCountry": "Kyrgyzstan" + }, + { + "label": "Laos", + "iso": "LA", + "cdcCountry": "Laos" + }, + { + "label": "Latvia", + "iso": "LV", + "cdcCountry": "Latvia" + }, + { + "label": "Lebanon", + "iso": "LB", + "cdcCountry": "Lebanon" + }, + { + "label": "Lesotho", + "iso": "LS", + "cdcCountry": "Lesotho" + }, + { + "label": "Liberia", + "iso": "LR", + "cdcCountry": "Liberia" + }, + { + "label": "Libya", + "iso": "LY", + "cdcCountry": "Libya" + }, + { + "label": "Liechtenstein", + "iso": "LI", + "cdcCountry": "Liechtenstein" + }, + { + "label": "Lithuania", + "iso": "LT", + "cdcCountry": "Lithuania" + }, + { + "label": "Luxembourg", + "iso": "LU", + "cdcCountry": "Luxembourg" + }, + { + "label": "Macao", + "iso": "MO", + "cdcCountry": "Macao" + }, + { + "label": "Madagascar", + "iso": "MG", + "cdcCountry": "Madagascar" + }, + { + "label": "Malawi", + "iso": "MW", + "cdcCountry": "Malawi" + }, + { + "label": "Malaysia", + "iso": "MY", + "cdcCountry": "Malaysia" + }, + { + "label": "Maldives", + "iso": "MV", + "cdcCountry": "Maldives" + }, + { + "label": "Mali", + "iso": "ML", + "cdcCountry": "Mali" + }, + { + "label": "Malta", + "iso": "MT", + "cdcCountry": "Malta" + }, + { + "label": "Marshall Islands", + "iso": "MH", + "cdcCountry": "Marshall Islands" + }, + { + "label": "Martinique", + "iso": "MQ", + "cdcCountry": "Martinique" + }, + { + "label": "Mauritania", + "iso": "MR", + "cdcCountry": "Mauritania" + }, + { + "label": "Mauritius", + "iso": "MU", + "cdcCountry": "Mauritius" + }, + { + "label": "Mayotte", + "iso": "YT", + "cdcCountry": "Mayotte" + }, + { + "label": "Mexico", + "iso": "MX", + "cdcCountry": "Mexico" + }, + { + "label": "Micronesia (FSM)", + "iso": "FM", + "cdcCountry": "Micronesia" + }, + { + "label": "Moldova", + "iso": "MD", + "cdcCountry": "Moldova" + }, + { + "label": "Monaco", + "iso": "MC", + "cdcCountry": "Monaco" + }, + { + "label": "Mongolia", + "iso": "MN", + "cdcCountry": "Mongolia" + }, + { + "label": "Montenegro", + "iso": "ME", + "cdcCountry": "Montenegro" + }, + { + "label": "Montserrat", + "iso": "MS", + "cdcCountry": "Montserrat" + }, + { + "label": "Morocco", + "iso": "MA", + "cdcCountry": "Morocco" + }, + { + "label": "Mozambique", + "iso": "MZ", + "cdcCountry": "Mozambique" + }, + { + "label": "Myanmar", + "iso": "MM", + "cdcCountry": "Myanmar" + }, + { + "label": "Namibia", + "iso": "NA", + "cdcCountry": "Namibia" + }, + { + "label": "Nauru", + "iso": "NR", + "cdcCountry": "Nauru" + }, + { + "label": "Nepal", + "iso": "NP", + "cdcCountry": "Nepal" + }, + { + "label": "Netherlands", + "iso": "NL", + "cdcCountry": "Netherlands" + }, + { + "label": "New Caledonia", + "iso": "NC", + "cdcCountry": "New Caledonia" + }, + { + "label": "New Zealand", + "iso": "NZ", + "cdcCountry": "New Zealand" + }, + { + "label": "Nicaragua", + "iso": "NI", + "cdcCountry": "Nicaragua" + }, + { + "label": "Niger", + "iso": "NE", + "cdcCountry": "Niger" + }, + { + "label": "Nigeria", + "iso": "NG", + "cdcCountry": "Nigeria" + }, + { + "label": "Niue", + "iso": "NU", + "cdcCountry": "Niue" + }, + { + "label": "North Korea", + "iso": "KP", + "cdcCountry": "North Korea" + }, + { + "label": "North Macedonia", + "iso": "MK", + "cdcCountry": "North Macedonia" + }, + { + "label": "Northern Marianas", + "iso": "MP", + "cdcCountry": "Northern Mariana Islands" + }, + { + "label": "Norway", + "iso": "NO", + "cdcCountry": "Norway" + }, + { + "label": "Oman", + "iso": "OM", + "cdcCountry": "Oman" + }, + { + "label": "Pakistan", + "iso": "PK", + "cdcCountry": "Pakistan" + }, + { + "label": "Palau", + "iso": "PW", + "cdcCountry": "Palau" + }, + { + "label": "Panama", + "iso": "PA", + "cdcCountry": "Panama" + }, + { + "label": "Papua New Guinea", + "iso": "PG", + "cdcCountry": "Papua New Guinea" + }, + { + "label": "Paraguay", + "iso": "PY", + "cdcCountry": "Paraguay" + }, + { + "label": "Peru", + "iso": "PE", + "cdcCountry": "Peru" + }, + { + "label": "Philippines", + "iso": "PH", + "cdcCountry": "Philippines" + }, + { + "label": "Poland", + "iso": "PL", + "cdcCountry": "Poland" + }, + { + "label": "Portugal", + "iso": "PT", + "cdcCountry": "Portugal" + }, + { + "label": "Puerto Rico", + "iso": "PR", + "cdcCountry": "Puerto Rico" + }, + { + "label": "Qatar", + "iso": "QA", + "cdcCountry": "Qatar" + }, + { + "label": "Republic of Congo", + "iso": "CG", + "cdcCountry": "Republic of the Congo" + }, + { + "label": "Réunion", + "iso": "RE", + "cdcCountry": "Réunion" + }, + { + "label": "Romania", + "iso": "RO", + "cdcCountry": "Romania" + }, + { + "label": "Russia", + "iso": "RU", + "cdcCountry": "Russia" + }, + { + "label": "Rwanda", + "iso": "RW", + "cdcCountry": "Rwanda" + }, + { + "label": "Saint Kitts and Nevis", + "iso": "KN", + "cdcCountry": "Saint Kitts and Nevis" + }, + { + "label": "Saint Lucia", + "iso": "LC", + "cdcCountry": "Saint Lucia" + }, + { + "label": "Saint Martin", + "iso": "MF", + "cdcCountry": "Saint Martin" + }, + { + "label": "Saint Vincent & the Grenadines", + "iso": "VC", + "cdcCountry": "Saint Vincent and the Grenadines" + }, + { + "label": "Saint-Barthélemy", + "iso": "BL", + "cdcCountry": "Saint-Barthélemy" + }, + { + "label": "Saint Pierre and Miquelon", + "iso": "PM", + "cdcCountry": "Saint Pierre and Miquelon" + }, + { + "label": "Samoa", + "iso": "WS", + "cdcCountry": "Samoa" + }, + { + "label": "San Marino", + "iso": "SM", + "cdcCountry": "San Marino" + }, + { + "label": "São Tomé and Principe", + "iso": "ST", + "cdcCountry": "São Tomé and Principe" + }, + { + "label": "Saudi Arabia", + "iso": "SA", + "cdcCountry": "Saudi Arabia" + }, + { + "label": "Senegal", + "iso": "SN", + "cdcCountry": "Senegal" + }, + { + "label": "Serbia", + "iso": "RS", + "cdcCountry": "Serbia" + }, + { + "label": "Seychelles", + "iso": "SC", + "cdcCountry": "Seychelles" + }, + { + "label": "Sierra Leone", + "iso": "SL", + "cdcCountry": "Sierra Leone" + }, + { + "label": "Singapore", + "iso": "SG", + "cdcCountry": "Singapore" + }, + { + "label": "Sint Maarten", + "iso": "SX", + "cdcCountry": "Sint Maarten" + }, + { + "label": "Slovakia", + "iso": "SK", + "cdcCountry": "Slovakia" + }, + { + "label": "Slovenia", + "iso": "SI", + "cdcCountry": "Slovenia" + }, + { + "label": "Solomon Islands", + "iso": "SB", + "cdcCountry": "Solomon Islands" + }, + { + "label": "Somalia", + "iso": "SO", + "cdcCountry": "Somalia" + }, + { + "label": "South Africa", + "iso": "ZA", + "cdcCountry": "South Africa" + }, + { + "label": "South Korea", + "iso": "KR", + "cdcCountry": "South Korea" + }, + { + "label": "South Sudan", + "iso": "SS", + "cdcCountry": "South Sudan" + }, + { + "label": "Spain", + "iso": "ES", + "cdcCountry": "Spain" + }, + { + "label": "Sri Lanka", + "iso": "LK", + "cdcCountry": "Sri Lanka" + }, + { + "label": "Sudan", + "iso": "SD", + "cdcCountry": "Sudan" + }, + { + "label": "Suriname", + "iso": "SR", + "cdcCountry": "Suriname" + }, + { + "label": "Swaziland", + "iso": "SZ", + "cdcCountry": "Swaziland" + }, + { + "label": "Sweden", + "iso": "SE", + "cdcCountry": "Sweden" + }, + { + "label": "Switzerland", + "iso": "CH", + "cdcCountry": "Switzerland" + }, + { + "label": "Syria", + "iso": "SY", + "cdcCountry": "Syria" + }, + { + "label": "Taiwan", + "iso": "TW", + "cdcCountry": "Taiwan" + }, + { + "label": "Tajikistan", + "iso": "TJ", + "cdcCountry": "Tajikistan" + }, + { + "label": "Tanzania", + "iso": "TZ", + "cdcCountry": "Tanzania" + }, + { + "label": "Thailand", + "iso": "TH", + "cdcCountry": "Thailand" + }, + { + "label": "Timor-Leste (East Timor)", + "iso": "TL", + "cdcCountry": "Timor-Leste (East Timor)" + }, + { + "label": "Togo", + "iso": "TG", + "cdcCountry": "Togo" + }, + { + "label": "Tokelau", + "iso": "TK", + "cdcCountry": "Tokelau" + }, + { + "label": "Tonga", + "iso": "TO", + "cdcCountry": "Tonga" + }, + { + "label": "Trinidad and Tobago", + "iso": "TT", + "cdcCountry": "Trinidad and Tobago" + }, + { + "label": "Tunisia", + "iso": "TN", + "cdcCountry": "Tunisia" + }, + { + "label": "Turkmenistan", + "iso": "TM", + "cdcCountry": "Turkmenistan" + }, + { + "label": "Turks and Caicos Islands", + "iso": "TC", + "cdcCountry": "Turks and Caicos Islands" + }, + { + "label": "Tuvalu", + "iso": "TV", + "cdcCountry": "Tuvalu" + }, + { + "label": "Turkey", + "iso": "TR", + "cdcCountry": "Turkey" + }, + { + "label": "Uganda", + "iso": "UG", + "cdcCountry": "Uganda" + }, + { + "label": "Ukraine", + "iso": "UA", + "cdcCountry": "Ukraine" + }, + { + "label": "United Arab Emirates", + "iso": "AE", + "cdcCountry": "United Arab Emirates" + }, + { + "label": "United Kingdom", + "iso": "GB", + "cdcCountry": "United Kingdom" + }, + { + "label": "United States", + "iso": "US", + "cdcCountry": "United States" + }, + { + "label": "Uruguay", + "iso": "UY", + "cdcCountry": "Uruguay" + }, + { + "label": "Uzbekistan", + "iso": "UZ", + "cdcCountry": "Uzbekistan" + }, + { + "label": "Vanuatu", + "iso": "VU", + "cdcCountry": "Vanuatu" + }, + { + "label": "Venezuela", + "iso": "VE", + "cdcCountry": "Venezuela" + }, + { + "label": "Vietnam", + "iso": "VN", + "cdcCountry": "Vietnam" + }, + { + "label": "Virgin Islands (U.S.)", + "iso": "VI", + "cdcCountry": "U.S. Virgin Islands" + }, + { + "label": "Yemen", + "iso": "YE", + "cdcCountry": "Yemen" + }, + { + "label": "Zambia", + "iso": "ZM", + "cdcCountry": "Zambia" + }, + { + "label": "Zimbabwe", + "iso": "ZW", + "cdcCountry": "Zimbabwe" + } +] \ No newline at end of file diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index d24141d..4f85c02 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -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() { Departure Arrival Status + Follow-up {travellers.length === 0 && !loading ? ( - + No travellers found in PocketBase database. ) : filteredTravellers.length === 0 ? ( - +
No patients found matching status "{STATUS_META[selectedStatus]?.label || selectedStatus}". @@ -264,13 +280,48 @@ export default function Dashboard() { {p.emrID} {p.name} {p.location || 'N/A'} - {new Date(p.departure).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} - {new Date(p.arrival).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} + {new Date(p.departure).toLocaleDateString('en-CA', { timeZone: 'UTC', month: 'short', day: 'numeric', year: 'numeric', })} + {new Date(p.arrival).toLocaleDateString('en-CA', { timeZone: 'UTC', month: 'short', day: 'numeric', year: 'numeric', })} {meta.label} + + {Array.isArray(p.followUpStatus) && p.followUpStatus.length > 0 ? ( +
+ {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 ( + + {fuMeta.label} + + ) + })} +
+ ) : ( + + )} + ) }) diff --git a/src/components/LiveTracking.jsx b/src/components/LiveTracking.jsx index 75fc9b8..0f7be00 100644 --- a/src/components/LiveTracking.jsx +++ b/src/components/LiveTracking.jsx @@ -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() {
Dates - {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'}
diff --git a/src/components/PreTravel.jsx b/src/components/PreTravel.jsx index 5be8442..7aa34ce 100644 --- a/src/components/PreTravel.jsx +++ b/src/components/PreTravel.jsx @@ -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, diff --git a/src/components/Records.jsx b/src/components/Records.jsx index 4b9f8a3..4f8e86f 100644 --- a/src/components/Records.jsx +++ b/src/components/Records.jsx @@ -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() { )} -
- {selectedTraveller.location && ( - Destination: {selectedTraveller.location} - )} - Departure: {selectedTraveller.departure ? formatDate(localDate(selectedTraveller.departure)) : 'N/A'} -
diff --git a/src/components/RiskAudit.jsx b/src/components/RiskAudit.jsx new file mode 100644 index 0000000..d28952a --- /dev/null +++ b/src/components/RiskAudit.jsx @@ -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 ( +
+ {/* ── Header ──────────────────────────────────────────────────────────── */} +
+
+
+ + Clinical Consultation Engine +
+

+ Travel Health Risk Audit +

+

+ Aggregate real-time travel health notices, disease outbreaks, and security advisories from GAC, CDC, and WHO. +

+
+ {(auditData || error) && ( + + )} +
+ + {/* ── Main 2-Column Layout ────────────────────────────────────────────── */} +
+ {/* ── Left Column: Configuration Form ───────────────────────────────── */} + + + {/* ── Right Column: Audit Results & Intelligence Display ────────────── */} +
+ {/* Error Banner */} + {error && ( +
+ +
+ Audit Service Connection Error +

{error}

+
+ +
+ )} + + {/* Loading Skeleton */} + {loading && ( +
+
+ +
+
+

Aggregating Global Health Data

+

+ Cross-referencing destination advisories with Canadian consular alerts, CDC travel notices, and WHO disease outbreaks… +

+
+
+ 1 + Fetching GAC Risk Level +
+
+ 2 + Checking CDC Health Notices +
+
+ 3 + Analyzing WHO DON Outbreaks +
+
+
+ )} + + {/* Empty State when no audit has been run */} + {!loading && !auditData && !error && ( +
+
+ +
+

Ready for Consultation Audit

+

+ Select a destination country, travel style, and departure date on the left panel to trigger an automated clinical health risk assessment. +

+
+
+ +
+ Gov. of Canada Advisories +

Security alerts, regional warnings, and health risks

+
+
+
+ +
+ CDC Travel Health Notices +

Level 1–4 health notices, vector-borne spikes

+
+
+
+ +
+ WHO Outbreak Bulletins +

Real-time epidemic tracking and global DON items

+
+
+
+
+ )} + + {/* Audit Results Content */} + {!loading && auditData && ( +
+ {/* Report Header Card */} +
+
+

+ Clinical Travel Health Profile +

+

+ Multi-agency assessment for: +

+ +
+ + + {auditData.destination || lastAuditMeta?.country?.label} + {lastAuditMeta?.country?.iso && ` (${lastAuditMeta.country.iso})`} + + + + {lastAuditMeta?.travelStyle} + + + + {lastAuditMeta?.travelDate} + + {lastAuditMeta?.timestamp && ( + + + Checked {lastAuditMeta.timestamp} + + )} +
+
+ + {/* Score Gauge Badge */} +
+
+ + + {scoreTheme.tier} + +
+
+ {auditData.riskScore ?? '—'} + /10 +
+
Composite Risk Index
+
+
+ + {/* Priority Red Flags (if any) */} + {auditData.redFlags && auditData.redFlags.length > 0 && ( +
+
+ +

Priority Red Flags & Active Notices

+ {auditData.redFlags.length} +
+
+ {auditData.redFlags.map((flag, idx) => { + const isStable = flag.toLowerCase().includes('stable') || flag.toLowerCase().includes('low risk') + return ( +
+
+ {flag} +
+ ) + })} +
+
+ )} + + {/* Clinical Summary Block */} + {auditData.clinicalSummary && ( +
+
+ +

Executive Clinical Summary

+
+

{auditData.clinicalSummary}

+
+ )} + + {/* 3-Way Agency Breakdown Grid */} +
+ {/* ── 1. Canadian Context (GAC) ─────────────────────────────── */} +
+
+
+ + Government of Canada (GAC) +
+
+ +
+ {auditData.canadianContext?.riskLevel && ( +
+ Official Advisory Level +
+ + {auditData.canadianContext.riskLevel} +
+ {auditData.canadianContext?.advisoryText && ( +

{auditData.canadianContext.advisoryText}

+ )} +
+ )} + + {auditData.gacRiskText && ( +
+ Consular & Security Note +

{auditData.gacRiskText}

+
+ )} + + {auditData.canadianContext?.recentUpdates && ( +
+ Latest Advisory Update + {auditData.canadianContext.recentUpdates} +
+ )} + + {/* Source Links */} +
+ {auditData.travelRiskUrl && ( + + GAC Travel Advisory + + + )} + {auditData.gcHealthRiskUrl && ( + + GAC Health Advisory + + + )} +
+
+
+ + {/* ── 2. CDC Context ─────────────────────────────────────────── */} +
+
+
+ + Centers for Disease Control (CDC) +
+
+ +
+ {auditData.cdcContext?.cdcNotices && auditData.cdcContext.cdcNotices.length > 0 ? ( +
+ {auditData.cdcContext.cdcNotices.map((notice, idx) => ( +
+
+ {notice.level || 'Health Notice'} + {notice.pubDate && ( + + {new Date(notice.pubDate).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + + )} +
+

{notice.title}

+ {notice.summary && ( +

{notice.summary}

+ )} + {notice.link && ( + + Read Full CDC Notice + + + )} +
+ ))} +
+ ) : ( +
+ CDC Notice Status +
+ + No active high-severity CDC Travel Health Notices found for this destination. +
+
+ )} + + {auditData.cdcHealthNoticeUrl && ( + + )} +
+
+ + {/* ── 3. WHO Context ─────────────────────────────────────────── */} +
+
+
+ + WHO Disease Outbreak News (DONs) +
+
+ +
+ {auditData.whoContext?.whoNotices && auditData.whoContext.whoNotices.length > 0 ? ( +
+ {auditData.whoContext.whoNotices.map((notice, idx) => ( + + + ) : auditData.donItems && auditData.donItems.length > 0 ? ( +
+ {auditData.donItems.map((item, idx) => { + const url = auditData.donUrls?.[idx] + return url ? ( + +
+
+ {item} +
+ +
+ ) : ( +
+
+
+ {item} +
+
+ ) + })} +
+ ) : ( +
+ + + {auditData.whoContext?.whoMessage || + auditData.donMessage || + 'No recent disease outbreak notices recorded for this destination.'} + +
+ )} +
+
+
+
+ )} +
+
+
+ ) +} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 5d70deb..cf33779 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -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 }) { ) })} + Tools + {TOOLS_ITEMS.map((item) => { + const Icon = item.icon + return ( + + ) + })} + System - {BOTTOM_ITEMS.map((item) => { + {SYSTEM_ITEMS.map((item) => { const Icon = item.icon return ( + ) : ( + + )}
-
- - { - setNewTripForm({ - ...newTripForm, - countriesISO: updatedISOs, - location: updatedLocationStr, - }) + {/* Error message if Juvonno lookup fails */} + {emrLookupError && ( +
-
- -
-
- - setNewTripForm({ ...newTripForm, departure: e.target.value })} - disabled={isEditingMode || submitting} - /> -
- -
- - setNewTripForm({ ...newTripForm, arrival: e.target.value })} - disabled={isEditingMode || submitting} - /> -
-
- -
- -
+ + {emrLookupError} +
+ )} + + {/* Phase 2: Patient Resolved - Show Traveller Name & Reveal Remaining Fields */} + {juvonnoPatient && ( + <> +
+
+ +
+
+
+ Traveller Name (from Juvonno) +
+
+ {juvonnoPatient.fullName} +
+
+
+
+ Date of Birth (from Juvonno) +
+
+ {juvonnoPatient.dob} +
+
+
+ +
+ + { + setNewTripForm({ + ...newTripForm, + countriesISO: updatedISOs, + location: updatedLocationStr, + }) + }} + disabled={isEditingMode || submitting} + placeholder="Search country (e.g. Spain, France)..." + /> +
+ +
+
+ + setNewTripForm({ ...newTripForm, departure: e.target.value })} + disabled={isEditingMode || submitting} + /> +
+ +
+ + setNewTripForm({ ...newTripForm, arrival: e.target.value })} + disabled={isEditingMode || submitting} + /> +
+
+ +
+ +
+ + )} @@ -491,15 +690,18 @@ export default function Trips() {
- + setEditTripForm({ ...editTripForm, emrID: e.target.value })} disabled={submitting} + required />
@@ -572,7 +774,7 @@ export default function Trips() {
- {/* Live chips preview and Follow-up Email checkbox for edit form */} + {/* Live chips preview and Follow-up status checkboxes for edit form */}
ISO Preview: @@ -587,9 +789,10 @@ export default function Trips() { )}
- {isPostTravel && ( +
+ Follow-up: + + - )} +
@@ -727,9 +954,31 @@ export default function Trips() { {formatDate(p.departure)} {formatDate(p.arrival)} - - {meta.label} - +
+ + {meta.label} + + {((Array.isArray(p.followUpStatus) && p.followUpStatus.length > 0) || (Array.isArray(p.followUpStatus) && p.followUpStatus.length > 0)) && ( +
+ {(p.followUpStatus).map((st) => ( + + ✓ + + ))} +
+ )} +
diff --git a/src/index.css b/src/index.css index 6c57006..3b4976f 100644 --- a/src/index.css +++ b/src/index.css @@ -524,7 +524,7 @@ body { } .journey-table-container { - max-height: clamp(600px, 50vh, 610px); + max-height: clamp(700px, 60vh, 720px); overflow-y: auto; position: relative; } @@ -1483,6 +1483,7 @@ button { font-family: var(--font-sans); outline: none; transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + color-scheme: dark; } .trips-form-input:focus { @@ -2222,7 +2223,7 @@ button { display: flex; align-items: center; gap: 8px; - background: var(--color-bg-base); + background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 6px 12px; @@ -2251,14 +2252,13 @@ button { } .pt-first-dose-date-input { - background: transparent; + background: var(--color-bg-subtle); border: none; outline: none; color: var(--color-text-primary); font-size: var(--font-size-xs); font-family: var(--font-sans); cursor: pointer; - color-scheme: dark; } .pt-first-dose-date-input::-webkit-calendar-picker-indicator { @@ -2270,7 +2270,7 @@ button { display: flex; align-items: center; gap: 8px; - background: var(--color-bg-base); + background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 8px 12px; @@ -2884,15 +2884,6 @@ button { gap: var(--space-3); } -.rec-dossier-meta { - display: flex; - align-items: center; - gap: var(--space-4); - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - margin-top: 4px; -} - .rec-compliance-bar-wrap { display: flex; flex-direction: column; @@ -3082,4 +3073,1147 @@ button { justify-content: flex-end; gap: var(--space-3); margin-top: var(--space-2); +} + +/* ───────────────────────────────────────────────────────────────────────────── + Risk Audit — Clinical Consultation Engine + ───────────────────────────────────────────────────────────────────────────── */ + +.risk-audit { + padding: var(--space-8) var(--space-8) var(--space-12); + max-width: 1600px; +} + +/* ── Page Header ──────────────────────────────────────────────────────────── */ +.ra-page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--space-8); + gap: var(--space-4); +} + +.ra-page-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + background: rgba(59, 158, 255, 0.12); + border: 1px solid rgba(59, 158, 255, 0.3); + border-radius: 999px; + color: var(--color-primary); + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: var(--space-2); +} + +.ra-reset-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-sm); + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + color: var(--color-text-primary); + cursor: pointer; + font-size: var(--font-size-xs); + font-weight: 600; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.ra-reset-btn:hover:not(:disabled) { + background: var(--color-bg-elevated); + border-color: var(--color-border-active); +} + +.ra-reset-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Main Layout ──────────────────────────────────────────────────────────── */ +.ra-layout { + display: grid; + grid-template-columns: 340px 1fr; + gap: var(--space-6); + align-items: start; +} + +@media (max-width: 1080px) { + .ra-layout { + grid-template-columns: 1fr; + } +} + +/* ── Left Column: Form Panel ──────────────────────────────────────────────── */ +.ra-form-panel { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.ra-panel-card { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow-md); +} + +.ra-panel-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: var(--space-5); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.ra-header-icon { + color: var(--color-primary); +} + +.ra-panel-title { + font-size: var(--font-size-base); + font-weight: 600; + color: var(--color-text-primary); +} + +.ra-form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.ra-field { + display: flex; + flex-direction: column; + gap: 6px; + position: relative; +} + +.ra-label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.ra-required { + color: var(--color-accent-rose); +} + +.ra-field-hint { + font-size: 0.72rem; + color: var(--color-text-muted); + line-height: 1.3; +} + +/* Combobox / Country Selector */ +.ra-combobox-wrap { + position: relative; +} + +.ra-combobox-input-box { + display: flex; + align-items: center; + gap: 8px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 8px 12px; + cursor: text; + transition: all var(--transition-fast); +} + +.ra-combobox-input-box.focused, +.ra-combobox-input-box:focus-within { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-glow); +} + +.ra-input-icon { + color: var(--color-text-muted); + flex-shrink: 0; +} + +.ra-combobox-input { + background: transparent; + border: none; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + width: 100%; + outline: none; +} + +.ra-combobox-input::placeholder { + color: var(--color-text-muted); +} + +.ra-iso-pill { + padding: 1px 6px; + border-radius: 4px; + background: rgba(59, 158, 255, 0.15); + border: 1px solid rgba(59, 158, 255, 0.3); + color: var(--color-primary-hover); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.05em; + flex-shrink: 0; +} + +.ra-clear-input-btn { + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: color var(--transition-fast); + flex-shrink: 0; +} + +.ra-clear-input-btn:hover { + color: var(--color-text-primary); +} + +.ra-chevron { + color: var(--color-text-muted); + transition: transform var(--transition-fast); + flex-shrink: 0; +} + +.ra-chevron.rotated { + transform: rotate(180deg); +} + +/* Dropdown Menu */ +.ra-dropdown-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 100; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + max-height: 240px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ra-dropdown-count { + padding: 6px 12px; + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text-muted); + background: var(--color-bg-subtle); + border-bottom: 1px solid var(--color-border); +} + +.ra-dropdown-list { + list-style: none; + overflow-y: auto; + padding: 4px; +} + +.ra-dropdown-item { + width: 100%; + text-align: left; + background: transparent; + border: none; + padding: 8px 10px; + border-radius: 4px; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + transition: background var(--transition-fast); +} + +.ra-dropdown-item:hover, +.ra-dropdown-item.selected { + background: rgba(59, 158, 255, 0.15); + color: var(--color-primary-hover); +} + +.ra-dropdown-country-name { + font-weight: 500; +} + +.ra-dropdown-country-iso { + font-size: 0.72rem; + color: var(--color-text-muted); + font-weight: 600; +} + +.ra-dropdown-empty { + padding: 16px 12px; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-align: center; +} + +/* Select Wrap */ +.ra-select-wrap { + position: relative; + display: flex; + align-items: center; +} + +.ra-select { + width: 100%; + appearance: none; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 8px 32px 8px 12px; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-family: inherit; + cursor: pointer; + transition: all var(--transition-fast); +} + +.ra-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-glow); +} + +.ra-select-chevron { + position: absolute; + right: 12px; + pointer-events: none; + color: var(--color-text-muted); +} + +/* Date Input & Presets */ +.ra-date-wrap { + display: flex; + align-items: center; + gap: 8px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 6px 12px; + transition: all var(--transition-fast); +} + +.ra-date-wrap:focus-within { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-glow); +} + +.ra-date-input { + background: transparent; + border: none; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-family: inherit; + width: 100%; + outline: none; + color-scheme: dark; +} + +.ra-preset-chip { + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: 4px; + color: var(--color-text-secondary); + font-size: 0.72rem; + padding: 2px 7px; + cursor: pointer; + font-weight: 500; + transition: all var(--transition-fast); +} + +.ra-preset-chip:hover { + background: var(--color-bg-elevated); + border-color: var(--color-border-active); + color: var(--color-primary-hover); +} + +/* Form Actions */ +.ra-form-actions { + margin-top: var(--space-3); +} + +.ra-submit-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--color-primary); + color: #fff; + border: none; + border-radius: var(--radius-sm); + padding: 10px 16px; + font-size: var(--font-size-sm); + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); + box-shadow: 0 2px 10px var(--color-primary-glow); +} + +.ra-submit-btn:hover:not(:disabled) { + background: var(--color-primary-hover); + box-shadow: 0 4px 16px rgba(59, 158, 255, 0.4); + transform: translateY(-1px); +} + +.ra-submit-btn:active:not(:disabled) { + transform: translateY(0); +} + +.ra-submit-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.ra-spinner { + animation: spin 1s linear infinite; +} + +/* ── Right Column: Results & Content ──────────────────────────────────────── */ +.ra-results-panel { + display: flex; + flex-direction: column; + gap: var(--space-5); + min-width: 0; +} + +/* Error Banner */ +.ra-error-banner { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px 20px; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: var(--radius-md); + color: #f87171; +} + +.ra-error-icon { + flex-shrink: 0; + margin-top: 2px; +} + +.ra-error-content strong { + display: block; + font-weight: 600; + margin-bottom: 2px; +} + +.ra-error-content p { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.ra-retry-btn { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: #ef4444; + color: #fff; + border: none; + border-radius: 4px; + font-size: var(--font-size-xs); + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + transition: opacity var(--transition-fast); +} + +.ra-retry-btn:hover { + opacity: 0.9; +} + +/* Loading Card */ +.ra-loading-card { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 48px 32px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + box-shadow: var(--shadow-md); +} + +.ra-loading-spinner-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-4); +} + +.ra-spinner-large { + color: var(--color-primary); + animation: spin 1.2s linear infinite; + z-index: 2; +} + +.ra-pulse-halo { + position: absolute; + width: 60px; + height: 60px; + border-radius: 50%; + background: var(--color-primary-glow); + animation: pulse-glow 2s ease-in-out infinite; +} + +@keyframes pulse-glow { + + 0%, + 100% { + transform: scale(1); + opacity: 0.4; + } + + 50% { + transform: scale(1.4); + opacity: 0.8; + } +} + +.ra-loading-title { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--color-text-primary); + margin-bottom: 6px; +} + +.ra-loading-sub { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + max-width: 520px; + line-height: 1.5; + margin-bottom: var(--space-6); +} + +.ra-loading-steps { + display: flex; + gap: var(--space-4); + flex-wrap: wrap; + justify-content: center; +} + +.ra-loading-step { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: 999px; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.ra-loading-step.active { + border-color: var(--color-border-active); + color: var(--color-primary-hover); +} + +.ra-step-num { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; +} + +/* Empty State Card */ +.ra-empty-card { + background: var(--color-bg-surface); + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); + padding: 48px 32px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.ra-empty-icon-wrap { + width: 64px; + height: 64px; + border-radius: 50%; + background: rgba(59, 158, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-4); +} + +.ra-empty-title { + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--color-text-primary); + margin-bottom: 8px; +} + +.ra-empty-sub { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + max-width: 480px; + line-height: 1.5; + margin-bottom: var(--space-8); +} + +.ra-empty-features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-4); + width: 100%; + max-width: 780px; + text-align: left; +} + +.ra-empty-feature { + display: flex; + gap: 12px; + padding: 16px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.ra-empty-feature strong { + display: block; + font-size: var(--font-size-sm); + color: var(--color-text-primary); + margin-bottom: 2px; +} + +.ra-empty-feature p { + font-size: 0.76rem; + color: var(--color-text-secondary); + line-height: 1.4; +} + +/* ── Results Report Container ─────────────────────────────────────────────── */ +.ra-report-wrap { + display: flex; + flex-direction: column; + gap: var(--space-5); + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Report Header */ +.ra-report-header { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-6); + box-shadow: var(--shadow-md); +} + +@media (max-width: 768px) { + .ra-report-header { + flex-direction: column; + align-items: flex-start; + } +} + +.ra-header-main { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ra-header-meta-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ra-badge-dest { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(59, 158, 255, 0.15); + border: 1px solid rgba(59, 158, 255, 0.35); + color: var(--color-primary-hover); + font-size: var(--font-size-xs); + font-weight: 700; +} + +.ra-badge-meta { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 999px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 500; +} + +.ra-badge-time { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + color: var(--color-text-muted); +} + +.ra-report-title { + font-size: var(--font-size-2xl); + font-weight: 800; + color: var(--color-text-primary); + letter-spacing: -0.01em; +} + +.ra-report-subtitle { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +/* Score Card */ +.ra-score-card { + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + padding: var(--space-4) var(--space-6); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 170px; + text-align: center; + flex-shrink: 0; + transition: all var(--transition-base); +} + +.ra-score-top { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; +} + +.ra-score-tier { + font-size: var(--font-size-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.ra-score-val { + font-size: 2.25rem; + font-weight: 900; + line-height: 1; + margin: 4px 0; +} + +.ra-score-max { + font-size: 1.1rem; + font-weight: 500; + opacity: 0.7; +} + +.ra-score-label { + font-size: 0.7rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +/* Section Card */ +.ra-section-card { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow-sm); +} + +.ra-section-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: var(--space-4); +} + +.ra-section-title { + font-size: var(--font-size-base); + font-weight: 700; + color: var(--color-text-primary); +} + +.ra-section-count { + padding: 1px 7px; + border-radius: 999px; + background: rgba(245, 158, 11, 0.2); + border: 1px solid rgba(245, 158, 11, 0.4); + color: #fbbf24; + font-size: 0.7rem; + font-weight: 700; +} + +/* Red Flags Grid */ +.ra-flags-grid { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ra-flag-card { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-bg-subtle); +} + +.ra-flag-card.warning { + background: rgba(245, 158, 11, 0.08); + border-color: rgba(245, 158, 11, 0.25); +} + +.ra-flag-card.stable { + background: rgba(16, 185, 129, 0.08); + border-color: rgba(16, 185, 129, 0.25); +} + +.ra-flag-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 5px; + flex-shrink: 0; +} + +.ra-flag-indicator.warning { + background: #f59e0b; + box-shadow: 0 0 6px #f59e0b; +} + +.ra-flag-indicator.stable { + background: #10b981; + box-shadow: 0 0 6px #10b981; +} + +.ra-flag-text { + font-size: var(--font-size-sm); + color: var(--color-text-primary); + line-height: 1.4; +} + +/* Clinical Summary Block */ +.clinical-summary-card { + background: linear-gradient(135deg, rgba(17, 24, 39, 0.95), rgba(26, 34, 53, 0.95)); + border-left: 3px solid var(--color-primary); +} + +.ra-clinical-text { + font-size: var(--font-size-sm); + line-height: 1.6; + color: #e2e8f0; +} + +/* 3-Way Agency Grid */ +.ra-agency-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--space-5); +} + +.ra-agency-card { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + display: flex; + flex-direction: column; + box-shadow: var(--shadow-sm); +} + +.ra-agency-header { + margin-bottom: var(--space-4); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.ra-agency-badge { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: var(--font-size-sm); + font-weight: 700; + color: var(--color-text-primary); +} + +.ra-agency-body { + display: flex; + flex-direction: column; + gap: var(--space-4); + flex-grow: 1; +} + +.ra-sub-label { + display: block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + margin-bottom: 4px; +} + +.ra-gac-level-box { + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.25); + border-radius: var(--radius-sm); + padding: 10px 12px; +} + +.ra-gac-level-text { + display: flex; + align-items: center; + gap: 6px; + color: #fbbf24; + font-size: var(--font-size-sm); + margin-bottom: 4px; +} + +.ra-gac-subtext { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.ra-agency-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ra-agency-desc { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + line-height: 1.5; +} + +.ra-update-pill { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + width: fit-content; +} + +.ra-agency-links { + margin-top: auto; + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 6px; +} + +.ra-ext-link { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 6px 10px; + border-radius: var(--radius-sm); + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + color: var(--color-primary-hover); + font-size: var(--font-size-xs); + font-weight: 500; + text-decoration: none; + transition: all var(--transition-fast); +} + +.ra-ext-link:hover { + background: var(--color-bg-elevated); + border-color: var(--color-border-active); + text-decoration: underline; +} + +.ra-ext-link.small { + margin-top: 6px; + width: fit-content; +} + +/* CDC Notice Items */ +.ra-cdc-notices-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ra-cdc-notice-item { + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.ra-cdc-notice-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.ra-cdc-level-badge { + display: inline-block; + padding: 2px 7px; + border-radius: 4px; + background: rgba(244, 63, 94, 0.15); + border: 1px solid rgba(244, 63, 94, 0.3); + color: #fb7185; + font-size: 0.7rem; + font-weight: 700; +} + +.ra-cdc-date { + font-size: 0.7rem; + color: var(--color-text-muted); +} + +.ra-cdc-notice-title { + font-size: var(--font-size-sm); + font-weight: 700; + color: var(--color-text-primary); +} + +.ra-cdc-summary-text { + font-size: 0.76rem; + color: var(--color-text-secondary); + line-height: 1.4; + max-height: 80px; + overflow-y: auto; +} + +/* WHO DON list */ +.ra-who-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ra-who-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + text-decoration: none; + color: var(--color-text-primary); + transition: all var(--transition-fast); +} + +.ra-who-item:hover { + background: var(--color-bg-elevated); + border-color: var(--color-border-active); + transform: translateX(2px); +} + +.ra-who-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #22d3c5; + box-shadow: 0 0 6px #22d3c5; + flex-shrink: 0; +} + +.ra-who-item-content { + display: flex; + flex-direction: column; + gap: 2px; + flex-grow: 1; + min-width: 0; +} + +.ra-who-item-title { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-primary); + white-space: normal; +} + +.ra-who-item-date { + font-size: 0.68rem; + color: var(--color-text-muted); +} + +.ra-who-link-icon { + color: var(--color-text-muted); + flex-shrink: 0; +} + +.ra-empty-agency-note { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + background: rgba(16, 185, 129, 0.08); + border: 1px solid rgba(16, 185, 129, 0.2); + border-radius: var(--radius-sm); + color: #34d399; + font-size: var(--font-size-xs); + line-height: 1.4; } \ No newline at end of file diff --git a/src/lib/juvonno.js b/src/lib/juvonno.js new file mode 100644 index 0000000..de578ac --- /dev/null +++ b/src/lib/juvonno.js @@ -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) +} diff --git a/src/lib/patientUtils.js b/src/lib/patientUtils.js index a809bb0..f74b431 100644 --- a/src/lib/patientUtils.js +++ b/src/lib/patientUtils.js @@ -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' diff --git a/src/lib/pocketbase.js b/src/lib/pocketbase.js index eab964b..2b11482 100644 --- a/src/lib/pocketbase.js +++ b/src/lib/pocketbase.js @@ -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)