first commit
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Vite renderer build output
|
||||
dist/
|
||||
|
||||
# Electron main/preload build output
|
||||
dist-electron/
|
||||
|
||||
# electron-builder packaged output
|
||||
release/
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,89 @@
|
||||
import { app, BrowserWindow, shell, ipcMain } from 'electron'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
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')
|
||||
|
||||
// ─── Dev server URL ─────────────────────────────────────────────────────────
|
||||
// vite-plugin-electron sets this env var when running in development mode.
|
||||
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
|
||||
|
||||
// ─── Window reference ────────────────────────────────────────────────────────
|
||||
let mainWindow
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
title: 'CTM Concierge',
|
||||
show: false, // show after ready-to-show to prevent visual flash
|
||||
backgroundColor: '#0f1117',
|
||||
webPreferences: {
|
||||
// ── Security defaults ──────────────────────────────────────────────
|
||||
contextIsolation: true, // Isolates renderer from Electron internals
|
||||
nodeIntegration: false, // Renderer cannot access Node.js APIs directly
|
||||
sandbox: true, // Extra process isolation
|
||||
webSecurity: true,
|
||||
// ── Preload ────────────────────────────────────────────────────────
|
||||
preload: PRELOAD_PATH,
|
||||
},
|
||||
})
|
||||
|
||||
// Gracefully show window once the DOM is ready to avoid white flash
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
// Open external links in the system browser, not Electron
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('https:') || url.startsWith('http:')) {
|
||||
shell.openExternal(url)
|
||||
}
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// ── Load the app ─────────────────────────────────────────────────────────
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
// Development: load from Vite HMR dev server
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL)
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
// Production: load compiled static files
|
||||
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── App lifecycle ───────────────────────────────────────────────────────────
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
|
||||
// macOS: re-create window when dock icon is clicked and no windows are open
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed (except on macOS)
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
mainWindow = null
|
||||
}
|
||||
})
|
||||
|
||||
// ─── IPC Handlers ────────────────────────────────────────────────────────────
|
||||
// Register main-process IPC handlers here.
|
||||
// Example: ipcMain.handle('channel-name', async (event, ...args) => { ... })
|
||||
|
||||
ipcMain.handle('app:get-version', () => app.getVersion())
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* preload.js
|
||||
*
|
||||
* Runs in a privileged context but is sandboxed from the renderer.
|
||||
* Use contextBridge to expose a safe, narrow API surface to the renderer.
|
||||
* Never expose raw ipcRenderer — always whitelist specific channels.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
// ─── Allowed IPC channels ─────────────────────────────────────────────────
|
||||
// Only messages on these channels will be forwarded.
|
||||
const ALLOWED_SEND_CHANNELS = [
|
||||
'patient:update-status',
|
||||
'patient:fetch-list',
|
||||
'schedule:fetch',
|
||||
'app:ready',
|
||||
]
|
||||
|
||||
const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'patient:status-updated',
|
||||
'patient:list-response',
|
||||
'schedule:response',
|
||||
'app:get-version',
|
||||
]
|
||||
|
||||
// ─── Exposed API ──────────────────────────────────────────────────────────
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
/**
|
||||
* Send a one-way message to the main process.
|
||||
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
|
||||
* @param {*} data
|
||||
*/
|
||||
send(channel, data) {
|
||||
if (ALLOWED_SEND_CHANNELS.includes(channel)) {
|
||||
ipcRenderer.send(channel, data)
|
||||
} else {
|
||||
console.warn(`[preload] Blocked send on unknown channel: "${channel}"`)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Invoke a main-process handler and await its response.
|
||||
* @param {string} channel - Must be in ALLOWED_SEND_CHANNELS
|
||||
* @param {...*} args
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
invoke(channel, ...args) {
|
||||
if (ALLOWED_SEND_CHANNELS.includes(channel) || ALLOWED_RECEIVE_CHANNELS.includes(channel)) {
|
||||
return ipcRenderer.invoke(channel, ...args)
|
||||
}
|
||||
return Promise.reject(new Error(`[preload] Blocked invoke on unknown channel: "${channel}"`))
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe to messages from the main process.
|
||||
* @param {string} channel - Must be in ALLOWED_RECEIVE_CHANNELS
|
||||
* @param {Function} callback - Called with (event, ...args) on each message
|
||||
* @returns {Function} Unsubscribe function — call it to remove the listener
|
||||
*/
|
||||
receive(channel, callback) {
|
||||
if (ALLOWED_RECEIVE_CHANNELS.includes(channel)) {
|
||||
const handler = (_event, ...args) => callback(...args)
|
||||
ipcRenderer.on(channel, handler)
|
||||
// Return cleanup function so React effects can unsubscribe
|
||||
return () => ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
console.warn(`[preload] Blocked receive on unknown channel: "${channel}"`)
|
||||
return () => {}
|
||||
},
|
||||
})
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Security: restrict resource origins -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; worker-src 'self' blob:; child-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://tiles.openfreemap.org https://openfreemap.org; connect-src 'self' https://tiles.openfreemap.org https://openfreemap.org;"
|
||||
/>
|
||||
|
||||
<!-- Google Fonts — Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<title>CTM Concierge</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "ctm-concierge",
|
||||
"version": "0.1.0",
|
||||
"description": "Internal real-time patient journey tracker for CTM medical clinic",
|
||||
"author": "CTM Internal",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"main": "dist-electron/main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && electron-builder",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-map-gl": "^8.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.7"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.ctm.concierge",
|
||||
"productName": "CTM Concierge",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
],
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "MedicalSoftware"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "public/icon.ico"
|
||||
},
|
||||
"mac": {
|
||||
"target": "dmg",
|
||||
"icon": "public/icon.icns"
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import React, { useState } from 'react'
|
||||
import Sidebar from './components/Sidebar.jsx'
|
||||
import Dashboard from './components/Dashboard.jsx'
|
||||
import TripTracker from './components/TripTracker.jsx'
|
||||
|
||||
/**
|
||||
* App — root layout component.
|
||||
* Implements a two-panel CSS Grid: fixed sidebar + scrollable main viewport.
|
||||
* Owns the active-page state so sidebar navigation drives content rendering.
|
||||
*/
|
||||
export default function App() {
|
||||
const [activePage, setActivePage] = useState('dashboard')
|
||||
|
||||
function renderPage() {
|
||||
switch (activePage) {
|
||||
case 'trip-tracker': return <TripTracker />
|
||||
default: return <Dashboard />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar activePage={activePage} onNavigate={setActivePage} />
|
||||
<main className="main-viewport">
|
||||
{renderPage()}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
||||
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' },
|
||||
]
|
||||
|
||||
const STATUS_META = {
|
||||
'in-transit': { label: 'In Transit', className: 'status-in-transit' },
|
||||
'at-risk': { label: 'At Risk', className: 'status-at-risk' },
|
||||
'post-travel': { label: 'Post-Travel', className: 'status-post-travel' },
|
||||
'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' },
|
||||
}
|
||||
|
||||
function getGreeting() {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return 'Good morning'
|
||||
if (h < 17) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard — main content viewport for CTM Concierge.
|
||||
* Shows real-time stat cards and a patient journey table.
|
||||
*/
|
||||
export default function Dashboard() {
|
||||
const today = new Date().toLocaleDateString('en-US', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="dashboard" aria-label="Dashboard">
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<header className="dashboard-header">
|
||||
<p className="dashboard-greeting">{getGreeting()}</p>
|
||||
<h1 className="dashboard-title">Patient Trip Overview</h1>
|
||||
<p className="dashboard-subtitle">{today}</p>
|
||||
</header>
|
||||
|
||||
{/* ── Stat Cards ─────────────────────────────────────────────────────── */}
|
||||
<div className="stat-grid" role="list" aria-label="Today's statistics">
|
||||
{STATS.map((stat) => (
|
||||
<article
|
||||
key={stat.id}
|
||||
id={`stat-${stat.id}`}
|
||||
className={`stat-card accent-${stat.accent}`}
|
||||
role="listitem"
|
||||
>
|
||||
<span className="stat-card-icon" aria-hidden="true">{stat.icon}</span>
|
||||
<div className="stat-card-value">{stat.value}</div>
|
||||
<div className="stat-card-label">{stat.label}</div>
|
||||
{stat.delta && (
|
||||
<span className={`stat-card-delta delta-${stat.deltaType}`} aria-label={`Change: ${stat.delta}`}>
|
||||
{stat.delta}
|
||||
</span>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</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 (
|
||||
<tr key={p.id} id={`patient-row-${p.id}`}>
|
||||
<td>{p.id}</td>
|
||||
<td className="patient-name">{p.name}</td>
|
||||
<td>{p.email}</td>
|
||||
<td>{p.departure}</td>
|
||||
<td>{p.arrival}</td>
|
||||
<td>
|
||||
<span className={`status-pill ${meta.className}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
const BOTTOM_ITEMS = [
|
||||
{ id: 'notifications', label: 'Notifications', icon: '◉', badge: '5' },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙', badge: null },
|
||||
]
|
||||
|
||||
/**
|
||||
* Sidebar — primary navigation for CTM Concierge.
|
||||
* @param {string} activePage — the currently active page id (owned by App)
|
||||
* @param {function} onNavigate — callback to change the active page
|
||||
*/
|
||||
export default function Sidebar({ activePage, onNavigate }) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
{/* Logo / App identity */}
|
||||
<div className="sidebar-logo">
|
||||
<div className="sidebar-logo-icon" aria-hidden="true">✦</div>
|
||||
<div className="sidebar-logo-text">
|
||||
<span className="sidebar-logo-name">CTM Concierge</span>
|
||||
<span className="sidebar-logo-tagline">Trip Tracker</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary navigation */}
|
||||
<nav className="sidebar-nav" aria-label="Primary navigation">
|
||||
<span className="sidebar-section-label">Main</span>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`nav-${item.id}`}
|
||||
className={`nav-item${activePage === item.id ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
aria-current={activePage === item.id ? 'page' : undefined}
|
||||
>
|
||||
<span className="nav-item-icon" aria-hidden="true">{item.icon}</span>
|
||||
{item.label}
|
||||
{item.badge && (
|
||||
<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>
|
||||
{BOTTOM_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`nav-${item.id}`}
|
||||
className={`nav-item${activePage === item.id ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
aria-current={activePage === item.id ? 'page' : undefined}
|
||||
>
|
||||
<span className="nav-item-icon" aria-hidden="true">{item.icon}</span>
|
||||
{item.label}
|
||||
{item.badge && (
|
||||
<span className="nav-item-badge" aria-label={`${item.badge} alerts`}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User identity footer */}
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-user" role="button" tabIndex={0} aria-label="User profile">
|
||||
<div className="sidebar-avatar" aria-hidden="true">JD</div>
|
||||
<div className="sidebar-user-info">
|
||||
<div className="sidebar-user-name">Dr. Jane Doe</div>
|
||||
<div className="sidebar-user-role">Senior Physician</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
+1119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import electron from 'vite-plugin-electron/simple'
|
||||
|
||||
// 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: {},
|
||||
}),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user