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 { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { fetchEntries } from './pocketbase.js'
|
||||
import { fetchEntries, createEntry, updateEntry, deleteEntry, fetchVaccines } from './pocketbase.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -90,3 +90,17 @@ ipcMain.handle('app:get-version', () => app.getVersion())
|
||||
ipcMain.handle('pb:fetch-entries', async () => {
|
||||
return await fetchEntries()
|
||||
})
|
||||
ipcMain.handle('pb:create-entry', async (_event, trip) => {
|
||||
return await createEntry(trip)
|
||||
})
|
||||
ipcMain.handle('pb:update-entry', async (_event, id, trip) => {
|
||||
return await updateEntry(id, trip)
|
||||
})
|
||||
ipcMain.handle('pb:delete-entry', async (_event, id) => {
|
||||
return await deleteEntry(id)
|
||||
})
|
||||
|
||||
ipcMain.handle('pb:fetch-vaccines', async () => {
|
||||
return await fetchVaccines()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import PocketBase from 'pocketbase'
|
||||
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
|
||||
|
||||
const COLLECTION = 'travellers'
|
||||
const VACCINES_COLLECTION = 'vaccines'
|
||||
|
||||
let pb = null
|
||||
let authPromise = null
|
||||
@@ -18,7 +19,8 @@ function getClient() {
|
||||
|
||||
function parseEntry(r) {
|
||||
return {
|
||||
id: r.emr_id,
|
||||
id: r.id,
|
||||
emrID: r.emr_id ?? '',
|
||||
name: r.name ?? '',
|
||||
location: r.location ?? '',
|
||||
countryISO: r.country_iso ?? '',
|
||||
@@ -58,3 +60,68 @@ export async function fetchEntries() {
|
||||
return list.map(parseEntry)
|
||||
}
|
||||
|
||||
export async function createEntry(trip) {
|
||||
await ensureAuth()
|
||||
const payload = {
|
||||
name: trip.name,
|
||||
location: trip.location,
|
||||
countries_iso: trip.countriesISO,
|
||||
departure: trip.departure,
|
||||
arrival: trip.arrival,
|
||||
}
|
||||
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
|
||||
const emrNum = Number(trip.emrID)
|
||||
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
|
||||
}
|
||||
const record = await getClient().collection(COLLECTION).create(payload)
|
||||
return parseEntry(record)
|
||||
}
|
||||
|
||||
export async function updateEntry(id, trip) {
|
||||
await ensureAuth()
|
||||
const payload = {
|
||||
name: trip.name,
|
||||
location: trip.location,
|
||||
countries_iso: trip.countriesISO,
|
||||
departure: trip.departure,
|
||||
arrival: trip.arrival,
|
||||
}
|
||||
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
|
||||
const emrNum = Number(trip.emrID)
|
||||
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
|
||||
} else if (trip.emrID === '') {
|
||||
payload.emr_id = null
|
||||
}
|
||||
const record = await getClient().collection(COLLECTION).update(id, payload)
|
||||
return parseEntry(record)
|
||||
}
|
||||
|
||||
export async function deleteEntry(id) {
|
||||
await ensureAuth()
|
||||
await getClient().collection(COLLECTION).delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all records from the vaccines collection.
|
||||
* Returns raw records with all fields preserved.
|
||||
*/
|
||||
export async function fetchVaccines() {
|
||||
await ensureAuth()
|
||||
const list = await getClient().collection(VACCINES_COLLECTION).getFullList({ sort: 'name' })
|
||||
return list.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name ?? '',
|
||||
vaccine_type: r.vaccine_type ?? '',
|
||||
total_doses: r.total_doses ?? 1,
|
||||
dose_intervals: r.dose_intervals ?? [],
|
||||
min_days_before_travel: r.min_days_before_travel ?? 0,
|
||||
allows_grace_period: r.allows_grace_period ?? false,
|
||||
has_accelerated_schedule: r.has_accelerated_schedule ?? false,
|
||||
requires_icvp_certificate: r.requires_icvp_certificate ?? false,
|
||||
created: r.created,
|
||||
updated: r.updated,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'schedule:fetch',
|
||||
'app:ready',
|
||||
'pb:fetch-entries',
|
||||
'pb:create-entry',
|
||||
'pb:update-entry',
|
||||
'pb:delete-entry',
|
||||
'pb:fetch-vaccines',
|
||||
]
|
||||
|
||||
const ALLOWED_RECEIVE_CHANNELS = [
|
||||
@@ -24,6 +28,10 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'schedule:response',
|
||||
'app:get-version',
|
||||
'pb:fetch-entries',
|
||||
'pb:create-entry',
|
||||
'pb:update-entry',
|
||||
'pb:delete-entry',
|
||||
'pb:fetch-vaccines',
|
||||
]
|
||||
|
||||
// ─── Exposed API ──────────────────────────────────────────────────────────
|
||||
@@ -33,6 +41,10 @@ contextBridge.exposeInMainWorld('api', {
|
||||
*/
|
||||
pb: {
|
||||
fetchEntries: () => ipcRenderer.invoke('pb:fetch-entries'),
|
||||
createEntry: (trip) => ipcRenderer.invoke('pb:create-entry', trip),
|
||||
updateEntry: (id, trip) => ipcRenderer.invoke('pb:update-entry', id, trip),
|
||||
deleteEntry: (id) => ipcRenderer.invoke('pb:delete-entry', id),
|
||||
fetchVaccines: () => ipcRenderer.invoke('pb:fetch-vaccines'),
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+7
-2
@@ -2,7 +2,11 @@
|
||||
"name": "ctm-concierge",
|
||||
"version": "1.0.0",
|
||||
"description": "CTM Concierge - Real-time patient journey tracker for CTM",
|
||||
"author": "Wirediv",
|
||||
"homepage": "https://canadatravelmed.ca",
|
||||
"author": {
|
||||
"name": "Wirediv",
|
||||
"email": "info@wirediv.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
@@ -43,7 +47,8 @@
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "MedicalSoftware"
|
||||
"category": "MedicalSoftware",
|
||||
"maintainer": "Wirediv <info@wirediv.com>"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
|
||||
+5
-2
@@ -1,8 +1,9 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import Sidebar from './components/Sidebar.jsx'
|
||||
import Dashboard from './components/Dashboard.jsx'
|
||||
import LiveTracking from './components/LiveTracking.jsx'
|
||||
|
||||
import Trips from './components/Trips.jsx'
|
||||
import PreTravel from './components/PreTravel.jsx'
|
||||
/**
|
||||
* App — root layout component.
|
||||
* Implements a two-panel CSS Grid: fixed sidebar + scrollable main viewport.
|
||||
@@ -14,6 +15,8 @@ export default function App() {
|
||||
function renderPage() {
|
||||
switch (activePage) {
|
||||
case 'live-tracking': return <LiveTracking />
|
||||
case 'trips': return <Trips />
|
||||
case 'pre-travel': return <PreTravel />
|
||||
default: return <Dashboard />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3984,6 +3984,22 @@
|
||||
"COUNTRYAFF": "Spain",
|
||||
"AFF_ISO": "ES"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [
|
||||
121.0298245462848,
|
||||
23.738342652311685
|
||||
]
|
||||
},
|
||||
"properties": {
|
||||
"COUNTRY": "Taiwan",
|
||||
"ISO": "TW",
|
||||
"COUNTRYAFF": "Taiwan",
|
||||
"AFF_ISO": "TW"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Briefcase,
|
||||
PlaneTakeoff,
|
||||
Plane,
|
||||
PlaneLanding,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Activity,
|
||||
RotateCw,
|
||||
X,
|
||||
Filter
|
||||
} from 'lucide-react'
|
||||
import { fetchEntries } from '../lib/pocketbase.js'
|
||||
|
||||
@@ -31,6 +33,7 @@ export default function Dashboard() {
|
||||
const [travellers, setTravellers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [selectedStatus, setSelectedStatus] = useState('all')
|
||||
|
||||
const today = new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
@@ -58,11 +61,15 @@ export default function Dashboard() {
|
||||
const atRiskCount = travellers.filter((t) => t.status === 'at-risk').length
|
||||
const postTravelCount = travellers.filter((t) => t.status === 'post-travel').length
|
||||
|
||||
const filteredTravellers = selectedStatus === 'all'
|
||||
? travellers
|
||||
: travellers.filter((t) => t.status === selectedStatus)
|
||||
|
||||
const stats = [
|
||||
{ id: 'pre-travel', icon: PlaneTakeoff, value: preTravelCount, label: 'In Pre-Travel', accent: 'violet' },
|
||||
{ id: 'in-transit', icon: Plane, value: inTransitCount, label: 'In Transit', accent: 'blue' },
|
||||
{ id: 'pre-travel', icon: Briefcase, value: preTravelCount, label: 'In Pre-Travel', accent: 'violet' },
|
||||
{ id: 'in-transit', icon: PlaneTakeoff, value: inTransitCount, label: 'In Transit', accent: 'blue' },
|
||||
{ id: 'at-risk', icon: AlertTriangle, value: atRiskCount, label: 'At Risk', accent: 'amber' },
|
||||
{ id: 'post-travel', icon: CheckCircle2, value: postTravelCount, label: 'In Post-Travel', accent: 'teal' },
|
||||
{ id: 'post-travel', icon: PlaneLanding, value: postTravelCount, label: 'In Post-Travel', accent: 'teal' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -128,15 +135,26 @@ export default function Dashboard() {
|
||||
) : (
|
||||
<>
|
||||
{/* ── Stat Cards ─────────────────────────────────────────────────────── */}
|
||||
<div className="stat-grid" role="list" aria-label="Today's statistics">
|
||||
<div className="stat-grid" role="region" aria-label="Status filter cards">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
const isActive = selectedStatus === stat.id
|
||||
return (
|
||||
<article
|
||||
key={stat.id}
|
||||
id={`stat-${stat.id}`}
|
||||
className={`stat-card accent-${stat.accent}`}
|
||||
role="listitem"
|
||||
className={`stat-card accent-${stat.accent}${isActive ? ' is-active' : ''}`}
|
||||
onClick={() => setSelectedStatus(isActive ? 'all' : stat.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setSelectedStatus(isActive ? 'all' : stat.id)
|
||||
}
|
||||
}}
|
||||
aria-pressed={isActive}
|
||||
aria-label={`Filter by ${stat.label}`}
|
||||
>
|
||||
<Icon className="stat-card-icon" aria-hidden="true" size={24} />
|
||||
<div className="stat-card-value">{loading ? '...' : stat.value}</div>
|
||||
@@ -151,14 +169,54 @@ export default function Dashboard() {
|
||||
<h2 className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Activity size={20} className="section-title-icon" style={{ color: 'var(--color-primary)' }} />
|
||||
Live Patient Board
|
||||
{selectedStatus !== 'all' && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--color-primary-glow)',
|
||||
color: 'var(--color-primary-hover)',
|
||||
border: '1px solid var(--color-border-active)',
|
||||
}}
|
||||
>
|
||||
{STATUS_META[selectedStatus]?.label || selectedStatus} ({filteredTravellers.length} of {travellers.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{selectedStatus !== 'all' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStatus('all')}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-bg-subtle)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: '600',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}
|
||||
title="Show all statuses"
|
||||
>
|
||||
<X size={14} />
|
||||
Show All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="journey-panel">
|
||||
<div className="journey-table-container">
|
||||
<table className="journey-table" aria-label="Live patient journey board">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">EMR ID</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Location</th>
|
||||
<th scope="col">Departure</th>
|
||||
@@ -173,12 +231,36 @@ export default function Dashboard() {
|
||||
No travellers found in PocketBase database.
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredTravellers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', padding: '32px 24px', color: 'var(--color-text-muted)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<Filter size={24} style={{ color: 'var(--color-text-muted)', opacity: 0.5 }} />
|
||||
<span>No patients found matching status "<strong>{STATUS_META[selectedStatus]?.label || selectedStatus}</strong>".</span>
|
||||
<button
|
||||
onClick={() => setSelectedStatus('all')}
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '4px 12px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--color-bg-subtle)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-primary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
travellers.map((p) => {
|
||||
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.id}</td>
|
||||
<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>
|
||||
@@ -195,6 +277,7 @@ export default function Dashboard() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+244
-52
@@ -1,11 +1,27 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import MapGL, { Marker, Popup } from 'react-map-gl/maplibre'
|
||||
import MapGL, { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import countryCentroids from '../assets/countries.json'
|
||||
import { RotateCw, AlertTriangle } from 'lucide-react'
|
||||
import countryPolygonsRaw from '../assets/world-countries-polygons.json'
|
||||
import { RotateCw, AlertTriangle, X } from 'lucide-react'
|
||||
import { fetchEntries } from '../lib/pocketbase.js'
|
||||
import { getPatientISOs } from '../lib/patientUtils.js'
|
||||
|
||||
// Pre-process polygon GeoJSON features with normalized ISO_CODE properties
|
||||
const countryPolygonsData = {
|
||||
...countryPolygonsRaw,
|
||||
features: countryPolygonsRaw.features.map((f) => {
|
||||
const iso2 = (f.properties.ISO_A2 !== '-99' ? f.properties.ISO_A2 : f.properties.ISO_A2_EH) || f.properties.POSTAL || ''
|
||||
return {
|
||||
...f,
|
||||
properties: {
|
||||
...f.properties,
|
||||
ISO_CODE: iso2 ? iso2.toUpperCase() : '',
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
// ── Country centroid lookup ─────────────────────────────────────────────────────
|
||||
// Builds an ISO-2 → [longitude, latitude] map from the GeoJSON centroid dataset.
|
||||
const CENTROID_BY_ISO = new Map(
|
||||
@@ -23,6 +39,20 @@ const STATUS_META = {
|
||||
'post-travel': { label: 'Post-Travel', colorVar: '--color-accent-emerald', cssClass: 'status-post-travel', markerClass: 'marker-emerald' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine priority status for a location group of patients:
|
||||
* 1. 'at-risk' (highest priority alert)
|
||||
* 2. 'post-travel' (second priority to highlight patients needing post-travel contact)
|
||||
* 3. 'in-transit'
|
||||
* 4. 'pre-travel'
|
||||
*/
|
||||
function getGroupStatus(patients = []) {
|
||||
if (patients.some((p) => p.status === 'at-risk')) return 'at-risk'
|
||||
if (patients.some((p) => p.status === 'post-travel')) return 'post-travel'
|
||||
if (patients.some((p) => p.status === 'in-transit')) return 'in-transit'
|
||||
return 'pre-travel'
|
||||
}
|
||||
|
||||
// ── Sub-component: PatientMarker ───────────────────────────────────────────────
|
||||
function PatientMarker({ patient, isSelected, onClick }) {
|
||||
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
|
||||
@@ -41,7 +71,28 @@ function PatientMarker({ patient, isSelected, onClick }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-component: PatientPopup ────────────────────────────────────────────────
|
||||
// ── Sub-component: GroupMarker (Multi-patient location) ───────────────────────
|
||||
function GroupMarker({ group, isSelected, onClick }) {
|
||||
const groupStatus = getGroupStatus(group.patients)
|
||||
const meta = STATUS_META[groupStatus] || STATUS_META['pre-travel']
|
||||
|
||||
return (
|
||||
<button
|
||||
id={`marker-group-${group.iso}`}
|
||||
className={`map-marker-btn map-marker-btn--group${isSelected ? ' map-marker-btn--selected' : ''}`}
|
||||
onClick={onClick}
|
||||
aria-label={`${group.patients.length} Travellers in ${group.iso} — ${meta.label}`}
|
||||
title={`${group.patients.length} Travellers in ${group.iso} (${meta.label})`}
|
||||
>
|
||||
<span className={`map-marker-ring ${meta.markerClass}-ring`} aria-hidden="true" />
|
||||
<span className={`map-marker-dot ${meta.markerClass}-dot map-marker-group-badge`}>
|
||||
{group.patients.length}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-component: PatientPopup (Single patient) ──────────────────────────────
|
||||
function PatientPopup({ patient, onClose }) {
|
||||
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
|
||||
|
||||
@@ -65,7 +116,7 @@ function PatientPopup({ patient, onClose }) {
|
||||
</div>
|
||||
<div className="patient-popup-identity">
|
||||
<h3 className="patient-popup-name">{patient.name}</h3>
|
||||
<p className="patient-popup-id">{patient.id}</p>
|
||||
<p className="patient-popup-id">{patient.emrID ? `EMR #${patient.emrID}` : ''}</p>
|
||||
</div>
|
||||
<button
|
||||
className="popup-close"
|
||||
@@ -73,7 +124,7 @@ function PatientPopup({ patient, onClose }) {
|
||||
aria-label="Close patient popup"
|
||||
id={`popup-close-${patient.id}`}
|
||||
>
|
||||
×
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -100,13 +151,66 @@ function PatientPopup({ patient, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-component: GroupPatientPopup (Multi-patient group) ─────────────────────
|
||||
function GroupPatientPopup({ group, onClose }) {
|
||||
const countryFeature = countryPolygonsData.features.find(
|
||||
(f) => f.properties.ISO_CODE === group.iso
|
||||
)
|
||||
const countryName = countryFeature?.properties?.NAME || group.iso
|
||||
|
||||
return (
|
||||
<div className="patient-popup group-popup" id={`popup-group-${group.iso}`} role="dialog" aria-label={`Patients in ${countryName}`}>
|
||||
{/* Header */}
|
||||
<div className="patient-popup-header group-popup-header">
|
||||
<div className="group-popup-title-area">
|
||||
<span className="group-popup-icon" aria-hidden="true">📍</span>
|
||||
<div className="patient-popup-identity">
|
||||
<h3 className="patient-popup-name">{countryName}</h3>
|
||||
<p className="patient-popup-id">{group.patients.length} Active Travellers</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="popup-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close group popup"
|
||||
id={`popup-close-group-${group.iso}`}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Patient List */}
|
||||
<div className="group-patient-list">
|
||||
{group.patients.map((patient) => {
|
||||
const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
|
||||
const initials = patient.name ? patient.name.split(' ').map((n) => n[0]).join('') : 'P'
|
||||
return (
|
||||
<div key={patient.id} className="group-patient-item" id={`group-item-${patient.id}`}>
|
||||
<div className="patient-popup-avatar group-avatar" aria-hidden="true">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="group-patient-identity">
|
||||
<span className="group-patient-name">{patient.name}</span>
|
||||
<span className="group-patient-emr">{patient.emrID ? `EMR #${patient.emrID}` : ''}</span>
|
||||
</div>
|
||||
<span className={`status-pill ${meta.cssClass} group-status-pill`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component: LiveTracking ────────────────────────────────────────────────
|
||||
/**
|
||||
* LiveTracking — full-screen locked world map showing active patient locations.
|
||||
* Uses react-map-gl/maplibre with the OpenFreeMap 'Fiord' tile style.
|
||||
*/
|
||||
export default function LiveTracking() {
|
||||
const [selectedMarkerKey, setSelectedMarkerKey] = useState(null)
|
||||
const [selectedGroupIso, setSelectedGroupIso] = useState(null)
|
||||
const [travellers, setTravellers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -129,43 +233,54 @@ export default function LiveTracking() {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const patientMarkers = travellers.flatMap((p) => {
|
||||
// Aggregate travellers into location groups by ISO
|
||||
const locationGroupsMap = new Map()
|
||||
travellers.forEach((p) => {
|
||||
const isos = getPatientISOs(p)
|
||||
return isos.map((iso) => ({
|
||||
key: `${p.id}-${iso}`,
|
||||
patient: p,
|
||||
iso,
|
||||
coordinate: CENTROID_BY_ISO.get(iso.toUpperCase()) ?? [0, 0],
|
||||
}))
|
||||
isos.forEach((iso) => {
|
||||
if (!iso) return
|
||||
const code = iso.trim().toUpperCase()
|
||||
if (!locationGroupsMap.has(code)) {
|
||||
locationGroupsMap.set(code, {
|
||||
iso: code,
|
||||
coordinate: CENTROID_BY_ISO.get(code) ?? [0, 0],
|
||||
patients: [],
|
||||
})
|
||||
}
|
||||
// Avoid duplicate patient entries per group
|
||||
if (!locationGroupsMap.get(code).patients.some((item) => item.id === p.id)) {
|
||||
locationGroupsMap.get(code).patients.push(p)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const selectedMarker = patientMarkers.find((m) => m.key === selectedMarkerKey) || null
|
||||
const selectedPatientId = selectedMarker ? selectedMarker.patient.id : null
|
||||
const locationGroups = Array.from(locationGroupsMap.values())
|
||||
const selectedGroup = locationGroups.find((g) => g.iso === selectedGroupIso) || null
|
||||
|
||||
function handleMarkerClick(marker) {
|
||||
const isSelecting = selectedMarkerKey !== marker.key
|
||||
setSelectedMarkerKey(isSelecting ? marker.key : null)
|
||||
function handleGroupClick(group) {
|
||||
const isSelecting = selectedGroupIso !== group.iso
|
||||
setSelectedGroupIso(isSelecting ? group.iso : null)
|
||||
|
||||
if (isSelecting) {
|
||||
mapRef.current?.flyTo({
|
||||
center: marker.coordinate,
|
||||
zoom: 2.2, // Focus in at a constant zoom level
|
||||
center: group.coordinate,
|
||||
zoom: 2.2,
|
||||
essential: true,
|
||||
duration: 1000 // smooth 1s transition
|
||||
duration: 1000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(patient) {
|
||||
const firstMarker = patientMarkers.find((m) => m.patient.id === patient.id)
|
||||
if (!firstMarker) return
|
||||
const targetGroup = locationGroups.find((g) => g.patients.some((p) => p.id === patient.id))
|
||||
if (!targetGroup) return
|
||||
|
||||
const isSelecting = selectedPatientId !== patient.id
|
||||
setSelectedMarkerKey(isSelecting ? firstMarker.key : null)
|
||||
const isSelecting = selectedGroupIso !== targetGroup.iso
|
||||
setSelectedGroupIso(isSelecting ? targetGroup.iso : null)
|
||||
|
||||
if (isSelecting) {
|
||||
mapRef.current?.flyTo({
|
||||
center: firstMarker.coordinate,
|
||||
center: targetGroup.coordinate,
|
||||
zoom: 2.2,
|
||||
essential: true,
|
||||
duration: 1000,
|
||||
@@ -174,16 +289,16 @@ export default function LiveTracking() {
|
||||
}
|
||||
|
||||
function handleDestinationChipClick(patient, iso) {
|
||||
const markerKey = `${patient.id}-${iso}`
|
||||
const targetMarker = patientMarkers.find((m) => m.key === markerKey)
|
||||
if (!targetMarker) return
|
||||
const code = iso.trim().toUpperCase()
|
||||
const targetGroup = locationGroups.find((g) => g.iso === code)
|
||||
if (!targetGroup) return
|
||||
|
||||
const isSelecting = selectedMarkerKey !== markerKey
|
||||
setSelectedMarkerKey(isSelecting ? markerKey : null)
|
||||
const isSelecting = selectedGroupIso !== code
|
||||
setSelectedGroupIso(isSelecting ? code : null)
|
||||
|
||||
if (isSelecting) {
|
||||
mapRef.current?.flyTo({
|
||||
center: targetMarker.coordinate,
|
||||
center: targetGroup.coordinate,
|
||||
zoom: 2.2,
|
||||
essential: true,
|
||||
duration: 1000,
|
||||
@@ -192,7 +307,7 @@ export default function LiveTracking() {
|
||||
}
|
||||
|
||||
function handlePopupClose() {
|
||||
setSelectedMarkerKey(null)
|
||||
setSelectedGroupIso(null)
|
||||
}
|
||||
|
||||
function applyCountryFilter(mapInstance, countryISOs) {
|
||||
@@ -213,7 +328,7 @@ export default function LiveTracking() {
|
||||
['in', ['get', 'country_code'], ['literal', validISOs]],
|
||||
['in', ['get', 'ISO_A2'], ['literal', validISOs]],
|
||||
]
|
||||
: ['==', 1, 0]
|
||||
: ['==', '$type', '']
|
||||
|
||||
for (const layer of style.layers) {
|
||||
if (layer.id === 'place_country_major' || layer.id === 'place_country_minor') {
|
||||
@@ -268,6 +383,47 @@ export default function LiveTracking() {
|
||||
const inTransitCount = travellers.filter((p) => p.status === 'in-transit').length
|
||||
const atRiskCount = travellers.filter((p) => p.status === 'at-risk').length
|
||||
|
||||
// Derive country status map for polygon fills
|
||||
const isoStatusMap = {}
|
||||
travellers.forEach((p) => {
|
||||
const isos = getPatientISOs(p)
|
||||
isos.forEach((iso) => {
|
||||
if (!iso) return
|
||||
const code = iso.trim().toUpperCase()
|
||||
if (p.status === 'at-risk') {
|
||||
isoStatusMap[code] = 'at-risk'
|
||||
} else if (!isoStatusMap[code]) {
|
||||
isoStatusMap[code] = p.status || 'pre-travel'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const atRiskISOs = Object.keys(isoStatusMap).filter((iso) => isoStatusMap[iso] === 'at-risk')
|
||||
const normalISOs = Object.keys(isoStatusMap).filter((iso) => isoStatusMap[iso] !== 'at-risk')
|
||||
|
||||
const fillMatchCases = []
|
||||
const outlineMatchCases = []
|
||||
if (atRiskISOs.length > 0) {
|
||||
fillMatchCases.push(atRiskISOs, 'rgba(233, 172, 66, 0.15)')
|
||||
outlineMatchCases.push(atRiskISOs, '#ecb85e5f')
|
||||
}
|
||||
if (normalISOs.length > 0) {
|
||||
fillMatchCases.push(normalISOs, 'rgba(48, 183, 138, 0.15)')
|
||||
outlineMatchCases.push(normalISOs, '#20ab7d5f')
|
||||
}
|
||||
|
||||
const fillColorExpr = fillMatchCases.length > 0
|
||||
? ['match', ['get', 'ISO_CODE'], ...fillMatchCases, 'transparent']
|
||||
: 'transparent'
|
||||
|
||||
const outlineColorExpr = outlineMatchCases.length > 0
|
||||
? ['match', ['get', 'ISO_CODE'], ...outlineMatchCases, 'transparent']
|
||||
: 'transparent'
|
||||
|
||||
const outlineWidthExpr = (atRiskISOs.length > 0 || normalISOs.length > 0)
|
||||
? ['match', ['get', 'ISO_CODE'], [...atRiskISOs, ...normalISOs], 1.5, 0]
|
||||
: 0
|
||||
|
||||
return (
|
||||
<section className="trip-tracker" aria-label="Trip Tracker — World Map">
|
||||
|
||||
@@ -379,38 +535,74 @@ export default function LiveTracking() {
|
||||
interactive={true}
|
||||
attributionControl={false}
|
||||
>
|
||||
{/* Patient markers */}
|
||||
{!error && patientMarkers.map((m) => (
|
||||
{/* Highlight shapes of visited countries */}
|
||||
{!error && (atRiskISOs.length > 0 || normalISOs.length > 0) && (
|
||||
<Source id="visited-countries-source" type="geojson" data={countryPolygonsData}>
|
||||
<Layer
|
||||
id="visited-countries-fill"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': fillColorExpr,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="visited-countries-outline"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': outlineColorExpr,
|
||||
'line-width': outlineWidthExpr,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Location Group Markers */}
|
||||
{!error && locationGroups.map((g) => (
|
||||
<Marker
|
||||
key={m.key}
|
||||
longitude={m.coordinate[0]}
|
||||
latitude={m.coordinate[1]}
|
||||
key={g.iso}
|
||||
longitude={g.coordinate[0]}
|
||||
latitude={g.coordinate[1]}
|
||||
anchor="center"
|
||||
>
|
||||
{g.patients.length === 1 ? (
|
||||
<PatientMarker
|
||||
patient={m.patient}
|
||||
isSelected={selectedMarkerKey === m.key}
|
||||
onClick={() => handleMarkerClick(m)}
|
||||
patient={g.patients[0]}
|
||||
isSelected={selectedGroupIso === g.iso}
|
||||
onClick={() => handleGroupClick(g)}
|
||||
/>
|
||||
) : (
|
||||
<GroupMarker
|
||||
group={g}
|
||||
isSelected={selectedGroupIso === g.iso}
|
||||
onClick={() => handleGroupClick(g)}
|
||||
/>
|
||||
)}
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Patient popup — mounts only when a marker is selected */}
|
||||
{!error && selectedMarker && (
|
||||
{/* Popup — mounts when a location group is selected */}
|
||||
{!error && selectedGroup && (
|
||||
<Popup
|
||||
key={selectedMarker.key}
|
||||
longitude={selectedMarker.coordinate[0]}
|
||||
latitude={selectedMarker.coordinate[1]}
|
||||
key={selectedGroup.iso}
|
||||
longitude={selectedGroup.coordinate[0]}
|
||||
latitude={selectedGroup.coordinate[1]}
|
||||
anchor="bottom"
|
||||
offset={20}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
className="trip-tracker-popup-wrapper"
|
||||
>
|
||||
{selectedGroup.patients.length === 1 ? (
|
||||
<PatientPopup
|
||||
patient={selectedMarker.patient}
|
||||
patient={selectedGroup.patients[0]}
|
||||
onClose={handlePopupClose}
|
||||
/>
|
||||
) : (
|
||||
<GroupPatientPopup
|
||||
group={selectedGroup}
|
||||
onClose={handlePopupClose}
|
||||
/>
|
||||
)}
|
||||
</Popup>
|
||||
)}
|
||||
</MapGL>
|
||||
@@ -440,7 +632,7 @@ export default function LiveTracking() {
|
||||
) : (
|
||||
travellers.map((p) => {
|
||||
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
|
||||
const isSelected = selectedPatientId === p.id
|
||||
const isSelected = selectedGroup?.patients.some((item) => item.id === p.id) || false
|
||||
const isos = getPatientISOs(p)
|
||||
const isMultiDestination = isos.length > 1
|
||||
|
||||
@@ -453,7 +645,7 @@ export default function LiveTracking() {
|
||||
>
|
||||
<div className="log-card-top">
|
||||
<div className="log-card-left">
|
||||
<span className="log-patient-id">{p.id}</span>
|
||||
<span className="log-patient-id">{p.emrID ? `EMR: ${p.emrID}` : ''}</span>
|
||||
<h3 className="log-patient-name">{p.name}</h3>
|
||||
</div>
|
||||
<span className={`status-pill ${meta.cssClass}`}>
|
||||
@@ -475,8 +667,8 @@ export default function LiveTracking() {
|
||||
{isMultiDestination ? (
|
||||
<div className="destination-chips-container">
|
||||
{isos.map((iso) => {
|
||||
const chipKey = `${p.id}-${iso}`
|
||||
const isChipSelected = selectedMarkerKey === chipKey
|
||||
const code = iso.trim().toUpperCase()
|
||||
const isChipSelected = selectedGroupIso === code
|
||||
return (
|
||||
<button
|
||||
key={iso}
|
||||
|
||||
@@ -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 {
|
||||
HeartPulse,
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
Plane,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Settings,
|
||||
MapPin,
|
||||
Briefcase,
|
||||
} from 'lucide-react'
|
||||
import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
|
||||
import { fetchEntries } from '../lib/pocketbase.js'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, badge: null },
|
||||
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: '3' },
|
||||
{ id: 'schedule', label: 'Schedule', icon: Calendar, badge: null, disabled: true },
|
||||
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: null },
|
||||
{ id: 'trips', label: 'Trips', icon: Plane, badge: null, disabled: false },
|
||||
{ id: 'pre-travel', label: 'Pre-Travel', icon: Briefcase, badge: null, disabled: false },
|
||||
|
||||
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
|
||||
]
|
||||
|
||||
const BOTTOM_ITEMS = [
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, disabled: true },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, badge: null, disabled: true },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, badge: null, disabled: true },
|
||||
]
|
||||
|
||||
@@ -28,6 +32,31 @@ const BOTTOM_ITEMS = [
|
||||
* @param {function} onNavigate — callback to change the active page
|
||||
*/
|
||||
export default function Sidebar({ activePage, onNavigate }) {
|
||||
const [postTravelCount, setPostTravelCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadBadgeData() {
|
||||
try {
|
||||
const data = await fetchEntries()
|
||||
const count = (data || []).filter((t) => t.status === 'post-travel').length
|
||||
setPostTravelCount(count)
|
||||
} catch {
|
||||
// Fallback for bridge unready or offline state
|
||||
}
|
||||
}
|
||||
loadBadgeData()
|
||||
}, [activePage])
|
||||
|
||||
const navItems = NAV_ITEMS.map((item) => {
|
||||
if (item.id === 'live-tracking') {
|
||||
return {
|
||||
...item,
|
||||
badge: postTravelCount > 0 ? String(postTravelCount) : null,
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
{/* Logo / App identity */}
|
||||
@@ -44,7 +73,7 @@ export default function Sidebar({ activePage, onNavigate }) {
|
||||
{/* Primary navigation */}
|
||||
<nav className="sidebar-nav" aria-label="Primary navigation">
|
||||
<span className="sidebar-section-label">Main</span>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
@@ -93,7 +122,7 @@ export default function Sidebar({ activePage, onNavigate }) {
|
||||
</nav>
|
||||
|
||||
{/* User identity footer */}
|
||||
<div className="sidebar-footer">
|
||||
{/*<div className="sidebar-footer">
|
||||
<div className="sidebar-user" role="button" tabIndex={0} aria-label="User profile">
|
||||
<div className="sidebar-avatar" aria-hidden="true">JD</div>
|
||||
<div className="sidebar-user-info">
|
||||
@@ -101,7 +130,7 @@ export default function Sidebar({ activePage, onNavigate }) {
|
||||
<div className="sidebar-user-role">Senior Physician</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>*/}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
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