Files
CTM-Concierge/src/components/Dashboard.jsx
T

290 lines
12 KiB
React

import React, { useState, useEffect } from 'react'
import {
Briefcase,
PlaneTakeoff,
PlaneLanding,
AlertTriangle,
Activity,
RotateCw,
X,
Filter
} from 'lucide-react'
import { fetchEntries } from '../lib/pocketbase.js'
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' },
'followed-up': { label: 'Followed Up', className: 'status-followed-up' },
}
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 powered by PocketBase.
*/
export default function Dashboard() {
const [travellers, setTravellers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [selectedStatus, setSelectedStatus] = useState('all')
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
})
async function loadData() {
setLoading(true)
setError(null)
try {
const data = await fetchEntries()
setTravellers(data || [])
} catch (err) {
setError(err.message || 'Unable to connect to PocketBase backend.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadData()
}, [])
const preTravelCount = travellers.filter((t) => t.status === 'pre-travel').length
const inTransitCount = travellers.filter((t) => t.status === 'in-transit').length
const atRiskCount = travellers.filter((t) => t.status === 'at-risk').length
const postTravelCount = travellers.filter((t) => t.status === 'post-travel').length
const filteredTravellers = selectedStatus === 'all'
? travellers
: travellers.filter((t) => t.status === selectedStatus)
const stats = [
{ id: 'pre-travel', icon: Briefcase, value: preTravelCount, label: 'In Pre-Travel', accent: 'violet' },
{ id: 'in-transit', icon: PlaneTakeoff, value: inTransitCount, label: 'In Transit', accent: 'blue' },
{ id: 'at-risk', icon: AlertTriangle, value: atRiskCount, label: 'At Risk', accent: 'amber' },
{ id: 'post-travel', icon: PlaneLanding, value: postTravelCount, label: 'In Post-Travel', accent: 'teal' },
]
return (
<section className="dashboard" aria-label="Dashboard">
{/* ── Header ─────────────────────────────────────────────────────────── */}
<header className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<p className="dashboard-greeting">{getGreeting()}</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>
{/* ── Error Banner ────────────────────────────────────────────────────── */}
{error ? (
<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' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<AlertTriangle size={22} style={{ color: '#ef4444' }} />
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 700, color: '#fca5a5' }}>PocketBase Connection Error</h3>
</div>
<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',
}}
>
Retry Connection
</button>
</div>
) : (
<>
{/* ── Stat Cards ─────────────────────────────────────────────────────── */}
<div className="stat-grid" role="region" aria-label="Status filter cards">
{stats.map((stat) => {
const Icon = stat.icon
const isActive = selectedStatus === stat.id
return (
<article
key={stat.id}
id={`stat-${stat.id}`}
className={`stat-card accent-${stat.accent}${isActive ? ' is-active' : ''}`}
onClick={() => setSelectedStatus(isActive ? 'all' : stat.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedStatus(isActive ? 'all' : stat.id)
}
}}
aria-pressed={isActive}
aria-label={`Filter by ${stat.label}`}
>
<Icon className="stat-card-icon" aria-hidden="true" size={24} />
<div className="stat-card-value">{loading ? '...' : stat.value}</div>
<div className="stat-card-label">{stat.label}</div>
</article>
)
})}
</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
{selectedStatus !== 'all' && (
<span
style={{
fontSize: '12px',
fontWeight: '600',
padding: '2px 8px',
borderRadius: '999px',
background: 'var(--color-primary-glow)',
color: 'var(--color-primary-hover)',
border: '1px solid var(--color-border-active)',
}}
>
{STATUS_META[selectedStatus]?.label || selectedStatus} ({filteredTravellers.length} of {travellers.length})
</span>
)}
</h2>
{selectedStatus !== 'all' && (
<button
type="button"
onClick={() => setSelectedStatus('all')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 12px',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg-subtle)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
fontSize: 'var(--font-size-xs)',
fontWeight: '600',
transition: 'background 0.2s, color 0.2s',
}}
title="Show all statuses"
>
<X size={14} />
Show All
</button>
)}
</div>
<div className="journey-panel">
<div className="journey-table-container">
<table className="journey-table" aria-label="Live patient journey board">
<thead>
<tr>
<th scope="col">EMR 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>
) : filteredTravellers.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '32px 24px', color: 'var(--color-text-muted)' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<Filter size={24} style={{ color: 'var(--color-text-muted)', opacity: 0.5 }} />
<span>No patients found matching status "<strong>{STATUS_META[selectedStatus]?.label || selectedStatus}</strong>".</span>
<button
onClick={() => setSelectedStatus('all')}
style={{
marginTop: '8px',
padding: '4px 12px',
fontSize: '12px',
borderRadius: '4px',
background: 'var(--color-bg-subtle)',
border: '1px solid var(--color-border)',
color: 'var(--color-primary)',
cursor: 'pointer',
}}
>
Reset Filter
</button>
</div>
</td>
</tr>
) : (
filteredTravellers.map((p) => {
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
return (
<tr key={p.id} id={`patient-row-${p.id}`}>
<td>{p.emrID}</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>
</div>
</>
)}
</section>
)
}