+ )}
+
{/* Global infeasibility warning */}
{hasInfeasible && (
diff --git a/src/components/Records.jsx b/src/components/Records.jsx
new file mode 100644
index 0000000..4b9f8a3
--- /dev/null
+++ b/src/components/Records.jsx
@@ -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 (
+
+ {/* ── Header ──────────────────────────────────────────────────────────── */}
+
+
+ {/* ── Error Banner ─────────────────────────────────────────────────────── */}
+ {error && (
+
+
+
+
Connection Error
+
{error}
+
+
+
+ )}
+
+ {/* ── Main Two-Panel Layout ────────────────────────────────────────────── */}
+
+
+ {/* ── Left Panel: Traveller List ──────────────────────────────────────── */}
+
+
+ {/* ── Main Viewport: Patient Dossier ─────────────────────────────────── */}
+
+ {!selectedTraveller ? (
+
+
+
Select a traveller from the left panel to view their vaccine dossier.
+
+ ) : (
+
+ {/* Dossier Header */}
+
+
+
+ {selectedTraveller.name}
+ {selectedTraveller.emrID && (
+
+ EMR #{selectedTraveller.emrID}
+
+ )}
+
+
+ {selectedTraveller.location && (
+ Destination: {selectedTraveller.location}
+ )}
+ Departure: {selectedTraveller.departure ? formatDate(localDate(selectedTraveller.departure)) : 'N/A'}
+
+
+
+
+ {/* Overall Compliance Progress */}
+
+
+ Immunization Progress
+ {overallStats.completedDoses} / {overallStats.totalDoses} Doses ({overallStats.percentage}%)
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ {/* Save Success Banner */}
+ {saveSuccess && (
+
+
+ {saveSuccess}
+
+ )}
+
+ {/* Vaccine Tracks List */}
+ {editableTimelines.length === 0 ? (
+
+
+
No vaccination records found for this traveller.
+
+ You can plan timelines in the Pre-Travel section and save them here, or click Add Vaccine above to record doses directly.
+
+
+ ) : (
+ editableTimelines.map((track, trackIdx) => {
+ const colorStyle = getVaccineColor(trackIdx)
+ return (
+
+
+
+
+ {track.vaccine_name}
+
+ ({track.total_doses} Dose{track.total_doses !== 1 ? 's' : ''})
+
+ {track.requires_icvp && (
+
+ ICVP Required
+
+ )}
+
+
+
+
+
+ {/* Dose Cards Grid */}
+
+ {(track.doses || []).map((dose, doseIdx) => (
+
+
+
+
+
+ {dose.completed ? 'Administered' : 'Pending'}
+
+
+
+
+
+ ))}
+
+
+ )
+ })
+ )}
+
+ )}
+
+
+
+ {/* ── Add Vaccine Modal ────────────────────────────────────────────────── */}
+ {showAddModal && (
+ setShowAddModal(false)}>
+
e.stopPropagation()}>
+
+ Add Vaccine to Dossier
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
index 28f5d10..5d70deb 100644
--- a/src/components/Sidebar.jsx
+++ b/src/components/Sidebar.jsx
@@ -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 },
]
diff --git a/src/components/Trips.jsx b/src/components/Trips.jsx
index d979d62..e24b9ae 100644
--- a/src/components/Trips.jsx
+++ b/src/components/Trips.jsx
@@ -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() {
Location
- 📍 {newDraftPatient.location}
+
+ {newDraftPatient.location}
@@ -433,7 +445,9 @@ export default function Trips() {
{newDraftISOs.length > 0 ? (
newDraftISOs.map((iso) => (
- 📍 {iso}
+
+ {iso}
+
))
) : (
None specified
@@ -558,15 +572,55 @@ export default function Trips() {