From 08b753f89ec229edfc5b85663e6a872b9f1165e1 Mon Sep 17 00:00:00 2001 From: Bruno Silva Date: Fri, 24 Jul 2026 10:44:07 -0400 Subject: [PATCH] Replaced mock data with live database data; minoe aesthetic changes --- README.md | 4 +- electron/{main.js => main/index.js} | 15 +- electron/main/pocketbase-config.js | 4 + electron/main/pocketbase.js | 60 +++ electron/{preload.js => preload/index.js} | 9 + package-lock.json | 17 + package.json | 14 +- src/App.jsx | 6 +- src/assets/CTM_Concierge_Icon_SVG.svg | 9 + src/components/Dashboard.jsx | 279 +++++++----- src/components/LiveTracking.jsx | 523 ++++++++++++++++++++++ src/components/Sidebar.jsx | 109 +++-- src/components/TripTracker.jsx | 383 ---------------- src/index.css | 45 ++ src/lib/patientUtils.js | 81 ++++ src/lib/pocketbase.js | 13 + vite.config.js | 86 +++- 17 files changed, 1084 insertions(+), 573 deletions(-) rename electron/{main.js => main/index.js} (87%) create mode 100644 electron/main/pocketbase-config.js create mode 100644 electron/main/pocketbase.js rename electron/{preload.js => preload/index.js} (93%) create mode 100644 src/assets/CTM_Concierge_Icon_SVG.svg create mode 100644 src/components/LiveTracking.jsx delete mode 100644 src/components/TripTracker.jsx create mode 100644 src/lib/patientUtils.js create mode 100644 src/lib/pocketbase.js 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 ─────────────────────────────────────────────────────────── */} -
-

{getGreeting()}

-

Patient Trip Overview

-

{today}

+
+
+

{getGreeting()}

+

Patient Trip Overview

+

{today}

+
+ +
- {/* ── Stat Cards ─────────────────────────────────────────────────────── */} -
- {STATS.map((stat) => ( -
+
+ +

PocketBase Connection Error

+
+

+ {error} +

+
- ))} -
- - {/* ── Patient Journey Table ───────────────────────────────────────────── */} -
-

Live Patient Board

- View all → -
- -
- - - - - - - - - - - - - {PATIENTS.map((p) => { - const meta = STATUS_META[p.status] + Retry Connection + + + ) : ( + <> + {/* ── Stat Cards ─────────────────────────────────────────────────────── */} +
+ {stats.map((stat) => { + const Icon = stat.icon return ( -
- - - - - - - +
+
) })} - -
IDNameEmailDepartureArrivalStatus
{p.id}{p.name}{p.email}{p.departure}{p.arrival} - - {meta.label} - -
-
+ + + {/* ── Patient Journey Table ───────────────────────────────────────────── */} +
+

+ + Live Patient Board +

+
+ +
+ + + + + + + + + + + + + {travellers.length === 0 && !loading ? ( + + + + ) : ( + travellers.map((p) => { + const meta = STATUS_META[p.status] || STATUS_META['pre-travel'] + return ( + + + + + + + + + ) + }) + )} + +
IDNameLocationDepartureArrivalStatus
+ No travellers found in PocketBase database. +
{p.id}{p.name}{p.location || 'N/A'}{new Date(p.departure).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}{new Date(p.arrival).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} + + {meta.label} + +
+
+ + )}
) } + + 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 ────────────────────────────────────────────── */} +
+
+

LIVE TRACKING

+

Patient World Map

+
+
+
+ + {inTransitCount} In Transit +
+
+ + {atRiskCount} At Risk +
+
+ {travellers.length} Total Active +
+ +
+
+ + {/* ── 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 ───────────────────────────────────────────────────────── */} +
+ {Object.entries(STATUS_META).map(([key, meta]) => ( +
+
+ ))} +
+
+ + {/* ── 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 ────────────────────────────────────────────── */} -
-
-

LIVE TRACKING

-

Patient World Map

