Added sorting to Trips and Dashboard tables; chenged Follow-up values to snapmed and ctm

This commit is contained in:
2026-09-14 16:31:21 -04:00
parent 8e14bb438d
commit 72ec7472c5
5 changed files with 298 additions and 69 deletions
+13 -3
View File
@@ -133,6 +133,16 @@ export async function updateVaccineRecord(id, data) {
}
const VALID_FOLLOW_UP_STATUSES = ['snapmed', 'ctm']
function sanitizeFollowUpStatus(val) {
if (!val) return []
const list = Array.isArray(val) ? val : [val]
return list
.map((s) => (typeof s === 'string' ? s.trim().toLowerCase() : ''))
.filter((s) => VALID_FOLLOW_UP_STATUSES.includes(s))
}
function parseEntry(r) {
const traveller = r.expand?.traveller_id
? (Array.isArray(r.expand.traveller_id) ? r.expand.traveller_id[0] : r.expand.traveller_id)
@@ -153,7 +163,7 @@ function parseEntry(r) {
locations: r.locations ?? r.location ?? '',
countryISO: r.country_iso ?? '',
countriesISO: r.countries_iso ?? [],
followUpStatus: r.follow_up_status ?? [],
followUpStatus: sanitizeFollowUpStatus(r.follow_up_status),
isAtRisk: r.is_at_risk ?? false,
archived: r.archived ?? false,
departure: r.departure ?? '',
@@ -255,7 +265,7 @@ export async function createEntry(trip) {
countries_iso: trip.countriesISO ?? trip.countries_iso ?? [],
departure: trip.departure ?? '',
arrival: trip.arrival ?? '',
follow_up_status: trip.followUpStatus ?? trip.follow_up_status ?? [],
follow_up_status: sanitizeFollowUpStatus(trip.followUpStatus ?? trip.follow_up_status),
is_at_risk: trip.isAtRisk ?? trip.is_at_risk ?? false,
}
@@ -300,7 +310,7 @@ export async function updateEntry(id, trip) {
tripPayload.arrival = trip.arrival
}
if (trip.followUpStatus !== undefined || trip.follow_up_status !== undefined) {
tripPayload.follow_up_status = trip.followUpStatus ?? trip.follow_up_status ?? []
tripPayload.follow_up_status = sanitizeFollowUpStatus(trip.followUpStatus ?? trip.follow_up_status)
}
if (trip.isAtRisk !== undefined || trip.is_at_risk !== undefined) {
tripPayload.is_at_risk = trip.isAtRisk ?? trip.is_at_risk ?? false
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ctm-concierge",
"version": "1.0.0",
"version": "1.1.0",
"description": "CTM Concierge - Real-time patient journey tracker for CTM",
"author": {
"name": "Wirediv",
+99 -16
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import {
Briefcase,
PlaneTakeoff,
@@ -7,7 +7,10 @@ import {
Activity,
RotateCw,
X,
Filter
Filter,
ArrowUp,
ArrowDown,
ArrowUpDown
} from 'lucide-react'
import { fetchEntries } from '../lib/pocketbase.js'
@@ -20,14 +23,14 @@ const STATUS_META = {
}
const FOLLOW_UP_META = {
'in-transit': {
label: 'In-Transit',
'snapmed': {
label: 'SnapMED',
color: '#60a5fa',
bg: 'rgba(59, 130, 246, 0.12)',
border: '1px solid rgba(59, 130, 246, 0.3)',
},
'post-travel': {
label: 'Post-Travel',
'ctm': {
label: 'CTM',
color: '#22d3ee',
bg: 'rgba(6, 182, 212, 0.12)',
border: '1px solid rgba(6, 182, 212, 0.3)',
@@ -43,13 +46,28 @@ function getGreeting() {
/**
* 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 [sortConfig, setSortConfig] = useState({ key: 'created', direction: 'desc' })
const handleSort = (key) => {
setSortConfig((prev) => {
if (prev.key === key) {
return {
key,
direction: prev.direction === 'asc' ? 'desc' : 'asc',
}
}
return {
key,
direction: 'asc',
}
})
}
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
@@ -81,6 +99,69 @@ export default function Dashboard() {
? travellers
: travellers.filter((t) => t.status === selectedStatus)
const sortedTravellers = useMemo(() => {
if (!sortConfig.key) return filteredTravellers
return [...filteredTravellers].sort((a, b) => {
const aVal = a[sortConfig.key]
const bVal = b[sortConfig.key]
if (['departure', 'arrival', 'created'].includes(sortConfig.key)) {
const aTime = aVal ? new Date(aVal).getTime() : 0
const bTime = bVal ? new Date(bVal).getTime() : 0
if (aTime === bTime) return 0
return sortConfig.direction === 'asc' ? aTime - bTime : bTime - aTime
}
if (sortConfig.key === 'status') {
const aLabel = STATUS_META[a.status]?.label || a.status || ''
const bLabel = STATUS_META[b.status]?.label || b.status || ''
const comp = aLabel.localeCompare(bLabel)
return sortConfig.direction === 'asc' ? comp : -comp
}
const aStr = (aVal ?? '').toString()
const bStr = (bVal ?? '').toString()
const comp = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' })
return sortConfig.direction === 'asc' ? comp : -comp
})
}, [filteredTravellers, sortConfig])
const renderSortHeader = (key, label) => {
const isActive = sortConfig.key === key
const currentDirection = isActive ? sortConfig.direction : 'none'
const nextDirection = isActive && sortConfig.direction === 'asc' ? 'descending' : 'ascending'
return (
<th
key={key}
scope="col"
aria-sort={isActive ? (sortConfig.direction === 'asc' ? 'ascending' : 'descending') : 'none'}
className={`th-sortable ${isActive ? 'th-sortable-active' : ''}`}
onClick={() => handleSort(key)}
>
<button
type="button"
className={`th-sort-button ${isActive ? 'is-active' : ''}`}
aria-label={`Sort by ${label}, currently sorted ${currentDirection}. Click to sort ${nextDirection}.`}
>
<span>{label}</span>
<span className="th-sort-icon" aria-hidden="true">
{isActive ? (
sortConfig.direction === 'asc' ? (
<ArrowUp size={14} className="sort-arrow" />
) : (
<ArrowDown size={14} className="sort-arrow" />
)
) : (
<ArrowUpDown size={13} className="sort-arrow-neutral" />
)}
</span>
</button>
</th>
)
}
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' },
@@ -232,25 +313,26 @@ export default function Dashboard() {
<table className="journey-table" aria-label="Live patient journey board">
<thead>
<tr>
<th scope="col">EMR ID</th>
<th scope="col">Name</th>
{renderSortHeader('emrID', 'EMR ID')}
{renderSortHeader('name', 'Name')}
<th scope="col">Location</th>
<th scope="col">Departure</th>
<th scope="col">Arrival</th>
{renderSortHeader('departure', 'Departure')}
{renderSortHeader('arrival', 'Arrival')}
<th scope="col">Status</th>
<th scope='col'>Follow-up</th>
<th scope="col">Follow-up</th>
{renderSortHeader('created', 'Date Created')}
</tr>
</thead>
<tbody>
{travellers.length === 0 && !loading ? (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: '24px', color: 'var(--color-text-muted)' }}>
<td colSpan={8} style={{ textAlign: 'center', padding: '24px', color: 'var(--color-text-muted)' }}>
No travellers found in PocketBase database.
</td>
</tr>
) : filteredTravellers.length === 0 ? (
) : sortedTravellers.length === 0 ? (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: '32px 24px', color: 'var(--color-text-muted)' }}>
<td colSpan={8} 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>
@@ -273,7 +355,7 @@ export default function Dashboard() {
</td>
</tr>
) : (
filteredTravellers.map((p) => {
sortedTravellers.map((p) => {
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
return (
<tr key={p.id} id={`patient-row-${p.id}`}>
@@ -322,6 +404,7 @@ export default function Dashboard() {
<span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs, 12px)' }}></span>
)}
</td>
<td>{new Date(p.created).toLocaleDateString('en-CA', { timeZone: 'UTC' })}</td>
</tr>
)
})
+130 -49
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import {
PlusCircle,
Edit3,
@@ -11,6 +11,9 @@ import {
Calendar,
MapPin,
Search,
ArrowUp,
ArrowDown,
ArrowUpDown,
} from 'lucide-react'
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
import { fetchCustomerChart } from '../lib/juvonno.js'
@@ -51,6 +54,80 @@ export default function Trips() {
const [editingTrip, setEditingTrip] = useState(null)
const [editTripForm, setEditTripForm] = useState(INITIAL_NEW_FORM)
// Sorting state for Trips Database List
const [sortConfig, setSortConfig] = useState({ key: 'created', direction: 'desc' })
const handleSort = (key) => {
setSortConfig((prev) => {
if (prev.key === key) {
return {
key,
direction: prev.direction === 'asc' ? 'desc' : 'asc',
}
}
return {
key,
direction: 'asc',
}
})
}
const sortedTravellers = useMemo(() => {
if (!sortConfig.key) return travellers
return [...travellers].sort((a, b) => {
const aVal = a[sortConfig.key]
const bVal = b[sortConfig.key]
if (['departure', 'arrival', 'created'].includes(sortConfig.key)) {
const aTime = aVal ? new Date(aVal).getTime() : 0
const bTime = bVal ? new Date(bVal).getTime() : 0
if (aTime === bTime) return 0
return sortConfig.direction === 'asc' ? aTime - bTime : bTime - aTime
}
const aStr = (aVal ?? '').toString()
const bStr = (bVal ?? '').toString()
const comp = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' })
return sortConfig.direction === 'asc' ? comp : -comp
})
}, [travellers, sortConfig])
const renderSortHeader = (key, label) => {
const isActive = sortConfig.key === key
const currentDirection = isActive ? sortConfig.direction : 'none'
const nextDirection = isActive && sortConfig.direction === 'asc' ? 'descending' : 'ascending'
return (
<th
key={key}
scope="col"
aria-sort={isActive ? (sortConfig.direction === 'asc' ? 'ascending' : 'descending') : 'none'}
className={`th-sortable ${isActive ? 'th-sortable-active' : ''}`}
onClick={() => handleSort(key)}
>
<button
type="button"
className={`th-sort-button ${isActive ? 'is-active' : ''}`}
aria-label={`Sort by ${label}, currently sorted ${currentDirection}. Click to sort ${nextDirection}.`}
>
<span>{label}</span>
<span className="th-sort-icon" aria-hidden="true">
{isActive ? (
sortConfig.direction === 'asc' ? (
<ArrowUp size={14} className="sort-arrow" />
) : (
<ArrowDown size={14} className="sort-arrow" />
)
) : (
<ArrowUpDown size={13} className="sort-arrow-neutral" />
)}
</span>
</button>
</th>
)
}
async function loadData() {
setLoading(true)
setError(null)
@@ -152,7 +229,7 @@ export default function Trips() {
countriesISO: isoArray,
departure: newTripForm.departure,
arrival: newTripForm.arrival,
followUpStatus: newTripForm.followUpStatus || [],
followUpStatus: (newTripForm.followUpStatus || []).filter((s) => s === 'snapmed' || s === 'ctm'),
}
try {
@@ -174,11 +251,10 @@ export default function Trips() {
setEditingTrip(trip)
const isos = getPatientISOs(trip)
const followUps = Array.isArray(trip.followUpStatus)
const rawFollowUps = Array.isArray(trip.followUpStatus)
? trip.followUpStatus
: Array.isArray(trip.followUpStatus)
? trip.followUpStatus
: []
: (Array.isArray(trip.follow_up_status) ? trip.follow_up_status : [])
const followUps = rawFollowUps.filter((v) => v === 'snapmed' || v === 'ctm')
setEditTripForm({
emrID: trip.emrID !== undefined && trip.emrID !== null ? trip.emrID : '',
@@ -199,9 +275,10 @@ export default function Trips() {
setEditTripForm(INITIAL_NEW_FORM)
}
// Toggle follow-up status values ('in-transit', 'post-travel')
// Toggle follow-up status values ('snapmed', 'ctm')
function handleFollowUpToggle(value) {
const current = editTripForm.followUpStatus || []
if (value !== 'snapmed' && value !== 'ctm') return
const current = (editTripForm.followUpStatus || []).filter((v) => v === 'snapmed' || v === 'ctm')
const next = current.includes(value)
? current.filter((v) => v !== value)
: [...current, value]
@@ -238,7 +315,7 @@ export default function Trips() {
countriesISO: isoArray,
departure: editTripForm.departure,
arrival: editTripForm.arrival,
followUpStatus: editTripForm.followUpStatus || [],
followUpStatus: (editTripForm.followUpStatus || []).filter((s) => s === 'snapmed' || s === 'ctm'),
}
try {
@@ -792,7 +869,7 @@ export default function Trips() {
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>Follow-up:</span>
<label
htmlFor="edit-followup-in-transit"
htmlFor="edit-followup-snapmed"
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -800,28 +877,28 @@ export default function Trips() {
cursor: 'pointer',
fontSize: '12px',
fontWeight: 600,
color: editTripForm.followUpStatus?.includes('in-transit') ? '#3b82f6' : 'var(--color-text-secondary)',
background: editTripForm.followUpStatus?.includes('in-transit') ? 'rgba(59, 130, 246, 0.12)' : 'rgba(255, 255, 255, 0.04)',
color: editTripForm.followUpStatus?.includes('snapmed') ? '#3b82f6' : 'var(--color-text-secondary)',
background: editTripForm.followUpStatus?.includes('snapmed') ? 'rgba(59, 130, 246, 0.12)' : 'rgba(255, 255, 255, 0.04)',
padding: '4px 10px',
borderRadius: 'var(--radius-md)',
border: editTripForm.followUpStatus?.includes('in-transit') ? '1px solid rgba(59, 130, 246, 0.35)' : '1px solid var(--color-border)',
border: editTripForm.followUpStatus?.includes('snapmed') ? '1px solid rgba(59, 130, 246, 0.35)' : '1px solid var(--color-border)',
transition: 'all var(--transition-fast)',
userSelect: 'none',
}}
>
<input
id="edit-followup-in-transit"
id="edit-followup-snapmed"
type="checkbox"
checked={Boolean(editTripForm.followUpStatus?.includes('in-transit'))}
onChange={() => handleFollowUpToggle('in-transit')}
checked={Boolean(editTripForm.followUpStatus?.includes('snapmed'))}
onChange={() => handleFollowUpToggle('snapmed')}
disabled={submitting}
style={{ cursor: 'pointer', accentColor: '#3b82f6' }}
/>
<span>In-Transit</span>
<span>SnapMED</span>
</label>
<label
htmlFor="edit-followup-post-travel"
htmlFor="edit-followup-ctm"
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -829,24 +906,24 @@ export default function Trips() {
cursor: 'pointer',
fontSize: '12px',
fontWeight: 600,
color: editTripForm.follow_up_sfollowUpStatustatus?.includes('post-travel') ? '#06b6d4' : 'var(--color-text-secondary)',
background: editTripForm.followUpStatus?.includes('post-travel') ? 'rgba(6, 182, 212, 0.12)' : 'rgba(255, 255, 255, 0.04)',
color: editTripForm.followUpStatus?.includes('ctm') ? '#06b6d4' : 'var(--color-text-secondary)',
background: editTripForm.followUpStatus?.includes('ctm') ? 'rgba(6, 182, 212, 0.12)' : 'rgba(255, 255, 255, 0.04)',
padding: '4px 10px',
borderRadius: 'var(--radius-md)',
border: editTripForm.followUpStatus?.includes('post-travel') ? '1px solid rgba(6, 182, 212, 0.35)' : '1px solid var(--color-border)',
border: editTripForm.followUpStatus?.includes('ctm') ? '1px solid rgba(6, 182, 212, 0.35)' : '1px solid var(--color-border)',
transition: 'all var(--transition-fast)',
userSelect: 'none',
}}
>
<input
id="edit-followup-post-travel"
id="edit-followup-ctm"
type="checkbox"
checked={Boolean(editTripForm.followUpStatus?.includes('post-travel'))}
onChange={() => handleFollowUpToggle('post-travel')}
checked={Boolean(editTripForm.followUpStatus?.includes('ctm'))}
onChange={() => handleFollowUpToggle('ctm')}
disabled={submitting}
style={{ cursor: 'pointer', accentColor: '#06b6d4' }}
/>
<span>Post-Travel</span>
<span>CTM</span>
</label>
</div>
</div>
@@ -897,24 +974,25 @@ export default function Trips() {
<table className="journey-table" aria-label="Database Trips List">
<thead>
<tr>
<th scope="col">EMR ID</th>
<th scope="col">Patient Name</th>
<th scope="col">Location & ISOs</th>
<th scope="col">Departure</th>
<th scope="col">Arrival</th>
{renderSortHeader('emrID', 'EMR ID')}
{renderSortHeader('name', 'Patient Name')}
<th scope="col">Location & ISO</th>
{renderSortHeader('departure', 'Departure')}
{renderSortHeader('arrival', 'Arrival')}
<th scope="col">Status</th>
{renderSortHeader('created', 'Date Created')}
<th scope="col" style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{travellers.length === 0 && !loading ? (
{sortedTravellers.length === 0 && !loading ? (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: '32px', color: 'var(--color-text-muted)' }}>
No trips found in database. Use the form above to add a new trip.
</td>
</tr>
) : (
travellers.map((p) => {
sortedTravellers.map((p) => {
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
const isBeingEdited = editingTrip?.id === p.id
const isos = getPatientISOs(p)
@@ -958,28 +1036,31 @@ export default function Trips() {
<span className={`status-pill ${meta.className}`}>
{meta.label}
</span>
{((Array.isArray(p.followUpStatus) && p.followUpStatus.length > 0) || (Array.isArray(p.followUpStatus) && p.followUpStatus.length > 0)) && (
{Array.isArray(p.followUpStatus) && p.followUpStatus.filter((st) => st === 'snapmed' || st === 'ctm').length > 0 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(p.followUpStatus).map((st) => (
<span
key={st}
style={{
fontSize: '10px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: '4px',
background: st === 'in-transit' ? 'rgba(59, 130, 246, 0.15)' : 'rgba(6, 182, 212, 0.15)',
color: st === 'in-transit' ? '#60a5fa' : '#22d3ee',
border: st === 'in-transit' ? '1px solid rgba(59, 130, 246, 0.3)' : '1px solid rgba(6, 182, 212, 0.3)',
}}
>
</span>
))}
{p.followUpStatus
.filter((st) => st === 'snapmed' || st === 'ctm')
.map((st) => (
<span
key={st}
style={{
fontSize: '10px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: '4px',
background: st === 'snapmed' ? 'rgba(59, 130, 246, 0.15)' : 'rgba(6, 182, 212, 0.15)',
color: st === 'snapmed' ? '#60a5fa' : '#22d3ee',
border: st === 'snapmed' ? '1px solid rgba(59, 130, 246, 0.3)' : '1px solid rgba(6, 182, 212, 0.3)',
}}
>
{st === 'snapmed' ? 'SnapMED' : 'CTM'}
</span>
))}
</div>
)}
</div>
</td>
<td>{new Date(p.created).toLocaleDateString('en-CA', { timeZone: 'UTC' })}</td>
<td style={{ textAlign: 'right' }}>
<div className="trips-list-actions" style={{ justifyContent: 'flex-end' }}>
<button
+55
View File
@@ -575,6 +575,61 @@ body {
text-align: left;
}
.journey-table th.th-sortable {
cursor: pointer;
user-select: none;
transition: color var(--transition-fast), background var(--transition-fast);
}
.journey-table th.th-sortable:hover {
color: var(--color-text-primary);
}
.th-sort-button {
all: unset;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
letter-spacing: inherit;
text-transform: inherit;
color: inherit;
}
.th-sort-button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.th-sort-button.is-active {
color: var(--color-primary-hover, var(--color-primary));
}
.th-sort-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.th-sort-icon .sort-arrow {
color: var(--color-primary-hover, var(--color-primary));
}
.th-sort-icon .sort-arrow-neutral {
color: var(--color-text-muted);
opacity: 0.35;
transition: opacity var(--transition-fast), color var(--transition-fast);
}
.journey-table th.th-sortable:hover .sort-arrow-neutral {
opacity: 0.8;
color: var(--color-text-secondary);
}
.journey-table td {
padding: var(--space-4) var(--space-5);
font-size: var(--font-size-sm);