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