-
-
-
- - {inTransitCount} In Transit -
-
- - {atRiskCount} At Risk -
-
- {ACTIVE_PATIENTS.length} Total Active -
-
-
- - {/* ── Map ──────────────────────────────────────────────────────────── */} - - {/* Patient markers */} - {ACTIVE_PATIENTS.map((patient) => ( - - handleMarkerClick(patient)} - /> - - ))} - - {/* Patient popup — mounts only when a marker is selected */} - {selectedPatient && ( - - - - )} - - - {/* ── Legend ───────────────────────────────────────────────────────── */} -
- {Object.entries(STATUS_META).map(([key, meta]) => ( -
-
- ))} -
-
- - {/* ── 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} records - Raw patient records from backend + * @param {Date} [refDate=new Date()] - Reference date + * @returns {Array} Enriched patient records + */ +export function processPatientRecords(records = [], refDate = new Date()) { + return records.map((record) => ({ + ...record, + status: calculatePatientStatus(record, refDate), + })) +} + +/** + * Extracts a list of destination country ISO codes from a patient object. + * Checks countriesISO array first, falling back to countryISO. + * + * @param {Object} patient + * @returns {string[]} Array of country ISO codes + */ +export function getPatientISOs(patient) { + if (!patient) return [] + if (Array.isArray(patient.countriesISO) && patient.countriesISO.length > 0) { + return patient.countriesISO + } + if (typeof patient.countriesISO === 'string' && patient.countriesISO.trim()) { + return patient.countriesISO.split(',').map((s) => s.trim()) + } + return patient.countryISO ? [patient.countryISO] : [] +} + diff --git a/src/lib/pocketbase.js b/src/lib/pocketbase.js new file mode 100644 index 0000000..dccec68 --- /dev/null +++ b/src/lib/pocketbase.js @@ -0,0 +1,13 @@ +import { processPatientRecords } from './patientUtils.js' + +function getPbApi() { + if (!window.api?.pb) { + throw new Error('PocketBase IPC bridge unavailable. Please launch inside CTM Concierge desktop app.') + } + return window.api.pb +} + +export async function fetchEntries() { + const data = await getPbApi().fetchEntries() + return processPatientRecords(data) +} diff --git a/vite.config.js b/vite.config.js index 9ace378..0745a59 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,23 +1,73 @@ +import { rmSync } from 'node:fs' +import path from 'node:path' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import electron from 'vite-plugin-electron/simple' +import pkg from './package.json' -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [ - react(), - electron({ - main: { - // Main process entry point — compiled to dist-electron/main.js - entry: 'electron/main.js', - }, - preload: { - // Preload script entry point — compiled to dist-electron/preload.js - input: 'electron/preload.js', - }, - // Activates vite-plugin-electron-renderer: - // polyfills Node built-ins (path, fs, etc.) that are used in renderer - renderer: {}, - }), - ], +export default defineConfig(({ command }) => { + // Clean compiled Electron output on each build to avoid stale artefacts + rmSync('dist-electron', { recursive: true, force: true }) + + const isServe = command === 'serve' + const isBuild = command === 'build' + const sourcemap = isServe || !!process.env.VSCODE_DEBUG + + return { + resolve: { + alias: { '@': path.join(__dirname, 'src') }, + }, + plugins: [ + react(), + electron({ + main: { + // Main process entry — compiled to dist-electron/main/index.js + entry: 'electron/main/index.js', + onstart(args) { + if (process.env.VSCODE_DEBUG) { + console.log('[startup] Electron App') + } else { + args.startup() + } + }, + vite: { + build: { + sourcemap, + minify: isBuild, + outDir: 'dist-electron/main', + rollupOptions: { + // Keeps runtime dependencies out of the main bundle + external: Object.keys(pkg.dependencies ?? {}), + }, + }, + }, + }, + preload: { + // Preload script — compiled to dist-electron/preload/index.cjs + // Explicit CJS output is required: root package.json has "type":"module", + // which makes Node.js treat .js and .mjs as ESM; .cjs is always CJS. + input: 'electron/preload/index.js', + vite: { + build: { + sourcemap: sourcemap ? 'inline' : undefined, + minify: isBuild, + outDir: 'dist-electron/preload', + rollupOptions: { + external: Object.keys(pkg.dependencies ?? {}), + output: { + format: 'cjs', + entryFileNames: '[name].cjs', + }, + }, + }, + }, + }, + // Polyfills Node built-ins used in the renderer process + renderer: {}, + }), + ], + clearScreen: false, + } }) + +