Added Records screen; added follow up status.

This commit is contained in:
2026-08-26 10:44:30 -04:00
parent e8c69d6558
commit 7085a478fb
13 changed files with 1430 additions and 58 deletions
+23 -1
View File
@@ -1,7 +1,16 @@
import { app, BrowserWindow, shell, ipcMain } from 'electron'
import { fileURLToPath } from 'node:url'
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))
@@ -104,3 +113,16 @@ ipcMain.handle('pb:fetch-vaccines', async () => {
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
View File
@@ -2,8 +2,96 @@ import PocketBase from 'pocketbase'
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
const COLLECTION = 'travellers'
const VACCINE_RECORDS_COLLECTION = 'records'
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 authPromise = null
@@ -20,6 +108,7 @@ function getClient() {
function parseEntry(r) {
return {
id: r.id,
recordID: r.record_id ?? r.recordID ?? '',
emrID: r.emr_id ?? '',
name: r.name ?? '',
location: r.location ?? '',
@@ -69,6 +158,12 @@ export async function createEntry(trip) {
departure: trip.departure,
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 !== '') {
const emrNum = Number(trip.emrID)
payload.emr_id = !isNaN(emrNum) ? emrNum : trip.emrID
@@ -86,6 +181,12 @@ export async function updateEntry(id, trip) {
departure: trip.departure,
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 !== '') {
const emrNum = Number(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)
}
/**
* 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,
}))
}
+9
View File
@@ -20,6 +20,9 @@ const ALLOWED_SEND_CHANNELS = [
'pb:update-entry',
'pb:delete-entry',
'pb:fetch-vaccines',
'pb:fetch-vaccine-records',
'pb:save-vaccine-record',
'pb:update-vaccine-record',
]
const ALLOWED_RECEIVE_CHANNELS = [
@@ -32,6 +35,9 @@ const ALLOWED_RECEIVE_CHANNELS = [
'pb:update-entry',
'pb:delete-entry',
'pb:fetch-vaccines',
'pb:fetch-vaccine-records',
'pb:save-vaccine-record',
'pb:update-vaccine-record',
]
// ─── Exposed API ──────────────────────────────────────────────────────────
@@ -45,6 +51,9 @@ contextBridge.exposeInMainWorld('api', {
updateEntry: (id, trip) => ipcRenderer.invoke('pb:update-entry', id, trip),
deleteEntry: (id) => ipcRenderer.invoke('pb:delete-entry', id),
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),
},
/**
+2
View File
@@ -3,6 +3,7 @@ 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 Records from './components/Records.jsx'
import PreTravel from './components/PreTravel.jsx'
/**
* App — root layout component.
@@ -16,6 +17,7 @@ export default function App() {
switch (activePage) {
case 'live-tracking': return <LiveTracking />
case 'trips': return <Trips />
case 'records': return <Records />
case 'pre-travel': return <PreTravel />
default: return <Dashboard />
}
+1
View File
@@ -16,6 +16,7 @@ const STATUS_META = {
'at-risk': { label: 'At Risk', className: 'status-at-risk' },
'post-travel': { label: 'Post-Travel', className: 'status-post-travel' },
'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' },
'followed-up': { label: 'Followed Up', className: 'status-followed-up' },
}
function getGreeting() {
+11 -6
View File
@@ -3,7 +3,7 @@ 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 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 { 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' },
'at-risk': { label: 'At Risk', colorVar: '--color-accent-amber', cssClass: 'status-at-risk', markerClass: 'marker-amber' },
'post-travel': { label: 'Post-Travel', colorVar: '--color-accent-emerald', cssClass: 'status-post-travel', markerClass: 'marker-emerald' },
'followed-up': { label: 'Followed Up', colorVar: '--color-primary', cssClass: 'status-followed-up', markerClass: 'marker-blue' },
}
/**
@@ -132,7 +133,9 @@ function PatientPopup({ patient, onClose }) {
<div className="patient-popup-body">
<div className="popup-field">
<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 className="popup-field">
<span className="popup-field-label">Departure</span>
@@ -163,7 +166,9 @@ function GroupPatientPopup({ group, onClose }) {
{/* Header */}
<div className="patient-popup-header group-popup-header">
<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">
<h3 className="patient-popup-name">{countryName}</h3>
<p className="patient-popup-id">{group.patients.length} Active Travellers</p>
@@ -680,14 +685,14 @@ export default function LiveTracking() {
}}
title={`Fly to ${iso}`}
>
📍 {iso}
<MapPin size={11} /> {iso}
</button>
)
})}
</div>
) : (
<span className="log-detail-val">
📍 {p.location || isos[0] || 'N/A'}
<span className="log-detail-val" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<MapPin size={12} /> {p.location || isos[0] || 'N/A'}
</span>
)}
</div>
+103 -4
View File
@@ -13,8 +13,10 @@ import {
CalendarDays,
MapPin,
AlertCircle,
Save,
Check,
} from 'lucide-react'
import { fetchEntries, fetchVaccines } from '../lib/pocketbase.js'
import { fetchEntries, fetchVaccines, saveVaccineRecord, updateEntry } from '../lib/pocketbase.js'
import {
computeIdealSchedule,
getDaysUntilDeparture,
@@ -421,8 +423,65 @@ export default function PreTravel() {
const [addedVaccines, setAddedVaccines] = useState([]) // array of vaccine objects
const [firstDoseDate, setFirstDoseDate] = useState('')
const [savingRecord, setSavingRecord] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(null)
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 ──────────────────────────────────────────────────────────
async function loadAll() {
@@ -435,8 +494,10 @@ export default function PreTravel() {
fetchEntries(),
fetchVaccines(),
])
// Only pre-travel trips
const preTravelTrips = (tripData || []).filter((t) => t.status === 'pre-travel')
// Only pre-travel trips without an existing vaccine record_id
const preTravelTrips = (tripData || []).filter(
(t) => t.status === 'pre-travel' && !t.recordID && !t.record_id
)
setTrips(preTravelTrips)
setVaccines(vaccineData || [])
} catch (err) {
@@ -622,7 +683,8 @@ export default function PreTravel() {
{/* ── Panel C: Timeline ────────────────────────────────────────────── */}
{schedules.length > 0 && (
<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' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<CalendarDays size={15} style={{ color: 'var(--color-accent-teal)' }} />
<span className="pt-panel-title">Vaccination Timeline</span>
<span className="pt-panel-sub">
@@ -633,6 +695,43 @@ export default function PreTravel() {
{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>
{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 */}
{hasInfeasible && (
+667
View File
@@ -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>
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import {
HeartPulse,
Syringe,
LayoutDashboard,
Plane,
BarChart3,
@@ -17,7 +17,7 @@ const NAV_ITEMS = [
{ 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: 'records', label: 'Records', icon: Syringe, badge: null, disabled: false },
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
]
+64 -7
View File
@@ -9,6 +9,7 @@ import {
Briefcase,
Trash2,
Calendar,
MapPin,
} from 'lucide-react'
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
@@ -19,6 +20,7 @@ const STATUS_META = {
'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' },
'followed-up': { label: 'Followed Up', className: 'status-followed-up' },
}
const INITIAL_NEW_FORM = {
@@ -28,6 +30,7 @@ const INITIAL_NEW_FORM = {
countriesISO: [],
departure: '',
arrival: '',
status: '',
}
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()) : [],
departure: trip.departure ? trip.departure.slice(0, 10) : '',
arrival: trip.arrival ? trip.arrival.slice(0, 10) : '',
status: trip.status || '',
})
// Scroll top container into view smoothly
@@ -152,6 +156,7 @@ export default function Trips() {
countriesISO: isoArray,
departure: editTripForm.departure,
arrival: editTripForm.arrival,
status: editTripForm.status || '',
}
try {
@@ -195,7 +200,7 @@ export default function Trips() {
// Compute live preview state for New Form draft
const newDraftPatient = {
emrID: newTripForm.emrID,
name: newTripForm.name || 'Jane / John Doe',
name: newTripForm.name || 'N/A',
location: newTripForm.location || 'Pending Location',
countriesISO: Array.isArray(newTripForm.countriesISO)
? newTripForm.countriesISO
@@ -222,10 +227,16 @@ export default function Trips() {
: [],
departure: editTripForm.departure,
arrival: editTripForm.arrival,
status: editTripForm.status,
}
const editDraftStatusKey = calculatePatientStatus(editDraftPatient)
const editDraftMeta = STATUS_META[editDraftStatusKey] || STATUS_META['pre-travel']
const editDraftISOs = getPatientISOs(editDraftPatient)
const isPostTravel =
calculatePatientStatus({
departure: editTripForm.departure,
arrival: editTripForm.arrival,
}) === 'post-travel' || editTripForm.status === 'followed-up'
const isEditingMode = editingTrip !== null
@@ -424,7 +435,8 @@ export default function Trips() {
<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}
<MapPin size={13} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '4px' }} />
{newDraftPatient.location}
</span>
</div>
@@ -433,7 +445,9 @@ export default function Trips() {
<div className="destination-chips-container">
{newDraftISOs.length > 0 ? (
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>
@@ -558,17 +572,57 @@ export default function Trips() {
</div>
</div>
{/* Live chips preview for edit form */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }}>
{/* Live chips preview and Follow-up Email checkbox for edit form */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px', marginTop: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
<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 key={iso} className="destination-chip">
<MapPin size={11} /> {iso}
</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 className="trips-form-actions">
@@ -655,7 +709,10 @@ export default function Trips() {
<td className="patient-name">{p.name}</td>
<td>
<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 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{isos.map((iso) => (
+415 -1
View File
@@ -659,6 +659,15 @@ body {
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 {
0%,
@@ -937,7 +946,10 @@ button {
}
.group-popup-icon {
font-size: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
flex-shrink: 0;
}
@@ -1862,6 +1874,32 @@ button {
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 ─────────────────────────────────────────────────────────── */
.pt-error-banner {
display: flex;
@@ -2669,3 +2707,379 @@ button {
letter-spacing: 0.05em;
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);
}
+4 -1
View File
@@ -20,10 +20,13 @@
export function calculatePatientStatus(patient, refDate = new Date()) {
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') {
return 'at-risk'
}
if (patient.status === 'followed-up') {
return 'followed-up'
}
const departureStr = (patient.departure || '').slice(0, 10)
const arrivalStr = (patient.arrival || '').slice(0, 10)
+13
View File
@@ -29,3 +29,16 @@ export async function 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)
}