Added Records screen; added follow up status.
This commit is contained in:
+23
-1
@@ -1,7 +1,16 @@
|
|||||||
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fetchEntries, createEntry, updateEntry, deleteEntry, fetchVaccines } from './pocketbase.js'
|
import {
|
||||||
|
fetchEntries,
|
||||||
|
createEntry,
|
||||||
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
|
fetchVaccines,
|
||||||
|
fetchVaccineRecords,
|
||||||
|
saveVaccineRecord,
|
||||||
|
updateVaccineRecord,
|
||||||
|
} from './pocketbase.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
@@ -104,3 +113,16 @@ ipcMain.handle('pb:fetch-vaccines', async () => {
|
|||||||
return await fetchVaccines()
|
return await fetchVaccines()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('pb:fetch-vaccine-records', async () => {
|
||||||
|
return await fetchVaccineRecords()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('pb:save-vaccine-record', async (_event, recordData) => {
|
||||||
|
return await saveVaccineRecord(recordData)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('pb:update-vaccine-record', async (_event, id, recordData) => {
|
||||||
|
return await updateVaccineRecord(id, recordData)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+101
-21
@@ -2,8 +2,96 @@ import PocketBase from 'pocketbase'
|
|||||||
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
|
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
|
||||||
|
|
||||||
const COLLECTION = 'travellers'
|
const COLLECTION = 'travellers'
|
||||||
|
const VACCINE_RECORDS_COLLECTION = 'records'
|
||||||
const VACCINES_COLLECTION = 'vaccines'
|
const VACCINES_COLLECTION = 'vaccines'
|
||||||
|
|
||||||
|
function parseVaccineRecord(r) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
travellerId: r.traveller_id ?? r.traveller ?? '',
|
||||||
|
travellerName: r.traveller_name ?? '',
|
||||||
|
emrID: r.emr_id ?? '',
|
||||||
|
timelines: r.timelines ?? r.vaccine_timelines ?? [],
|
||||||
|
created: r.created,
|
||||||
|
updated: r.updated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVaccineRecords() {
|
||||||
|
await ensureAuth()
|
||||||
|
const list = await getClient().collection(VACCINE_RECORDS_COLLECTION).getFullList({ sort: '-created' })
|
||||||
|
return list.map(parseVaccineRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVaccineRecord(data) {
|
||||||
|
await ensureAuth()
|
||||||
|
const client = getClient()
|
||||||
|
const travellerId = data.traveller_id
|
||||||
|
|
||||||
|
let existing = null
|
||||||
|
try {
|
||||||
|
const list = await client.collection(VACCINE_RECORDS_COLLECTION).getList(1, 50, {
|
||||||
|
filter: `traveller_id = "${travellerId}"`
|
||||||
|
})
|
||||||
|
if (list.items && list.items.length > 0) {
|
||||||
|
existing = list.items[0]
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore filter error
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
traveller_id: travellerId,
|
||||||
|
traveller_name: data.travellerName || data.traveller_name || '',
|
||||||
|
emr_id: data.emrID || data.emr_id || null,
|
||||||
|
timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const record = await client.collection(VACCINE_RECORDS_COLLECTION).update(existing.id, payload)
|
||||||
|
return parseVaccineRecord(record)
|
||||||
|
} else {
|
||||||
|
const record = await client.collection(VACCINE_RECORDS_COLLECTION).create(payload)
|
||||||
|
return parseVaccineRecord(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVaccineRecord(id, data) {
|
||||||
|
await ensureAuth()
|
||||||
|
const travellerId = data.traveller_id
|
||||||
|
const payload = {
|
||||||
|
traveller_id: travellerId,
|
||||||
|
traveller_name: data.travellerName || data.traveller_name || '',
|
||||||
|
emr_id: data.emrID || data.emr_id || null,
|
||||||
|
timelines: data.timelines || data.vaccineTimelines || data.vaccine_timelines || [],
|
||||||
|
}
|
||||||
|
const record = await getClient().collection(VACCINE_RECORDS_COLLECTION).update(id, payload)
|
||||||
|
return parseVaccineRecord(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
let pb = null
|
let pb = null
|
||||||
let authPromise = null
|
let authPromise = null
|
||||||
|
|
||||||
@@ -20,6 +108,7 @@ function getClient() {
|
|||||||
function parseEntry(r) {
|
function parseEntry(r) {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
recordID: r.record_id ?? r.recordID ?? '',
|
||||||
emrID: r.emr_id ?? '',
|
emrID: r.emr_id ?? '',
|
||||||
name: r.name ?? '',
|
name: r.name ?? '',
|
||||||
location: r.location ?? '',
|
location: r.location ?? '',
|
||||||
@@ -69,6 +158,12 @@ export async function createEntry(trip) {
|
|||||||
departure: trip.departure,
|
departure: trip.departure,
|
||||||
arrival: trip.arrival,
|
arrival: trip.arrival,
|
||||||
}
|
}
|
||||||
|
if (trip.status !== undefined) {
|
||||||
|
payload.status = trip.status
|
||||||
|
}
|
||||||
|
if (trip.recordID || trip.record_id) {
|
||||||
|
payload.record_id = trip.recordID || trip.record_id
|
||||||
|
}
|
||||||
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
|
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
|
||||||
const emrNum = Number(trip.emrID)
|
const emrNum = Number(trip.emrID)
|
||||||
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
|
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
|
||||||
@@ -86,6 +181,12 @@ export async function updateEntry(id, trip) {
|
|||||||
departure: trip.departure,
|
departure: trip.departure,
|
||||||
arrival: trip.arrival,
|
arrival: trip.arrival,
|
||||||
}
|
}
|
||||||
|
if (trip.status !== undefined) {
|
||||||
|
payload.status = trip.status
|
||||||
|
}
|
||||||
|
if (trip.recordID !== undefined || trip.record_id !== undefined) {
|
||||||
|
payload.record_id = trip.recordID || trip.record_id || null
|
||||||
|
}
|
||||||
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
|
if (trip.emrID !== undefined && trip.emrID !== null && trip.emrID !== '') {
|
||||||
const emrNum = Number(trip.emrID)
|
const emrNum = Number(trip.emrID)
|
||||||
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
|
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
|
||||||
@@ -101,27 +202,6 @@ export async function deleteEntry(id) {
|
|||||||
await getClient().collection(COLLECTION).delete(id)
|
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,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'pb:update-entry',
|
'pb:update-entry',
|
||||||
'pb:delete-entry',
|
'pb:delete-entry',
|
||||||
'pb:fetch-vaccines',
|
'pb:fetch-vaccines',
|
||||||
|
'pb:fetch-vaccine-records',
|
||||||
|
'pb:save-vaccine-record',
|
||||||
|
'pb:update-vaccine-record',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
@@ -32,6 +35,9 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'pb:update-entry',
|
'pb:update-entry',
|
||||||
'pb:delete-entry',
|
'pb:delete-entry',
|
||||||
'pb:fetch-vaccines',
|
'pb:fetch-vaccines',
|
||||||
|
'pb:fetch-vaccine-records',
|
||||||
|
'pb:save-vaccine-record',
|
||||||
|
'pb:update-vaccine-record',
|
||||||
]
|
]
|
||||||
|
|
||||||
// ─── Exposed API ──────────────────────────────────────────────────────────
|
// ─── Exposed API ──────────────────────────────────────────────────────────
|
||||||
@@ -45,6 +51,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
updateEntry: (id, trip) => ipcRenderer.invoke('pb:update-entry', id, trip),
|
updateEntry: (id, trip) => ipcRenderer.invoke('pb:update-entry', id, trip),
|
||||||
deleteEntry: (id) => ipcRenderer.invoke('pb:delete-entry', id),
|
deleteEntry: (id) => ipcRenderer.invoke('pb:delete-entry', id),
|
||||||
fetchVaccines: () => ipcRenderer.invoke('pb:fetch-vaccines'),
|
fetchVaccines: () => ipcRenderer.invoke('pb:fetch-vaccines'),
|
||||||
|
fetchVaccineRecords: () => ipcRenderer.invoke('pb:fetch-vaccine-records'),
|
||||||
|
saveVaccineRecord: (data) => ipcRenderer.invoke('pb:save-vaccine-record', data),
|
||||||
|
updateVaccineRecord: (id, data) => ipcRenderer.invoke('pb:update-vaccine-record', id, data),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Sidebar from './components/Sidebar.jsx'
|
|||||||
import Dashboard from './components/Dashboard.jsx'
|
import Dashboard from './components/Dashboard.jsx'
|
||||||
import LiveTracking from './components/LiveTracking.jsx'
|
import LiveTracking from './components/LiveTracking.jsx'
|
||||||
import Trips from './components/Trips.jsx'
|
import Trips from './components/Trips.jsx'
|
||||||
|
import Records from './components/Records.jsx'
|
||||||
import PreTravel from './components/PreTravel.jsx'
|
import PreTravel from './components/PreTravel.jsx'
|
||||||
/**
|
/**
|
||||||
* App — root layout component.
|
* App — root layout component.
|
||||||
@@ -16,6 +17,7 @@ export default function App() {
|
|||||||
switch (activePage) {
|
switch (activePage) {
|
||||||
case 'live-tracking': return <LiveTracking />
|
case 'live-tracking': return <LiveTracking />
|
||||||
case 'trips': return <Trips />
|
case 'trips': return <Trips />
|
||||||
|
case 'records': return <Records />
|
||||||
case 'pre-travel': return <PreTravel />
|
case 'pre-travel': return <PreTravel />
|
||||||
default: return <Dashboard />
|
default: return <Dashboard />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const STATUS_META = {
|
|||||||
'at-risk': { label: 'At Risk', className: 'status-at-risk' },
|
'at-risk': { label: 'At Risk', className: 'status-at-risk' },
|
||||||
'post-travel': { label: 'Post-Travel', className: 'status-post-travel' },
|
'post-travel': { label: 'Post-Travel', className: 'status-post-travel' },
|
||||||
'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' },
|
'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' },
|
||||||
|
'followed-up': { label: 'Followed Up', className: 'status-followed-up' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGreeting() {
|
function getGreeting() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MapGL, { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'
|
|||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import countryCentroids from '../assets/countries.json'
|
import countryCentroids from '../assets/countries.json'
|
||||||
import countryPolygonsRaw from '../assets/world-countries-polygons.json'
|
import countryPolygonsRaw from '../assets/world-countries-polygons.json'
|
||||||
import { RotateCw, AlertTriangle, X } from 'lucide-react'
|
import { RotateCw, AlertTriangle, X, MapPin } from 'lucide-react'
|
||||||
import { fetchEntries } from '../lib/pocketbase.js'
|
import { fetchEntries } from '../lib/pocketbase.js'
|
||||||
import { getPatientISOs } from '../lib/patientUtils.js'
|
import { getPatientISOs } from '../lib/patientUtils.js'
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ const STATUS_META = {
|
|||||||
'in-transit': { label: 'In Transit', colorVar: '--color-primary', cssClass: 'status-in-transit', markerClass: 'marker-blue' },
|
'in-transit': { label: 'In Transit', colorVar: '--color-primary', cssClass: 'status-in-transit', markerClass: 'marker-blue' },
|
||||||
'at-risk': { label: 'At Risk', colorVar: '--color-accent-amber', cssClass: 'status-at-risk', markerClass: 'marker-amber' },
|
'at-risk': { label: 'At Risk', colorVar: '--color-accent-amber', cssClass: 'status-at-risk', markerClass: 'marker-amber' },
|
||||||
'post-travel': { label: 'Post-Travel', colorVar: '--color-accent-emerald', cssClass: 'status-post-travel', markerClass: 'marker-emerald' },
|
'post-travel': { label: 'Post-Travel', colorVar: '--color-accent-emerald', cssClass: 'status-post-travel', markerClass: 'marker-emerald' },
|
||||||
|
'followed-up': { label: 'Followed Up', colorVar: '--color-primary', cssClass: 'status-followed-up', markerClass: 'marker-blue' },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +133,9 @@ function PatientPopup({ patient, onClose }) {
|
|||||||
<div className="patient-popup-body">
|
<div className="patient-popup-body">
|
||||||
<div className="popup-field">
|
<div className="popup-field">
|
||||||
<span className="popup-field-label">Destination</span>
|
<span className="popup-field-label">Destination</span>
|
||||||
<span className="popup-field-value">📍 {destinationText}</span>
|
<span className="popup-field-value" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<MapPin size={12} /> {destinationText}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="popup-field">
|
<div className="popup-field">
|
||||||
<span className="popup-field-label">Departure</span>
|
<span className="popup-field-label">Departure</span>
|
||||||
@@ -163,7 +166,9 @@ function GroupPatientPopup({ group, onClose }) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="patient-popup-header group-popup-header">
|
<div className="patient-popup-header group-popup-header">
|
||||||
<div className="group-popup-title-area">
|
<div className="group-popup-title-area">
|
||||||
<span className="group-popup-icon" aria-hidden="true">📍</span>
|
<span className="group-popup-icon" aria-hidden="true">
|
||||||
|
<MapPin size={16} />
|
||||||
|
</span>
|
||||||
<div className="patient-popup-identity">
|
<div className="patient-popup-identity">
|
||||||
<h3 className="patient-popup-name">{countryName}</h3>
|
<h3 className="patient-popup-name">{countryName}</h3>
|
||||||
<p className="patient-popup-id">{group.patients.length} Active Travellers</p>
|
<p className="patient-popup-id">{group.patients.length} Active Travellers</p>
|
||||||
@@ -680,14 +685,14 @@ export default function LiveTracking() {
|
|||||||
}}
|
}}
|
||||||
title={`Fly to ${iso}`}
|
title={`Fly to ${iso}`}
|
||||||
>
|
>
|
||||||
📍 {iso}
|
<MapPin size={11} /> {iso}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="log-detail-val">
|
<span className="log-detail-val" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
📍 {p.location || isos[0] || 'N/A'}
|
<MapPin size={12} /> {p.location || isos[0] || 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+112
-13
@@ -13,8 +13,10 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
MapPin,
|
MapPin,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Save,
|
||||||
|
Check,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { fetchEntries, fetchVaccines } from '../lib/pocketbase.js'
|
import { fetchEntries, fetchVaccines, saveVaccineRecord, updateEntry } from '../lib/pocketbase.js'
|
||||||
import {
|
import {
|
||||||
computeIdealSchedule,
|
computeIdealSchedule,
|
||||||
getDaysUntilDeparture,
|
getDaysUntilDeparture,
|
||||||
@@ -421,8 +423,65 @@ export default function PreTravel() {
|
|||||||
const [addedVaccines, setAddedVaccines] = useState([]) // array of vaccine objects
|
const [addedVaccines, setAddedVaccines] = useState([]) // array of vaccine objects
|
||||||
const [firstDoseDate, setFirstDoseDate] = useState('')
|
const [firstDoseDate, setFirstDoseDate] = useState('')
|
||||||
|
|
||||||
|
const [savingRecord, setSavingRecord] = useState(false)
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(null)
|
||||||
|
|
||||||
const today = useMemo(() => new Date(), [])
|
const today = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
|
async function handleSavePlan() {
|
||||||
|
if (!selectedTrip || schedules.length === 0) return
|
||||||
|
setSavingRecord(true)
|
||||||
|
setSaveSuccess(null)
|
||||||
|
try {
|
||||||
|
const vaccineTimelines = schedules.map(({ vaccine, schedule }) => ({
|
||||||
|
vaccine_id: vaccine.id,
|
||||||
|
vaccine_name: vaccine.name,
|
||||||
|
requires_icvp: !!vaccine.requires_icvp_certificate,
|
||||||
|
total_doses: vaccine.total_doses,
|
||||||
|
doses: schedule.doses.map((d) => {
|
||||||
|
const dDate = d.date
|
||||||
|
const dateStr = dDate ? `${dDate.getFullYear()}-${String(dDate.getMonth() + 1).padStart(2, '0')}-${String(dDate.getDate()).padStart(2, '0')}` : ''
|
||||||
|
return {
|
||||||
|
dose_number: d.doseNumber,
|
||||||
|
label: d.label,
|
||||||
|
suggested_date: dateStr,
|
||||||
|
administered_date: d.isInPast ? dateStr : null,
|
||||||
|
completed: d.isInPast,
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const savedRec = await saveVaccineRecord({
|
||||||
|
traveller_id: selectedTrip.id,
|
||||||
|
traveller: selectedTrip.id,
|
||||||
|
traveller_name: selectedTrip.name,
|
||||||
|
emr_id: selectedTrip.emrID,
|
||||||
|
timelines: vaccineTimelines,
|
||||||
|
vaccine_timelines: vaccineTimelines,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (savedRec && savedRec.id) {
|
||||||
|
await updateEntry(selectedTrip.id, {
|
||||||
|
...selectedTrip,
|
||||||
|
recordID: savedRec.id,
|
||||||
|
record_id: savedRec.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveSuccess(`Vaccination plan for ${selectedTrip.name} saved and linked to records!`)
|
||||||
|
setTrips((prev) => prev.filter((t) => t.id !== selectedTrip.id))
|
||||||
|
setSelectedTrip(null)
|
||||||
|
setAddedVaccines([])
|
||||||
|
setFirstDoseDate('')
|
||||||
|
setTimeout(() => setSaveSuccess(null), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to save vaccination plan.')
|
||||||
|
} finally {
|
||||||
|
setSavingRecord(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Data loading ──────────────────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
@@ -435,8 +494,10 @@ export default function PreTravel() {
|
|||||||
fetchEntries(),
|
fetchEntries(),
|
||||||
fetchVaccines(),
|
fetchVaccines(),
|
||||||
])
|
])
|
||||||
// Only pre-travel trips
|
// Only pre-travel trips without an existing vaccine record_id
|
||||||
const preTravelTrips = (tripData || []).filter((t) => t.status === 'pre-travel')
|
const preTravelTrips = (tripData || []).filter(
|
||||||
|
(t) => t.status === 'pre-travel' && !t.recordID && !t.record_id
|
||||||
|
)
|
||||||
setTrips(preTravelTrips)
|
setTrips(preTravelTrips)
|
||||||
setVaccines(vaccineData || [])
|
setVaccines(vaccineData || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -622,18 +683,56 @@ export default function PreTravel() {
|
|||||||
{/* ── Panel C: Timeline ────────────────────────────────────────────── */}
|
{/* ── Panel C: Timeline ────────────────────────────────────────────── */}
|
||||||
{schedules.length > 0 && (
|
{schedules.length > 0 && (
|
||||||
<div className="pt-panel pt-panel--timeline" aria-label="Vaccination timeline">
|
<div className="pt-panel pt-panel--timeline" aria-label="Vaccination timeline">
|
||||||
<div className="pt-panel-header">
|
<div className="pt-panel-header" style={{ justifyContent: 'space-between' }}>
|
||||||
<CalendarDays size={15} style={{ color: 'var(--color-accent-teal)' }} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<span className="pt-panel-title">Vaccination Timeline</span>
|
<CalendarDays size={15} style={{ color: 'var(--color-accent-teal)' }} />
|
||||||
<span className="pt-panel-sub">
|
<span className="pt-panel-title">Vaccination Timeline</span>
|
||||||
Departure in <strong style={{ color: daysUntilDep <= 30 ? 'var(--color-accent-amber)' : 'var(--color-text-primary)' }}>
|
<span className="pt-panel-sub">
|
||||||
{daysUntilDep}d
|
Departure in <strong style={{ color: daysUntilDep <= 30 ? 'var(--color-accent-amber)' : 'var(--color-text-primary)' }}>
|
||||||
</strong>
|
{daysUntilDep}d
|
||||||
{' · '}
|
</strong>
|
||||||
{schedules.length} vaccine{schedules.length !== 1 ? 's' : ''}
|
{' · '}
|
||||||
</span>
|
{schedules.length} vaccine{schedules.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="pt-save-plan-btn"
|
||||||
|
className="pt-save-plan-btn"
|
||||||
|
onClick={handleSavePlan}
|
||||||
|
disabled={savingRecord}
|
||||||
|
>
|
||||||
|
{savingRecord ? (
|
||||||
|
<>
|
||||||
|
<RotateCw size={13} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={13} />
|
||||||
|
Save Plan to Records
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{saveSuccess && (
|
||||||
|
<div className="pt-save-success-banner" style={{
|
||||||
|
margin: '12px 16px 0 16px',
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
border: '1px solid #10b981',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#34d399',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
}}>
|
||||||
|
<Check size={16} />
|
||||||
|
<span>{saveSuccess}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Global infeasibility warning */}
|
{/* Global infeasibility warning */}
|
||||||
{hasInfeasible && (
|
{hasInfeasible && (
|
||||||
<div className="pt-global-warning">
|
<div className="pt-global-warning">
|
||||||
|
|||||||
@@ -0,0 +1,667 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
Syringe,
|
||||||
|
User,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
CheckCircle,
|
||||||
|
Calendar,
|
||||||
|
AlertTriangle,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
RotateCw,
|
||||||
|
Check,
|
||||||
|
FileText,
|
||||||
|
ShieldCheck,
|
||||||
|
CalendarDays,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
fetchEntries,
|
||||||
|
fetchVaccineRecords,
|
||||||
|
fetchVaccines,
|
||||||
|
saveVaccineRecord,
|
||||||
|
} from '../lib/pocketbase.js'
|
||||||
|
import { getVaccineColor, formatDate, localDate, addDays } from '../lib/vaccineScheduleUtils.js'
|
||||||
|
|
||||||
|
export default function Records() {
|
||||||
|
const [travellers, setTravellers] = useState([])
|
||||||
|
const [vaccineRecords, setVaccineRecords] = useState([])
|
||||||
|
const [vaccineCatalog, setVaccineCatalog] = useState([])
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const [selectedTravellerId, setSelectedTravellerId] = useState(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
const [editableTimelines, setEditableTimelines] = useState([])
|
||||||
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(null)
|
||||||
|
|
||||||
|
// Modal state for adding custom vaccine to dossier
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [addVaccineId, setAddVaccineId] = useState('')
|
||||||
|
const [addFirstDoseDate, setAddFirstDoseDate] = useState('')
|
||||||
|
|
||||||
|
// ── Load All Data ──────────────────────────────────────────────────────────
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [travellerData, recordData, catalogData] = await Promise.all([
|
||||||
|
fetchEntries(),
|
||||||
|
fetchVaccineRecords(),
|
||||||
|
fetchVaccines(),
|
||||||
|
])
|
||||||
|
|
||||||
|
setTravellers(travellerData || [])
|
||||||
|
setVaccineRecords(recordData || [])
|
||||||
|
setVaccineCatalog(catalogData || [])
|
||||||
|
|
||||||
|
// Auto-select first traveller if none selected
|
||||||
|
if (!selectedTravellerId && (travellerData || []).length > 0) {
|
||||||
|
setSelectedTravellerId(travellerData[0].id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to load vaccine records.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Sync Editable State when Selected Traveller changes ────────────────────
|
||||||
|
const selectedTraveller = useMemo(() => {
|
||||||
|
return travellers.find((t) => t.id === selectedTravellerId) || null
|
||||||
|
}, [travellers, selectedTravellerId])
|
||||||
|
|
||||||
|
const existingRecord = useMemo(() => {
|
||||||
|
if (!selectedTravellerId) return null
|
||||||
|
return (
|
||||||
|
vaccineRecords.find(
|
||||||
|
(r) =>
|
||||||
|
(selectedTraveller?.recordID && r.id === selectedTraveller.recordID) ||
|
||||||
|
(selectedTraveller?.record_id && r.id === selectedTraveller.record_id) ||
|
||||||
|
(r.travellerId || r.traveller) === selectedTravellerId
|
||||||
|
) || null
|
||||||
|
)
|
||||||
|
}, [vaccineRecords, selectedTravellerId, selectedTraveller])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timelines = existingRecord?.timelines || existingRecord?.vaccineTimelines
|
||||||
|
if (existingRecord && timelines) {
|
||||||
|
setEditableTimelines(JSON.parse(JSON.stringify(timelines)))
|
||||||
|
} else {
|
||||||
|
setEditableTimelines([])
|
||||||
|
}
|
||||||
|
setIsDirty(false)
|
||||||
|
setSaveSuccess(null)
|
||||||
|
}, [existingRecord, selectedTravellerId])
|
||||||
|
|
||||||
|
// Map of traveller ID -> completion summary
|
||||||
|
const travellerRecordSummaries = useMemo(() => {
|
||||||
|
const summaryMap = {}
|
||||||
|
for (const rec of vaccineRecords) {
|
||||||
|
const timelines = rec.timelines || rec.vaccineTimelines || []
|
||||||
|
let totalDoses = 0
|
||||||
|
let completedDoses = 0
|
||||||
|
for (const t of timelines) {
|
||||||
|
const doses = t.doses || []
|
||||||
|
totalDoses += doses.length
|
||||||
|
completedDoses += doses.filter((d) => d.completed).length
|
||||||
|
}
|
||||||
|
const key = rec.travellerId || rec.traveller
|
||||||
|
if (key) {
|
||||||
|
summaryMap[key] = { totalDoses, completedDoses }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summaryMap
|
||||||
|
}, [vaccineRecords])
|
||||||
|
|
||||||
|
// Filtered traveller list
|
||||||
|
const filteredTravellers = useMemo(() => {
|
||||||
|
const q = searchQuery.toLowerCase().trim()
|
||||||
|
if (!q) return travellers
|
||||||
|
return travellers.filter((t) => {
|
||||||
|
const nameMatch = (t.name || '').toLowerCase().includes(q)
|
||||||
|
const emrMatch = (t.emrID ? String(t.emrID) : '').toLowerCase().includes(q)
|
||||||
|
return nameMatch || emrMatch
|
||||||
|
})
|
||||||
|
}, [travellers, searchQuery])
|
||||||
|
|
||||||
|
// ── Dossier Calculations ───────────────────────────────────────────────────
|
||||||
|
const overallStats = useMemo(() => {
|
||||||
|
let totalDoses = 0
|
||||||
|
let completedDoses = 0
|
||||||
|
let icvpRequiredCount = 0
|
||||||
|
let icvpCompletedCount = 0
|
||||||
|
|
||||||
|
for (const track of editableTimelines) {
|
||||||
|
if (track.requires_icvp) {
|
||||||
|
icvpRequiredCount++
|
||||||
|
}
|
||||||
|
let trackComplete = true
|
||||||
|
for (const dose of track.doses || []) {
|
||||||
|
totalDoses++
|
||||||
|
if (dose.completed) {
|
||||||
|
completedDoses++
|
||||||
|
} else {
|
||||||
|
trackComplete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (track.requires_icvp && trackComplete) {
|
||||||
|
icvpCompletedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = totalDoses > 0 ? Math.round((completedDoses / totalDoses) * 100) : 0
|
||||||
|
return { totalDoses, completedDoses, percentage, icvpRequiredCount, icvpCompletedCount }
|
||||||
|
}, [editableTimelines])
|
||||||
|
|
||||||
|
// ── Dose Edit Handlers ─────────────────────────────────────────────────────
|
||||||
|
function handleToggleDose(trackIndex, doseIndex) {
|
||||||
|
setEditableTimelines((prev) => {
|
||||||
|
const next = JSON.parse(JSON.stringify(prev))
|
||||||
|
const dose = next[trackIndex].doses[doseIndex]
|
||||||
|
dose.completed = !dose.completed
|
||||||
|
|
||||||
|
// Auto-set administered_date when checking completed
|
||||||
|
if (dose.completed && !(dose.administered_date)) {
|
||||||
|
const todayStr = new Date().toISOString().slice(0, 10)
|
||||||
|
const targetDate = dose.suggested_date || todayStr
|
||||||
|
dose.administered_date = targetDate
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setIsDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoseDateChange(trackIndex, doseIndex, field, value) {
|
||||||
|
setEditableTimelines((prev) => {
|
||||||
|
const next = JSON.parse(JSON.stringify(prev))
|
||||||
|
next[trackIndex].doses[doseIndex][field] = value
|
||||||
|
if (field === 'administered_date') {
|
||||||
|
next[trackIndex].doses[doseIndex].administered_date = value
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setIsDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDoseNotesChange(trackIndex, doseIndex, notes) {
|
||||||
|
setEditableTimelines((prev) => {
|
||||||
|
const next = JSON.parse(JSON.stringify(prev))
|
||||||
|
next[trackIndex].doses[doseIndex].notes = notes
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setIsDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveVaccineTrack(trackIndex) {
|
||||||
|
setEditableTimelines((prev) => prev.filter((_, idx) => idx !== trackIndex))
|
||||||
|
setIsDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save Dossier ───────────────────────────────────────────────────────────
|
||||||
|
async function handleSaveDossier() {
|
||||||
|
if (!selectedTraveller) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveSuccess(null)
|
||||||
|
try {
|
||||||
|
await saveVaccineRecord({
|
||||||
|
traveller_id: selectedTraveller.id,
|
||||||
|
traveller_name: selectedTraveller.name,
|
||||||
|
emr_id: selectedTraveller.emrID,
|
||||||
|
timelines: editableTimelines,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reload vaccine records
|
||||||
|
const recordData = await fetchVaccineRecords()
|
||||||
|
setVaccineRecords(recordData || [])
|
||||||
|
setIsDirty(false)
|
||||||
|
setSaveSuccess(`Vaccine record for ${selectedTraveller.name} updated successfully!`)
|
||||||
|
setTimeout(() => setSaveSuccess(null), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to update vaccine record.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add Vaccine to Dossier Modal ───────────────────────────────────────────
|
||||||
|
function handleAddVaccineSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!addVaccineId) return
|
||||||
|
|
||||||
|
const vaccineObj = vaccineCatalog.find((v) => v.id === addVaccineId)
|
||||||
|
if (!vaccineObj) return
|
||||||
|
|
||||||
|
const totalDoses = vaccineObj.total_doses || 1
|
||||||
|
const intervals = vaccineObj.dose_intervals || []
|
||||||
|
const intervalMap = {}
|
||||||
|
for (const inv of intervals) {
|
||||||
|
intervalMap[inv.dose_number] = inv.recommended_interval_days || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = addFirstDoseDate ? localDate(addFirstDoseDate) : new Date()
|
||||||
|
const doses = []
|
||||||
|
let currentDate = startDate
|
||||||
|
|
||||||
|
for (let d = 1; d <= totalDoses; d++) {
|
||||||
|
if (d > 1) {
|
||||||
|
const gap = intervalMap[d] || 30
|
||||||
|
currentDate = addDays(currentDate, gap)
|
||||||
|
}
|
||||||
|
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
doses.push({
|
||||||
|
dose_number: d,
|
||||||
|
label: totalDoses === 1 ? 'Single Dose' : `Dose ${d}`,
|
||||||
|
suggested_date: dateStr,
|
||||||
|
administered_date: null,
|
||||||
|
completed: false,
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTrack = {
|
||||||
|
vaccine_id: vaccineObj.id,
|
||||||
|
vaccine_name: vaccineObj.name,
|
||||||
|
requires_icvp: !!vaccineObj.requires_icvp_certificate,
|
||||||
|
total_doses: totalDoses,
|
||||||
|
doses: doses,
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditableTimelines((prev) => [...prev, newTrack])
|
||||||
|
setIsDirty(true)
|
||||||
|
setShowAddModal(false)
|
||||||
|
setAddVaccineId('')
|
||||||
|
setAddFirstDoseDate('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render Component ───────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<section className="records" aria-label="Vaccine Records Management">
|
||||||
|
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||||
|
<header className="rec-header">
|
||||||
|
<div>
|
||||||
|
<p className="dashboard-greeting">Clinical Records</p>
|
||||||
|
<h1 className="dashboard-title" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
Vaccine Dossiers
|
||||||
|
</h1>
|
||||||
|
<p className="dashboard-subtitle">
|
||||||
|
Manage administered doses, verify ICVP immunizations, and update patient vaccination timelines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
id="rec-refresh-btn"
|
||||||
|
className="pt-refresh-btn"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="Refresh data"
|
||||||
|
>
|
||||||
|
<RotateCw size={14} style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }} />
|
||||||
|
{loading ? 'Loading…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Error Banner ─────────────────────────────────────────────────────── */}
|
||||||
|
{error && (
|
||||||
|
<div className="pt-error-banner">
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>Connection Error</strong>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={loadData} className="pt-error-retry">Retry</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Main Two-Panel Layout ────────────────────────────────────────────── */}
|
||||||
|
<div className="rec-layout">
|
||||||
|
|
||||||
|
{/* ── Left Panel: Traveller List ──────────────────────────────────────── */}
|
||||||
|
<aside className="rec-panel" aria-label="Travellers">
|
||||||
|
<div className="rec-panel-header">
|
||||||
|
<User size={15} style={{ color: 'var(--color-primary)' }} />
|
||||||
|
<span>Travellers</span>
|
||||||
|
<span className="pt-panel-count">{travellers.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rec-search-wrap">
|
||||||
|
<Search size={14} style={{ color: 'var(--color-text-muted)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="rec-search-input"
|
||||||
|
placeholder="Search by name or EMR..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rec-traveller-list">
|
||||||
|
{loading ? (
|
||||||
|
<div className="pt-loading-state">
|
||||||
|
<RotateCw size={20} style={{ animation: 'spin 1s linear infinite', color: 'var(--color-primary)' }} />
|
||||||
|
<span>Loading records...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredTravellers.length === 0 ? (
|
||||||
|
<div className="pt-empty-state">
|
||||||
|
<User size={28} style={{ color: 'var(--color-text-muted)', marginBottom: '8px' }} />
|
||||||
|
<p>No travellers found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTravellers.map((traveller) => {
|
||||||
|
const isSelected = traveller.id === selectedTravellerId
|
||||||
|
const summary = travellerRecordSummaries[traveller.id]
|
||||||
|
|
||||||
|
let badgeClass = 'rec-badge-none'
|
||||||
|
let badgeText = 'No Record'
|
||||||
|
|
||||||
|
if (summary && summary.totalDoses > 0) {
|
||||||
|
if (summary.completedDoses === summary.totalDoses) {
|
||||||
|
badgeClass = 'rec-badge-complete'
|
||||||
|
badgeText = `${summary.completedDoses}/${summary.totalDoses} Complete`
|
||||||
|
} else {
|
||||||
|
badgeClass = 'rec-badge-partial'
|
||||||
|
badgeText = `${summary.completedDoses}/${summary.totalDoses} Doses`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={traveller.id}
|
||||||
|
id={`traveller-rec-card-${traveller.id}`}
|
||||||
|
className={`rec-traveller-card${isSelected ? ' rec-traveller-card--selected' : ''}`}
|
||||||
|
onClick={() => setSelectedTravellerId(traveller.id)}
|
||||||
|
>
|
||||||
|
<div className="rec-traveller-name">
|
||||||
|
<span>{traveller.name}</span>
|
||||||
|
<span className={`rec-traveller-badge ${badgeClass}`}>{badgeText}</span>
|
||||||
|
</div>
|
||||||
|
{traveller.emrID && (
|
||||||
|
<div className="rec-traveller-emr">EMR #{traveller.emrID}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Main Viewport: Patient Dossier ─────────────────────────────────── */}
|
||||||
|
<main className="rec-dossier" aria-label="Vaccine Dossier View">
|
||||||
|
{!selectedTraveller ? (
|
||||||
|
<div className="pt-panel pt-placeholder" style={{ height: '100%', justifyContent: 'center' }}>
|
||||||
|
<User size={36} style={{ color: 'var(--color-text-muted)' }} />
|
||||||
|
<p>Select a traveller from the left panel to view their vaccine dossier.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rec-dossier-card">
|
||||||
|
{/* Dossier Header */}
|
||||||
|
<div className="rec-dossier-top">
|
||||||
|
<div>
|
||||||
|
<div className="rec-dossier-title">
|
||||||
|
<span>{selectedTraveller.name}</span>
|
||||||
|
{selectedTraveller.emrID && (
|
||||||
|
<span className="pt-trip-card-emr" style={{ fontSize: '0.8rem' }}>
|
||||||
|
EMR #{selectedTraveller.emrID}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rec-dossier-meta">
|
||||||
|
{selectedTraveller.location && (
|
||||||
|
<span>Destination: {selectedTraveller.location}</span>
|
||||||
|
)}
|
||||||
|
<span>Departure: {selectedTraveller.departure ? formatDate(localDate(selectedTraveller.departure)) : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
{/* Overall Compliance Progress */}
|
||||||
|
<div className="rec-compliance-bar-wrap">
|
||||||
|
<div className="rec-compliance-text">
|
||||||
|
<span>Immunization Progress</span>
|
||||||
|
<span>{overallStats.completedDoses} / {overallStats.totalDoses} Doses ({overallStats.percentage}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="rec-progress-bg">
|
||||||
|
<div className="rec-progress-fill" style={{ width: `${overallStats.percentage}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<button
|
||||||
|
id="rec-add-vaccine-btn"
|
||||||
|
className="pt-refresh-btn"
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
style={{ background: 'var(--color-bg-subtle)' }}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add Vaccine
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="rec-save-dossier-btn"
|
||||||
|
className="pt-save-plan-btn"
|
||||||
|
onClick={handleSaveDossier}
|
||||||
|
disabled={saving || !isDirty}
|
||||||
|
style={{
|
||||||
|
background: isDirty ? 'var(--color-accent-emerald, #10b981)' : 'var(--color-bg-subtle)',
|
||||||
|
color: isDirty ? '#fff' : 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<RotateCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={14} />
|
||||||
|
{isDirty ? 'Save Changes' : 'Saved'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Success Banner */}
|
||||||
|
{saveSuccess && (
|
||||||
|
<div className="pt-save-success-banner" style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
border: '1px solid #10b981',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#34d399',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
}}>
|
||||||
|
<Check size={16} />
|
||||||
|
<span>{saveSuccess}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vaccine Tracks List */}
|
||||||
|
{editableTimelines.length === 0 ? (
|
||||||
|
<div className="pt-empty-state" style={{ padding: '40px 20px' }}>
|
||||||
|
<Syringe size={36} style={{ color: 'var(--color-text-muted)', marginBottom: '12px' }} />
|
||||||
|
<p>No vaccination records found for this traveller.</p>
|
||||||
|
<p className="pt-empty-sub">
|
||||||
|
You can plan timelines in the <strong>Pre-Travel</strong> section and save them here, or click <strong>Add Vaccine</strong> above to record doses directly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
editableTimelines.map((track, trackIdx) => {
|
||||||
|
const colorStyle = getVaccineColor(trackIdx)
|
||||||
|
return (
|
||||||
|
<div key={track.vaccine_id || trackIdx} className="rec-track-card">
|
||||||
|
<div className="rec-track-header">
|
||||||
|
<div className="rec-track-title" style={{ color: colorStyle.text }}>
|
||||||
|
<Syringe size={16} />
|
||||||
|
<span>{track.vaccine_name}</span>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: 'var(--color-text-muted)', fontWeight: 500 }}>
|
||||||
|
({track.total_doses} Dose{track.total_doses !== 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
{track.requires_icvp && (
|
||||||
|
<span className="pt-badge-icvp" title="ICVP Certificate Required" style={{ marginLeft: '8px' }}>
|
||||||
|
<ShieldCheck size={11} style={{ marginRight: '3px' }} /> ICVP Required
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="pt-vaccine-chip-remove"
|
||||||
|
onClick={() => handleRemoveVaccineTrack(trackIdx)}
|
||||||
|
title="Remove vaccine course from dossier"
|
||||||
|
style={{ color: 'var(--color-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dose Cards Grid */}
|
||||||
|
<div className="rec-dose-grid">
|
||||||
|
{(track.doses || []).map((dose, doseIdx) => (
|
||||||
|
<div
|
||||||
|
key={dose.dose_number || doseIdx}
|
||||||
|
className={`rec-dose-card${dose.completed ? ' rec-dose-card--completed' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="rec-dose-card-top">
|
||||||
|
<label className="rec-dose-toggle">
|
||||||
|
<div
|
||||||
|
className={`rec-checkbox${dose.completed ? ' rec-checkbox--checked' : ''}`}
|
||||||
|
onClick={() => handleToggleDose(trackIdx, doseIdx)}
|
||||||
|
>
|
||||||
|
{dose.completed && <Check size={13} />}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: dose.completed ? '#34d399' : 'var(--color-text-primary)' }}>
|
||||||
|
{dose.label || `Dose ${dose.dose_number}`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span className={`rec-traveller-badge ${dose.completed ? 'rec-badge-complete' : 'rec-badge-none'}`}>
|
||||||
|
{dose.completed ? 'Administered' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rec-dose-fields">
|
||||||
|
<div className="rec-field-group">
|
||||||
|
<span className="rec-field-label">Suggested Date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="rec-date-input"
|
||||||
|
value={dose.suggested_date || ''}
|
||||||
|
onChange={(e) => handleDoseDateChange(trackIdx, doseIdx, 'suggested_date', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rec-field-group">
|
||||||
|
<span className="rec-field-label">Administered Date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="rec-date-input"
|
||||||
|
value={dose.administered_date || ''}
|
||||||
|
onChange={(e) => handleDoseDateChange(trackIdx, doseIdx, 'administered_date', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rec-field-group">
|
||||||
|
<span className="rec-field-label">Notes / Clinic / Lot #</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="rec-text-input"
|
||||||
|
placeholder="e.g. Lot #A1290"
|
||||||
|
value={dose.notes || ''}
|
||||||
|
onChange={(e) => handleDoseNotesChange(trackIdx, doseIdx, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Add Vaccine Modal ────────────────────────────────────────────────── */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="rec-modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||||
|
<div className="rec-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="rec-modal-title">
|
||||||
|
<span>Add Vaccine to Dossier</span>
|
||||||
|
<button
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }}
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddVaccineSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<div className="rec-field-group">
|
||||||
|
<label className="rec-field-label">Select Vaccine</label>
|
||||||
|
<select
|
||||||
|
className="rec-date-input"
|
||||||
|
style={{ width: '100%', padding: '8px' }}
|
||||||
|
value={addVaccineId}
|
||||||
|
onChange={(e) => setAddVaccineId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">-- Choose a vaccine --</option>
|
||||||
|
{vaccineCatalog.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name} ({v.total_doses} dose{v.total_doses !== 1 ? 's' : ''})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rec-field-group">
|
||||||
|
<label className="rec-field-label">First Dose Start Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="rec-date-input"
|
||||||
|
style={{ width: '100%', padding: '8px' }}
|
||||||
|
value={addFirstDoseDate}
|
||||||
|
onChange={(e) => setAddFirstDoseDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rec-modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pt-refresh-btn"
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="pt-save-plan-btn"
|
||||||
|
disabled={!addVaccineId}
|
||||||
|
>
|
||||||
|
Add Course
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
HeartPulse,
|
Syringe,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Plane,
|
Plane,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -17,7 +17,7 @@ const NAV_ITEMS = [
|
|||||||
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: null },
|
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: null },
|
||||||
{ id: 'trips', label: 'Trips', icon: Plane, badge: null, disabled: false },
|
{ id: 'trips', label: 'Trips', icon: Plane, badge: null, disabled: false },
|
||||||
{ id: 'pre-travel', label: 'Pre-Travel', icon: Briefcase, badge: null, disabled: false },
|
{ id: 'pre-travel', label: 'Pre-Travel', icon: Briefcase, badge: null, disabled: false },
|
||||||
|
{ id: 'records', label: 'Records', icon: Syringe, badge: null, disabled: false },
|
||||||
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
|
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+70
-13
@@ -9,6 +9,7 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
Trash2,
|
Trash2,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
MapPin,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
|
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
|
||||||
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
|
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
|
||||||
@@ -19,6 +20,7 @@ const STATUS_META = {
|
|||||||
'in-transit': { label: 'In Transit', className: 'status-in-transit' },
|
'in-transit': { label: 'In Transit', className: 'status-in-transit' },
|
||||||
'at-risk': { label: 'At Risk', className: 'status-at-risk' },
|
'at-risk': { label: 'At Risk', className: 'status-at-risk' },
|
||||||
'post-travel': { label: 'Post-Travel', className: 'status-post-travel' },
|
'post-travel': { label: 'Post-Travel', className: 'status-post-travel' },
|
||||||
|
'followed-up': { label: 'Followed Up', className: 'status-followed-up' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_NEW_FORM = {
|
const INITIAL_NEW_FORM = {
|
||||||
@@ -28,6 +30,7 @@ const INITIAL_NEW_FORM = {
|
|||||||
countriesISO: [],
|
countriesISO: [],
|
||||||
departure: '',
|
departure: '',
|
||||||
arrival: '',
|
arrival: '',
|
||||||
|
status: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Trips() {
|
export default function Trips() {
|
||||||
@@ -118,6 +121,7 @@ export default function Trips() {
|
|||||||
countriesISO: Array.isArray(isos) ? isos : typeof isos === 'string' && isos ? isos.split(',').map((s) => s.trim().toUpperCase()) : [],
|
countriesISO: Array.isArray(isos) ? isos : typeof isos === 'string' && isos ? isos.split(',').map((s) => s.trim().toUpperCase()) : [],
|
||||||
departure: trip.departure ? trip.departure.slice(0, 10) : '',
|
departure: trip.departure ? trip.departure.slice(0, 10) : '',
|
||||||
arrival: trip.arrival ? trip.arrival.slice(0, 10) : '',
|
arrival: trip.arrival ? trip.arrival.slice(0, 10) : '',
|
||||||
|
status: trip.status || '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Scroll top container into view smoothly
|
// Scroll top container into view smoothly
|
||||||
@@ -152,6 +156,7 @@ export default function Trips() {
|
|||||||
countriesISO: isoArray,
|
countriesISO: isoArray,
|
||||||
departure: editTripForm.departure,
|
departure: editTripForm.departure,
|
||||||
arrival: editTripForm.arrival,
|
arrival: editTripForm.arrival,
|
||||||
|
status: editTripForm.status || '',
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -195,7 +200,7 @@ export default function Trips() {
|
|||||||
// Compute live preview state for New Form draft
|
// Compute live preview state for New Form draft
|
||||||
const newDraftPatient = {
|
const newDraftPatient = {
|
||||||
emrID: newTripForm.emrID,
|
emrID: newTripForm.emrID,
|
||||||
name: newTripForm.name || 'Jane / John Doe',
|
name: newTripForm.name || 'N/A',
|
||||||
location: newTripForm.location || 'Pending Location',
|
location: newTripForm.location || 'Pending Location',
|
||||||
countriesISO: Array.isArray(newTripForm.countriesISO)
|
countriesISO: Array.isArray(newTripForm.countriesISO)
|
||||||
? newTripForm.countriesISO
|
? newTripForm.countriesISO
|
||||||
@@ -222,10 +227,16 @@ export default function Trips() {
|
|||||||
: [],
|
: [],
|
||||||
departure: editTripForm.departure,
|
departure: editTripForm.departure,
|
||||||
arrival: editTripForm.arrival,
|
arrival: editTripForm.arrival,
|
||||||
|
status: editTripForm.status,
|
||||||
}
|
}
|
||||||
const editDraftStatusKey = calculatePatientStatus(editDraftPatient)
|
const editDraftStatusKey = calculatePatientStatus(editDraftPatient)
|
||||||
const editDraftMeta = STATUS_META[editDraftStatusKey] || STATUS_META['pre-travel']
|
const editDraftMeta = STATUS_META[editDraftStatusKey] || STATUS_META['pre-travel']
|
||||||
const editDraftISOs = getPatientISOs(editDraftPatient)
|
const editDraftISOs = getPatientISOs(editDraftPatient)
|
||||||
|
const isPostTravel =
|
||||||
|
calculatePatientStatus({
|
||||||
|
departure: editTripForm.departure,
|
||||||
|
arrival: editTripForm.arrival,
|
||||||
|
}) === 'post-travel' || editTripForm.status === 'followed-up'
|
||||||
|
|
||||||
const isEditingMode = editingTrip !== null
|
const isEditingMode = editingTrip !== null
|
||||||
|
|
||||||
@@ -424,7 +435,8 @@ export default function Trips() {
|
|||||||
<div className="log-detail-item">
|
<div className="log-detail-item">
|
||||||
<span className="log-detail-label">Location</span>
|
<span className="log-detail-label">Location</span>
|
||||||
<span className="log-detail-val" style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
<span className="log-detail-val" style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||||
📍 {newDraftPatient.location}
|
<MapPin size={13} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '4px' }} />
|
||||||
|
{newDraftPatient.location}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -433,7 +445,9 @@ export default function Trips() {
|
|||||||
<div className="destination-chips-container">
|
<div className="destination-chips-container">
|
||||||
{newDraftISOs.length > 0 ? (
|
{newDraftISOs.length > 0 ? (
|
||||||
newDraftISOs.map((iso) => (
|
newDraftISOs.map((iso) => (
|
||||||
<span key={iso} className="destination-chip">📍 {iso}</span>
|
<span key={iso} className="destination-chip">
|
||||||
|
<MapPin size={11} /> {iso}
|
||||||
|
</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="log-detail-val" style={{ fontStyle: 'italic', color: 'var(--color-text-muted)' }}>None specified</span>
|
<span className="log-detail-val" style={{ fontStyle: 'italic', color: 'var(--color-text-muted)' }}>None specified</span>
|
||||||
@@ -558,15 +572,55 @@ export default function Trips() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live chips preview for edit form */}
|
{/* Live chips preview and Follow-up Email checkbox for edit form */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px', marginTop: '6px' }}>
|
||||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>ISO Preview:</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
|
||||||
{editDraftISOs.length > 0 ? (
|
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>ISO Preview:</span>
|
||||||
editDraftISOs.map((iso) => (
|
{editDraftISOs.length > 0 ? (
|
||||||
<span key={iso} className="destination-chip">📍 {iso}</span>
|
editDraftISOs.map((iso) => (
|
||||||
))
|
<span key={iso} className="destination-chip">
|
||||||
) : (
|
<MapPin size={11} /> {iso}
|
||||||
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>None</span>
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>None</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPostTravel && (
|
||||||
|
<label
|
||||||
|
htmlFor="edit-followup-email"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: editTripForm.status === 'followed-up' ? '#06b6d4' : 'var(--color-text-secondary)',
|
||||||
|
background: editTripForm.status === 'followed-up' ? 'rgba(6, 182, 212, 0.12)' : 'rgba(255, 255, 255, 0.04)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: editTripForm.status === 'followed-up' ? '1px solid rgba(6, 182, 212, 0.35)' : '1px solid var(--color-border)',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="edit-followup-email"
|
||||||
|
type="checkbox"
|
||||||
|
checked={editTripForm.status === 'followed-up'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditTripForm({
|
||||||
|
...editTripForm,
|
||||||
|
status: e.target.checked ? 'followed-up' : '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={submitting}
|
||||||
|
style={{ cursor: 'pointer', accentColor: '#06b6d4' }}
|
||||||
|
/>
|
||||||
|
<span>Follow-up Email</span>
|
||||||
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -655,7 +709,10 @@ export default function Trips() {
|
|||||||
<td className="patient-name">{p.name}</td>
|
<td className="patient-name">{p.name}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
<span>📍 {p.location || 'N/A'}</span>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--color-text-muted)' }} />
|
||||||
|
{p.location || 'N/A'}
|
||||||
|
</span>
|
||||||
{isos.length > 0 && (
|
{isos.length > 0 && (
|
||||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||||
{isos.map((iso) => (
|
{isos.map((iso) => (
|
||||||
|
|||||||
+415
-1
@@ -659,6 +659,15 @@ body {
|
|||||||
background: var(--color-accent-violet);
|
background: var(--color-accent-violet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-followed-up {
|
||||||
|
background: rgba(6, 182, 212, 0.12);
|
||||||
|
color: #06b6d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-followed-up::before {
|
||||||
|
background: #06b6d4;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse-dot {
|
@keyframes pulse-dot {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
@@ -937,7 +946,10 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-popup-icon {
|
.group-popup-icon {
|
||||||
font-size: 18px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1862,6 +1874,32 @@ button {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pt-save-plan-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-save-plan-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
box-shadow: 0 0 12px var(--color-primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-save-plan-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Error banner ─────────────────────────────────────────────────────────── */
|
/* ── Error banner ─────────────────────────────────────────────────────────── */
|
||||||
.pt-error-banner {
|
.pt-error-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2668,4 +2706,380 @@ button {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Records Management View Styles (.rec-*)
|
||||||
|
───────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.records {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--space-6);
|
||||||
|
gap: var(--space-5);
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--color-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
gap: var(--space-5);
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left panel: Travellers */
|
||||||
|
.rec-panel {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-panel-header {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-search-wrap {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-search-input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-traveller-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-3);
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-traveller-card {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-traveller-card:hover {
|
||||||
|
border-color: var(--color-border-active);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-traveller-card--selected {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-glow);
|
||||||
|
box-shadow: 0 0 12px var(--color-primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-traveller-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-traveller-emr {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-traveller-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-badge-complete {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-badge-partial {
|
||||||
|
background: rgba(59, 158, 255, 0.2);
|
||||||
|
color: #63b3ff;
|
||||||
|
border: 1px solid #3b9eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-badge-none {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dossier Main View */
|
||||||
|
.rec-dossier {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dossier-card {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dossier-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dossier-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dossier-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-compliance-bar-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-compliance-text {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-progress-bg {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #3b9eff, #22d3c5);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vaccine Track Card in Records */
|
||||||
|
.rec-track-card {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-track-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px dashed var(--color-border);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-track-title {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dose-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dose-card {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dose-card--completed {
|
||||||
|
border-color: rgba(16, 185, 129, 0.4);
|
||||||
|
background: rgba(16, 185, 129, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dose-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dose-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid var(--color-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-checkbox--checked {
|
||||||
|
background: #10b981;
|
||||||
|
border-color: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dose-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-date-input,
|
||||||
|
.rec-text-input {
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 5px 8px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-date-input:focus,
|
||||||
|
.rec-text-input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Vaccine Modal */
|
||||||
|
.rec-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-modal {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-6);
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-modal-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
@@ -20,10 +20,13 @@
|
|||||||
export function calculatePatientStatus(patient, refDate = new Date()) {
|
export function calculatePatientStatus(patient, refDate = new Date()) {
|
||||||
if (!patient) return 'pre-travel'
|
if (!patient) return 'pre-travel'
|
||||||
|
|
||||||
// Priority override: operational 'at-risk' flag
|
// Priority override: operational 'at-risk' and 'followed-up' flags
|
||||||
if (patient.status === 'at-risk') {
|
if (patient.status === 'at-risk') {
|
||||||
return 'at-risk'
|
return 'at-risk'
|
||||||
}
|
}
|
||||||
|
if (patient.status === 'followed-up') {
|
||||||
|
return 'followed-up'
|
||||||
|
}
|
||||||
|
|
||||||
const departureStr = (patient.departure || '').slice(0, 10)
|
const departureStr = (patient.departure || '').slice(0, 10)
|
||||||
const arrivalStr = (patient.arrival || '').slice(0, 10)
|
const arrivalStr = (patient.arrival || '').slice(0, 10)
|
||||||
|
|||||||
@@ -29,3 +29,16 @@ export async function fetchVaccines() {
|
|||||||
return getPbApi().fetchVaccines()
|
return getPbApi().fetchVaccines()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchVaccineRecords() {
|
||||||
|
return getPbApi().fetchVaccineRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVaccineRecord(recordData) {
|
||||||
|
return getPbApi().saveVaccineRecord(recordData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVaccineRecord(id, recordData) {
|
||||||
|
return getPbApi().updateVaccineRecord(id, recordData)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user