Replaced mock data with live database data; minoe aesthetic changes
This commit is contained in:
@@ -36,7 +36,7 @@ graph TD
|
|||||||
│ ├── components/ # Reusable React UI components
|
│ ├── components/ # Reusable React UI components
|
||||||
│ │ ├── Dashboard.jsx # Metrics cards and Live Patient Board list
|
│ │ ├── Dashboard.jsx # Metrics cards and Live Patient Board list
|
||||||
│ │ ├── Sidebar.jsx # Navigation bar and user/physician profile context
|
│ │ ├── 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
|
│ ├── App.jsx # App root layout shell & page navigation controller
|
||||||
│ ├── index.css # CSS styles, layout grid, CSS custom properties (variables)
|
│ ├── index.css # CSS styles, layout grid, CSS custom properties (variables)
|
||||||
│ └── main.jsx # React application entry point
|
│ └── main.jsx # React application entry point
|
||||||
@@ -49,7 +49,7 @@ graph TD
|
|||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
### 1. Interactive Patient World Map
|
### 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`).
|
- **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.
|
- **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.
|
- **English Label Translation**: Coerces country names on the map to display in English.
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { fetchEntries } from './pocketbase.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
// ─── Paths ──────────────────────────────────────────────────────────────────
|
// ─── Paths ──────────────────────────────────────────────────────────────────
|
||||||
// dist-electron/main.js is the compiled output location.
|
// Compiled main lives in dist-electron/main/ — go up two levels to reach dist/
|
||||||
// dist/ is the Vite renderer build output.
|
const RENDERER_DIST = path.join(__dirname, '../../dist')
|
||||||
const RENDERER_DIST = path.join(__dirname, '../dist')
|
// Preload is compiled to dist-electron/preload/index.cjs (explicitly CJS)
|
||||||
// vite-plugin-electron outputs preload as .mjs when "type":"module" is set in package.json
|
const PRELOAD_PATH = path.join(__dirname, '../preload/index.cjs')
|
||||||
const PRELOAD_PATH = path.join(__dirname, 'preload.mjs')
|
|
||||||
|
|
||||||
// ─── Dev server URL ─────────────────────────────────────────────────────────
|
// ─── Dev server URL ─────────────────────────────────────────────────────────
|
||||||
// vite-plugin-electron sets this env var when running in development mode.
|
// vite-plugin-electron sets this env var when running in development mode.
|
||||||
@@ -31,7 +31,7 @@ function createWindow() {
|
|||||||
// ── Security defaults ──────────────────────────────────────────────
|
// ── Security defaults ──────────────────────────────────────────────
|
||||||
contextIsolation: true, // Isolates renderer from Electron internals
|
contextIsolation: true, // Isolates renderer from Electron internals
|
||||||
nodeIntegration: false, // Renderer cannot access Node.js APIs directly
|
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,
|
webSecurity: true,
|
||||||
// ── Preload ────────────────────────────────────────────────────────
|
// ── Preload ────────────────────────────────────────────────────────
|
||||||
preload: PRELOAD_PATH,
|
preload: PRELOAD_PATH,
|
||||||
@@ -87,3 +87,6 @@ app.on('window-all-closed', () => {
|
|||||||
// Example: ipcMain.handle('channel-name', async (event, ...args) => { ... })
|
// Example: ipcMain.handle('channel-name', async (event, ...args) => { ... })
|
||||||
|
|
||||||
ipcMain.handle('app:get-version', () => app.getVersion())
|
ipcMain.handle('app:get-version', () => app.getVersion())
|
||||||
|
ipcMain.handle('pb:fetch-entries', async () => {
|
||||||
|
return await fetchEntries()
|
||||||
|
})
|
||||||
@@ -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!!__'
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'patient:fetch-list',
|
'patient:fetch-list',
|
||||||
'schedule:fetch',
|
'schedule:fetch',
|
||||||
'app:ready',
|
'app:ready',
|
||||||
|
'pb:fetch-entries',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
@@ -22,10 +23,18 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'patient:list-response',
|
'patient:list-response',
|
||||||
'schedule:response',
|
'schedule:response',
|
||||||
'app:get-version',
|
'app:get-version',
|
||||||
|
'pb:fetch-entries',
|
||||||
]
|
]
|
||||||
|
|
||||||
// ─── Exposed API ──────────────────────────────────────────────────────────
|
// ─── Exposed API ──────────────────────────────────────────────────────────
|
||||||
contextBridge.exposeInMainWorld('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.
|
* Send a one-way message to the main process.
|
||||||
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
|
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
|
||||||
Generated
+17
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.3.0",
|
||||||
"maplibre-gl": "^5.24.0",
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"pocketbase": "^0.27.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-map-gl": "^8.1.1"
|
"react-map-gl": "^8.1.1"
|
||||||
@@ -5207,6 +5209,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/make-fetch-happen": {
|
||||||
"version": "10.2.1",
|
"version": "10.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz",
|
||||||
@@ -5975,6 +5986,12 @@
|
|||||||
"node": ">=10.4.0"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.19",
|
"version": "8.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||||
|
|||||||
+8
-6
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "ctm-concierge",
|
"name": "ctm-concierge",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"description": "Internal real-time patient journey tracker for CTM medical clinic",
|
"description": "CTM Concierge - Real-time patient journey tracker for CTM",
|
||||||
"author": "CTM Internal",
|
"author": "Wirediv",
|
||||||
"license": "UNLICENSED",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.3.0",
|
||||||
"maplibre-gl": "^5.24.0",
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"pocketbase": "^0.27.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-map-gl": "^8.1.1"
|
"react-map-gl": "^8.1.1"
|
||||||
@@ -52,4 +54,4 @@
|
|||||||
"icon": "public/icon.icns"
|
"icon": "public/icon.icns"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Sidebar from './components/Sidebar.jsx'
|
import Sidebar from './components/Sidebar.jsx'
|
||||||
import Dashboard from './components/Dashboard.jsx'
|
import Dashboard from './components/Dashboard.jsx'
|
||||||
import TripTracker from './components/TripTracker.jsx'
|
import LiveTracking from './components/LiveTracking.jsx'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App — root layout component.
|
* App — root layout component.
|
||||||
@@ -13,8 +13,8 @@ export default function App() {
|
|||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
switch (activePage) {
|
switch (activePage) {
|
||||||
case 'trip-tracker': return <TripTracker />
|
case 'live-tracking': return <LiveTracking />
|
||||||
default: return <Dashboard />
|
default: return <Dashboard />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 741 741" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g id="Background" transform="matrix(1,0,0,1,0,67)">
|
||||||
|
<path d="M357.525,585.96C346.364,586.744 346.345,585.899 288.485,589.256C278.012,589.864 213.933,594.81 195.505,596.555C192.872,596.804 164.06,599.532 162.559,599.268C158.629,598.576 161.751,596.329 184.064,554.267C191.752,539.774 191.872,539.837 192.136,538.44C192.717,535.371 184.567,531.206 175.593,524.374C175.12,524.014 95.801,466.867 94.323,465.746C83.151,457.274 82.751,457.868 71.589,449.379C70.338,448.428 43.488,429.189 40.299,426.78C29.423,418.567 29.127,419.053 18.239,410.859C11.359,405.682 11.096,406.098 4.261,400.818C2.11,399.157 1.315,398.195 3.728,396.906C24.663,385.716 24.702,385.868 45.583,374.647C50.51,372 52.428,371.461 51.192,367.593C48.18,358.167 47.669,358.354 8.179,251.622C4.945,242.882 2.461,238.89 6.497,239.522C11.552,240.315 106.102,261.252 108.417,260.271C111.767,258.851 134.647,225.534 140.656,217.613C145.438,211.308 146.228,207.816 150.745,212.25C262.084,321.552 262.167,321.637 271.647,331.357C276.078,335.901 276.668,336.912 277.421,335.469C277.603,335.12 248.728,233.885 236.054,187.623C234.208,180.882 222.712,138.92 212.464,103.51C208.708,90.531 208.558,90.154 209.489,89.483C210.727,88.59 210.686,89.911 257.337,114.805C290.785,132.654 290.78,133.387 292.639,132.827C293.999,132.418 301.156,119.055 306.75,109.643C307.745,107.968 351.39,33.103 351.696,32.619C354.945,27.483 367.959,3.855 369.698,2.946C371.698,1.901 375.332,10.034 385.24,26.649C397,46.37 410.784,70.398 412.976,74.219C414.546,76.956 414.732,76.828 432.961,108.225C438.952,118.543 439.119,118.413 445.066,128.751C447.051,132.202 447.456,133.995 451.66,131.819C459.75,127.632 527.47,90.921 528.44,90.38C528.658,90.258 530.878,89.021 531.448,89.552C532.191,90.244 529.608,97.329 528.301,102.446C528.146,103.052 464.882,329.855 464.273,331.428C464.153,331.739 462.888,335.009 463.482,335.519C464.697,336.563 466.766,333.512 471.651,328.652C493.491,306.919 553.658,247.823 560.801,240.806C589.096,213.015 588.968,212.874 591.714,210.791C594.765,208.476 596.654,213.632 602.651,221.389C607.805,228.054 629.308,258.736 631.7,260.127C633.392,261.111 633.589,260.694 679.415,251.107C708.886,244.941 734.333,239.221 735.382,239.797C737.62,241.026 735.934,241.861 726.32,268.435C723.411,276.475 696.659,348.393 694.073,355.344C689.233,368.357 688.177,370.198 690.718,372.22C691.692,372.994 693.52,374.019 721.352,388.778C737.034,397.094 737.299,396.916 737.887,398.326C738.889,400.731 733.058,402.953 725.604,408.644C725.107,409.023 616.759,487.196 616.44,487.419C614.654,488.661 602.871,496.859 594.358,503.304C589.25,507.171 550.308,534.304 548.672,536.609C547.196,538.689 548.613,538.988 573.215,585.65C578.958,596.543 579.261,596.449 579.326,597.515C579.494,600.28 574.475,598.872 562.546,598.074C555.703,597.615 499.949,592.876 494.506,592.413C484.682,591.578 445.681,588.886 428.525,588.115C392.457,586.494 392.51,585.132 357.525,585.96Z" style="fill:rgb(17,38,73);fill-opacity:0.99;"/>
|
||||||
|
<path d="M367.5,495.965C265.198,495.965 265.172,496.087 263.467,495.623C256.923,493.843 252.807,481.727 263.639,474.736C271.744,469.505 265.632,463.851 275.293,436.425C286.358,405.015 320.334,387.452 344.419,383.069C352.922,381.522 352.896,381.484 361.515,380.659C366.712,380.162 365.641,375.442 361.531,375.17C358.459,374.967 358.279,375.565 356.57,374.386C355.373,373.561 350.11,367.032 360.599,360.666C360.858,360.509 366.121,358.01 364.283,355.67C362.157,352.964 341.77,359.22 341.956,344.522C342.088,334.196 357.178,335.93 370.502,335.897C388.613,335.853 398.812,334.586 399.045,345.492C399.242,354.728 387.749,354.795 386.5,354.802C385.857,354.806 373.526,353.766 376.819,358.258C378.21,360.154 388.667,363.44 386.402,371.472C384.469,378.325 374.806,372.679 375.956,378.428C376.662,381.958 383.286,379.659 400.403,383.891C436.32,392.772 452.41,414.105 452.643,414.385C462.943,426.755 466.349,436.094 471.421,456.52C473.204,463.703 471.313,470.761 477.773,475.095C477.865,475.157 485.408,478.287 483.89,488.556C483.752,489.495 482.336,495.336 477.545,495.862C477.241,495.895 477.258,495.956 423.5,495.957C404.833,495.959 386.167,495.962 367.5,495.965Z" style="fill:rgb(254,254,254);"/>
|
||||||
|
<path d="M355.5,548.966C251.484,548.966 251.519,548.642 242.488,549.07C237.578,549.303 238.065,547.44 237.966,542.508C237.79,533.728 242.599,536.066 252.246,523.316C258.748,514.723 254.332,507.127 264.5,507.072C265.058,507.069 470.521,507.02 474.5,507.02C482.051,507.02 482.886,507.756 485.295,515.561C490.692,533.051 502.753,531.644 502.921,540.483C503.042,546.828 503.813,548.929 498.503,548.948C450.836,548.954 403.168,548.96 355.5,548.966Z" style="fill:rgb(253,253,253);"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
+168
-111
@@ -1,53 +1,13 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
// ── Stat card data ────────────────────────────────────────────────────────────
|
PlaneTakeoff,
|
||||||
const STATS = [
|
Plane,
|
||||||
{
|
AlertTriangle,
|
||||||
id: 'pre-travel',
|
CheckCircle2,
|
||||||
icon: '♥',
|
Activity,
|
||||||
value: '24',
|
RotateCw,
|
||||||
label: 'In Pre-Travel',
|
} from 'lucide-react'
|
||||||
delta: '+3',
|
import { fetchEntries } from '../lib/pocketbase.js'
|
||||||
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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const STATUS_META = {
|
const STATUS_META = {
|
||||||
'in-transit': { label: 'In Transit', className: 'status-in-transit' },
|
'in-transit': { label: 'In Transit', className: 'status-in-transit' },
|
||||||
@@ -65,84 +25,181 @@ function getGreeting() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard — main content viewport for CTM Concierge.
|
* 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() {
|
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', {
|
const today = new Date().toLocaleDateString('en-US', {
|
||||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
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 (
|
return (
|
||||||
<section className="dashboard" aria-label="Dashboard">
|
<section className="dashboard" aria-label="Dashboard">
|
||||||
|
|
||||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||||
<header className="dashboard-header">
|
<header className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<p className="dashboard-greeting">{getGreeting()}</p>
|
<div>
|
||||||
<h1 className="dashboard-title">Patient Trip Overview</h1>
|
<p className="dashboard-greeting">{getGreeting()}</p>
|
||||||
<p className="dashboard-subtitle">{today}</p>
|
<h1 className="dashboard-title">Patient Trip Overview</h1>
|
||||||
|
<p className="dashboard-subtitle">{today}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'var(--color-bg-subtle)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
cursor: loading ? 'wait' : 'pointer',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: '600',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCw size={14} style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }} />
|
||||||
|
{loading ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Stat Cards ─────────────────────────────────────────────────────── */}
|
{/* ── Error Banner ────────────────────────────────────────────────────── */}
|
||||||
<div className="stat-grid" role="list" aria-label="Today's statistics">
|
{error ? (
|
||||||
{STATS.map((stat) => (
|
<div style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '8px', padding: '20px', margin: '20px 0', color: '#f87171' }}>
|
||||||
<article
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||||
key={stat.id}
|
<AlertTriangle size={22} style={{ color: '#ef4444' }} />
|
||||||
id={`stat-${stat.id}`}
|
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 700, color: '#fca5a5' }}>PocketBase Connection Error</h3>
|
||||||
className={`stat-card accent-${stat.accent}`}
|
</div>
|
||||||
role="listitem"
|
<p style={{ margin: '0 0 14px 0', fontSize: '14px', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="stat-card-icon" aria-hidden="true">{stat.icon}</span>
|
Retry Connection
|
||||||
<div className="stat-card-value">{stat.value}</div>
|
</button>
|
||||||
<div className="stat-card-label">{stat.label}</div>
|
</div>
|
||||||
{stat.delta && (
|
) : (
|
||||||
<span className={`stat-card-delta delta-${stat.deltaType}`} aria-label={`Change: ${stat.delta}`}>
|
<>
|
||||||
{stat.delta}
|
{/* ── Stat Cards ─────────────────────────────────────────────────────── */}
|
||||||
</span>
|
<div className="stat-grid" role="list" aria-label="Today's statistics">
|
||||||
)}
|
{stats.map((stat) => {
|
||||||
</article>
|
const Icon = stat.icon
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Patient Journey Table ───────────────────────────────────────────── */}
|
|
||||||
<div className="section-heading">
|
|
||||||
<h2 className="section-title">Live Patient Board</h2>
|
|
||||||
<span className="section-action" role="button" tabIndex={0}>View all →</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="journey-panel">
|
|
||||||
<table className="journey-table" aria-label="Live patient journey board">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">ID</th>
|
|
||||||
<th scope="col">Name</th>
|
|
||||||
<th scope="col">Email</th>
|
|
||||||
<th scope="col">Departure</th>
|
|
||||||
<th scope="col">Arrival</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{PATIENTS.map((p) => {
|
|
||||||
const meta = STATUS_META[p.status]
|
|
||||||
return (
|
return (
|
||||||
<tr key={p.id} id={`patient-row-${p.id}`}>
|
<article
|
||||||
<td>{p.id}</td>
|
key={stat.id}
|
||||||
<td className="patient-name">{p.name}</td>
|
id={`stat-${stat.id}`}
|
||||||
<td>{p.email}</td>
|
className={`stat-card accent-${stat.accent}`}
|
||||||
<td>{p.departure}</td>
|
role="listitem"
|
||||||
<td>{p.arrival}</td>
|
>
|
||||||
<td>
|
<Icon className="stat-card-icon" aria-hidden="true" size={24} />
|
||||||
<span className={`status-pill ${meta.className}`}>
|
<div className="stat-card-value">{loading ? '...' : stat.value}</div>
|
||||||
{meta.label}
|
<div className="stat-card-label">{stat.label}</div>
|
||||||
</span>
|
</article>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
{/* ── Patient Journey Table ───────────────────────────────────────────── */}
|
||||||
|
<div className="section-heading">
|
||||||
|
<h2 className="section-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Activity size={20} className="section-title-icon" style={{ color: 'var(--color-primary)' }} />
|
||||||
|
Live Patient Board
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="journey-panel">
|
||||||
|
<table className="journey-table" aria-label="Live patient journey board">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col">Location</th>
|
||||||
|
<th scope="col">Departure</th>
|
||||||
|
<th scope="col">Arrival</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{travellers.length === 0 && !loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} style={{ textAlign: 'center', padding: '24px', color: 'var(--color-text-muted)' }}>
|
||||||
|
No travellers found in PocketBase database.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
travellers.map((p) => {
|
||||||
|
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
|
||||||
|
return (
|
||||||
|
<tr key={p.id} id={`patient-row-${p.id}`}>
|
||||||
|
<td>{p.id}</td>
|
||||||
|
<td className="patient-name">{p.name}</td>
|
||||||
|
<td>{p.location || 'N/A'}</td>
|
||||||
|
<td>{new Date(p.departure).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}</td>
|
||||||
|
<td>{new Date(p.arrival).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-pill ${meta.className}`}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
id={`marker-${patient.id}`}
|
||||||
|
className={`map-marker-btn${isSelected ? ' map-marker-btn--selected' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={`${patient.name} — ${meta.label}`}
|
||||||
|
title={patient.name}
|
||||||
|
>
|
||||||
|
<span className={`map-marker-ring ${meta.markerClass}-ring`} aria-hidden="true" />
|
||||||
|
<span className={`map-marker-dot ${meta.markerClass}-dot`} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="patient-popup" id={`popup-${patient.id}`} role="dialog" aria-label={`Details for ${patient.name}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="patient-popup-header">
|
||||||
|
<div className="patient-popup-avatar" aria-hidden="true">
|
||||||
|
{patient.name ? patient.name.split(' ').map((n) => n[0]).join('') : 'P'}
|
||||||
|
</div>
|
||||||
|
<div className="patient-popup-identity">
|
||||||
|
<h3 className="patient-popup-name">{patient.name}</h3>
|
||||||
|
<p className="patient-popup-id">{patient.id}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="popup-close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close patient popup"
|
||||||
|
id={`popup-close-${patient.id}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="patient-popup-body">
|
||||||
|
<div className="popup-field">
|
||||||
|
<span className="popup-field-label">Destination</span>
|
||||||
|
<span className="popup-field-value">📍 {destinationText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="popup-field">
|
||||||
|
<span className="popup-field-label">Departure</span>
|
||||||
|
<span className="popup-field-value">{formatDate(patient.departure)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="popup-field">
|
||||||
|
<span className="popup-field-label">Return</span>
|
||||||
|
<span className="popup-field-value">{formatDate(patient.arrival)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="popup-field">
|
||||||
|
<span className="popup-field-label">Status</span>
|
||||||
|
<span className={`status-pill ${meta.cssClass}`}>{meta.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<section className="trip-tracker" aria-label="Trip Tracker — World Map">
|
||||||
|
|
||||||
|
{/* ── Top Panel: Map ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="trip-tracker-map-panel" style={{ position: 'relative' }}>
|
||||||
|
{/* ── Top overlay header ────────────────────────────────────────────── */}
|
||||||
|
<header className="trip-tracker-header">
|
||||||
|
<div className="trip-tracker-header-left">
|
||||||
|
<p className="trip-tracker-label">LIVE TRACKING</p>
|
||||||
|
<h1 className="trip-tracker-title">Patient World Map</h1>
|
||||||
|
</div>
|
||||||
|
<div className="trip-tracker-chips" style={{ alignItems: 'center' }}>
|
||||||
|
<div className="tracker-chip tracker-chip--blue">
|
||||||
|
<span className="tracker-chip-dot" />
|
||||||
|
<span>{inTransitCount} In Transit</span>
|
||||||
|
</div>
|
||||||
|
<div className="tracker-chip tracker-chip--amber">
|
||||||
|
<span className="tracker-chip-dot" />
|
||||||
|
<span>{atRiskCount} At Risk</span>
|
||||||
|
</div>
|
||||||
|
<div className="tracker-chip tracker-chip--default">
|
||||||
|
<span>{travellers.length} Total Active</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: loading ? 'wait' : 'pointer',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCw size={14} style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }} />
|
||||||
|
{loading ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Connection Error Banner Overlay ───────────────────────────────── */}
|
||||||
|
{error ? (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '80px',
|
||||||
|
left: '24px',
|
||||||
|
right: '24px',
|
||||||
|
zIndex: 10,
|
||||||
|
background: 'rgba(15, 17, 23, 0.92)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
color: '#f87171',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||||
|
<AlertTriangle size={24} style={{ color: '#ef4444' }} />
|
||||||
|
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 700, color: '#fca5a5' }}>PocketBase Connection Error</h3>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0 0 16px 0', fontSize: '14px', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: '#ef4444',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Map ──────────────────────────────────────────────────────────── */}
|
||||||
|
<MapGL
|
||||||
|
ref={mapRef}
|
||||||
|
id="trip-tracker-map"
|
||||||
|
initialViewState={{
|
||||||
|
latitude: 30,
|
||||||
|
longitude: 30,
|
||||||
|
zoom: 2,
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
mapStyle="https://tiles.openfreemap.org/styles/fiord"
|
||||||
|
renderWorldCopies={false}
|
||||||
|
minZoom={0.5}
|
||||||
|
maxZoom={2.8}
|
||||||
|
onLoad={handleMapLoad}
|
||||||
|
dragPan={true}
|
||||||
|
scrollZoom={true}
|
||||||
|
boxZoom={false}
|
||||||
|
doubleClickZoom={false}
|
||||||
|
touchZoomRotate={false}
|
||||||
|
dragRotate={false}
|
||||||
|
keyboard={false}
|
||||||
|
interactive={true}
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
{/* Patient markers */}
|
||||||
|
{!error && patientMarkers.map((m) => (
|
||||||
|
<Marker
|
||||||
|
key={m.key}
|
||||||
|
longitude={m.coordinate[0]}
|
||||||
|
latitude={m.coordinate[1]}
|
||||||
|
anchor="center"
|
||||||
|
>
|
||||||
|
<PatientMarker
|
||||||
|
patient={m.patient}
|
||||||
|
isSelected={selectedMarkerKey === m.key}
|
||||||
|
onClick={() => handleMarkerClick(m)}
|
||||||
|
/>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Patient popup — mounts only when a marker is selected */}
|
||||||
|
{!error && selectedMarker && (
|
||||||
|
<Popup
|
||||||
|
key={selectedMarker.key}
|
||||||
|
longitude={selectedMarker.coordinate[0]}
|
||||||
|
latitude={selectedMarker.coordinate[1]}
|
||||||
|
anchor="bottom"
|
||||||
|
offset={20}
|
||||||
|
closeButton={false}
|
||||||
|
closeOnClick={false}
|
||||||
|
className="trip-tracker-popup-wrapper"
|
||||||
|
>
|
||||||
|
<PatientPopup
|
||||||
|
patient={selectedMarker.patient}
|
||||||
|
onClose={handlePopupClose}
|
||||||
|
/>
|
||||||
|
</Popup>
|
||||||
|
)}
|
||||||
|
</MapGL>
|
||||||
|
|
||||||
|
{/* ── Legend ───────────────────────────────────────────────────────── */}
|
||||||
|
<footer className="trip-tracker-legend" aria-label="Map legend">
|
||||||
|
{Object.entries(STATUS_META).map(([key, meta]) => (
|
||||||
|
<div key={key} className="legend-item">
|
||||||
|
<span className={`legend-dot ${meta.markerClass}-dot`} aria-hidden="true" />
|
||||||
|
<span className="legend-label">{meta.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bottom Panel: Active Itinerary Details ──────────────────────────── */}
|
||||||
|
<div className="trip-tracker-bottom-panel">
|
||||||
|
<div className="bottom-panel-header">
|
||||||
|
<h2 className="bottom-panel-title">Active Patient Itineraries</h2>
|
||||||
|
<p className="bottom-panel-subtitle">Real-time status logs of patient coordinates and destinations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!error && (
|
||||||
|
<div className="patient-log-grid">
|
||||||
|
{travellers.length === 0 && !loading ? (
|
||||||
|
<p style={{ color: 'var(--color-text-muted)', gridColumn: '1 / -1' }}>No patient itineraries found.</p>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={`patient-log-card${isSelected ? ' patient-log-card--selected' : ''}`}
|
||||||
|
onClick={!isMultiDestination ? () => handleCardClick(p) : undefined}
|
||||||
|
style={{ cursor: !isMultiDestination ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
<div className="log-card-top">
|
||||||
|
<div className="log-card-left">
|
||||||
|
<span className="log-patient-id">{p.id}</span>
|
||||||
|
<h3 className="log-patient-name">{p.name}</h3>
|
||||||
|
</div>
|
||||||
|
<span className={`status-pill ${meta.cssClass}`}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="log-card-details">
|
||||||
|
<div
|
||||||
|
className="log-detail-item"
|
||||||
|
style={{
|
||||||
|
flexDirection: isMultiDestination ? 'column' : 'row',
|
||||||
|
alignItems: isMultiDestination ? 'flex-start' : 'center',
|
||||||
|
gap: isMultiDestination ? '4px' : '0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="log-detail-label">
|
||||||
|
{isMultiDestination ? 'Destinations' : 'Destination'}
|
||||||
|
</span>
|
||||||
|
{isMultiDestination ? (
|
||||||
|
<div className="destination-chips-container">
|
||||||
|
{isos.map((iso) => {
|
||||||
|
const chipKey = `${p.id}-${iso}`
|
||||||
|
const isChipSelected = selectedMarkerKey === chipKey
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={iso}
|
||||||
|
type="button"
|
||||||
|
className={`destination-chip${isChipSelected ? ' destination-chip--selected' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDestinationChipClick(p, iso)
|
||||||
|
}}
|
||||||
|
title={`Fly to ${iso}`}
|
||||||
|
>
|
||||||
|
📍 {iso}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="log-detail-val">
|
||||||
|
📍 {p.location || isos[0] || 'N/A'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="log-detail-item">
|
||||||
|
<span className="log-detail-label">Dates</span>
|
||||||
|
<span className="log-detail-val">
|
||||||
|
{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'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+65
-44
@@ -1,17 +1,25 @@
|
|||||||
import React from 'react'
|
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 = [
|
const NAV_ITEMS = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: '⬡', badge: null },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, badge: null },
|
||||||
{ id: 'trip-tracker', label: 'Trip Tracker', icon: '♥', badge: '3' },
|
{ id: 'live-tracking', label: 'Live Tracking', icon: MapPin, badge: '3' },
|
||||||
{ id: 'schedule', label: 'Schedule', icon: '◷', badge: null },
|
{ id: 'schedule', label: 'Schedule', icon: Calendar, badge: null, disabled: true },
|
||||||
{ id: 'providers', label: 'Providers', icon: '✦', badge: null },
|
{ id: 'reports', label: 'Reports', icon: BarChart3, badge: null, disabled: true },
|
||||||
{ id: 'rooms', label: 'Rooms', icon: '▣', badge: null },
|
|
||||||
{ id: 'reports', label: 'Reports', icon: '◈', badge: null },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const BOTTOM_ITEMS = [
|
const BOTTOM_ITEMS = [
|
||||||
{ id: 'notifications', label: 'Notifications', icon: '◉', badge: '5' },
|
{ id: 'notifications', label: 'Notifications', icon: Bell, disabled: true },
|
||||||
{ id: 'settings', label: 'Settings', icon: '⚙', badge: null },
|
{ id: 'settings', label: 'Settings', icon: Settings, badge: null, disabled: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,52 +32,64 @@ export default function Sidebar({ activePage, onNavigate }) {
|
|||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
{/* Logo / App identity */}
|
{/* Logo / App identity */}
|
||||||
<div className="sidebar-logo">
|
<div className="sidebar-logo">
|
||||||
<div className="sidebar-logo-icon" aria-hidden="true">✦</div>
|
<div className="sidebar-logo-icon" aria-hidden="true">
|
||||||
|
<img src={AppLogo} alt="CTM Concierge" style={{ width: '25px', height: '25px' }} />
|
||||||
|
</div>
|
||||||
<div className="sidebar-logo-text">
|
<div className="sidebar-logo-text">
|
||||||
<span className="sidebar-logo-name">CTM Concierge</span>
|
<span className="sidebar-logo-name">CTM Concierge</span>
|
||||||
<span className="sidebar-logo-tagline">Trip Tracker</span>
|
<span className="sidebar-logo-tagline">Trip Monitor</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary navigation */}
|
{/* Primary navigation */}
|
||||||
<nav className="sidebar-nav" aria-label="Primary navigation">
|
<nav className="sidebar-nav" aria-label="Primary navigation">
|
||||||
<span className="sidebar-section-label">Main</span>
|
<span className="sidebar-section-label">Main</span>
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => {
|
||||||
<button
|
const Icon = item.icon
|
||||||
key={item.id}
|
return (
|
||||||
id={`nav-${item.id}`}
|
<button
|
||||||
className={`nav-item${activePage === item.id ? ' active' : ''}`}
|
key={item.id}
|
||||||
onClick={() => onNavigate(item.id)}
|
id={`nav-${item.id}`}
|
||||||
aria-current={activePage === item.id ? 'page' : undefined}
|
className={`nav-item${activePage === item.id ? ' active' : ''}`}
|
||||||
>
|
onClick={() => onNavigate(item.id)}
|
||||||
<span className="nav-item-icon" aria-hidden="true">{item.icon}</span>
|
aria-current={activePage === item.id ? 'page' : undefined}
|
||||||
{item.label}
|
disabled={item.disabled}
|
||||||
{item.badge && (
|
style={item.disabled ? { opacity: 0.6, cursor: 'not-allowed' } : {}}
|
||||||
<span className="nav-item-badge" aria-label={`${item.badge} alerts`}>
|
>
|
||||||
{item.badge}
|
<Icon className="nav-item-icon" aria-hidden="true" size={18} />
|
||||||
</span>
|
{item.label}
|
||||||
)}
|
{item.badge && (
|
||||||
</button>
|
<span className="nav-item-badge" aria-label={`${item.badge} alerts`}>
|
||||||
))}
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<span className="sidebar-section-label" style={{ marginTop: 'var(--space-4)' }}>System</span>
|
<span className="sidebar-section-label" style={{ marginTop: 'var(--space-4)' }}>System</span>
|
||||||
{BOTTOM_ITEMS.map((item) => (
|
{BOTTOM_ITEMS.map((item) => {
|
||||||
<button
|
const Icon = item.icon
|
||||||
key={item.id}
|
return (
|
||||||
id={`nav-${item.id}`}
|
<button
|
||||||
className={`nav-item${activePage === item.id ? ' active' : ''}`}
|
key={item.id}
|
||||||
onClick={() => onNavigate(item.id)}
|
id={`nav-${item.id}`}
|
||||||
aria-current={activePage === item.id ? 'page' : undefined}
|
className={`nav-item${activePage === item.id ? ' active' : ''}`}
|
||||||
>
|
onClick={() => onNavigate(item.id)}
|
||||||
<span className="nav-item-icon" aria-hidden="true">{item.icon}</span>
|
aria-current={activePage === item.id ? 'page' : undefined}
|
||||||
{item.label}
|
disabled={item.disabled}
|
||||||
{item.badge && (
|
style={item.disabled ? { opacity: 0.6, cursor: 'not-allowed' } : {}}
|
||||||
<span className="nav-item-badge" aria-label={`${item.badge} alerts`}>
|
>
|
||||||
{item.badge}
|
<Icon className="nav-item-icon" aria-hidden="true" size={18} />
|
||||||
</span>
|
{item.label}
|
||||||
)}
|
{item.badge && (
|
||||||
</button>
|
<span className="nav-item-badge" aria-label={`${item.badge} alerts`}>
|
||||||
))}
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User identity footer */}
|
{/* User identity footer */}
|
||||||
@@ -85,3 +105,4 @@ export default function Sidebar({ activePage, onNavigate }) {
|
|||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<button
|
|
||||||
id={`marker-${patient.id}`}
|
|
||||||
className={`map-marker-btn${isSelected ? ' map-marker-btn--selected' : ''}`}
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={`${patient.name} — ${meta.label}`}
|
|
||||||
title={patient.name}
|
|
||||||
>
|
|
||||||
<span className={`map-marker-ring ${meta.markerClass}-ring`} aria-hidden="true" />
|
|
||||||
<span className={`map-marker-dot ${meta.markerClass}-dot`} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<div className="patient-popup" id={`popup-${patient.id}`} role="dialog" aria-label={`Details for ${patient.name}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="patient-popup-header">
|
|
||||||
<div className="patient-popup-avatar" aria-hidden="true">
|
|
||||||
{patient.name.split(' ').map((n) => n[0]).join('')}
|
|
||||||
</div>
|
|
||||||
<div className="patient-popup-identity">
|
|
||||||
<h3 className="patient-popup-name">{patient.name}</h3>
|
|
||||||
<p className="patient-popup-id">{patient.id}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="popup-close"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close patient popup"
|
|
||||||
id={`popup-close-${patient.id}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="patient-popup-body">
|
|
||||||
<div className="popup-field">
|
|
||||||
<span className="popup-field-label">Destination</span>
|
|
||||||
<span className="popup-field-value">📍 {patient.currentDestination}</span>
|
|
||||||
</div>
|
|
||||||
<div className="popup-field">
|
|
||||||
<span className="popup-field-label">Departure</span>
|
|
||||||
<span className="popup-field-value">{fmtDate(patient.departureDate)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="popup-field">
|
|
||||||
<span className="popup-field-label">Return</span>
|
|
||||||
<span className="popup-field-value">{fmtDate(patient.returnDate)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="popup-field">
|
|
||||||
<span className="popup-field-label">Status</span>
|
|
||||||
<span className={`status-pill ${meta.cssClass}`}>{meta.label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 (
|
|
||||||
<section className="trip-tracker" aria-label="Trip Tracker — World Map">
|
|
||||||
|
|
||||||
{/* ── Top Panel: Map ─────────────────────────────────────────────────── */}
|
|
||||||
<div className="trip-tracker-map-panel">
|
|
||||||
{/* ── Top overlay header ────────────────────────────────────────────── */}
|
|
||||||
<header className="trip-tracker-header">
|
|
||||||
<div className="trip-tracker-header-left">
|
|
||||||
<p className="trip-tracker-label">LIVE TRACKING</p>
|
|
||||||
<h1 className="trip-tracker-title">Patient World Map</h1>
|
|
||||||
</div>
|
|
||||||
<div className="trip-tracker-chips">
|
|
||||||
<div className="tracker-chip tracker-chip--blue">
|
|
||||||
<span className="tracker-chip-dot" />
|
|
||||||
<span>{inTransitCount} In Transit</span>
|
|
||||||
</div>
|
|
||||||
<div className="tracker-chip tracker-chip--amber">
|
|
||||||
<span className="tracker-chip-dot" />
|
|
||||||
<span>{atRiskCount} At Risk</span>
|
|
||||||
</div>
|
|
||||||
<div className="tracker-chip tracker-chip--default">
|
|
||||||
<span>{ACTIVE_PATIENTS.length} Total Active</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── Map ──────────────────────────────────────────────────────────── */}
|
|
||||||
<MapGL
|
|
||||||
ref={mapRef}
|
|
||||||
id="trip-tracker-map"
|
|
||||||
initialViewState={{
|
|
||||||
latitude: 30,
|
|
||||||
longitude: 30,
|
|
||||||
zoom: 2,
|
|
||||||
}}
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
mapStyle="https://tiles.openfreemap.org/styles/fiord"
|
|
||||||
renderWorldCopies={false}
|
|
||||||
minZoom={0.5}
|
|
||||||
maxZoom={2.8}
|
|
||||||
onLoad={handleMapLoad}
|
|
||||||
/* Lock all user interaction */
|
|
||||||
dragPan={true}
|
|
||||||
scrollZoom={true}
|
|
||||||
boxZoom={false}
|
|
||||||
doubleClickZoom={false}
|
|
||||||
touchZoomRotate={false}
|
|
||||||
dragRotate={false}
|
|
||||||
keyboard={false}
|
|
||||||
interactive={true}
|
|
||||||
attributionControl={false}
|
|
||||||
>
|
|
||||||
{/* Patient markers */}
|
|
||||||
{ACTIVE_PATIENTS.map((patient) => (
|
|
||||||
<Marker
|
|
||||||
key={patient.id}
|
|
||||||
longitude={patient.coordinate[0]}
|
|
||||||
latitude={patient.coordinate[1]}
|
|
||||||
anchor="center"
|
|
||||||
>
|
|
||||||
<PatientMarker
|
|
||||||
patient={patient}
|
|
||||||
isSelected={selectedPatientId === patient.id}
|
|
||||||
onClick={() => handleMarkerClick(patient)}
|
|
||||||
/>
|
|
||||||
</Marker>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Patient popup — mounts only when a marker is selected */}
|
|
||||||
{selectedPatient && (
|
|
||||||
<Popup
|
|
||||||
key={selectedPatient.id}
|
|
||||||
longitude={selectedPatient.coordinate[0]}
|
|
||||||
latitude={selectedPatient.coordinate[1]}
|
|
||||||
anchor="bottom"
|
|
||||||
offset={20}
|
|
||||||
closeButton={false}
|
|
||||||
closeOnClick={false}
|
|
||||||
className="trip-tracker-popup-wrapper"
|
|
||||||
>
|
|
||||||
<PatientPopup
|
|
||||||
patient={selectedPatient}
|
|
||||||
onClose={handlePopupClose}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
)}
|
|
||||||
</MapGL>
|
|
||||||
|
|
||||||
{/* ── Legend ───────────────────────────────────────────────────────── */}
|
|
||||||
<footer className="trip-tracker-legend" aria-label="Map legend">
|
|
||||||
{Object.entries(STATUS_META).map(([key, meta]) => (
|
|
||||||
<div key={key} className="legend-item">
|
|
||||||
<span className={`legend-dot ${meta.markerClass}-dot`} aria-hidden="true" />
|
|
||||||
<span className="legend-label">{meta.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Bottom Panel: Active Itinerary Details ──────────────────────────── */}
|
|
||||||
<div className="trip-tracker-bottom-panel">
|
|
||||||
<div className="bottom-panel-header">
|
|
||||||
<h2 className="bottom-panel-title">Active Patient Itineraries</h2>
|
|
||||||
<p className="bottom-panel-subtitle">Real-time status logs of patient coordinates and destinations.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="patient-log-grid">
|
|
||||||
{ACTIVE_PATIENTS.map((p) => {
|
|
||||||
const meta = STATUS_META[p.currentStatus] || STATUS_META['pre-travel']
|
|
||||||
const isSelected = selectedPatientId === p.id
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className={`patient-log-card${isSelected ? ' patient-log-card--selected' : ''}`}
|
|
||||||
onClick={() => handleMarkerClick(p)}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<div className="log-card-top">
|
|
||||||
<div className="log-card-left">
|
|
||||||
<span className="log-patient-id">{p.id}</span>
|
|
||||||
<h3 className="log-patient-name">{p.name}</h3>
|
|
||||||
</div>
|
|
||||||
<span className={`status-pill ${meta.cssClass}`}>
|
|
||||||
{meta.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="log-card-details">
|
|
||||||
<div className="log-detail-item">
|
|
||||||
<span className="log-detail-label">Destination</span>
|
|
||||||
<span className="log-detail-val">📍 {p.currentDestination}</span>
|
|
||||||
</div>
|
|
||||||
<div className="log-detail-item">
|
|
||||||
<span className="log-detail-label">Dates</span>
|
|
||||||
<span className="log-detail-val">
|
|
||||||
{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' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1116,4 +1116,49 @@ button {
|
|||||||
.log-detail-val {
|
.log-detail-val {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-secondary);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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<Object>} records - Raw patient records from backend
|
||||||
|
* @param {Date} [refDate=new Date()] - Reference date
|
||||||
|
* @returns {Array<Object>} 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] : []
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+68
-18
@@ -1,23 +1,73 @@
|
|||||||
|
import { rmSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import electron from 'vite-plugin-electron/simple'
|
import electron from 'vite-plugin-electron/simple'
|
||||||
|
import pkg from './package.json'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
export default defineConfig(({ command }) => {
|
||||||
export default defineConfig({
|
// Clean compiled Electron output on each build to avoid stale artefacts
|
||||||
plugins: [
|
rmSync('dist-electron', { recursive: true, force: true })
|
||||||
react(),
|
|
||||||
electron({
|
const isServe = command === 'serve'
|
||||||
main: {
|
const isBuild = command === 'build'
|
||||||
// Main process entry point — compiled to dist-electron/main.js
|
const sourcemap = isServe || !!process.env.VSCODE_DEBUG
|
||||||
entry: 'electron/main.js',
|
|
||||||
},
|
return {
|
||||||
preload: {
|
resolve: {
|
||||||
// Preload script entry point — compiled to dist-electron/preload.js
|
alias: { '@': path.join(__dirname, 'src') },
|
||||||
input: 'electron/preload.js',
|
},
|
||||||
},
|
plugins: [
|
||||||
// Activates vite-plugin-electron-renderer:
|
react(),
|
||||||
// polyfills Node built-ins (path, fs, etc.) that are used in renderer
|
electron({
|
||||||
renderer: {},
|
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,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user