diff --git a/README.md b/README.md
index 2d9b666..217bdab 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ graph TD
│ ├── components/ # Reusable React UI components
│ │ ├── Dashboard.jsx # Metrics cards and Live Patient Board list
│ │ ├── Sidebar.jsx # Navigation bar and user/physician profile context
-│ │ └── TripTracker.jsx # Interactive MapLibre world map & active patient logs
+│ │ └── LiveTracking.jsx # Interactive MapLibre world map & active patient logs
│ ├── App.jsx # App root layout shell & page navigation controller
│ ├── index.css # CSS styles, layout grid, CSS custom properties (variables)
│ └── main.jsx # React application entry point
@@ -49,7 +49,7 @@ graph TD
## Core Features
### 1. Interactive Patient World Map
-Located in `src/components/TripTracker.jsx`, this component renders patient destinations across the globe:
+Located in `src/components/LiveTracking.jsx`, this component renders patient destinations across the globe:
- **Centroid Coordinates**: Destructuring ISO-2 country codes (e.g., `TH`, `GH`, `MX`) to map them to coordinates using a centroid lookup dataset (`src/assets/countries.json`).
- **Interactive Markers**: Displays pulsed, color-coded rings corresponding to the patient's status. Clicking a marker centers the map using a smooth transition (`flyTo`) and displays a detailed information popup.
- **English Label Translation**: Coerces country names on the map to display in English.
diff --git a/electron/main.js b/electron/main/index.js
similarity index 87%
rename from electron/main.js
rename to electron/main/index.js
index 319edb6..b634f42 100644
--- a/electron/main.js
+++ b/electron/main/index.js
@@ -1,15 +1,15 @@
import { app, BrowserWindow, shell, ipcMain } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
+import { fetchEntries } from './pocketbase.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// ─── Paths ──────────────────────────────────────────────────────────────────
-// dist-electron/main.js is the compiled output location.
-// dist/ is the Vite renderer build output.
-const RENDERER_DIST = path.join(__dirname, '../dist')
-// vite-plugin-electron outputs preload as .mjs when "type":"module" is set in package.json
-const PRELOAD_PATH = path.join(__dirname, 'preload.mjs')
+// Compiled main lives in dist-electron/main/ — go up two levels to reach dist/
+const RENDERER_DIST = path.join(__dirname, '../../dist')
+// Preload is compiled to dist-electron/preload/index.cjs (explicitly CJS)
+const PRELOAD_PATH = path.join(__dirname, '../preload/index.cjs')
// ─── Dev server URL ─────────────────────────────────────────────────────────
// vite-plugin-electron sets this env var when running in development mode.
@@ -31,7 +31,7 @@ function createWindow() {
// ── Security defaults ──────────────────────────────────────────────
contextIsolation: true, // Isolates renderer from Electron internals
nodeIntegration: false, // Renderer cannot access Node.js APIs directly
- sandbox: true, // Extra process isolation
+ sandbox: false, // Must be false so preload can use require('electron') for contextBridge
webSecurity: true,
// ── Preload ────────────────────────────────────────────────────────
preload: PRELOAD_PATH,
@@ -87,3 +87,6 @@ app.on('window-all-closed', () => {
// Example: ipcMain.handle('channel-name', async (event, ...args) => { ... })
ipcMain.handle('app:get-version', () => app.getVersion())
+ipcMain.handle('pb:fetch-entries', async () => {
+ return await fetchEntries()
+})
diff --git a/electron/main/pocketbase-config.js b/electron/main/pocketbase-config.js
new file mode 100644
index 0000000..4a95683
--- /dev/null
+++ b/electron/main/pocketbase-config.js
@@ -0,0 +1,4 @@
+/** Internal admin app — PocketBase connection (bundled with the desktop build). */
+export const POCKETBASE_URL = 'https://ctm-pocketbase.wirediv.dev'
+export const PB_ADMIN_EMAIL = 'info@wirediv.com'
+export const PB_ADMIN_PASSWORD = 'Supernova99!!__'
diff --git a/electron/main/pocketbase.js b/electron/main/pocketbase.js
new file mode 100644
index 0000000..97ae868
--- /dev/null
+++ b/electron/main/pocketbase.js
@@ -0,0 +1,60 @@
+import PocketBase from 'pocketbase'
+import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
+
+const COLLECTION = 'travellers'
+
+let pb = null
+let authPromise = null
+
+function getClient() {
+ if (!pb) {
+ pb = new PocketBase(POCKETBASE_URL)
+ // Main process may receive overlapping IPC calls (e.g. React Strict Mode).
+ // Default auto-cancel would abort duplicate in-flight PocketBase requests.
+ pb.autoCancellation(false)
+ }
+ return pb
+}
+
+function parseEntry(r) {
+ return {
+ id: r.emr_id,
+ name: r.name ?? '',
+ location: r.location ?? '',
+ countryISO: r.country_iso ?? '',
+ countriesISO: r.countries_iso ?? [],
+ status: r.status ?? '',
+ departure: r.departure ?? '',
+ arrival: r.arrival ?? '',
+ created: r.created,
+ updated: r.updated,
+ }
+}
+
+export async function ensureAuth() {
+ const client = getClient()
+ if (client.authStore.isValid) return
+
+ if (authPromise) {
+ await authPromise
+ return
+ }
+
+ // PocketBase v0.23+ removed /api/admins/*; superusers authenticate as a collection.
+ authPromise = client
+ .collection('_superusers')
+ .authWithPassword(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
+ try {
+ await authPromise
+ } catch (err) {
+ authPromise = null
+ throw err
+ }
+}
+
+export async function fetchEntries() {
+ await ensureAuth()
+ const list = await getClient().collection(COLLECTION).getFullList({ sort: '-created' })
+ return list.map(parseEntry)
+}
+
diff --git a/electron/preload.js b/electron/preload/index.js
similarity index 93%
rename from electron/preload.js
rename to electron/preload/index.js
index db8c541..75f3b4b 100644
--- a/electron/preload.js
+++ b/electron/preload/index.js
@@ -15,6 +15,7 @@ const ALLOWED_SEND_CHANNELS = [
'patient:fetch-list',
'schedule:fetch',
'app:ready',
+ 'pb:fetch-entries',
]
const ALLOWED_RECEIVE_CHANNELS = [
@@ -22,10 +23,18 @@ const ALLOWED_RECEIVE_CHANNELS = [
'patient:list-response',
'schedule:response',
'app:get-version',
+ 'pb:fetch-entries',
]
// ─── Exposed API ──────────────────────────────────────────────────────────
contextBridge.exposeInMainWorld('api', {
+ /**
+ * PocketBase helper methods for renderer
+ */
+ pb: {
+ fetchEntries: () => ipcRenderer.invoke('pb:fetch-entries'),
+ },
+
/**
* Send a one-way message to the main process.
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
diff --git a/package-lock.json b/package-lock.json
index 05af153..cf8656b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,9 @@
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
+ "lucide-react": "^1.3.0",
"maplibre-gl": "^5.24.0",
+ "pocketbase": "^0.27.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-map-gl": "^8.1.1"
@@ -5207,6 +5209,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.3.0.tgz",
+ "integrity": "sha512-aKUU4jKpXe26Y3xmF3DGg+NXHteRCVT96r/+Jp7wziqvmFr0/x8ReRsES0wHgTvW7OLxiHvFLN+YeyDnK6h0dQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/make-fetch-happen": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz",
@@ -5975,6 +5986,12 @@
"node": ">=10.4.0"
}
},
+ "node_modules/pocketbase": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.27.0.tgz",
+ "integrity": "sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==",
+ "license": "MIT"
+ },
"node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
diff --git a/package.json b/package.json
index c7c4c59..1dc077d 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,11 @@
{
"name": "ctm-concierge",
- "version": "0.1.0",
- "description": "Internal real-time patient journey tracker for CTM medical clinic",
- "author": "CTM Internal",
- "license": "UNLICENSED",
+ "version": "1.0.0",
+ "description": "CTM Concierge - Real-time patient journey tracker for CTM",
+ "author": "Wirediv",
+ "license": "MIT",
"private": true,
- "main": "dist-electron/main.js",
+ "main": "dist-electron/main/index.js",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,7 +13,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "lucide-react": "^1.3.0",
"maplibre-gl": "^5.24.0",
+ "pocketbase": "^0.27.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-map-gl": "^8.1.1"
@@ -52,4 +54,4 @@
"icon": "public/icon.icns"
}
}
-}
+}
\ No newline at end of file
diff --git a/src/App.jsx b/src/App.jsx
index 57db7a9..5849859 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import Sidebar from './components/Sidebar.jsx'
import Dashboard from './components/Dashboard.jsx'
-import TripTracker from './components/TripTracker.jsx'
+import LiveTracking from './components/LiveTracking.jsx'
/**
* App — root layout component.
@@ -13,8 +13,8 @@ export default function App() {
function renderPage() {
switch (activePage) {
- case 'trip-tracker': return
- default: return
+ case 'live-tracking': return
+ default: return
}
}
diff --git a/src/assets/CTM_Concierge_Icon_SVG.svg b/src/assets/CTM_Concierge_Icon_SVG.svg
new file mode 100644
index 0000000..7d5373d
--- /dev/null
+++ b/src/assets/CTM_Concierge_Icon_SVG.svg
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx
index 25f2ad1..00fea1a 100644
--- a/src/components/Dashboard.jsx
+++ b/src/components/Dashboard.jsx
@@ -1,53 +1,13 @@
-import React from 'react'
-
-// ── Stat card data ────────────────────────────────────────────────────────────
-const STATS = [
- {
- id: 'pre-travel',
- icon: '♥',
- value: '24',
- label: 'In Pre-Travel',
- delta: '+3',
- deltaType: 'up',
- accent: 'violet',
- },
- {
- id: 'in-transit',
- icon: '◷',
- value: '8',
- label: 'In Transit',
- delta: null,
- accent: 'blue',
- },
- {
- id: 'at-risk',
- icon: '⌛',
- value: '11',
- label: 'At Risk',
- delta: '+2',
- deltaType: 'up',
- accent: 'amber',
- },
- {
- id: 'post-travel',
- icon: '✓',
- value: '47',
- label: 'In Post-Travel',
- delta: '+12%',
- deltaType: 'up',
- accent: 'teal',
- },
-]
-
-// ── Placeholder patient journey rows ─────────────────────────────────────────
-const PATIENTS = [
- { id: 'P-001', name: 'Maria Santos', departure: '2026-07-14', arrival: '2026-07-19', status: 'in-transit', email: 'name@example.com' },
- { id: 'P-002', name: 'James Owusu', departure: '2026-07-12', arrival: '2026-07-20', status: 'at-risk', email: 'name@example.com' },
- { id: 'P-003', name: 'Anya Kowalski', departure: '2026-07-12', arrival: '2026-07-21', status: 'in-transit', email: 'name@example.com' },
- { id: 'P-004', name: 'Carlos Mendoza', departure: '2026-07-19', arrival: '2026-07-22', status: 'at-risk', email: 'name@example.com' },
- { id: 'P-005', name: 'Priya Nair', departure: '2026-07-02', arrival: '2026-07-13', status: 'post-travel', email: 'name@example.com' },
- { id: 'P-006', name: 'David Okonkwo', departure: '2026-07-19', arrival: '2026-07-24', status: 'pre-travel', email: 'name@example.com' },
-]
+import React, { useState, useEffect } from 'react'
+import {
+ PlaneTakeoff,
+ Plane,
+ AlertTriangle,
+ CheckCircle2,
+ Activity,
+ RotateCw,
+} from 'lucide-react'
+import { fetchEntries } from '../lib/pocketbase.js'
const STATUS_META = {
'in-transit': { label: 'In Transit', className: 'status-in-transit' },
@@ -65,84 +25,181 @@ function getGreeting() {
/**
* Dashboard — main content viewport for CTM Concierge.
- * Shows real-time stat cards and a patient journey table.
+ * Shows real-time stat cards and a patient journey table powered by PocketBase.
*/
export default function Dashboard() {
+ const [travellers, setTravellers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
})
+ async function loadData() {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await fetchEntries()
+ setTravellers(data || [])
+ } catch (err) {
+ setError(err.message || 'Unable to connect to PocketBase backend.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadData()
+ }, [])
+
+ const preTravelCount = travellers.filter((t) => t.status === 'pre-travel').length
+ const inTransitCount = travellers.filter((t) => t.status === 'in-transit').length
+ const atRiskCount = travellers.filter((t) => t.status === 'at-risk').length
+ const postTravelCount = travellers.filter((t) => t.status === 'post-travel').length
+
+ const stats = [
+ { id: 'pre-travel', icon: PlaneTakeoff, value: preTravelCount, label: 'In Pre-Travel', accent: 'violet' },
+ { id: 'in-transit', icon: Plane, value: inTransitCount, label: 'In Transit', accent: 'blue' },
+ { id: 'at-risk', icon: AlertTriangle, value: atRiskCount, label: 'At Risk', accent: 'amber' },
+ { id: 'post-travel', icon: CheckCircle2, value: postTravelCount, label: 'In Post-Travel', accent: 'teal' },
+ ]
+
return (
{/* ── Header ─────────────────────────────────────────────────────────── */}
-
)
}
+
+
diff --git a/src/components/LiveTracking.jsx b/src/components/LiveTracking.jsx
new file mode 100644
index 0000000..483868c
--- /dev/null
+++ b/src/components/LiveTracking.jsx
@@ -0,0 +1,523 @@
+import { useState, useRef, useEffect } from 'react'
+import MapGL, { Marker, Popup } from 'react-map-gl/maplibre'
+import 'maplibre-gl/dist/maplibre-gl.css'
+import countryCentroids from '../assets/countries.json'
+import { RotateCw, AlertTriangle } from 'lucide-react'
+import { fetchEntries } from '../lib/pocketbase.js'
+import { getPatientISOs } from '../lib/patientUtils.js'
+
+// ── Country centroid lookup ─────────────────────────────────────────────────────
+// Builds an ISO-2 → [longitude, latitude] map from the GeoJSON centroid dataset.
+const CENTROID_BY_ISO = new Map(
+ countryCentroids.features.map((f) => [
+ f.properties.ISO,
+ f.geometry.coordinates, // [longitude, latitude]
+ ])
+)
+
+// ── Status metadata ────────────────────────────────────────────────────────────
+const STATUS_META = {
+ 'pre-travel': { label: 'Pre-Travel', colorVar: '--color-accent-violet', cssClass: 'status-pre-travel', markerClass: 'marker-violet' },
+ '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' },
+}
+
+// ── Sub-component: PatientMarker ───────────────────────────────────────────────
+function PatientMarker({ patient, isSelected, onClick }) {
+ const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
+
+ return (
+
+ )
+}
+
+// ── Sub-component: PatientPopup ────────────────────────────────────────────────
+function PatientPopup({ patient, onClose }) {
+ const meta = STATUS_META[patient.status] || STATUS_META['pre-travel']
+
+ const formatDate = (date) => {
+ if (!date) return 'N/A'
+ return new Date(date).toLocaleDateString('en-US', {
+ month: 'short', day: 'numeric', year: 'numeric',
+ })
+ }
+
+ const destinationText = patient.location
+ ? patient.location
+ : getPatientISOs(patient).join(', ') || 'N/A'
+
+ return (
+
+ )
+}
+
+// ── Main component: LiveTracking ────────────────────────────────────────────────
+/**
+ * LiveTracking — full-screen locked world map showing active patient locations.
+ * Uses react-map-gl/maplibre with the OpenFreeMap 'Fiord' tile style.
+ */
+export default function LiveTracking() {
+ const [selectedMarkerKey, setSelectedMarkerKey] = useState(null)
+ const [travellers, setTravellers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const mapRef = useRef(null)
+
+ async function loadData() {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await fetchEntries()
+ setTravellers(data || [])
+ } catch (err) {
+ setError(err.message || 'Unable to connect to PocketBase backend.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadData()
+ }, [])
+
+ const patientMarkers = travellers.flatMap((p) => {
+ const isos = getPatientISOs(p)
+ return isos.map((iso) => ({
+ key: `${p.id}-${iso}`,
+ patient: p,
+ iso,
+ coordinate: CENTROID_BY_ISO.get(iso.toUpperCase()) ?? [0, 0],
+ }))
+ })
+
+ const selectedMarker = patientMarkers.find((m) => m.key === selectedMarkerKey) || null
+ const selectedPatientId = selectedMarker ? selectedMarker.patient.id : null
+
+ function handleMarkerClick(marker) {
+ const isSelecting = selectedMarkerKey !== marker.key
+ setSelectedMarkerKey(isSelecting ? marker.key : null)
+
+ if (isSelecting) {
+ mapRef.current?.flyTo({
+ center: marker.coordinate,
+ zoom: 2.2, // Focus in at a constant zoom level
+ essential: true,
+ duration: 1000 // smooth 1s transition
+ })
+ }
+ }
+
+ function handleCardClick(patient) {
+ const firstMarker = patientMarkers.find((m) => m.patient.id === patient.id)
+ if (!firstMarker) return
+
+ const isSelecting = selectedPatientId !== patient.id
+ setSelectedMarkerKey(isSelecting ? firstMarker.key : null)
+
+ if (isSelecting) {
+ mapRef.current?.flyTo({
+ center: firstMarker.coordinate,
+ zoom: 2.2,
+ essential: true,
+ duration: 1000,
+ })
+ }
+ }
+
+ function handleDestinationChipClick(patient, iso) {
+ const markerKey = `${patient.id}-${iso}`
+ const targetMarker = patientMarkers.find((m) => m.key === markerKey)
+ if (!targetMarker) return
+
+ const isSelecting = selectedMarkerKey !== markerKey
+ setSelectedMarkerKey(isSelecting ? markerKey : null)
+
+ if (isSelecting) {
+ mapRef.current?.flyTo({
+ center: targetMarker.coordinate,
+ zoom: 2.2,
+ essential: true,
+ duration: 1000,
+ })
+ }
+ }
+
+ function handlePopupClose() {
+ setSelectedMarkerKey(null)
+ }
+
+ function applyCountryFilter(mapInstance, countryISOs) {
+ if (!mapInstance) return
+ const style = mapInstance.getStyle ? mapInstance.getStyle() : null
+ if (!style || !style.layers) return
+
+ const isoSet = new Set(
+ (countryISOs || []).flatMap((c) => [c.toUpperCase(), c.toLowerCase()])
+ )
+ const validISOs = Array.from(isoSet)
+
+ const filterExpr = validISOs.length > 0
+ ? [
+ 'any',
+ ['in', ['get', 'iso_a2'], ['literal', validISOs]],
+ ['in', ['get', 'iso2'], ['literal', validISOs]],
+ ['in', ['get', 'country_code'], ['literal', validISOs]],
+ ['in', ['get', 'ISO_A2'], ['literal', validISOs]],
+ ]
+ : ['==', 1, 0]
+
+ for (const layer of style.layers) {
+ if (layer.id === 'place_country_major' || layer.id === 'place_country_minor') {
+ try {
+ mapInstance.setFilter(layer.id, filterExpr)
+ } catch (e) {
+ console.warn('Could not set layer filter on', layer.id, e)
+ }
+ }
+ }
+ }
+
+ function handleMapLoad(evt) {
+ const map = evt.target
+ const style = map.getStyle()
+
+ if (style && style.layers) {
+ for (const layer of style.layers) {
+ if (layer.id === 'place_country_major' || layer.id === 'place_country_minor') {
+ // Force English name translation
+ map.setLayoutProperty(layer.id, 'text-field', [
+ 'coalesce',
+ ['get', 'name:en'],
+ ['get', 'name'],
+ ])
+ }
+ // Hide all other symbol layers (continents, states, provinces, districts, cities, towns, roads, etc.)
+ else if (layer.type === 'symbol') {
+ map.setLayoutProperty(layer.id, 'visibility', 'none')
+ }
+ }
+ }
+
+ const isos = travellers.flatMap(getPatientISOs).filter(Boolean)
+ applyCountryFilter(map, isos)
+ }
+
+ useEffect(() => {
+ const map = mapRef.current?.getMap?.() || mapRef.current
+ if (!map) return
+ const isos = travellers.flatMap(getPatientISOs).filter(Boolean)
+ if (map.isStyleLoaded && map.isStyleLoaded()) {
+ applyCountryFilter(map, isos)
+ } else if (map.once) {
+ map.once('styledata', () => {
+ applyCountryFilter(map, isos)
+ })
+ }
+ }, [travellers])
+
+
+ const inTransitCount = travellers.filter((p) => p.status === 'in-transit').length
+ const atRiskCount = travellers.filter((p) => p.status === 'at-risk').length
+
+ return (
+
+
+ {/* ── Top Panel: Map ─────────────────────────────────────────────────── */}
+
+ {/* ── Top overlay header ────────────────────────────────────────────── */}
+
+
+ {/* ── Connection Error Banner Overlay ───────────────────────────────── */}
+ {error ? (
+
+
+
+
PocketBase Connection Error
+
+
+ {error}
+
+
+
+ ) : null}
+
+ {/* ── Map ──────────────────────────────────────────────────────────── */}
+
+ {/* Patient markers */}
+ {!error && patientMarkers.map((m) => (
+
+ handleMarkerClick(m)}
+ />
+
+ ))}
+
+ {/* Patient popup — mounts only when a marker is selected */}
+ {!error && selectedMarker && (
+
+
+
+ )}
+
+
+ {/* ── Legend ───────────────────────────────────────────────────────── */}
+
+
+
+ {/* ── Bottom Panel: Active Itinerary Details ──────────────────────────── */}
+
+
+
Active Patient Itineraries
+
Real-time status logs of patient coordinates and destinations.
+
+
+ {!error && (
+
+ {travellers.length === 0 && !loading ? (
+
No patient itineraries found.
+ ) : (
+ travellers.map((p) => {
+ const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
+ const isSelected = selectedPatientId === p.id
+ const isos = getPatientISOs(p)
+ const isMultiDestination = isos.length > 1
+
+ return (
+
handleCardClick(p) : undefined}
+ style={{ cursor: !isMultiDestination ? 'pointer' : 'default' }}
+ >
+
+
+ {p.id}
+
{p.name}
+
+
+ {meta.label}
+
+
+
+
+
+ {isMultiDestination ? 'Destinations' : 'Destination'}
+
+ {isMultiDestination ? (
+
+ {isos.map((iso) => {
+ const chipKey = `${p.id}-${iso}`
+ const isChipSelected = selectedMarkerKey === chipKey
+ return (
+
+ )
+ })}
+
+ ) : (
+
+ 📍 {p.location || isos[0] || 'N/A'}
+
+ )}
+
+
+ Dates
+
+ {p.departure ? new Date(p.departure).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A'} – {p.arrival ? new Date(p.arrival).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'N/A'}
+
+
+
+
+ )
+ })
+ )}
+
+ )}
+
+
+
+ )
+}
+
+
+
+
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
index 30bd4c4..76210d9 100644
--- a/src/components/Sidebar.jsx
+++ b/src/components/Sidebar.jsx
@@ -1,17 +1,25 @@
import React from 'react'
+import {
+ HeartPulse,
+ LayoutDashboard,
+ Calendar,
+ BarChart3,
+ Bell,
+ Settings,
+ MapPin,
+} from 'lucide-react'
+import AppLogo from '../assets/CTM_Concierge_Icon_SVG.svg'
const NAV_ITEMS = [
- { id: 'dashboard', label: 'Dashboard', icon: '⬡', badge: null },
- { id: 'trip-tracker', label: 'Trip Tracker', icon: '♥', badge: '3' },
- { id: 'schedule', label: 'Schedule', icon: '◷', badge: null },
- { id: 'providers', label: 'Providers', icon: '✦', badge: null },
- { id: 'rooms', label: 'Rooms', icon: '▣', badge: null },
- { id: 'reports', label: 'Reports', icon: '◈', badge: null },
+ { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, badge: null },
+ { id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: '3' },
+ { id: 'schedule', label: 'Schedule', icon: Calendar, badge: null, disabled: true },
+ { id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
]
const BOTTOM_ITEMS = [
- { id: 'notifications', label: 'Notifications', icon: '◉', badge: '5' },
- { id: 'settings', label: 'Settings', icon: '⚙', badge: null },
+ { id: 'notifications', label: 'Notifications', icon: Bell, disabled: true },
+ { id: 'settings', label: 'Settings', icon: Settings, badge: null, disabled: true },
]
/**
@@ -24,52 +32,64 @@ export default function Sidebar({ activePage, onNavigate }) {
)
}
+
diff --git a/src/components/TripTracker.jsx b/src/components/TripTracker.jsx
deleted file mode 100644
index c57b938..0000000
--- a/src/components/TripTracker.jsx
+++ /dev/null
@@ -1,383 +0,0 @@
-import React, { useState, useRef } from 'react'
-import MapGL, { Marker, Popup } from 'react-map-gl/maplibre'
-import 'maplibre-gl/dist/maplibre-gl.css'
-import countryCentroids from '../assets/countries.json'
-
-// ── Country centroid lookup ─────────────────────────────────────────────────────
-// Builds an ISO-2 → [longitude, latitude] map from the GeoJSON centroid dataset.
-const CENTROID_BY_ISO = new Map(
- countryCentroids.features.map((f) => [
- f.properties.ISO,
- f.geometry.coordinates, // [longitude, latitude]
- ])
-)
-
-// ── Mock patient data ──────────────────────────────────────────────────────────
-const ACTIVE_PATIENTS_RAW = [
- {
- id: 'P-001',
- name: 'Maria Santos',
- currentDestination: 'Bangkok, Thailand',
- countryISO: 'TH',
- currentStatus: 'in-transit',
- departureDate: '2026-07-10',
- returnDate: '2026-07-24',
- },
- {
- id: 'P-002',
- name: 'James Owusu',
- currentDestination: 'Accra, Ghana',
- countryISO: 'GH',
- currentStatus: 'at-risk',
- departureDate: '2026-07-08',
- returnDate: '2026-07-20',
- },
- {
- id: 'P-003',
- name: 'Anya Kowalski',
- currentDestination: 'Rome, Italy',
- countryISO: 'IT',
- currentStatus: 'pre-travel',
- departureDate: '2026-07-20',
- returnDate: '2026-07-30',
- },
- {
- id: 'P-004',
- name: 'Carlos Mendoza',
- currentDestination: 'Mexico City, Mexico',
- countryISO: 'MX',
- currentStatus: 'in-transit',
- departureDate: '2026-07-12',
- returnDate: '2026-07-22',
- },
- {
- id: 'P-005',
- name: 'Priya Nair',
- currentDestination: 'Mumbai, India',
- countryISO: 'IN',
- currentStatus: 'post-travel',
- departureDate: '2026-06-28',
- returnDate: '2026-07-10',
- },
- {
- id: 'P-006',
- name: 'David Okonkwo',
- currentDestination: 'Lagos, Nigeria',
- countryISO: 'NG',
- currentStatus: 'at-risk',
- departureDate: '2026-07-05',
- returnDate: '2026-07-19',
- },
- {
- id: 'P-007',
- name: 'Sofia Andersen',
- currentDestination: 'Copenhagen, Denmark',
- countryISO: 'DK',
- currentStatus: 'pre-travel',
- departureDate: '2026-07-22',
- returnDate: '2026-08-01',
- },
-]
-
-// Resolve centroid coordinates from the GeoJSON lookup map.
-// Falls back to [0, 0] (null island) if an ISO code is not found.
-const ACTIVE_PATIENTS = ACTIVE_PATIENTS_RAW.map((p) => ({
- ...p,
- coordinate: CENTROID_BY_ISO.get(p.countryISO) ?? [0, 0],
-}))
-
-// ── Status metadata ────────────────────────────────────────────────────────────
-const STATUS_META = {
- 'pre-travel': { label: 'Pre-Travel', colorVar: '--color-accent-violet', cssClass: 'status-pre-travel', markerClass: 'marker-violet' },
- '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' },
-}
-
-// ── Sub-component: PatientMarker ───────────────────────────────────────────────
-function PatientMarker({ patient, isSelected, onClick }) {
- const meta = STATUS_META[patient.currentStatus] || STATUS_META['pre-travel']
-
- return (
-
- )
-}
-
-// ── Sub-component: PatientPopup ────────────────────────────────────────────────
-function PatientPopup({ patient, onClose }) {
- const meta = STATUS_META[patient.currentStatus] || STATUS_META['pre-travel']
-
- const fmtDate = (iso) =>
- new Date(iso + 'T00:00:00').toLocaleDateString('en-US', {
- month: 'short', day: 'numeric', year: 'numeric',
- })
-
- return (
-
- )
-}
-
-// ── Main component: TripTracker ────────────────────────────────────────────────
-/**
- * TripTracker — full-screen locked world map showing active patient locations.
- * Uses react-map-gl/maplibre with the OpenFreeMap 'Fiord' tile style.
- */
-export default function TripTracker() {
- const [selectedPatientId, setSelectedPatientId] = useState(null)
- const mapRef = useRef(null)
-
- const selectedPatient = ACTIVE_PATIENTS.find((p) => p.id === selectedPatientId) || null
-
- function handleMarkerClick(patient) {
- const isSelecting = selectedPatientId !== patient.id
- setSelectedPatientId(isSelecting ? patient.id : null)
-
- if (isSelecting) {
- mapRef.current?.flyTo({
- center: patient.coordinate,
- zoom: 2.2, // Focus in at a constant zoom level
- essential: true,
- duration: 1000 // smooth 1s transition
- })
- }
- }
-
- function handlePopupClose() {
- setSelectedPatientId(null)
- }
-
- function handleMapLoad(evt) {
- const map = evt.target
- const style = map.getStyle()
- const activeCountryCodes = ['TH', 'GH', 'PL', 'MX', 'IN', 'NG', 'DK']
-
- if (style && style.layers) {
- for (const layer of style.layers) {
- if (layer.id === 'place_country_major' || layer.id === 'place_country_minor') {
- // Force English name translation
- map.setLayoutProperty(layer.id, 'text-field', [
- 'coalesce',
- ['get', 'name:en'],
- ['get', 'name'],
- ])
-
- // Filter by active country ISO-2 codes using 'match' (safe & widely supported)
- const originalFilter = map.getFilter(layer.id)
- if (originalFilter) {
- let newFilter
- const matchFilter = ['match', ['get', 'iso_a2'], activeCountryCodes, true, false]
- if (Array.isArray(originalFilter) && originalFilter[0] === 'all') {
- newFilter = [...originalFilter, matchFilter]
- } else {
- newFilter = ['all', originalFilter, matchFilter]
- }
- map.setFilter(layer.id, newFilter)
- }
- }
- // Hide all other symbol layers (continents, states, provinces, districts, cities, towns, roads, etc.)
- else if (layer.type === 'symbol') {
- map.setLayoutProperty(layer.id, 'visibility', 'none')
- }
- }
- }
- }
-
- const inTransitCount = ACTIVE_PATIENTS.filter((p) => p.currentStatus === 'in-transit').length
- const atRiskCount = ACTIVE_PATIENTS.filter((p) => p.currentStatus === 'at-risk').length
-
- return (
-
-
- {/* ── Top Panel: Map ─────────────────────────────────────────────────── */}
-
- {/* ── Top overlay header ────────────────────────────────────────────── */}
-
-
- {/* ── Map ──────────────────────────────────────────────────────────── */}
-
- {/* Patient markers */}
- {ACTIVE_PATIENTS.map((patient) => (
-
- handleMarkerClick(patient)}
- />
-
- ))}
-
- {/* Patient popup — mounts only when a marker is selected */}
- {selectedPatient && (
-
-
-
- )}
-
-
- {/* ── Legend ───────────────────────────────────────────────────────── */}
-
-
-
- {/* ── Bottom Panel: Active Itinerary Details ──────────────────────────── */}
-
-
-
Active Patient Itineraries
-
Real-time status logs of patient coordinates and destinations.
-
-
-
- {ACTIVE_PATIENTS.map((p) => {
- const meta = STATUS_META[p.currentStatus] || STATUS_META['pre-travel']
- const isSelected = selectedPatientId === p.id
- return (
-
handleMarkerClick(p)}
- style={{ cursor: 'pointer' }}
- >
-
-
- {p.id}
-
{p.name}
-
-
- {meta.label}
-
-
-
-
- Destination
- 📍 {p.currentDestination}
-
-
- Dates
-
- {new Date(p.departureDate + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – {new Date(p.returnDate + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
-
-
-
-
- )
- })}
-
-
-
-
- )
-}
-
diff --git a/src/index.css b/src/index.css
index a80053b..7b27bd3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1116,4 +1116,49 @@ button {
.log-detail-val {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
+}
+
+.destination-chips-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1);
+ margin-top: 2px;
+}
+
+.destination-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ border-radius: var(--radius-full);
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ color: var(--color-text-secondary);
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.destination-chip:hover {
+ background: rgba(59, 130, 246, 0.15);
+ border-color: rgba(59, 130, 246, 0.4);
+ color: #fff;
+ transform: translateY(-1px);
+}
+
+.destination-chip--selected {
+ background: var(--color-primary);
+ border-color: var(--color-primary-light);
+ color: #fff;
+ box-shadow: 0 0 8px var(--color-primary-glow);
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
}
\ No newline at end of file
diff --git a/src/lib/patientUtils.js b/src/lib/patientUtils.js
new file mode 100644
index 0000000..603b656
--- /dev/null
+++ b/src/lib/patientUtils.js
@@ -0,0 +1,81 @@
+/**
+ * Utility functions for calculating dynamic patient travel status.
+ */
+
+/**
+ * Calculates the dynamic travel status of a patient based on departure and arrival dates.
+ *
+ * Rules:
+ * 1. If patient status is explicitly 'at-risk', preserve 'at-risk'.
+ * 2. If departure or arrival date is missing, fallback to existing status or 'pre-travel'.
+ * 3. Day-based comparison against current date:
+ * - Before departure date -> 'pre-travel'
+ * - Between departure and arrival date (inclusive) -> 'in-transit'
+ * - After arrival date -> 'post-travel'
+ *
+ * @param {Object} patient - Patient record containing departure, arrival, and optional status
+ * @param {Date} [refDate=new Date()] - Reference date for comparison
+ * @returns {string} Calculated status ('at-risk' | 'pre-travel' | 'in-transit' | 'post-travel')
+ */
+export function calculatePatientStatus(patient, refDate = new Date()) {
+ if (!patient) return 'pre-travel'
+
+ // Priority override: operational 'at-risk' flag
+ if (patient.status === 'at-risk') {
+ return 'at-risk'
+ }
+
+ const departureStr = (patient.departure || '').slice(0, 10)
+ const arrivalStr = (patient.arrival || '').slice(0, 10)
+
+ if (!departureStr || !arrivalStr) {
+ return patient.status || 'pre-travel'
+ }
+
+ // Format reference date to YYYY-MM-DD in local time
+ const refYear = refDate.getFullYear()
+ const refMonth = String(refDate.getMonth() + 1).padStart(2, '0')
+ const refDay = String(refDate.getDate()).padStart(2, '0')
+ const todayStr = `${refYear}-${refMonth}-${refDay}`
+
+ if (todayStr < departureStr) {
+ return 'pre-travel'
+ }
+ if (todayStr >= departureStr && todayStr <= arrivalStr) {
+ return 'in-transit'
+ }
+ return 'post-travel'
+}
+
+/**
+ * Enriches an array of raw patient records with dynamically calculated statuses.
+ *
+ * @param {Array