Added pre-travel screen; added country polygons for maps

This commit is contained in:
2026-08-10 10:52:04 -04:00
parent 08b753f89e
commit e8c69d6558
16 changed files with 7842 additions and 4109 deletions
+15 -1
View File
@@ -1,7 +1,7 @@
import { app, BrowserWindow, shell, ipcMain } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { fetchEntries } from './pocketbase.js'
import { fetchEntries, createEntry, updateEntry, deleteEntry, fetchVaccines } from './pocketbase.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -90,3 +90,17 @@ ipcMain.handle('app:get-version', () => app.getVersion())
ipcMain.handle('pb:fetch-entries', async () => {
return await fetchEntries()
})
ipcMain.handle('pb:create-entry', async (_event, trip) => {
return await createEntry(trip)
})
ipcMain.handle('pb:update-entry', async (_event, id, trip) => {
return await updateEntry(id, trip)
})
ipcMain.handle('pb:delete-entry', async (_event, id) => {
return await deleteEntry(id)
})
ipcMain.handle('pb:fetch-vaccines', async () => {
return await fetchVaccines()
})
+68 -1
View File
@@ -2,6 +2,7 @@ import PocketBase from 'pocketbase'
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
const COLLECTION = 'travellers'
const VACCINES_COLLECTION = 'vaccines'
let pb = null
let authPromise = null
@@ -18,7 +19,8 @@ function getClient() {
function parseEntry(r) {
return {
id: r.emr_id,
id: r.id,
emrID: r.emr_id ?? '',
name: r.name ?? '',
location: r.location ?? '',
countryISO: r.country_iso ?? '',
@@ -58,3 +60,68 @@ export async function fetchEntries() {
return list.map(parseEntry)
}
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,
}
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
const emrNum = Number(trip.emrID)
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
}
const record = await getClient().collection(COLLECTION).create(payload)
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,
}
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
}
const record = await getClient().collection(COLLECTION).update(id, payload)
return parseEntry(record)
}
export async function deleteEntry(id) {
await ensureAuth()
await getClient().collection(COLLECTION).delete(id)
}
/**
* Fetches all records from the vaccines collection.
* Returns raw records with all fields preserved.
*/
export async function fetchVaccines() {
await ensureAuth()
const list = await getClient().collection(VACCINES_COLLECTION).getFullList({ sort: 'name' })
return list.map((r) => ({
id: r.id,
name: r.name ?? '',
vaccine_type: r.vaccine_type ?? '',
total_doses: r.total_doses ?? 1,
dose_intervals: r.dose_intervals ?? [],
min_days_before_travel: r.min_days_before_travel ?? 0,
allows_grace_period: r.allows_grace_period ?? false,
has_accelerated_schedule: r.has_accelerated_schedule ?? false,
requires_icvp_certificate: r.requires_icvp_certificate ?? false,
created: r.created,
updated: r.updated,
}))
}
+12
View File
@@ -16,6 +16,10 @@ const ALLOWED_SEND_CHANNELS = [
'schedule:fetch',
'app:ready',
'pb:fetch-entries',
'pb:create-entry',
'pb:update-entry',
'pb:delete-entry',
'pb:fetch-vaccines',
]
const ALLOWED_RECEIVE_CHANNELS = [
@@ -24,6 +28,10 @@ const ALLOWED_RECEIVE_CHANNELS = [
'schedule:response',
'app:get-version',
'pb:fetch-entries',
'pb:create-entry',
'pb:update-entry',
'pb:delete-entry',
'pb:fetch-vaccines',
]
// ─── Exposed API ──────────────────────────────────────────────────────────
@@ -33,6 +41,10 @@ contextBridge.exposeInMainWorld('api', {
*/
pb: {
fetchEntries: () => ipcRenderer.invoke('pb:fetch-entries'),
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),
fetchVaccines: () => ipcRenderer.invoke('pb:fetch-vaccines'),
},
/**
+7 -2
View File
@@ -2,7 +2,11 @@
"name": "ctm-concierge",
"version": "1.0.0",
"description": "CTM Concierge - Real-time patient journey tracker for CTM",
"author": "Wirediv",
"homepage": "https://canadatravelmed.ca",
"author": {
"name": "Wirediv",
"email": "info@wirediv.com"
},
"license": "MIT",
"private": true,
"main": "dist-electron/main/index.js",
@@ -43,7 +47,8 @@
"AppImage",
"deb"
],
"category": "MedicalSoftware"
"category": "MedicalSoftware",
"maintainer": "Wirediv <info@wirediv.com>"
},
"win": {
"target": "nsis",
+5 -2
View File
@@ -1,8 +1,9 @@
import React, { useState } from 'react'
import { useState } from 'react'
import Sidebar from './components/Sidebar.jsx'
import Dashboard from './components/Dashboard.jsx'
import LiveTracking from './components/LiveTracking.jsx'
import Trips from './components/Trips.jsx'
import PreTravel from './components/PreTravel.jsx'
/**
* App — root layout component.
* Implements a two-panel CSS Grid: fixed sidebar + scrollable main viewport.
@@ -14,6 +15,8 @@ export default function App() {
function renderPage() {
switch (activePage) {
case 'live-tracking': return <LiveTracking />
case 'trips': return <Trips />
case 'pre-travel': return <PreTravel />
default: return <Dashboard />
}
}
+4003 -3987
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+197
View File
@@ -0,0 +1,197 @@
import React, { useState, useRef, useEffect } from 'react'
import { Search, X, ChevronDown, Check } from 'lucide-react'
import countryData from '../assets/countries.json'
// Parse and deduplicate country list from countries.json
const COUNTRIES_LIST = (() => {
const map = new Map()
if (countryData && Array.isArray(countryData.features)) {
countryData.features.forEach((feature) => {
const props = feature.properties
if (props && props.COUNTRY && props.ISO) {
const country = props.COUNTRY.trim()
const iso = props.ISO.trim().toUpperCase()
if (!map.has(iso)) {
map.set(iso, { country, iso })
}
}
})
}
return Array.from(map.values()).sort((a, b) => a.country.localeCompare(b.country))
})()
export default function CountrySelect({
selectedISOs = [],
onChange,
disabled = false,
placeholder = 'Type to search country...',
}) {
const [searchTerm, setSearchTerm] = useState('')
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef(null)
const inputRef = useRef(null)
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(event) {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Filter countries by search term
const filteredCountries = COUNTRIES_LIST.filter((item) => {
const term = searchTerm.trim().toLowerCase()
if (!term) return true
return (
item.country.toLowerCase().includes(term) ||
item.iso.toLowerCase().includes(term)
)
})
// Safe selected ISOs array
const currentSelectedISOs = Array.isArray(selectedISOs)
? selectedISOs
: typeof selectedISOs === 'string'
? selectedISOs.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: []
// Helper to resolve ISO to country object
const getCountryByIso = (iso) => {
return COUNTRIES_LIST.find((c) => c.iso === iso) || { country: iso, iso }
}
const handleSelect = (countryItem) => {
if (disabled) return
let updated
if (currentSelectedISOs.includes(countryItem.iso)) {
updated = currentSelectedISOs.filter((iso) => iso !== countryItem.iso)
} else {
updated = [...currentSelectedISOs, countryItem.iso]
}
const updatedLocationStr = updated
.map((iso) => getCountryByIso(iso).country)
.join(', ')
onChange(updated, updatedLocationStr)
setSearchTerm('')
if (inputRef.current) {
inputRef.current.focus()
}
}
const handleRemove = (isoToRemove, e) => {
e.stopPropagation()
if (disabled) return
const updated = currentSelectedISOs.filter((iso) => iso !== isoToRemove)
const updatedLocationStr = updated
.map((iso) => getCountryByIso(iso).country)
.join(', ')
onChange(updated, updatedLocationStr)
}
return (
<div
className={`country-select-container ${disabled ? 'country-select-disabled' : ''}`}
ref={containerRef}
>
<div
className={`country-select-input-wrapper ${isOpen ? 'country-select-focused' : ''}`}
onClick={() => {
if (!disabled) {
setIsOpen(true)
if (inputRef.current) inputRef.current.focus()
}
}}
>
<div className="country-select-chips-and-input">
{currentSelectedISOs.map((iso) => {
const countryObj = getCountryByIso(iso)
return (
<span key={iso} className="country-select-chip">
<span className="country-chip-name">{countryObj.country}</span>
<span className="country-chip-iso">{countryObj.iso}</span>
{!disabled && (
<button
type="button"
className="country-chip-remove"
onClick={(e) => handleRemove(iso, e)}
title={`Remove ${countryObj.country}`}
>
<X size={12} />
</button>
)}
</span>
)
})}
<input
ref={inputRef}
type="text"
className="country-select-search-input"
placeholder={currentSelectedISOs.length === 0 ? placeholder : 'Add another country...'}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
if (!isOpen) setIsOpen(true)
}}
onFocus={() => !disabled && setIsOpen(true)}
disabled={disabled}
/>
</div>
<div className="country-select-icons">
{searchTerm && (
<button
type="button"
className="country-select-clear-btn"
onClick={(e) => {
e.stopPropagation()
setSearchTerm('')
}}
>
<X size={14} />
</button>
)}
<ChevronDown
size={16}
className={`country-select-arrow ${isOpen ? 'country-select-arrow--open' : ''}`}
/>
</div>
</div>
{isOpen && !disabled && (
<div className="country-select-dropdown">
{filteredCountries.length > 0 ? (
<ul className="country-select-list">
{filteredCountries.map((item) => {
const isSelected = currentSelectedISOs.includes(item.iso)
return (
<li
key={item.iso}
className={`country-select-item ${isSelected ? 'country-select-item--selected' : ''}`}
onClick={() => handleSelect(item)}
>
<div className="country-item-info">
<span className="country-item-name">{item.country}</span>
<span className="country-item-iso">{item.iso}</span>
</div>
{isSelected && <Check size={14} className="country-item-check" />}
</li>
)
})}
</ul>
) : (
<div className="country-select-no-results">
No matching country found for "{searchTerm}"
</div>
)}
</div>
)}
</div>
)
}
+129 -46
View File
@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react'
import {
Briefcase,
PlaneTakeoff,
Plane,
PlaneLanding,
AlertTriangle,
CheckCircle2,
Activity,
RotateCw,
X,
Filter
} from 'lucide-react'
import { fetchEntries } from '../lib/pocketbase.js'
@@ -31,6 +33,7 @@ export default function Dashboard() {
const [travellers, setTravellers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [selectedStatus, setSelectedStatus] = useState('all')
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
@@ -58,11 +61,15 @@ export default function Dashboard() {
const atRiskCount = travellers.filter((t) => t.status === 'at-risk').length
const postTravelCount = travellers.filter((t) => t.status === 'post-travel').length
const filteredTravellers = selectedStatus === 'all'
? travellers
: travellers.filter((t) => t.status === selectedStatus)
const stats = [
{ id: 'pre-travel', icon: PlaneTakeoff, value: preTravelCount, label: 'In Pre-Travel', accent: 'violet' },
{ id: 'in-transit', icon: Plane, value: inTransitCount, label: 'In Transit', accent: 'blue' },
{ id: 'pre-travel', icon: Briefcase, value: preTravelCount, label: 'In Pre-Travel', accent: 'violet' },
{ id: 'in-transit', icon: PlaneTakeoff, value: inTransitCount, label: 'In Transit', accent: 'blue' },
{ id: 'at-risk', icon: AlertTriangle, value: atRiskCount, label: 'At Risk', accent: 'amber' },
{ id: 'post-travel', icon: CheckCircle2, value: postTravelCount, label: 'In Post-Travel', accent: 'teal' },
{ id: 'post-travel', icon: PlaneLanding, value: postTravelCount, label: 'In Post-Travel', accent: 'teal' },
]
return (
@@ -128,15 +135,26 @@ export default function Dashboard() {
) : (
<>
{/* ── Stat Cards ─────────────────────────────────────────────────────── */}
<div className="stat-grid" role="list" aria-label="Today's statistics">
<div className="stat-grid" role="region" aria-label="Status filter cards">
{stats.map((stat) => {
const Icon = stat.icon
const isActive = selectedStatus === stat.id
return (
<article
key={stat.id}
id={`stat-${stat.id}`}
className={`stat-card accent-${stat.accent}`}
role="listitem"
className={`stat-card accent-${stat.accent}${isActive ? ' is-active' : ''}`}
onClick={() => setSelectedStatus(isActive ? 'all' : stat.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedStatus(isActive ? 'all' : stat.id)
}
}}
aria-pressed={isActive}
aria-label={`Filter by ${stat.label}`}
>
<Icon className="stat-card-icon" aria-hidden="true" size={24} />
<div className="stat-card-value">{loading ? '...' : stat.value}</div>
@@ -151,49 +169,114 @@ export default function Dashboard() {
<h2 className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Activity size={20} className="section-title-icon" style={{ color: 'var(--color-primary)' }} />
Live Patient Board
{selectedStatus !== 'all' && (
<span
style={{
fontSize: '12px',
fontWeight: '600',
padding: '2px 8px',
borderRadius: '999px',
background: 'var(--color-primary-glow)',
color: 'var(--color-primary-hover)',
border: '1px solid var(--color-border-active)',
}}
>
{STATUS_META[selectedStatus]?.label || selectedStatus} ({filteredTravellers.length} of {travellers.length})
</span>
)}
</h2>
</div>
{selectedStatus !== 'all' && (
<button
type="button"
onClick={() => setSelectedStatus('all')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 12px',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg-subtle)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
fontSize: 'var(--font-size-xs)',
fontWeight: '600',
transition: 'background 0.2s, color 0.2s',
}}
title="Show all statuses"
>
<X size={14} />
Show All
</button>
)}
</div>
<div className="journey-panel">
<table className="journey-table" aria-label="Live patient journey board">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Name</th>
<th scope="col">Location</th>
<th scope="col">Departure</th>
<th scope="col">Arrival</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{travellers.length === 0 && !loading ? (
<div className="journey-table-container">
<table className="journey-table" aria-label="Live patient journey board">
<thead>
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '24px', color: 'var(--color-text-muted)' }}>
No travellers found in PocketBase database.
</td>
<th scope="col">EMR ID</th>
<th scope="col">Name</th>
<th scope="col">Location</th>
<th scope="col">Departure</th>
<th scope="col">Arrival</th>
<th scope="col">Status</th>
</tr>
) : (
travellers.map((p) => {
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
return (
<tr key={p.id} id={`patient-row-${p.id}`}>
<td>{p.id}</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>
<span className={`status-pill ${meta.className}`}>
{meta.label}
</span>
</td>
</tr>
)
})
)}
</tbody>
</table>
</thead>
<tbody>
{travellers.length === 0 && !loading ? (
<tr>
<td colSpan={6} 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)' }}>
<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>
<button
onClick={() => setSelectedStatus('all')}
style={{
marginTop: '8px',
padding: '4px 12px',
fontSize: '12px',
borderRadius: '4px',
background: 'var(--color-bg-subtle)',
border: '1px solid var(--color-border)',
color: 'var(--color-primary)',
cursor: 'pointer',
}}
>
Reset Filter
</button>
</div>
</td>
</tr>
) : (
filteredTravellers.map((p) => {
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
return (
<tr key={p.id} id={`patient-row-${p.id}`}>
<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>
<span className={`status-pill ${meta.className}`}>
{meta.label}
</span>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</>
)}
+250 -58
View File
@@ -1,11 +1,27 @@
import { useState, useRef, useEffect } from 'react'
import MapGL, { Marker, Popup } from 'react-map-gl/maplibre'
import MapGL, { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'
import 'maplibre-gl/dist/maplibre-gl.css'
import countryCentroids from '../assets/countries.json'
import { RotateCw, AlertTriangle } from 'lucide-react'
import countryPolygonsRaw from '../assets/world-countries-polygons.json'
import { RotateCw, AlertTriangle, X } from 'lucide-react'
import { fetchEntries } from '../lib/pocketbase.js'
import { getPatientISOs } from '../lib/patientUtils.js'
// Pre-process polygon GeoJSON features with normalized ISO_CODE properties
const countryPolygonsData = {
...countryPolygonsRaw,
features: countryPolygonsRaw.features.map((f) => {
const iso2 = (f.properties.ISO_A2 !== '-99' ? f.properties.ISO_A2 : f.properties.ISO_A2_EH) || f.properties.POSTAL || ''
return {
...f,
properties: {
...f.properties,
ISO_CODE: iso2 ? iso2.toUpperCase() : '',
},
}
}),
}
// ── Country centroid lookup ─────────────────────────────────────────────────────
// Builds an ISO-2 → [longitude, latitude] map from the GeoJSON centroid dataset.
const CENTROID_BY_ISO = new Map(
@@ -23,6 +39,20 @@ const STATUS_META = {
'post-travel': { label: 'Post-Travel', colorVar: '--color-accent-emerald', cssClass: 'status-post-travel', markerClass: 'marker-emerald' },
}
/**
* Helper to determine priority status for a location group of patients:
* 1. 'at-risk' (highest priority alert)
* 2. 'post-travel' (second priority to highlight patients needing post-travel contact)
* 3. 'in-transit'
* 4. 'pre-travel'
*/
function getGroupStatus(patients = []) {
if (patients.some((p) => p.status === 'at-risk')) return 'at-risk'
if (patients.some((p) => p.status === 'post-travel')) return 'post-travel'
if (patients.some((p) => p.status === 'in-transit')) return 'in-transit'
return 'pre-travel'
}
// ── Sub-component: PatientMarker ───────────────────────────────────────────────
function PatientMarker({ patient, isSelected, onClick }) {
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
@@ -36,12 +66,33 @@ function PatientMarker({ patient, isSelected, onClick }) {
title={patient.name}
>
<span className={`map-marker-ring ${meta.markerClass}-ring`} aria-hidden="true" />
<span className={`map-marker-dot ${meta.markerClass}-dot`} aria-hidden="true" />
<span className={`map-marker-dot ${meta.markerClass}-dot`} aria-hidden="true" />
</button>
)
}
// ── Sub-component: PatientPopup ────────────────────────────────────────────────
// ── Sub-component: GroupMarker (Multi-patient location) ───────────────────────
function GroupMarker({ group, isSelected, onClick }) {
const groupStatus = getGroupStatus(group.patients)
const meta = STATUS_META[groupStatus] || STATUS_META['pre-travel']
return (
<button
id={`marker-group-${group.iso}`}
className={`map-marker-btn map-marker-btn--group${isSelected ? ' map-marker-btn--selected' : ''}`}
onClick={onClick}
aria-label={`${group.patients.length} Travellers in ${group.iso}${meta.label}`}
title={`${group.patients.length} Travellers in ${group.iso} (${meta.label})`}
>
<span className={`map-marker-ring ${meta.markerClass}-ring`} aria-hidden="true" />
<span className={`map-marker-dot ${meta.markerClass}-dot map-marker-group-badge`}>
{group.patients.length}
</span>
</button>
)
}
// ── Sub-component: PatientPopup (Single patient) ──────────────────────────────
function PatientPopup({ patient, onClose }) {
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
@@ -65,7 +116,7 @@ function PatientPopup({ patient, onClose }) {
</div>
<div className="patient-popup-identity">
<h3 className="patient-popup-name">{patient.name}</h3>
<p className="patient-popup-id">{patient.id}</p>
<p className="patient-popup-id">{patient.emrID ? `EMR #${patient.emrID}` : ''}</p>
</div>
<button
className="popup-close"
@@ -73,7 +124,7 @@ function PatientPopup({ patient, onClose }) {
aria-label="Close patient popup"
id={`popup-close-${patient.id}`}
>
×
<X size={16} />
</button>
</div>
@@ -100,13 +151,66 @@ function PatientPopup({ patient, onClose }) {
)
}
// ── Sub-component: GroupPatientPopup (Multi-patient group) ─────────────────────
function GroupPatientPopup({ group, onClose }) {
const countryFeature = countryPolygonsData.features.find(
(f) => f.properties.ISO_CODE === group.iso
)
const countryName = countryFeature?.properties?.NAME || group.iso
return (
<div className="patient-popup group-popup" id={`popup-group-${group.iso}`} role="dialog" aria-label={`Patients in ${countryName}`}>
{/* Header */}
<div className="patient-popup-header group-popup-header">
<div className="group-popup-title-area">
<span className="group-popup-icon" aria-hidden="true">📍</span>
<div className="patient-popup-identity">
<h3 className="patient-popup-name">{countryName}</h3>
<p className="patient-popup-id">{group.patients.length} Active Travellers</p>
</div>
</div>
<button
className="popup-close"
onClick={onClose}
aria-label="Close group popup"
id={`popup-close-group-${group.iso}`}
>
<X size={16} />
</button>
</div>
{/* Patient List */}
<div className="group-patient-list">
{group.patients.map((patient) => {
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
const initials = patient.name ? patient.name.split(' ').map((n) => n[0]).join('') : 'P'
return (
<div key={patient.id} className="group-patient-item" id={`group-item-${patient.id}`}>
<div className="patient-popup-avatar group-avatar" aria-hidden="true">
{initials}
</div>
<div className="group-patient-identity">
<span className="group-patient-name">{patient.name}</span>
<span className="group-patient-emr">{patient.emrID ? `EMR #${patient.emrID}` : ''}</span>
</div>
<span className={`status-pill ${meta.cssClass} group-status-pill`}>
{meta.label}
</span>
</div>
)
})}
</div>
</div>
)
}
// ── Main component: LiveTracking ────────────────────────────────────────────────
/**
* LiveTracking — full-screen locked world map showing active patient locations.
* Uses react-map-gl/maplibre with the OpenFreeMap 'Fiord' tile style.
*/
export default function LiveTracking() {
const [selectedMarkerKey, setSelectedMarkerKey] = useState(null)
const [selectedGroupIso, setSelectedGroupIso] = useState(null)
const [travellers, setTravellers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -129,43 +233,54 @@ export default function LiveTracking() {
loadData()
}, [])
const patientMarkers = travellers.flatMap((p) => {
// Aggregate travellers into location groups by ISO
const locationGroupsMap = new Map()
travellers.forEach((p) => {
const isos = getPatientISOs(p)
return isos.map((iso) => ({
key: `${p.id}-${iso}`,
patient: p,
iso,
coordinate: CENTROID_BY_ISO.get(iso.toUpperCase()) ?? [0, 0],
}))
isos.forEach((iso) => {
if (!iso) return
const code = iso.trim().toUpperCase()
if (!locationGroupsMap.has(code)) {
locationGroupsMap.set(code, {
iso: code,
coordinate: CENTROID_BY_ISO.get(code) ?? [0, 0],
patients: [],
})
}
// Avoid duplicate patient entries per group
if (!locationGroupsMap.get(code).patients.some((item) => item.id === p.id)) {
locationGroupsMap.get(code).patients.push(p)
}
})
})
const selectedMarker = patientMarkers.find((m) => m.key === selectedMarkerKey) || null
const selectedPatientId = selectedMarker ? selectedMarker.patient.id : null
const locationGroups = Array.from(locationGroupsMap.values())
const selectedGroup = locationGroups.find((g) => g.iso === selectedGroupIso) || null
function handleMarkerClick(marker) {
const isSelecting = selectedMarkerKey !== marker.key
setSelectedMarkerKey(isSelecting ? marker.key : null)
function handleGroupClick(group) {
const isSelecting = selectedGroupIso !== group.iso
setSelectedGroupIso(isSelecting ? group.iso : null)
if (isSelecting) {
mapRef.current?.flyTo({
center: marker.coordinate,
zoom: 2.2, // Focus in at a constant zoom level
center: group.coordinate,
zoom: 2.2,
essential: true,
duration: 1000 // smooth 1s transition
duration: 1000,
})
}
}
function handleCardClick(patient) {
const firstMarker = patientMarkers.find((m) => m.patient.id === patient.id)
if (!firstMarker) return
const targetGroup = locationGroups.find((g) => g.patients.some((p) => p.id === patient.id))
if (!targetGroup) return
const isSelecting = selectedPatientId !== patient.id
setSelectedMarkerKey(isSelecting ? firstMarker.key : null)
const isSelecting = selectedGroupIso !== targetGroup.iso
setSelectedGroupIso(isSelecting ? targetGroup.iso : null)
if (isSelecting) {
mapRef.current?.flyTo({
center: firstMarker.coordinate,
center: targetGroup.coordinate,
zoom: 2.2,
essential: true,
duration: 1000,
@@ -174,16 +289,16 @@ export default function LiveTracking() {
}
function handleDestinationChipClick(patient, iso) {
const markerKey = `${patient.id}-${iso}`
const targetMarker = patientMarkers.find((m) => m.key === markerKey)
if (!targetMarker) return
const code = iso.trim().toUpperCase()
const targetGroup = locationGroups.find((g) => g.iso === code)
if (!targetGroup) return
const isSelecting = selectedMarkerKey !== markerKey
setSelectedMarkerKey(isSelecting ? markerKey : null)
const isSelecting = selectedGroupIso !== code
setSelectedGroupIso(isSelecting ? code : null)
if (isSelecting) {
mapRef.current?.flyTo({
center: targetMarker.coordinate,
center: targetGroup.coordinate,
zoom: 2.2,
essential: true,
duration: 1000,
@@ -192,7 +307,7 @@ export default function LiveTracking() {
}
function handlePopupClose() {
setSelectedMarkerKey(null)
setSelectedGroupIso(null)
}
function applyCountryFilter(mapInstance, countryISOs) {
@@ -213,7 +328,7 @@ export default function LiveTracking() {
['in', ['get', 'country_code'], ['literal', validISOs]],
['in', ['get', 'ISO_A2'], ['literal', validISOs]],
]
: ['==', 1, 0]
: ['==', '$type', '']
for (const layer of style.layers) {
if (layer.id === 'place_country_major' || layer.id === 'place_country_minor') {
@@ -268,6 +383,47 @@ export default function LiveTracking() {
const inTransitCount = travellers.filter((p) => p.status === 'in-transit').length
const atRiskCount = travellers.filter((p) => p.status === 'at-risk').length
// Derive country status map for polygon fills
const isoStatusMap = {}
travellers.forEach((p) => {
const isos = getPatientISOs(p)
isos.forEach((iso) => {
if (!iso) return
const code = iso.trim().toUpperCase()
if (p.status === 'at-risk') {
isoStatusMap[code] = 'at-risk'
} else if (!isoStatusMap[code]) {
isoStatusMap[code] = p.status || 'pre-travel'
}
})
})
const atRiskISOs = Object.keys(isoStatusMap).filter((iso) => isoStatusMap[iso] === 'at-risk')
const normalISOs = Object.keys(isoStatusMap).filter((iso) => isoStatusMap[iso] !== 'at-risk')
const fillMatchCases = []
const outlineMatchCases = []
if (atRiskISOs.length > 0) {
fillMatchCases.push(atRiskISOs, 'rgba(233, 172, 66, 0.15)')
outlineMatchCases.push(atRiskISOs, '#ecb85e5f')
}
if (normalISOs.length > 0) {
fillMatchCases.push(normalISOs, 'rgba(48, 183, 138, 0.15)')
outlineMatchCases.push(normalISOs, '#20ab7d5f')
}
const fillColorExpr = fillMatchCases.length > 0
? ['match', ['get', 'ISO_CODE'], ...fillMatchCases, 'transparent']
: 'transparent'
const outlineColorExpr = outlineMatchCases.length > 0
? ['match', ['get', 'ISO_CODE'], ...outlineMatchCases, 'transparent']
: 'transparent'
const outlineWidthExpr = (atRiskISOs.length > 0 || normalISOs.length > 0)
? ['match', ['get', 'ISO_CODE'], [...atRiskISOs, ...normalISOs], 1.5, 0]
: 0
return (
<section className="trip-tracker" aria-label="Trip Tracker — World Map">
@@ -379,38 +535,74 @@ export default function LiveTracking() {
interactive={true}
attributionControl={false}
>
{/* Patient markers */}
{!error && patientMarkers.map((m) => (
{/* Highlight shapes of visited countries */}
{!error && (atRiskISOs.length > 0 || normalISOs.length > 0) && (
<Source id="visited-countries-source" type="geojson" data={countryPolygonsData}>
<Layer
id="visited-countries-fill"
type="fill"
paint={{
'fill-color': fillColorExpr,
}}
/>
<Layer
id="visited-countries-outline"
type="line"
paint={{
'line-color': outlineColorExpr,
'line-width': outlineWidthExpr,
}}
/>
</Source>
)}
{/* Location Group Markers */}
{!error && locationGroups.map((g) => (
<Marker
key={m.key}
longitude={m.coordinate[0]}
latitude={m.coordinate[1]}
key={g.iso}
longitude={g.coordinate[0]}
latitude={g.coordinate[1]}
anchor="center"
>
<PatientMarker
patient={m.patient}
isSelected={selectedMarkerKey === m.key}
onClick={() => handleMarkerClick(m)}
/>
{g.patients.length === 1 ? (
<PatientMarker
patient={g.patients[0]}
isSelected={selectedGroupIso === g.iso}
onClick={() => handleGroupClick(g)}
/>
) : (
<GroupMarker
group={g}
isSelected={selectedGroupIso === g.iso}
onClick={() => handleGroupClick(g)}
/>
)}
</Marker>
))}
{/* Patient popup — mounts only when a marker is selected */}
{!error && selectedMarker && (
{/* Popup — mounts when a location group is selected */}
{!error && selectedGroup && (
<Popup
key={selectedMarker.key}
longitude={selectedMarker.coordinate[0]}
latitude={selectedMarker.coordinate[1]}
key={selectedGroup.iso}
longitude={selectedGroup.coordinate[0]}
latitude={selectedGroup.coordinate[1]}
anchor="bottom"
offset={20}
closeButton={false}
closeOnClick={false}
className="trip-tracker-popup-wrapper"
>
<PatientPopup
patient={selectedMarker.patient}
onClose={handlePopupClose}
/>
{selectedGroup.patients.length === 1 ? (
<PatientPopup
patient={selectedGroup.patients[0]}
onClose={handlePopupClose}
/>
) : (
<GroupPatientPopup
group={selectedGroup}
onClose={handlePopupClose}
/>
)}
</Popup>
)}
</MapGL>
@@ -440,7 +632,7 @@ export default function LiveTracking() {
) : (
travellers.map((p) => {
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
const isSelected = selectedPatientId === p.id
const isSelected = selectedGroup?.patients.some((item) => item.id === p.id) || false
const isos = getPatientISOs(p)
const isMultiDestination = isos.length > 1
@@ -453,7 +645,7 @@ export default function LiveTracking() {
>
<div className="log-card-top">
<div className="log-card-left">
<span className="log-patient-id">{p.id}</span>
<span className="log-patient-id">{p.emrID ? `EMR: ${p.emrID}` : ''}</span>
<h3 className="log-patient-name">{p.name}</h3>
</div>
<span className={`status-pill ${meta.cssClass}`}>
@@ -475,8 +667,8 @@ export default function LiveTracking() {
{isMultiDestination ? (
<div className="destination-chips-container">
{isos.map((iso) => {
const chipKey = `${p.id}-${iso}`
const isChipSelected = selectedMarkerKey === chipKey
const code = iso.trim().toUpperCase()
const isChipSelected = selectedGroupIso === code
return (
<button
key={iso}
+667
View File
@@ -0,0 +1,667 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import {
Syringe,
PlaneTakeoff,
Search,
X,
RotateCw,
AlertTriangle,
Zap,
Clock,
CheckCircle,
ChevronRight,
CalendarDays,
MapPin,
AlertCircle,
} from 'lucide-react'
import { fetchEntries, fetchVaccines } from '../lib/pocketbase.js'
import {
computeIdealSchedule,
getDaysUntilDeparture,
getVaccineColor,
localDate,
formatDate,
daysBetween,
} from '../lib/vaccineScheduleUtils.js'
// ── Helpers ───────────────────────────────────────────────────────────────────
function urgencyLabel(daysUntil) {
if (daysUntil < 0) return { text: 'Departed', cls: 'urgency-departed' }
if (daysUntil === 0) return { text: 'Departs Today', cls: 'urgency-today' }
if (daysUntil <= 14) return { text: `${daysUntil}d`, cls: 'urgency-critical' }
if (daysUntil <= 30) return { text: `${daysUntil}d`, cls: 'urgency-soon' }
if (daysUntil <= 90) return { text: `${daysUntil}d`, cls: 'urgency-moderate' }
return { text: `${daysUntil}d`, cls: 'urgency-ok' }
}
// ── Sub-components ────────────────────────────────────────────────────────────
function TripCard({ trip, isSelected, onClick }) {
const daysUntil = getDaysUntilDeparture(trip.departure)
const urgency = urgencyLabel(daysUntil)
const depDate = localDate(trip.departure)
return (
<button
id={`trip-card-${trip.id}`}
className={`pt-trip-card${isSelected ? ' pt-trip-card--selected' : ''}`}
onClick={onClick}
aria-pressed={isSelected}
>
<div className="pt-trip-card-top">
<div className="pt-trip-card-name">{trip.name}</div>
<span className={`pt-urgency-badge ${urgency.cls}`}>{urgency.text}</span>
</div>
{trip.emrID && (
<div className="pt-trip-card-emr">EMR #{trip.emrID}</div>
)}
<div className="pt-trip-card-meta">
{trip.location && (
<span className="pt-trip-card-dest">
<MapPin size={11} />
{trip.location}
</span>
)}
<span className="pt-trip-card-date">
<CalendarDays size={11} />
{formatDate(depDate)}
</span>
</div>
{isSelected && (
<div className="pt-trip-card-selected-indicator" aria-hidden="true" />
)}
</button>
)
}
function VaccineSearchPanel({
vaccines,
addedVaccineIds,
onAdd,
loading,
firstDoseDate,
onFirstDoseDateChange,
}) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const containerRef = useRef(null)
const filtered = useMemo(() => {
const q = query.toLowerCase().trim()
return vaccines.filter(
(v) => !addedVaccineIds.has(v.id) && (q === '' || v.name.toLowerCase().includes(q))
)
}, [vaccines, addedVaccineIds, query])
// Close dropdown on outside click
useEffect(() => {
function handleClick(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
function handleSelect(vaccine) {
onAdd(vaccine)
setQuery('')
setOpen(false)
}
return (
<div className="pt-vaccine-search" ref={containerRef}>
<div className="pt-vaccine-search-controls">
<div className="pt-vaccine-search-input-wrap">
<Search size={14} className="pt-vaccine-search-icon" />
<input
id="vaccine-search-input"
className="pt-vaccine-search-input"
placeholder={loading ? 'Loading vaccines…' : 'Search vaccines…'}
value={query}
disabled={loading}
onChange={(e) => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
autoComplete="off"
/>
</div>
<div className="pt-first-dose-date-wrap">
<label htmlFor="first-dose-date-input" className="pt-first-dose-date-label">
<CalendarDays size={13} className="pt-first-dose-date-icon" />
<span>1st Dose Date</span>
</label>
<input
type="date"
id="first-dose-date-input"
className="pt-first-dose-date-input"
value={firstDoseDate || ''}
onChange={(e) => onFirstDoseDateChange(e.target.value)}
title="Set date of the first dose (appointment date)"
/>
</div>
</div>
{open && filtered.length > 0 && (
<ul className="pt-vaccine-dropdown" role="listbox" aria-label="Vaccine options">
{filtered.map((v) => (
<li key={v.id} role="option" aria-selected={false}>
<button
id={`vaccine-option-${v.id}`}
className="pt-vaccine-dropdown-item"
onMouseDown={(e) => { e.preventDefault(); handleSelect(v) }}
>
<span className="pt-vaccine-dropdown-name">{v.name}</span>
<span className="pt-vaccine-dropdown-meta">
{v.total_doses} dose{v.total_doses !== 1 ? 's' : ''}
{v.has_accelerated_schedule && (
<span className="pt-badge-accel" title="Accelerated schedule available">
<Zap size={9} /> Accel
</span>
)}
</span>
</button>
</li>
))}
</ul>
)}
{open && !loading && filtered.length === 0 && query.trim() !== '' && (
<div className="pt-vaccine-dropdown pt-vaccine-dropdown--empty">
No vaccines match "{query}"
</div>
)}
</div>
)
}
function VaccineChip({ vaccine, colorStyle, onRemove }) {
return (
<div
id={`chip-${vaccine.id}`}
className="pt-vaccine-chip"
style={{ borderColor: colorStyle.border, background: colorStyle.bg }}
>
<span className="pt-vaccine-chip-dot" style={{ background: colorStyle.border }} />
<span className="pt-vaccine-chip-name" style={{ color: colorStyle.text }}>
{vaccine.name}
</span>
<span className="pt-vaccine-chip-doses" style={{ color: colorStyle.text }}>
{vaccine.total_doses}D
</span>
<button
className="pt-vaccine-chip-remove"
onClick={() => onRemove(vaccine.id)}
aria-label={`Remove ${vaccine.name}`}
style={{ color: colorStyle.text }}
>
<X size={12} />
</button>
</div>
)
}
// ── Timeline ──────────────────────────────────────────────────────────────────
function VaccineTrack({ vaccine, schedule, colorStyle, departureDate, today }) {
const { doses, isFeasible, needsAcceleratedSuggestion, totalLeadTimeDays } = schedule
// Timeline span: from first dose to departure
const firstDoseDate = doses[0]?.date
const spanDays = firstDoseDate ? daysBetween(departureDate, firstDoseDate) : 0
const todayOffset = firstDoseDate ? daysBetween(today, firstDoseDate) : 0
return (
<div className="pt-track" id={`track-${vaccine.id}`}>
{/* Track header */}
<div className="pt-track-header">
<div className="pt-track-label" style={{ borderLeftColor: colorStyle.border }}>
<span className="pt-track-vaccine-name" style={{ color: colorStyle.text }}>
{vaccine.name}
</span>
<span className="pt-track-meta">
{vaccine.total_doses} dose{vaccine.total_doses !== 1 ? 's' : ''}
{' · '}
{totalLeadTimeDays} day lead time
{vaccine.requires_icvp_certificate && (
<span className="pt-badge-icvp" title="ICVP Certificate Required">ICVP</span>
)}
</span>
</div>
{/* Status indicators */}
<div className="pt-track-status-flags">
{schedule.needsAcceleratedSuggestion && (
<div className="pt-flag pt-flag--accel" title="Standard schedule not feasible — accelerated available">
<Zap size={12} />
Accelerated schedule available
</div>
)}
{schedule.isImmunityTargetMissed && !schedule.needsAcceleratedSuggestion && (
<div className="pt-flag pt-flag--accel" title={`Full immunity is reached ${schedule.minDaysBefore} days after dose`}>
<AlertTriangle size={12} />
Full immunity target after departure
</div>
)}
{!schedule.isFeasible && schedule.lastDoseAfterDeparture && !schedule.needsAcceleratedSuggestion && (
<div className="pt-flag pt-flag--infeasible">
<AlertCircle size={12} />
Last dose after departure
</div>
)}
{schedule.isFeasible && (
<div className="pt-flag pt-flag--ok">
<CheckCircle size={12} />
Schedule feasible
</div>
)}
</div>
</div>
{/* Timeline track items (doses + departure marker) */}
<div className="pt-track-doses">
{(() => {
const splitIdx = doses.findIndex((d) => d.daysBeforeDeparture < 0)
const insertIdx = splitIdx === -1 ? doses.length : splitIdx
const timelineItems = [
...doses.slice(0, insertIdx).map((d) => ({
type: 'dose',
dose: d,
date: d.date,
key: `dose-${d.doseNumber}`,
})),
{
type: 'departure',
date: departureDate,
key: 'departure-marker',
},
...doses.slice(insertIdx).map((d) => ({
type: 'dose',
dose: d,
date: d.date,
key: `dose-${d.doseNumber}`,
})),
]
return timelineItems.map((item, idx) => {
const isLast = idx === timelineItems.length - 1
const nextItem = !isLast ? timelineItems[idx + 1] : null
if (item.type === 'departure') {
const connectorColor = nextItem
? nextItem.dose?.daysBeforeDeparture < 0
? '#f43f5e'
: colorStyle.border
: colorStyle.border
return (
<React.Fragment key={item.key}>
<div className="pt-departure-marker">
<div className="pt-departure-marker-label" style={{ color: colorStyle.text }}>
Departure
</div>
<div className="pt-departure-marker-icon" style={{ background: colorStyle.border }}>
<PlaneTakeoff size={12} color="#fff" />
</div>
<div className="pt-departure-marker-label" style={{ color: colorStyle.text }}>
{formatDate(item.date)}
</div>
</div>
{!isLast && nextItem && (
<div className="pt-dose-connector" style={{ borderColor: connectorColor }}>
<ChevronRight size={14} style={{ color: connectorColor }} />
<span className="pt-dose-gap" style={{ color: 'var(--color-text-muted)' }}>
{`${daysBetween(nextItem.date, item.date)}d`}
</span>
</div>
)}
</React.Fragment>
)
}
const dose = item.dose
const isAfterDeparture = dose.daysBeforeDeparture < 0
const isWarning = dose.isImmunityTargetMissed
const doseColorBg = isWarning
? 'rgba(245, 158, 11, 0.18)'
: isAfterDeparture
? 'rgba(244, 63, 94, 0.12)'
: dose.isInPast
? 'rgba(255,255,255,0.04)'
: colorStyle.bg
const doseBorder = isWarning
? '#f59e0b'
: isAfterDeparture
? '#f43f5e'
: dose.isInPast
? 'rgba(255,255,255,0.1)'
: colorStyle.border
const doseTextColor = isWarning
? '#fbbf24'
: isAfterDeparture
? '#fb7185'
: dose.isInPast
? 'var(--color-text-muted)'
: colorStyle.text
return (
<React.Fragment key={item.key}>
<div
id={`dose-${vaccine.id}-${dose.doseNumber}`}
className={`pt-dose-card${dose.isInPast ? ' pt-dose-card--past' : ''}${dose.isImmunityTargetMissed ? ' pt-dose-card--immunity-warning' : ''}`}
style={{ background: doseColorBg, borderColor: doseBorder }}
>
{/* Dose number badge */}
<div className="pt-dose-badge" style={{ background: doseBorder, color: '#fff' }}>
{dose.doseNumber}
</div>
<div className="pt-dose-info">
<div className="pt-dose-label" style={{ color: doseTextColor }}>
{dose.label}
</div>
<div className="pt-dose-date" style={{ color: doseTextColor }}>
{formatDate(dose.date)}
</div>
<div className="pt-dose-countdown">
{dose.isInPast ? (
<span className="pt-dose-tag pt-dose-tag--past">
<Clock size={10} /> In the past
</span>
) : isAfterDeparture ? (
<span className="pt-dose-tag" style={{ color: '#fb7185' }}>
<AlertCircle size={10} />
{Math.abs(dose.daysBeforeDeparture)}d after departure
</span>
) : (
<span className="pt-dose-tag" style={{ color: 'var(--color-text-secondary)' }}>
<CalendarDays size={10} />
{dose.daysBeforeDeparture === 0 ? 'On departure day' : `${dose.daysBeforeDeparture}d before departure`}
</span>
)}
{dose.isImmunityTargetMissed && (
<span className="pt-dose-tag pt-dose-tag--immunity-warning" title={`Full immunity requires ${schedule.minDaysBefore}d before travel`}>
<AlertTriangle size={10} /> Full immunity {dose.daysShortOfImmunity}d after departure
</span>
)}
</div>
</div>
</div>
{/* Connector arrow between items */}
{!isLast && nextItem && (
<div className="pt-dose-connector" style={{ borderColor: doseBorder }}>
<ChevronRight size={14} style={{ color: doseBorder }} />
<span className="pt-dose-gap" style={{ color: 'var(--color-text-muted)' }}>
{`${daysBetween(nextItem.date, item.date)}d`}
</span>
</div>
)}
</React.Fragment>
)
})
})()}
</div>
</div>
)
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function PreTravel() {
const [trips, setTrips] = useState([])
const [vaccines, setVaccines] = useState([])
const [loadingTrips, setLoadingTrips] = useState(true)
const [loadingVaccines, setLoadingVaccines] = useState(true)
const [error, setError] = useState(null)
const [selectedTrip, setSelectedTrip] = useState(null)
const [addedVaccines, setAddedVaccines] = useState([]) // array of vaccine objects
const [firstDoseDate, setFirstDoseDate] = useState('')
const today = useMemo(() => new Date(), [])
// ── Data loading ──────────────────────────────────────────────────────────
async function loadAll() {
setLoadingTrips(true)
setLoadingVaccines(true)
setError(null)
try {
const [tripData, vaccineData] = await Promise.all([
fetchEntries(),
fetchVaccines(),
])
// Only pre-travel trips
const preTravelTrips = (tripData || []).filter((t) => t.status === 'pre-travel')
setTrips(preTravelTrips)
setVaccines(vaccineData || [])
} catch (err) {
setError(err.message || 'Unable to connect to PocketBase backend.')
} finally {
setLoadingTrips(false)
setLoadingVaccines(false)
}
}
useEffect(() => {
loadAll()
}, [])
// ── Vaccine plan state ────────────────────────────────────────────────────
const addedVaccineIds = useMemo(() => new Set(addedVaccines.map((v) => v.id)), [addedVaccines])
function handleAddVaccine(vaccine) {
setAddedVaccines((prev) => [...prev, vaccine])
}
function handleRemoveVaccine(vaccineId) {
setAddedVaccines((prev) => prev.filter((v) => v.id !== vaccineId))
}
// ── Schedule computation ─────────────────────────────────────────────────
const schedules = useMemo(() => {
if (!selectedTrip || addedVaccines.length === 0) return []
const depDate = localDate(selectedTrip.departure)
const customFirstDose = firstDoseDate ? localDate(firstDoseDate) : null
return addedVaccines.map((vaccine) => ({
vaccine,
schedule: computeIdealSchedule(vaccine, depDate, today, customFirstDose),
colorStyle: getVaccineColor(addedVaccines.indexOf(vaccine)),
}))
}, [selectedTrip, addedVaccines, today, firstDoseDate])
const daysUntilDep = selectedTrip ? getDaysUntilDeparture(selectedTrip.departure, today) : null
const depDateFormatted = selectedTrip ? formatDate(localDate(selectedTrip.departure)) : null
const depDate = selectedTrip ? localDate(selectedTrip.departure) : null
const hasInfeasible = schedules.some((s) => !s.schedule.isFeasible)
// ── Render ────────────────────────────────────────────────────────────────
return (
<section className="pretravel" aria-label="Pre-Travel Planning">
{/* ── Page Header ──────────────────────────────────────────────────────── */}
<header className="pretravel-header">
<div>
<p className="dashboard-greeting">Pre-Travel Planning</p>
<h1 className="dashboard-title" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
Vaccination Planner
</h1>
<p className="dashboard-subtitle">
Select a pre-travel trip and build a personalised vaccination timeline.
</p>
</div>
<button
id="pretravel-refresh-btn"
className="pt-refresh-btn"
onClick={() => { loadAll(); setSelectedTrip(null); setAddedVaccines([]); setFirstDoseDate('') }}
disabled={loadingTrips || loadingVaccines}
aria-label="Refresh data"
>
<RotateCw size={14} style={{ animation: (loadingTrips || loadingVaccines) ? 'spin 1s linear infinite' : 'none' }} />
{(loadingTrips || loadingVaccines) ? 'Loading…' : 'Refresh'}
</button>
</header>
{/* ── Error Banner ─────────────────────────────────────────────────────── */}
{error && (
<div className="pt-error-banner">
<AlertTriangle size={18} />
<div>
<strong>Connection Error</strong>
<p>{error}</p>
</div>
<button onClick={loadAll} className="pt-error-retry">Retry</button>
</div>
)}
{/* ── Main Layout ──────────────────────────────────────────────────────── */}
<div className="pretravel-layout">
{/* ── Panel A: Trip Selector ──────────────────────────────────────────── */}
<aside className="pt-panel pt-panel--trips" aria-label="Trip selector">
<div className="pt-panel-header">
<PlaneTakeoff size={15} style={{ color: 'var(--color-primary)' }} />
<span className="pt-panel-title">Pre-Travel Trips</span>
<span className="pt-panel-count">{loadingTrips ? '…' : trips.length}</span>
</div>
{loadingTrips ? (
<div className="pt-loading-state">
<RotateCw size={22} style={{ animation: 'spin 1s linear infinite', color: 'var(--color-primary)' }} />
<span>Loading trips</span>
</div>
) : trips.length === 0 && !error ? (
<div className="pt-empty-state">
<PlaneTakeoff size={32} style={{ color: 'var(--color-text-muted)', marginBottom: '10px' }} />
<p>No pre-travel trips found.</p>
<p className="pt-empty-sub">Trips will appear here when their departure date is in the future.</p>
</div>
) : (
<div className="pt-trip-list">
{trips.map((trip) => (
<TripCard
key={trip.id}
trip={trip}
isSelected={selectedTrip?.id === trip.id}
onClick={() => {
setSelectedTrip(trip)
setAddedVaccines([])
setFirstDoseDate('')
}}
/>
))}
</div>
)}
</aside>
{/* ── Right column: Builder + Timeline ───────────────────────────────── */}
<div className="pt-right-col">
{/* ── Panel B: Vaccine Builder ──────────────────────────────────────── */}
<div className={`pt-panel pt-panel--builder${!selectedTrip ? ' pt-panel--disabled' : ''}`}
aria-label="Vaccine builder"
>
<div className="pt-panel-header">
<Syringe size={15} style={{ color: 'var(--color-accent-violet)' }} />
<span className="pt-panel-title">Vaccine Plan</span>
{selectedTrip && (
<span className="pt-panel-sub">
for <strong style={{ color: 'var(--color-text-primary)' }}>{selectedTrip.name}</strong>
{' · '}
<span style={{ color: depDate && daysUntilDep <= 30 ? 'var(--color-accent-amber)' : 'var(--color-text-muted)' }}>
{depDateFormatted}
</span>
</span>
)}
</div>
{!selectedTrip ? (
<div className="pt-placeholder">
<PlaneTakeoff size={28} style={{ color: 'var(--color-text-muted)' }} />
<p>Select a trip from the left panel to begin planning.</p>
</div>
) : (
<div className="pt-builder-body">
<VaccineSearchPanel
vaccines={vaccines}
addedVaccineIds={addedVaccineIds}
onAdd={handleAddVaccine}
loading={loadingVaccines}
firstDoseDate={firstDoseDate}
onFirstDoseDateChange={setFirstDoseDate}
/>
{addedVaccines.length > 0 ? (
<div className="pt-chips-wrap" aria-label="Added vaccines">
{addedVaccines.map((v, idx) => (
<VaccineChip
key={v.id}
vaccine={v}
colorStyle={getVaccineColor(idx)}
onRemove={handleRemoveVaccine}
/>
))}
</div>
) : (
<div className="pt-chips-empty">
Search and add vaccines above to generate a timeline.
</div>
)}
</div>
)}
</div>
{/* ── Panel C: Timeline ────────────────────────────────────────────── */}
{schedules.length > 0 && (
<div className="pt-panel pt-panel--timeline" aria-label="Vaccination timeline">
<div className="pt-panel-header">
<CalendarDays size={15} style={{ color: 'var(--color-accent-teal)' }} />
<span className="pt-panel-title">Vaccination Timeline</span>
<span className="pt-panel-sub">
Departure in <strong style={{ color: daysUntilDep <= 30 ? 'var(--color-accent-amber)' : 'var(--color-text-primary)' }}>
{daysUntilDep}d
</strong>
{' · '}
{schedules.length} vaccine{schedules.length !== 1 ? 's' : ''}
</span>
</div>
{/* Global infeasibility warning */}
{hasInfeasible && (
<div className="pt-global-warning">
<AlertTriangle size={16} />
<div>
<strong>Some schedules may not be feasible</strong>
<p>One or more vaccines cannot complete their standard course before departure. Check for accelerated schedule options below.</p>
</div>
</div>
)}
{/* Per-vaccine tracks */}
<div className="pt-timeline-body">
{schedules.map(({ vaccine, schedule, colorStyle }) => (
<VaccineTrack
key={vaccine.id}
vaccine={vaccine}
schedule={schedule}
colorStyle={colorStyle}
departureDate={depDate}
today={today}
/>
))}
</div>
</div>
)}
</div>
</div>
</section>
)
}
+37 -8
View File
@@ -1,24 +1,28 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import {
HeartPulse,
LayoutDashboard,
Calendar,
Plane,
BarChart3,
Bell,
Settings,
MapPin,
Briefcase,
} from 'lucide-react'
import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
import { fetchEntries } from '../lib/pocketbase.js'
const NAV_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, badge: null },
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: '3' },
{ id: 'schedule', label: 'Schedule', icon: Calendar, badge: null, disabled: true },
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: null },
{ id: 'trips', label: 'Trips', icon: Plane, badge: null, disabled: false },
{ id: 'pre-travel', label: 'Pre-Travel', icon: Briefcase, badge: null, disabled: false },
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
]
const BOTTOM_ITEMS = [
{ id: 'notifications', label: 'Notifications', icon: Bell, disabled: true },
{ id: 'notifications', label: 'Notifications', icon: Bell, badge: null, disabled: true },
{ id: 'settings', label: 'Settings', icon: Settings, badge: null, disabled: true },
]
@@ -28,6 +32,31 @@ const BOTTOM_ITEMS = [
* @param {function} onNavigate — callback to change the active page
*/
export default function Sidebar({ activePage, onNavigate }) {
const [postTravelCount, setPostTravelCount] = useState(0)
useEffect(() => {
async function loadBadgeData() {
try {
const data = await fetchEntries()
const count = (data || []).filter((t) => t.status === 'post-travel').length
setPostTravelCount(count)
} catch {
// Fallback for bridge unready or offline state
}
}
loadBadgeData()
}, [activePage])
const navItems = NAV_ITEMS.map((item) => {
if (item.id === 'live-tracking') {
return {
...item,
badge: postTravelCount > 0 ? String(postTravelCount) : null,
}
}
return item
})
return (
<aside className="sidebar">
{/* Logo / App identity */}
@@ -44,7 +73,7 @@ export default function Sidebar({ activePage, onNavigate }) {
{/* Primary navigation */}
<nav className="sidebar-nav" aria-label="Primary navigation">
<span className="sidebar-section-label">Main</span>
{NAV_ITEMS.map((item) => {
{navItems.map((item) => {
const Icon = item.icon
return (
<button
@@ -93,7 +122,7 @@ export default function Sidebar({ activePage, onNavigate }) {
</nav>
{/* User identity footer */}
<div className="sidebar-footer">
{/*<div className="sidebar-footer">
<div className="sidebar-user" role="button" tabIndex={0} aria-label="User profile">
<div className="sidebar-avatar" aria-hidden="true">JD</div>
<div className="sidebar-user-info">
@@ -101,7 +130,7 @@ export default function Sidebar({ activePage, onNavigate }) {
<div className="sidebar-user-role">Senior Physician</div>
</div>
</div>
</div>
</div>*/}
</aside>
)
}
+712
View File
@@ -0,0 +1,712 @@
import React, { useState, useEffect } from 'react'
import {
PlusCircle,
Edit3,
Check,
X,
RotateCw,
AlertTriangle,
Briefcase,
Trash2,
Calendar,
} from 'lucide-react'
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
import CountrySelect from './CountrySelect.jsx'
const STATUS_META = {
'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' },
'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' },
}
const INITIAL_NEW_FORM = {
emrID: '',
name: '',
location: '',
countriesISO: [],
departure: '',
arrival: '',
}
export default function Trips() {
const [travellers, setTravellers] = useState([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [successMsg, setSuccessMsg] = useState(null)
// New Trip form state (Left Panel)
const [newTripForm, setNewTripForm] = useState(INITIAL_NEW_FORM)
// Edit Trip state (Right Panel when editing)
const [editingTrip, setEditingTrip] = useState(null)
const [editTripForm, setEditTripForm] = useState(INITIAL_NEW_FORM)
async function loadData() {
setLoading(true)
setError(null)
try {
const data = await fetchEntries()
setTravellers(data || [])
} catch (err) {
setError(err.message || 'Unable to connect to PocketBase backend.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadData()
}, [])
// Auto-dismiss success notification
useEffect(() => {
if (successMsg) {
const timer = setTimeout(() => setSuccessMsg(null), 4000)
return () => clearTimeout(timer)
}
}, [successMsg])
// Handle New Trip Form submission (Left Form)
async function handleCreateTrip(e) {
e.preventDefault()
if (!newTripForm.name.trim()) {
setError('Please enter a patient / traveller name.')
return
}
setSubmitting(true)
setError(null)
// Convert comma-separated string to array if needed
const isoArray = typeof newTripForm.countriesISO === 'string'
? newTripForm.countriesISO.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: newTripForm.countriesISO
const payload = {
emrID: newTripForm.emrID ? Number(newTripForm.emrID) : null,
name: newTripForm.name.trim(),
location: newTripForm.location.trim(),
countriesISO: isoArray,
departure: newTripForm.departure,
arrival: newTripForm.arrival,
}
try {
await createEntry(payload)
setNewTripForm(INITIAL_NEW_FORM)
setSuccessMsg('New trip created successfully!')
await loadData()
} catch (err) {
setError(err.message || 'Failed to create trip record.')
} finally {
setSubmitting(false)
}
}
// Initiate Edit mode for a specific trip
function startEditing(trip) {
setEditingTrip(trip)
const isos = getPatientISOs(trip)
setEditTripForm({
emrID: trip.emrID !== undefined && trip.emrID !== null ? trip.emrID : '',
name: trip.name || '',
location: trip.location || '',
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) : '',
})
// Scroll top container into view smoothly
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function cancelEditing() {
setEditingTrip(null)
setEditTripForm(INITIAL_NEW_FORM)
}
// Handle Save Changes submission (Right Edit Form)
async function handleUpdateTrip(e) {
e.preventDefault()
if (!editingTrip) return
if (!editTripForm.name.trim()) {
setError('Patient name cannot be empty.')
return
}
setSubmitting(true)
setError(null)
const isoArray = typeof editTripForm.countriesISO === 'string'
? editTripForm.countriesISO.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: editTripForm.countriesISO
const payload = {
emrID: editTripForm.emrID ? Number(editTripForm.emrID) : null,
name: editTripForm.name.trim(),
location: editTripForm.location.trim(),
countriesISO: isoArray,
departure: editTripForm.departure,
arrival: editTripForm.arrival,
}
try {
await updateEntry(editingTrip.id, payload)
setSuccessMsg(`Trip #${editingTrip.id} updated successfully!`)
setEditingTrip(null)
setEditTripForm(INITIAL_NEW_FORM)
await loadData()
} catch (err) {
setError(err.message || `Failed to update trip #${editingTrip.id}.`)
} finally {
setSubmitting(false)
}
}
// Handle Delete Trip
async function handleDeleteTrip(id, name) {
if (!window.confirm(`Are you sure you want to delete trip ${id} (${name})?`)) return
setError(null)
try {
await deleteEntry(id)
if (editingTrip?.id === id) {
cancelEditing()
}
setSuccessMsg(`Trip ${id} deleted successfully.`)
await loadData()
} catch (err) {
setError(err.message || `Failed to delete trip ${id}.`)
}
}
// Helper for date formatting
const formatDate = (dateStr) => {
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' })
}
// Compute live preview state for New Form draft
const newDraftPatient = {
emrID: newTripForm.emrID,
name: newTripForm.name || 'Jane / John Doe',
location: newTripForm.location || 'Pending Location',
countriesISO: Array.isArray(newTripForm.countriesISO)
? newTripForm.countriesISO
: typeof newTripForm.countriesISO === 'string'
? newTripForm.countriesISO.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: [],
departure: newTripForm.departure,
arrival: newTripForm.arrival,
}
const newDraftStatusKey = calculatePatientStatus(newDraftPatient)
const newDraftMeta = STATUS_META[newDraftStatusKey] || STATUS_META['pre-travel']
const newDraftISOs = getPatientISOs(newDraftPatient)
// Compute live preview state for Edit Form draft
const editDraftPatient = {
...editingTrip,
emrID: editTripForm.emrID,
name: editTripForm.name,
location: editTripForm.location,
countriesISO: Array.isArray(editTripForm.countriesISO)
? editTripForm.countriesISO
: typeof editTripForm.countriesISO === 'string'
? editTripForm.countriesISO.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: [],
departure: editTripForm.departure,
arrival: editTripForm.arrival,
}
const editDraftStatusKey = calculatePatientStatus(editDraftPatient)
const editDraftMeta = STATUS_META[editDraftStatusKey] || STATUS_META['pre-travel']
const editDraftISOs = getPatientISOs(editDraftPatient)
const isEditingMode = editingTrip !== null
return (
<div className="trips-view" aria-label="Trip Management Workspace">
{/* ── Top Header ──────────────────────────────────────────────────────── */}
<header className="trips-view-header">
<div>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 700, color: 'var(--color-primary)', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: '4px' }}>
CONCIERGE OPERATIONS
</p>
<h1 style={{ fontSize: 'var(--font-size-3xl)', fontWeight: 800, color: 'var(--color-text-primary)', letterSpacing: '-0.02em', margin: 0 }}>
Trip Management
</h1>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', marginTop: '4px' }}>
Create new patient travel itineraries and manage existing trip records.
</p>
</div>
<button
onClick={loadData}
disabled={loading}
className="btn-secondary"
style={{ padding: '8px 16px', fontSize: 'var(--font-size-xs)' }}
>
<RotateCw size={14} style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }} />
{loading ? 'Refreshing...' : 'Refresh Database'}
</button>
</header>
{/* ── Banners & Notifications ────────────────────────────────────────── */}
{error && (
<div style={{ background: 'rgba(239, 68, 68, 0.12)', border: '1px solid rgba(239, 68, 68, 0.35)', borderRadius: 'var(--radius-md)', padding: '16px 20px', marginBottom: '24px', color: '#f87171', display: 'flex', alignItems: 'center', gap: '12px' }}>
<AlertTriangle size={20} style={{ color: '#ef4444', flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 'var(--font-size-sm)', lineHeight: 1.4 }}>{error}</div>
<button onClick={() => setError(null)} style={{ background: 'transparent', border: 'none', color: '#f87171', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
)}
{successMsg && (
<div style={{ background: 'rgba(16, 185, 129, 0.12)', border: '1px solid rgba(16, 185, 129, 0.35)', borderRadius: 'var(--radius-md)', padding: '16px 20px', marginBottom: '24px', color: '#34d399', display: 'flex', alignItems: 'center', gap: '12px' }}>
<Check size={20} style={{ color: '#10b981', flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 'var(--font-size-sm)', fontWeight: 600 }}>{successMsg}</div>
<button onClick={() => setSuccessMsg(null)} style={{ background: 'transparent', border: 'none', color: '#34d399', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
)}
{/* ── TOP HALF: Split Container ───────────────────────────────────────── */}
<div className="trips-top-split">
{/* ── Left Panel: Form for New Cards ─────────────────────────────── */}
<section className={`trips-panel-card ${isEditingMode ? 'trips-panel-card--disabled' : ''}`}>
<div className="trips-panel-header">
<h2 className="trips-panel-title">
<PlusCircle size={20} style={{ color: 'var(--color-primary)' }} />
Create New Trip
</h2>
<span className={`trips-badge ${isEditingMode ? 'trips-badge--disabled' : 'trips-badge--new'}`}>
{isEditingMode ? 'Form Paused' : 'New Trip Form'}
</span>
</div>
{isEditingMode && (
<div style={{ background: 'rgba(139, 92, 246, 0.15)', border: '1px solid rgba(139, 92, 246, 0.3)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: '16px', fontSize: 'var(--font-size-xs)', color: 'var(--color-accent-violet)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Edit3 size={14} />
Editing in progress on right panel New trip creation is currently disabled.
</div>
)}
<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>
<input
id="new-emr-id"
type="number"
className="trips-form-input"
placeholder="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}
required
/>
</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>
{/* ── Right Panel: Live Preview Card or Editable Card ────────────── */}
<section className={`trips-panel-card ${isEditingMode ? 'trips-panel-card--editing' : ''}`}>
<div className="trips-panel-header">
<h2 className="trips-panel-title">
{isEditingMode ? (
<>
<Edit3 size={20} style={{ color: 'var(--color-accent-violet)' }} />
Editing Trip ({editingTrip.id})
</>
) : (
<>
<Briefcase size={20} style={{ color: 'var(--color-primary)' }} />
Live Itinerary Preview
</>
)}
</h2>
<span className={`trips-badge ${isEditingMode ? 'trips-badge--edit' : 'trips-badge--new'}`}>
{isEditingMode ? 'Edit Mode Active' : 'Real-time Preview'}
</span>
</div>
{/* Mode 1: Create Preview Card (Non-editing mode) */}
{!isEditingMode && (
<div>
<div className="trips-preview-card">
<div className="log-card-top">
<div className="log-card-left">
<span className="log-patient-id" style={{ background: 'rgba(59, 158, 255, 0.15)', color: 'var(--color-primary)', border: '1px solid rgba(59, 158, 255, 0.3)' }}>
{newDraftPatient.emrID ? `EMR: ${newDraftPatient.emrID}` : 'NEW-TRIP'}
</span>
<h3 className="log-patient-name" style={{ marginTop: '4px', fontSize: 'var(--font-size-base)' }}>
{newDraftPatient.name}
</h3>
</div>
<span className={`status-pill ${newDraftMeta.className}`}>
{newDraftMeta.label}
</span>
</div>
<div className="log-card-details" style={{ marginTop: '8px', borderTop: '1px dashed var(--color-border)', paddingTop: '12px' }}>
<div className="log-detail-item">
<span className="log-detail-label">Location</span>
<span className="log-detail-val" style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
📍 {newDraftPatient.location}
</span>
</div>
<div className="log-detail-item" style={{ marginTop: '8px' }}>
<span className="log-detail-label">Destinations (ISOs)</span>
<div className="destination-chips-container">
{newDraftISOs.length > 0 ? (
newDraftISOs.map((iso) => (
<span key={iso} className="destination-chip">📍 {iso}</span>
))
) : (
<span className="log-detail-val" style={{ fontStyle: 'italic', color: 'var(--color-text-muted)' }}>None specified</span>
)}
</div>
</div>
<div className="log-detail-item" style={{ marginTop: '8px' }}>
<span className="log-detail-label">Travel Window</span>
<span className="log-detail-val">
<Calendar size={13} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '4px' }} />
{formatDate(newDraftPatient.departure)} {formatDate(newDraftPatient.arrival)}
</span>
</div>
</div>
</div>
<p className="trips-form-hint" style={{ marginTop: '12px', textAlign: 'center' }}>
This card reflects how the new trip will appear in the Live Tracking dashboard.
</p>
</div>
)}
{/* Mode 2: Editable Preview Form (Editing mode) */}
{isEditingMode && (
<form onSubmit={handleUpdateTrip} className="trips-form">
<div className="trips-preview-card trips-preview-card--editing" style={{ background: 'var(--color-bg-subtle)' }}>
<div className="log-card-top">
<div className="log-card-left">
<span className="log-patient-id" style={{ background: 'rgba(139, 92, 246, 0.2)', color: 'var(--color-accent-violet)', border: '1px solid rgba(139, 92, 246, 0.4)' }}>
{editTripForm.emrID ? `EMR: ${editTripForm.emrID}` : 'NO-EMR'}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)', marginLeft: '8px' }}>
DB ID: {editingTrip.id} | Created: {formatDate(editingTrip.created)}
</span>
</div>
<span className={`status-pill ${editDraftMeta.className}`}>
{editDraftMeta.label}
</span>
</div>
<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>
<input
id="edit-emr-id"
type="number"
className="trips-form-input"
placeholder="e.g. 1042"
value={editTripForm.emrID}
onChange={(e) => setEditTripForm({ ...editTripForm, emrID: e.target.value })}
disabled={submitting}
/>
</div>
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-patient-name">Patient / Traveller Name *</label>
<input
id="edit-patient-name"
type="text"
className="trips-form-input"
value={editTripForm.name}
onChange={(e) => setEditTripForm({ ...editTripForm, name: e.target.value })}
disabled={submitting}
required
/>
</div>
</div>
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-location">Primary Location</label>
<input
id="edit-location"
type="text"
className="trips-form-input"
value={editTripForm.location}
onChange={(e) => setEditTripForm({ ...editTripForm, location: e.target.value })}
disabled={submitting}
/>
</div>
<div className="trips-form-field">
<label className="trips-form-label">Destination Country(ies)</label>
<CountrySelect
selectedISOs={editTripForm.countriesISO}
onChange={(updatedISOs, updatedLocationStr) => {
setEditTripForm({
...editTripForm,
countriesISO: updatedISOs,
location: updatedLocationStr,
})
}}
disabled={submitting}
placeholder="Search country..."
/>
</div>
<div className="trips-form-row">
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-departure">Departure Date</label>
<input
id="edit-departure"
type="date"
className="trips-form-input"
value={editTripForm.departure}
onChange={(e) => setEditTripForm({ ...editTripForm, departure: e.target.value })}
disabled={submitting}
/>
</div>
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-arrival">Return / Arrival Date</label>
<input
id="edit-arrival"
type="date"
className="trips-form-input"
value={editTripForm.arrival}
onChange={(e) => setEditTripForm({ ...editTripForm, arrival: e.target.value })}
disabled={submitting}
/>
</div>
</div>
</div>
{/* Live chips preview for edit form */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>ISO Preview:</span>
{editDraftISOs.length > 0 ? (
editDraftISOs.map((iso) => (
<span key={iso} className="destination-chip">📍 {iso}</span>
))
) : (
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>None</span>
)}
</div>
</div>
<div className="trips-form-actions">
<button
type="submit"
className="btn-primary"
style={{ background: 'var(--color-accent-violet)' }}
disabled={submitting}
>
<Check size={16} />
{submitting ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
className="btn-secondary"
onClick={cancelEditing}
disabled={submitting}
>
<X size={16} />
Cancel Edit
</button>
</div>
</form>
)}
</section>
</div>
{/* ── BOTTOM HALF: Trips Database List ────────────────────────────────── */}
<section className="journey-panel" style={{ marginTop: 'var(--space-4)' }}>
<div className="trips-bottom-panel-header" style={{ padding: 'var(--space-5) var(--space-6)', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 className="bottom-panel-title" style={{ margin: 0, fontSize: 'var(--font-size-lg)', fontWeight: 700 }}>
All Active Trips List
</h2>
</div>
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 600, color: 'var(--color-primary)', background: 'var(--color-primary-glow)', padding: '4px 12px', borderRadius: '999px', border: '1px solid var(--color-border-active)' }}>
Total Records: {travellers.length}
</div>
</div>
<div className="journey-table-container">
<table className="journey-table" aria-label="Database Trips List">
<thead>
<tr>
<th scope="col">EMR ID</th>
<th scope="col">Patient Name</th>
<th scope="col">Location & ISOs</th>
<th scope="col">Departure</th>
<th scope="col">Arrival</th>
<th scope="col">Status</th>
<th scope="col" style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{travellers.length === 0 && !loading ? (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: '32px', color: 'var(--color-text-muted)' }}>
No trips found in database. Use the form above to add a new trip.
</td>
</tr>
) : (
travellers.map((p) => {
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
const isBeingEdited = editingTrip?.id === p.id
const isos = getPatientISOs(p)
return (
<tr
key={p.id}
id={`trip-row-${p.id}`}
style={{
background: isBeingEdited ? 'rgba(139, 92, 246, 0.08)' : undefined,
transition: 'background var(--transition-fast)'
}}
>
<td>
<span className="log-patient-id" style={{ opacity: isBeingEdited ? 1 : 0.85 }}>
{p.emrID ? p.emrID : '—'}
</span>
</td>
<td className="patient-name">{p.name}</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span>📍 {p.location || 'N/A'}</span>
{isos.length > 0 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{isos.map((iso) => (
<span key={iso} className="destination-chip" style={{ fontSize: '10px', padding: '1px 6px' }}>
{iso}
</span>
))}
</div>
)}
</div>
</td>
<td>{formatDate(p.departure)}</td>
<td>{formatDate(p.arrival)}</td>
<td>
<span className={`status-pill ${meta.className}`}>
{meta.label}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div className="trips-list-actions" style={{ justifyContent: 'flex-end' }}>
<button
type="button"
className="btn-table-edit"
onClick={() => startEditing(p)}
disabled={submitting}
title={`Edit trip ${p.id}`}
>
<Edit3 size={13} />
{isBeingEdited ? 'Editing...' : 'Edit'}
</button>
<button
type="button"
className="btn-danger"
onClick={() => handleDeleteTrip(p.id, p.name)}
disabled={submitting}
title={`Delete trip ${p.id}`}
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</section>
</div>
)
}
+1511 -4
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -11,3 +11,21 @@ export async function fetchEntries() {
const data = await getPbApi().fetchEntries()
return processPatientRecords(data)
}
export async function createEntry(tripData) {
const payload = tripData?.trip ? tripData.trip : tripData
return getPbApi().createEntry(payload)
}
export async function updateEntry(id, data) {
return getPbApi().updateEntry(id, data)
}
export async function deleteEntry(id) {
return getPbApi().deleteEntry(id)
}
export async function fetchVaccines() {
return getPbApi().fetchVaccines()
}
+210
View File
@@ -0,0 +1,210 @@
/**
* vaccineScheduleUtils.js
*
* Pure functions for computing vaccination schedules, working forward
* from a patient's first dose date. All functions are side-effect-free and
* have no UI dependencies.
*/
// ── Date helpers ─────────────────────────────────────────────────────────────
/**
* Returns a Date object representing midnight local time for a YYYY-MM-DD string.
* Avoids UTC-offset issues that plague `new Date('YYYY-MM-DD')`.
* @param {string} str — YYYY-MM-DD
* @returns {Date}
*/
export function localDate(str) {
const [y, m, d] = str.slice(0, 10).split('-').map(Number)
return new Date(y, m - 1, d)
}
/**
* Formats a Date as a human-readable short string: "Aug 15, 2026"
* @param {Date} date
* @returns {string}
*/
export function formatDate(date) {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
/**
* Adds (or subtracts) a number of days to a Date, returning a new Date.
* Does not mutate the input.
* @param {Date} date
* @param {number} days — can be negative
* @returns {Date}
*/
export function addDays(date, days) {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
/**
* Returns the number of whole days between two dates (a - b).
* @param {Date} a
* @param {Date} b
* @returns {number}
*/
export function daysBetween(a, b) {
const msPerDay = 1000 * 60 * 60 * 24
return Math.round((a.getTime() - b.getTime()) / msPerDay)
}
// ── Core scheduling algorithm ─────────────────────────────────────────────────
/**
* Computes the vaccination schedule for one vaccine, working forward from the
* patient's first dose date (or today).
*
* Algorithm:
* 1. dose[1] = customFirstDoseDate || today
* 2. For each subsequent dose d, dose[d] = dose[d-1] + recommended_interval_days
* 3. targetImmunityDate = finalDoseDate + min_days_before_travel
* 4. Compare dates against departureDate to determine overall feasibility
* and immunity target deficits.
*
* @param {Object} vaccine — Vaccine record from PocketBase
* @param {Date} departureDate — Patient's departure date (local midnight)
* @param {Date} [today=new Date()] — Reference date for past status calculation
* @param {Date} [customFirstDoseDate] — Optional specified 1st dose appointment date
*
* @returns {{
* doses: Array<{
* doseNumber: number,
* date: Date,
* label: string,
* daysBeforeDeparture: number,
* isInPast: boolean,
* isImmunityTargetMissed: boolean,
* daysShortOfImmunity: number,
* }>,
* targetImmunityDate: Date,
* minDaysBefore: number,
* isFeasible: boolean,
* isImmunityTargetMissed: boolean,
* lastDoseAfterDeparture: boolean,
* needsAcceleratedSuggestion: boolean,
* totalLeadTimeDays: number,
* }}
*/
export function computeIdealSchedule(vaccine, departureDate, today = new Date(), customFirstDoseDate = null) {
const {
total_doses: totalDoses,
dose_intervals: intervals = [],
min_days_before_travel: minDaysBefore = 0,
has_accelerated_schedule: hasAccelerated = false,
} = vaccine
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const startDose1 = customFirstDoseDate
? new Date(customFirstDoseDate.getFullYear(), customFirstDoseDate.getMonth(), customFirstDoseDate.getDate())
: todayMidnight
// Build an interval lookup: dose_number → interval object
// dose_intervals[i].dose_number is the dose that comes AFTER the gap
// e.g. dose_number=2 means "gap between dose 1 and dose 2"
const intervalByDoseNumber = {}
for (const interval of intervals) {
intervalByDoseNumber[interval.dose_number] = interval
}
// Step 1: Forward calculation starting from startDose1 (or TODAY) for Dose 1
const doseDates = new Array(totalDoses + 1) // 1-indexed
doseDates[1] = startDose1
for (let d = 2; d <= totalDoses; d++) {
const interval = intervalByDoseNumber[d]
const gap = interval ? interval.recommended_interval_days : 0
doseDates[d] = addDays(doseDates[d - 1], gap)
}
const finalDoseDate = doseDates[totalDoses]
const targetImmunityDate = addDays(finalDoseDate, minDaysBefore)
// Step 2: Build the dose result objects
const doses = []
for (let d = 1; d <= totalDoses; d++) {
const date = doseDates[d]
const daysBeforeDeparture = daysBetween(departureDate, date)
const isInPast = daysBetween(date, todayMidnight) < 0
doses.push({
doseNumber: d,
date,
label: totalDoses === 1 ? 'Single Dose' : `Dose ${d}`,
daysBeforeDeparture,
isInPast,
isImmunityTargetMissed: false,
daysShortOfImmunity: 0,
})
}
// Feasibility calculations against departure date
const daysUntilDepartureFromImmunity = daysBetween(departureDate, targetImmunityDate)
const daysUntilDepartureFromFinalDose = daysBetween(departureDate, finalDoseDate)
// Fully feasible: Full immunity target date is on or before departure
const isFeasible = daysUntilDepartureFromImmunity >= 0
// Immunity target check for the last pre-departure dose
const preDepartureDoses = doses.filter((d) => d.daysBeforeDeparture >= 0)
const lastPreDepartureDose = preDepartureDoses.length > 0 ? preDepartureDoses[preDepartureDoses.length - 1] : null
let isImmunityTargetMissed = false
if (lastPreDepartureDose && minDaysBefore > 0 && lastPreDepartureDose.daysBeforeDeparture < minDaysBefore) {
isImmunityTargetMissed = true
lastPreDepartureDose.isImmunityTargetMissed = true
lastPreDepartureDose.daysShortOfImmunity = minDaysBefore - lastPreDepartureDose.daysBeforeDeparture
}
const lastDoseAfterDeparture = daysUntilDepartureFromFinalDose < 0
const needsAcceleratedSuggestion = hasAccelerated && (!isFeasible || lastDoseAfterDeparture)
// Total ideal lead time required (days from Dose 1 to Target Immunity Date)
const totalLeadTimeDays = daysBetween(targetImmunityDate, doseDates[1])
return {
doses,
targetImmunityDate,
minDaysBefore,
isFeasible,
isImmunityTargetMissed,
lastDoseAfterDeparture,
needsAcceleratedSuggestion,
totalLeadTimeDays,
}
}
/**
* Computes the number of days remaining until departure.
* Returns a negative number if departure has already passed.
* @param {Date|string} departureDate
* @param {Date} [today]
* @returns {number}
*/
export function getDaysUntilDeparture(departureDate, today = new Date()) {
const dep = typeof departureDate === 'string' ? localDate(departureDate) : departureDate
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate())
return daysBetween(dep, todayMidnight)
}
/**
* Returns a palette-friendly color object for a vaccine based on its
* index in a list (cycles through available accent colors).
* Note: Amber and Rose are excluded from this decorative palette as they
* are reserved for warning and post-departure states respectively.
*
* @param {number} index
* @returns {{ bg: string, border: string, text: string, glow: string }}
*/
export function getVaccineColor(index) {
const palette = [
{ bg: 'rgba(59, 158, 255, 0.15)', border: '#3b9eff', text: '#63b3ff', glow: 'rgba(59, 158, 255, 0.3)' },
{ bg: 'rgba(34, 211, 197, 0.15)', border: '#22d3c5', text: '#22d3c5', glow: 'rgba(34, 211, 197, 0.3)' },
{ bg: 'rgba(139, 92, 246, 0.15)', border: '#8b5cf6', text: '#a78bfa', glow: 'rgba(139, 92, 246, 0.3)' },
{ bg: 'rgba(16, 185, 129, 0.15)', border: '#10b981', text: '#34d399', glow: 'rgba(16, 185, 129, 0.3)' },
]
return palette[index % palette.length]
}