From 7085a478fb54e4ae8e2fb48712015f9ceece0e75 Mon Sep 17 00:00:00 2001 From: Bruno Silva Date: Wed, 26 Aug 2026 10:44:30 -0400 Subject: [PATCH] Added Records screen; added follow up status. --- electron/main/index.js | 24 +- electron/main/pocketbase.js | 122 +++++- electron/preload/index.js | 9 + src/App.jsx | 2 + src/components/Dashboard.jsx | 1 + src/components/LiveTracking.jsx | 17 +- src/components/PreTravel.jsx | 125 +++++- src/components/Records.jsx | 667 ++++++++++++++++++++++++++++++++ src/components/Sidebar.jsx | 4 +- src/components/Trips.jsx | 83 +++- src/index.css | 416 +++++++++++++++++++- src/lib/patientUtils.js | 5 +- src/lib/pocketbase.js | 13 + 13 files changed, 1430 insertions(+), 58 deletions(-) create mode 100644 src/components/Records.jsx diff --git a/electron/main/index.js b/electron/main/index.js index d40285a..7d1a341 100644 --- a/electron/main/index.js +++ b/electron/main/index.js @@ -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) +}) + + diff --git a/electron/main/pocketbase.js b/electron/main/pocketbase.js index 64afeac..f2db24f 100644 --- a/electron/main/pocketbase.js +++ b/electron/main/pocketbase.js @@ -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, - })) -} diff --git a/electron/preload/index.js b/electron/preload/index.js index 5aa1c79..ea5688d 100644 --- a/electron/preload/index.js +++ b/electron/preload/index.js @@ -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), }, /** diff --git a/src/App.jsx b/src/App.jsx index 69a4cbd..f262b14 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 case 'trips': return + case 'records': return case 'pre-travel': return default: return } diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index 14c0ed3..d24141d 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -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() { diff --git a/src/components/LiveTracking.jsx b/src/components/LiveTracking.jsx index 7e09865..75fc9b8 100644 --- a/src/components/LiveTracking.jsx +++ b/src/components/LiveTracking.jsx @@ -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 }) {
Destination - 📍 {destinationText} + + {destinationText} +
Departure @@ -163,7 +166,9 @@ function GroupPatientPopup({ group, onClose }) { {/* Header */}
- +

{countryName}

{group.patients.length} Active Travellers

@@ -680,14 +685,14 @@ export default function LiveTracking() { }} title={`Fly to ${iso}`} > - 📍 {iso} + {iso} ) })}
) : ( - - 📍 {p.location || isos[0] || 'N/A'} + + {p.location || isos[0] || 'N/A'} )}
diff --git a/src/components/PreTravel.jsx b/src/components/PreTravel.jsx index 3402439..5be8442 100644 --- a/src/components/PreTravel.jsx +++ b/src/components/PreTravel.jsx @@ -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,18 +683,56 @@ export default function PreTravel() { {/* ── Panel C: Timeline ────────────────────────────────────────────── */} {schedules.length > 0 && (
-
- - Vaccination Timeline - - Departure in - {daysUntilDep}d - - {' · '} - {schedules.length} vaccine{schedules.length !== 1 ? 's' : ''} - +
+
+ + Vaccination Timeline + + Departure in + {daysUntilDep}d + + {' · '} + {schedules.length} vaccine{schedules.length !== 1 ? 's' : ''} + +
+
+ {saveSuccess && ( +
+ + {saveSuccess} +
+ )} + {/* 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 ──────────────────────────────────────────────────────────── */} +
+
+

Clinical Records

+

+ Vaccine Dossiers +

+

+ Manage administered doses, verify ICVP immunizations, and update patient vaccination timelines. +

+
+ +
+ +
+
+ + {/* ── 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'} + +
+ +
+
+ Suggested Date + handleDoseDateChange(trackIdx, doseIdx, 'suggested_date', e.target.value)} + /> +
+ +
+ Administered Date + handleDoseDateChange(trackIdx, doseIdx, 'administered_date', e.target.value)} + /> +
+ +
+ Notes / Clinic / Lot # + handleDoseNotesChange(trackIdx, doseIdx, e.target.value)} + /> +
+
+
+ ))} +
+
+ ) + }) + )} +
+ )} +
+
+ + {/* ── Add Vaccine Modal ────────────────────────────────────────────────── */} + {showAddModal && ( +
setShowAddModal(false)}> +
e.stopPropagation()}> +
+ Add Vaccine to Dossier + +
+ +
+
+ + +
+ +
+ + setAddFirstDoseDate(e.target.value)} + /> +
+ +
+ + +
+
+
+
+ )} +
+ ) +} 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() {
- {/* Live chips preview for edit form */} -
- ISO Preview: - {editDraftISOs.length > 0 ? ( - editDraftISOs.map((iso) => ( - 📍 {iso} - )) - ) : ( - None + {/* Live chips preview and Follow-up Email checkbox for edit form */} +
+
+ ISO Preview: + {editDraftISOs.length > 0 ? ( + editDraftISOs.map((iso) => ( + + {iso} + + )) + ) : ( + None + )} +
+ + {isPostTravel && ( + )}
@@ -655,7 +709,10 @@ export default function Trips() { {p.name}
- 📍 {p.location || 'N/A'} + + + {p.location || 'N/A'} + {isos.length > 0 && (
{isos.map((iso) => ( diff --git a/src/index.css b/src/index.css index a5c3da7..6c57006 100644 --- a/src/index.css +++ b/src/index.css @@ -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; @@ -2668,4 +2706,380 @@ button { text-transform: uppercase; 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); } \ No newline at end of file diff --git a/src/lib/patientUtils.js b/src/lib/patientUtils.js index 603b656..a809bb0 100644 --- a/src/lib/patientUtils.js +++ b/src/lib/patientUtils.js @@ -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) diff --git a/src/lib/pocketbase.js b/src/lib/pocketbase.js index 117ccaa..eab964b 100644 --- a/src/lib/pocketbase.js +++ b/src/lib/pocketbase.js @@ -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) +} + +