Added pre-travel screen; added country polygons for maps
This commit is contained in:
+15
-1
@@ -1,7 +1,7 @@
|
|||||||
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
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))
|
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 () => {
|
ipcMain.handle('pb:fetch-entries', async () => {
|
||||||
return await fetchEntries()
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import PocketBase from 'pocketbase'
|
|||||||
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
|
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
|
||||||
|
|
||||||
const COLLECTION = 'travellers'
|
const COLLECTION = 'travellers'
|
||||||
|
const VACCINES_COLLECTION = 'vaccines'
|
||||||
|
|
||||||
let pb = null
|
let pb = null
|
||||||
let authPromise = null
|
let authPromise = null
|
||||||
@@ -18,7 +19,8 @@ function getClient() {
|
|||||||
|
|
||||||
function parseEntry(r) {
|
function parseEntry(r) {
|
||||||
return {
|
return {
|
||||||
id: r.emr_id,
|
id: r.id,
|
||||||
|
emrID: r.emr_id ?? '',
|
||||||
name: r.name ?? '',
|
name: r.name ?? '',
|
||||||
location: r.location ?? '',
|
location: r.location ?? '',
|
||||||
countryISO: r.country_iso ?? '',
|
countryISO: r.country_iso ?? '',
|
||||||
@@ -58,3 +60,68 @@ export async function fetchEntries() {
|
|||||||
return list.map(parseEntry)
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'schedule:fetch',
|
'schedule:fetch',
|
||||||
'app:ready',
|
'app:ready',
|
||||||
'pb:fetch-entries',
|
'pb:fetch-entries',
|
||||||
|
'pb:create-entry',
|
||||||
|
'pb:update-entry',
|
||||||
|
'pb:delete-entry',
|
||||||
|
'pb:fetch-vaccines',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
@@ -24,6 +28,10 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'schedule:response',
|
'schedule:response',
|
||||||
'app:get-version',
|
'app:get-version',
|
||||||
'pb:fetch-entries',
|
'pb:fetch-entries',
|
||||||
|
'pb:create-entry',
|
||||||
|
'pb:update-entry',
|
||||||
|
'pb:delete-entry',
|
||||||
|
'pb:fetch-vaccines',
|
||||||
]
|
]
|
||||||
|
|
||||||
// ─── Exposed API ──────────────────────────────────────────────────────────
|
// ─── Exposed API ──────────────────────────────────────────────────────────
|
||||||
@@ -33,6 +41,10 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
*/
|
*/
|
||||||
pb: {
|
pb: {
|
||||||
fetchEntries: () => ipcRenderer.invoke('pb:fetch-entries'),
|
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
@@ -2,7 +2,11 @@
|
|||||||
"name": "ctm-concierge",
|
"name": "ctm-concierge",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "CTM Concierge - Real-time patient journey tracker for CTM",
|
"description": "CTM Concierge - Real-time patient journey tracker for CTM",
|
||||||
"author": "Wirediv",
|
"homepage": "https://canadatravelmed.ca",
|
||||||
|
"author": {
|
||||||
|
"name": "Wirediv",
|
||||||
|
"email": "info@wirediv.com"
|
||||||
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -43,7 +47,8 @@
|
|||||||
"AppImage",
|
"AppImage",
|
||||||
"deb"
|
"deb"
|
||||||
],
|
],
|
||||||
"category": "MedicalSoftware"
|
"category": "MedicalSoftware",
|
||||||
|
"maintainer": "Wirediv <info@wirediv.com>"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
|
|||||||
+5
-2
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Sidebar from './components/Sidebar.jsx'
|
import Sidebar from './components/Sidebar.jsx'
|
||||||
import Dashboard from './components/Dashboard.jsx'
|
import Dashboard from './components/Dashboard.jsx'
|
||||||
import LiveTracking from './components/LiveTracking.jsx'
|
import LiveTracking from './components/LiveTracking.jsx'
|
||||||
|
import Trips from './components/Trips.jsx'
|
||||||
|
import PreTravel from './components/PreTravel.jsx'
|
||||||
/**
|
/**
|
||||||
* App — root layout component.
|
* App — root layout component.
|
||||||
* Implements a two-panel CSS Grid: fixed sidebar + scrollable main viewport.
|
* Implements a two-panel CSS Grid: fixed sidebar + scrollable main viewport.
|
||||||
@@ -14,6 +15,8 @@ export default function App() {
|
|||||||
function renderPage() {
|
function renderPage() {
|
||||||
switch (activePage) {
|
switch (activePage) {
|
||||||
case 'live-tracking': return <LiveTracking />
|
case 'live-tracking': return <LiveTracking />
|
||||||
|
case 'trips': return <Trips />
|
||||||
|
case 'pre-travel': return <PreTravel />
|
||||||
default: return <Dashboard />
|
default: return <Dashboard />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4003
-3987
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
|
Briefcase,
|
||||||
PlaneTakeoff,
|
PlaneTakeoff,
|
||||||
Plane,
|
PlaneLanding,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
|
||||||
Activity,
|
Activity,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
X,
|
||||||
|
Filter
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { fetchEntries } from '../lib/pocketbase.js'
|
import { fetchEntries } from '../lib/pocketbase.js'
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ export default function Dashboard() {
|
|||||||
const [travellers, setTravellers] = useState([])
|
const [travellers, setTravellers] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||||
|
|
||||||
const today = new Date().toLocaleDateString('en-US', {
|
const today = new Date().toLocaleDateString('en-US', {
|
||||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
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 atRiskCount = travellers.filter((t) => t.status === 'at-risk').length
|
||||||
const postTravelCount = travellers.filter((t) => t.status === 'post-travel').length
|
const postTravelCount = travellers.filter((t) => t.status === 'post-travel').length
|
||||||
|
|
||||||
|
const filteredTravellers = selectedStatus === 'all'
|
||||||
|
? travellers
|
||||||
|
: travellers.filter((t) => t.status === selectedStatus)
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ id: 'pre-travel', icon: PlaneTakeoff, value: preTravelCount, label: 'In Pre-Travel', accent: 'violet' },
|
{ id: 'pre-travel', icon: Briefcase, value: preTravelCount, label: 'In Pre-Travel', accent: 'violet' },
|
||||||
{ id: 'in-transit', icon: Plane, value: inTransitCount, label: 'In Transit', accent: 'blue' },
|
{ 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: '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 (
|
return (
|
||||||
@@ -128,15 +135,26 @@ export default function Dashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* ── Stat Cards ─────────────────────────────────────────────────────── */}
|
{/* ── 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) => {
|
{stats.map((stat) => {
|
||||||
const Icon = stat.icon
|
const Icon = stat.icon
|
||||||
|
const isActive = selectedStatus === stat.id
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={stat.id}
|
key={stat.id}
|
||||||
id={`stat-${stat.id}`}
|
id={`stat-${stat.id}`}
|
||||||
className={`stat-card accent-${stat.accent}`}
|
className={`stat-card accent-${stat.accent}${isActive ? ' is-active' : ''}`}
|
||||||
role="listitem"
|
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} />
|
<Icon className="stat-card-icon" aria-hidden="true" size={24} />
|
||||||
<div className="stat-card-value">{loading ? '...' : stat.value}</div>
|
<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' }}>
|
<h2 className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<Activity size={20} className="section-title-icon" style={{ color: 'var(--color-primary)' }} />
|
<Activity size={20} className="section-title-icon" style={{ color: 'var(--color-primary)' }} />
|
||||||
Live Patient Board
|
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>
|
</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">
|
<div className="journey-panel">
|
||||||
<table className="journey-table" aria-label="Live patient journey board">
|
<div className="journey-table-container">
|
||||||
<thead>
|
<table className="journey-table" aria-label="Live patient journey board">
|
||||||
<tr>
|
<thead>
|
||||||
<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 ? (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} style={{ textAlign: 'center', padding: '24px', color: 'var(--color-text-muted)' }}>
|
<th scope="col">EMR ID</th>
|
||||||
No travellers found in PocketBase database.
|
<th scope="col">Name</th>
|
||||||
</td>
|
<th scope="col">Location</th>
|
||||||
|
<th scope="col">Departure</th>
|
||||||
|
<th scope="col">Arrival</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
</thead>
|
||||||
travellers.map((p) => {
|
<tbody>
|
||||||
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
|
{travellers.length === 0 && !loading ? (
|
||||||
return (
|
<tr>
|
||||||
<tr key={p.id} id={`patient-row-${p.id}`}>
|
<td colSpan={6} style={{ textAlign: 'center', padding: '24px', color: 'var(--color-text-muted)' }}>
|
||||||
<td>{p.id}</td>
|
No travellers found in PocketBase database.
|
||||||
<td className="patient-name">{p.name}</td>
|
</td>
|
||||||
<td>{p.location || 'N/A'}</td>
|
</tr>
|
||||||
<td>{new Date(p.departure).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}</td>
|
) : filteredTravellers.length === 0 ? (
|
||||||
<td>{new Date(p.arrival).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}</td>
|
<tr>
|
||||||
<td>
|
<td colSpan={6} style={{ textAlign: 'center', padding: '32px 24px', color: 'var(--color-text-muted)' }}>
|
||||||
<span className={`status-pill ${meta.className}`}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||||
{meta.label}
|
<Filter size={24} style={{ color: 'var(--color-text-muted)', opacity: 0.5 }} />
|
||||||
</span>
|
<span>No patients found matching status "<strong>{STATUS_META[selectedStatus]?.label || selectedStatus}</strong>".</span>
|
||||||
</td>
|
<button
|
||||||
</tr>
|
onClick={() => setSelectedStatus('all')}
|
||||||
)
|
style={{
|
||||||
})
|
marginTop: '8px',
|
||||||
)}
|
padding: '4px 12px',
|
||||||
</tbody>
|
fontSize: '12px',
|
||||||
</table>
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+250
-58
@@ -1,11 +1,27 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
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 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import countryCentroids from '../assets/countries.json'
|
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 { fetchEntries } from '../lib/pocketbase.js'
|
||||||
import { getPatientISOs } from '../lib/patientUtils.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 ─────────────────────────────────────────────────────
|
// ── Country centroid lookup ─────────────────────────────────────────────────────
|
||||||
// Builds an ISO-2 → [longitude, latitude] map from the GeoJSON centroid dataset.
|
// Builds an ISO-2 → [longitude, latitude] map from the GeoJSON centroid dataset.
|
||||||
const CENTROID_BY_ISO = new Map(
|
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' },
|
'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 ───────────────────────────────────────────────
|
// ── Sub-component: PatientMarker ───────────────────────────────────────────────
|
||||||
function PatientMarker({ patient, isSelected, onClick }) {
|
function PatientMarker({ patient, isSelected, onClick }) {
|
||||||
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
|
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
|
||||||
@@ -36,12 +66,33 @@ function PatientMarker({ patient, isSelected, onClick }) {
|
|||||||
title={patient.name}
|
title={patient.name}
|
||||||
>
|
>
|
||||||
<span className={`map-marker-ring ${meta.markerClass}-ring`} aria-hidden="true" />
|
<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>
|
</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 }) {
|
function PatientPopup({ patient, onClose }) {
|
||||||
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
|
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
|
||||||
|
|
||||||
@@ -65,7 +116,7 @@ function PatientPopup({ patient, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="patient-popup-identity">
|
<div className="patient-popup-identity">
|
||||||
<h3 className="patient-popup-name">{patient.name}</h3>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="popup-close"
|
className="popup-close"
|
||||||
@@ -73,7 +124,7 @@ function PatientPopup({ patient, onClose }) {
|
|||||||
aria-label="Close patient popup"
|
aria-label="Close patient popup"
|
||||||
id={`popup-close-${patient.id}`}
|
id={`popup-close-${patient.id}`}
|
||||||
>
|
>
|
||||||
×
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ────────────────────────────────────────────────
|
// ── Main component: LiveTracking ────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* LiveTracking — full-screen locked world map showing active patient locations.
|
* LiveTracking — full-screen locked world map showing active patient locations.
|
||||||
* Uses react-map-gl/maplibre with the OpenFreeMap 'Fiord' tile style.
|
* Uses react-map-gl/maplibre with the OpenFreeMap 'Fiord' tile style.
|
||||||
*/
|
*/
|
||||||
export default function LiveTracking() {
|
export default function LiveTracking() {
|
||||||
const [selectedMarkerKey, setSelectedMarkerKey] = useState(null)
|
const [selectedGroupIso, setSelectedGroupIso] = useState(null)
|
||||||
const [travellers, setTravellers] = useState([])
|
const [travellers, setTravellers] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -129,43 +233,54 @@ export default function LiveTracking() {
|
|||||||
loadData()
|
loadData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const patientMarkers = travellers.flatMap((p) => {
|
// Aggregate travellers into location groups by ISO
|
||||||
|
const locationGroupsMap = new Map()
|
||||||
|
travellers.forEach((p) => {
|
||||||
const isos = getPatientISOs(p)
|
const isos = getPatientISOs(p)
|
||||||
return isos.map((iso) => ({
|
isos.forEach((iso) => {
|
||||||
key: `${p.id}-${iso}`,
|
if (!iso) return
|
||||||
patient: p,
|
const code = iso.trim().toUpperCase()
|
||||||
iso,
|
if (!locationGroupsMap.has(code)) {
|
||||||
coordinate: CENTROID_BY_ISO.get(iso.toUpperCase()) ?? [0, 0],
|
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 locationGroups = Array.from(locationGroupsMap.values())
|
||||||
const selectedPatientId = selectedMarker ? selectedMarker.patient.id : null
|
const selectedGroup = locationGroups.find((g) => g.iso === selectedGroupIso) || null
|
||||||
|
|
||||||
function handleMarkerClick(marker) {
|
function handleGroupClick(group) {
|
||||||
const isSelecting = selectedMarkerKey !== marker.key
|
const isSelecting = selectedGroupIso !== group.iso
|
||||||
setSelectedMarkerKey(isSelecting ? marker.key : null)
|
setSelectedGroupIso(isSelecting ? group.iso : null)
|
||||||
|
|
||||||
if (isSelecting) {
|
if (isSelecting) {
|
||||||
mapRef.current?.flyTo({
|
mapRef.current?.flyTo({
|
||||||
center: marker.coordinate,
|
center: group.coordinate,
|
||||||
zoom: 2.2, // Focus in at a constant zoom level
|
zoom: 2.2,
|
||||||
essential: true,
|
essential: true,
|
||||||
duration: 1000 // smooth 1s transition
|
duration: 1000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCardClick(patient) {
|
function handleCardClick(patient) {
|
||||||
const firstMarker = patientMarkers.find((m) => m.patient.id === patient.id)
|
const targetGroup = locationGroups.find((g) => g.patients.some((p) => p.id === patient.id))
|
||||||
if (!firstMarker) return
|
if (!targetGroup) return
|
||||||
|
|
||||||
const isSelecting = selectedPatientId !== patient.id
|
const isSelecting = selectedGroupIso !== targetGroup.iso
|
||||||
setSelectedMarkerKey(isSelecting ? firstMarker.key : null)
|
setSelectedGroupIso(isSelecting ? targetGroup.iso : null)
|
||||||
|
|
||||||
if (isSelecting) {
|
if (isSelecting) {
|
||||||
mapRef.current?.flyTo({
|
mapRef.current?.flyTo({
|
||||||
center: firstMarker.coordinate,
|
center: targetGroup.coordinate,
|
||||||
zoom: 2.2,
|
zoom: 2.2,
|
||||||
essential: true,
|
essential: true,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
@@ -174,16 +289,16 @@ export default function LiveTracking() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDestinationChipClick(patient, iso) {
|
function handleDestinationChipClick(patient, iso) {
|
||||||
const markerKey = `${patient.id}-${iso}`
|
const code = iso.trim().toUpperCase()
|
||||||
const targetMarker = patientMarkers.find((m) => m.key === markerKey)
|
const targetGroup = locationGroups.find((g) => g.iso === code)
|
||||||
if (!targetMarker) return
|
if (!targetGroup) return
|
||||||
|
|
||||||
const isSelecting = selectedMarkerKey !== markerKey
|
const isSelecting = selectedGroupIso !== code
|
||||||
setSelectedMarkerKey(isSelecting ? markerKey : null)
|
setSelectedGroupIso(isSelecting ? code : null)
|
||||||
|
|
||||||
if (isSelecting) {
|
if (isSelecting) {
|
||||||
mapRef.current?.flyTo({
|
mapRef.current?.flyTo({
|
||||||
center: targetMarker.coordinate,
|
center: targetGroup.coordinate,
|
||||||
zoom: 2.2,
|
zoom: 2.2,
|
||||||
essential: true,
|
essential: true,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
@@ -192,7 +307,7 @@ export default function LiveTracking() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePopupClose() {
|
function handlePopupClose() {
|
||||||
setSelectedMarkerKey(null)
|
setSelectedGroupIso(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCountryFilter(mapInstance, countryISOs) {
|
function applyCountryFilter(mapInstance, countryISOs) {
|
||||||
@@ -213,7 +328,7 @@ export default function LiveTracking() {
|
|||||||
['in', ['get', 'country_code'], ['literal', validISOs]],
|
['in', ['get', 'country_code'], ['literal', validISOs]],
|
||||||
['in', ['get', 'ISO_A2'], ['literal', validISOs]],
|
['in', ['get', 'ISO_A2'], ['literal', validISOs]],
|
||||||
]
|
]
|
||||||
: ['==', 1, 0]
|
: ['==', '$type', '']
|
||||||
|
|
||||||
for (const layer of style.layers) {
|
for (const layer of style.layers) {
|
||||||
if (layer.id === 'place_country_major' || layer.id === 'place_country_minor') {
|
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 inTransitCount = travellers.filter((p) => p.status === 'in-transit').length
|
||||||
const atRiskCount = travellers.filter((p) => p.status === 'at-risk').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 (
|
return (
|
||||||
<section className="trip-tracker" aria-label="Trip Tracker — World Map">
|
<section className="trip-tracker" aria-label="Trip Tracker — World Map">
|
||||||
|
|
||||||
@@ -379,38 +535,74 @@ export default function LiveTracking() {
|
|||||||
interactive={true}
|
interactive={true}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
{/* Patient markers */}
|
{/* Highlight shapes of visited countries */}
|
||||||
{!error && patientMarkers.map((m) => (
|
{!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
|
<Marker
|
||||||
key={m.key}
|
key={g.iso}
|
||||||
longitude={m.coordinate[0]}
|
longitude={g.coordinate[0]}
|
||||||
latitude={m.coordinate[1]}
|
latitude={g.coordinate[1]}
|
||||||
anchor="center"
|
anchor="center"
|
||||||
>
|
>
|
||||||
<PatientMarker
|
{g.patients.length === 1 ? (
|
||||||
patient={m.patient}
|
<PatientMarker
|
||||||
isSelected={selectedMarkerKey === m.key}
|
patient={g.patients[0]}
|
||||||
onClick={() => handleMarkerClick(m)}
|
isSelected={selectedGroupIso === g.iso}
|
||||||
/>
|
onClick={() => handleGroupClick(g)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GroupMarker
|
||||||
|
group={g}
|
||||||
|
isSelected={selectedGroupIso === g.iso}
|
||||||
|
onClick={() => handleGroupClick(g)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Patient popup — mounts only when a marker is selected */}
|
{/* Popup — mounts when a location group is selected */}
|
||||||
{!error && selectedMarker && (
|
{!error && selectedGroup && (
|
||||||
<Popup
|
<Popup
|
||||||
key={selectedMarker.key}
|
key={selectedGroup.iso}
|
||||||
longitude={selectedMarker.coordinate[0]}
|
longitude={selectedGroup.coordinate[0]}
|
||||||
latitude={selectedMarker.coordinate[1]}
|
latitude={selectedGroup.coordinate[1]}
|
||||||
anchor="bottom"
|
anchor="bottom"
|
||||||
offset={20}
|
offset={20}
|
||||||
closeButton={false}
|
closeButton={false}
|
||||||
closeOnClick={false}
|
closeOnClick={false}
|
||||||
className="trip-tracker-popup-wrapper"
|
className="trip-tracker-popup-wrapper"
|
||||||
>
|
>
|
||||||
<PatientPopup
|
{selectedGroup.patients.length === 1 ? (
|
||||||
patient={selectedMarker.patient}
|
<PatientPopup
|
||||||
onClose={handlePopupClose}
|
patient={selectedGroup.patients[0]}
|
||||||
/>
|
onClose={handlePopupClose}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GroupPatientPopup
|
||||||
|
group={selectedGroup}
|
||||||
|
onClose={handlePopupClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Popup>
|
</Popup>
|
||||||
)}
|
)}
|
||||||
</MapGL>
|
</MapGL>
|
||||||
@@ -440,7 +632,7 @@ export default function LiveTracking() {
|
|||||||
) : (
|
) : (
|
||||||
travellers.map((p) => {
|
travellers.map((p) => {
|
||||||
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
|
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 isos = getPatientISOs(p)
|
||||||
const isMultiDestination = isos.length > 1
|
const isMultiDestination = isos.length > 1
|
||||||
|
|
||||||
@@ -453,7 +645,7 @@ export default function LiveTracking() {
|
|||||||
>
|
>
|
||||||
<div className="log-card-top">
|
<div className="log-card-top">
|
||||||
<div className="log-card-left">
|
<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>
|
<h3 className="log-patient-name">{p.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className={`status-pill ${meta.cssClass}`}>
|
<span className={`status-pill ${meta.cssClass}`}>
|
||||||
@@ -475,8 +667,8 @@ export default function LiveTracking() {
|
|||||||
{isMultiDestination ? (
|
{isMultiDestination ? (
|
||||||
<div className="destination-chips-container">
|
<div className="destination-chips-container">
|
||||||
{isos.map((iso) => {
|
{isos.map((iso) => {
|
||||||
const chipKey = `${p.id}-${iso}`
|
const code = iso.trim().toUpperCase()
|
||||||
const isChipSelected = selectedMarkerKey === chipKey
|
const isChipSelected = selectedGroupIso === code
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={iso}
|
key={iso}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
HeartPulse,
|
HeartPulse,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Calendar,
|
Plane,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Bell,
|
Bell,
|
||||||
Settings,
|
Settings,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Briefcase,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
|
import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
|
||||||
|
import { fetchEntries } from '../lib/pocketbase.js'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, badge: null },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, badge: null },
|
||||||
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: '3' },
|
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: null },
|
||||||
{ id: 'schedule', label: 'Schedule', icon: Calendar, badge: null, disabled: true },
|
{ 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 },
|
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const BOTTOM_ITEMS = [
|
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 },
|
{ 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
|
* @param {function} onNavigate — callback to change the active page
|
||||||
*/
|
*/
|
||||||
export default function Sidebar({ activePage, onNavigate }) {
|
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 (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
{/* Logo / App identity */}
|
{/* Logo / App identity */}
|
||||||
@@ -44,7 +73,7 @@ export default function Sidebar({ activePage, onNavigate }) {
|
|||||||
{/* Primary navigation */}
|
{/* Primary navigation */}
|
||||||
<nav className="sidebar-nav" aria-label="Primary navigation">
|
<nav className="sidebar-nav" aria-label="Primary navigation">
|
||||||
<span className="sidebar-section-label">Main</span>
|
<span className="sidebar-section-label">Main</span>
|
||||||
{NAV_ITEMS.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -93,7 +122,7 @@ export default function Sidebar({ activePage, onNavigate }) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User identity footer */}
|
{/* 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-user" role="button" tabIndex={0} aria-label="User profile">
|
||||||
<div className="sidebar-avatar" aria-hidden="true">JD</div>
|
<div className="sidebar-avatar" aria-hidden="true">JD</div>
|
||||||
<div className="sidebar-user-info">
|
<div className="sidebar-user-info">
|
||||||
@@ -101,7 +130,7 @@ export default function Sidebar({ activePage, onNavigate }) {
|
|||||||
<div className="sidebar-user-role">Senior Physician</div>
|
<div className="sidebar-user-role">Senior Physician</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>*/}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -11,3 +11,21 @@ export async function fetchEntries() {
|
|||||||
const data = await getPbApi().fetchEntries()
|
const data = await getPbApi().fetchEntries()
|
||||||
return processPatientRecords(data)
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user