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

713 lines
30 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react'
import {
PlusCircle,
Edit3,
Check,
X,
RotateCw,
AlertTriangle,
Briefcase,
Trash2,
Calendar,
} from 'lucide-react'
import { fetchEntries, createEntry, updateEntry, deleteEntry } from '../lib/pocketbase.js'
import { calculatePatientStatus, getPatientISOs } from '../lib/patientUtils.js'
import CountrySelect from './CountrySelect.jsx'
const STATUS_META = {
'pre-travel': { label: 'Pre-Travel', className: 'status-pre-travel' },
'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' },
}
const INITIAL_NEW_FORM = {
emrID: '',
name: '',
location: '',
countriesISO: [],
departure: '',
arrival: '',
}
export default function Trips() {
const [travellers, setTravellers] = useState([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [successMsg, setSuccessMsg] = useState(null)
// New Trip form state (Left Panel)
const [newTripForm, setNewTripForm] = useState(INITIAL_NEW_FORM)
// Edit Trip state (Right Panel when editing)
const [editingTrip, setEditingTrip] = useState(null)
const [editTripForm, setEditTripForm] = useState(INITIAL_NEW_FORM)
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()
}, [])
// Auto-dismiss success notification
useEffect(() => {
if (successMsg) {
const timer = setTimeout(() => setSuccessMsg(null), 4000)
return () => clearTimeout(timer)
}
}, [successMsg])
// Handle New Trip Form submission (Left Form)
async function handleCreateTrip(e) {
e.preventDefault()
if (!newTripForm.name.trim()) {
setError('Please enter a patient / traveller name.')
return
}
setSubmitting(true)
setError(null)
// Convert comma-separated string to array if needed
const isoArray = typeof newTripForm.countriesISO === 'string'
? newTripForm.countriesISO.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: newTripForm.countriesISO
const payload = {
emrID: newTripForm.emrID ? Number(newTripForm.emrID) : null,
name: newTripForm.name.trim(),
location: newTripForm.location.trim(),
countriesISO: isoArray,
departure: newTripForm.departure,
arrival: newTripForm.arrival,
}
try {
await createEntry(payload)
setNewTripForm(INITIAL_NEW_FORM)
setSuccessMsg('New trip created successfully!')
await loadData()
} catch (err) {
setError(err.message || 'Failed to create trip record.')
} finally {
setSubmitting(false)
}
}
// Initiate Edit mode for a specific trip
function startEditing(trip) {
setEditingTrip(trip)
const isos = getPatientISOs(trip)
setEditTripForm({
emrID: trip.emrID !== undefined && trip.emrID !== null ? trip.emrID : '',
name: trip.name || '',
location: trip.location || '',
countriesISO: Array.isArray(isos) ? isos : typeof isos === 'string' && isos ? isos.split(',').map((s) => s.trim().toUpperCase()) : [],
departure: trip.departure ? trip.departure.slice(0, 10) : '',
arrival: trip.arrival ? trip.arrival.slice(0, 10) : '',
})
// Scroll top container into view smoothly
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function cancelEditing() {
setEditingTrip(null)
setEditTripForm(INITIAL_NEW_FORM)
}
// Handle Save Changes submission (Right Edit Form)
async function handleUpdateTrip(e) {
e.preventDefault()
if (!editingTrip) return
if (!editTripForm.name.trim()) {
setError('Patient name cannot be empty.')
return
}
setSubmitting(true)
setError(null)
const isoArray = typeof editTripForm.countriesISO === 'string'
? editTripForm.countriesISO.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: editTripForm.countriesISO
const payload = {
emrID: editTripForm.emrID ? Number(editTripForm.emrID) : null,
name: editTripForm.name.trim(),
location: editTripForm.location.trim(),
countriesISO: isoArray,
departure: editTripForm.departure,
arrival: editTripForm.arrival,
}
try {
await updateEntry(editingTrip.id, payload)
setSuccessMsg(`Trip #${editingTrip.id} updated successfully!`)
setEditingTrip(null)
setEditTripForm(INITIAL_NEW_FORM)
await loadData()
} catch (err) {
setError(err.message || `Failed to update trip #${editingTrip.id}.`)
} finally {
setSubmitting(false)
}
}
// Handle Delete Trip
async function handleDeleteTrip(id, name) {
if (!window.confirm(`Are you sure you want to delete trip ${id} (${name})?`)) return
setError(null)
try {
await deleteEntry(id)
if (editingTrip?.id === id) {
cancelEditing()
}
setSuccessMsg(`Trip ${id} deleted successfully.`)
await loadData()
} catch (err) {
setError(err.message || `Failed to delete trip ${id}.`)
}
}
// Helper for date formatting
const formatDate = (dateStr) => {
if (!dateStr) return 'N/A'
const date = new Date(dateStr)
if (isNaN(date.getTime())) return 'N/A'
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
// Compute live preview state for New Form draft
const newDraftPatient = {
emrID: newTripForm.emrID,
name: newTripForm.name || 'Jane / John Doe',
location: newTripForm.location || 'Pending Location',
countriesISO: Array.isArray(newTripForm.countriesISO)
? newTripForm.countriesISO
: typeof newTripForm.countriesISO === 'string'
? newTripForm.countriesISO.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: [],
departure: newTripForm.departure,
arrival: newTripForm.arrival,
}
const newDraftStatusKey = calculatePatientStatus(newDraftPatient)
const newDraftMeta = STATUS_META[newDraftStatusKey] || STATUS_META['pre-travel']
const newDraftISOs = getPatientISOs(newDraftPatient)
// Compute live preview state for Edit Form draft
const editDraftPatient = {
...editingTrip,
emrID: editTripForm.emrID,
name: editTripForm.name,
location: editTripForm.location,
countriesISO: Array.isArray(editTripForm.countriesISO)
? editTripForm.countriesISO
: typeof editTripForm.countriesISO === 'string'
? editTripForm.countriesISO.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean)
: [],
departure: editTripForm.departure,
arrival: editTripForm.arrival,
}
const editDraftStatusKey = calculatePatientStatus(editDraftPatient)
const editDraftMeta = STATUS_META[editDraftStatusKey] || STATUS_META['pre-travel']
const editDraftISOs = getPatientISOs(editDraftPatient)
const isEditingMode = editingTrip !== null
return (
<div className="trips-view" aria-label="Trip Management Workspace">
{/* ── Top Header ──────────────────────────────────────────────────────── */}
<header className="trips-view-header">
<div>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 700, color: 'var(--color-primary)', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: '4px' }}>
CONCIERGE OPERATIONS
</p>
<h1 style={{ fontSize: 'var(--font-size-3xl)', fontWeight: 800, color: 'var(--color-text-primary)', letterSpacing: '-0.02em', margin: 0 }}>
Trip Management
</h1>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', marginTop: '4px' }}>
Create new patient travel itineraries and manage existing trip records.
</p>
</div>
<button
onClick={loadData}
disabled={loading}
className="btn-secondary"
style={{ padding: '8px 16px', fontSize: 'var(--font-size-xs)' }}
>
<RotateCw size={14} style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }} />
{loading ? 'Refreshing...' : 'Refresh Database'}
</button>
</header>
{/* ── Banners & Notifications ────────────────────────────────────────── */}
{error && (
<div style={{ background: 'rgba(239, 68, 68, 0.12)', border: '1px solid rgba(239, 68, 68, 0.35)', borderRadius: 'var(--radius-md)', padding: '16px 20px', marginBottom: '24px', color: '#f87171', display: 'flex', alignItems: 'center', gap: '12px' }}>
<AlertTriangle size={20} style={{ color: '#ef4444', flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 'var(--font-size-sm)', lineHeight: 1.4 }}>{error}</div>
<button onClick={() => setError(null)} style={{ background: 'transparent', border: 'none', color: '#f87171', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
)}
{successMsg && (
<div style={{ background: 'rgba(16, 185, 129, 0.12)', border: '1px solid rgba(16, 185, 129, 0.35)', borderRadius: 'var(--radius-md)', padding: '16px 20px', marginBottom: '24px', color: '#34d399', display: 'flex', alignItems: 'center', gap: '12px' }}>
<Check size={20} style={{ color: '#10b981', flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 'var(--font-size-sm)', fontWeight: 600 }}>{successMsg}</div>
<button onClick={() => setSuccessMsg(null)} style={{ background: 'transparent', border: 'none', color: '#34d399', cursor: 'pointer', fontSize: '18px' }}>×</button>
</div>
)}
{/* ── TOP HALF: Split Container ───────────────────────────────────────── */}
<div className="trips-top-split">
{/* ── Left Panel: Form for New Cards ─────────────────────────────── */}
<section className={`trips-panel-card ${isEditingMode ? 'trips-panel-card--disabled' : ''}`}>
<div className="trips-panel-header">
<h2 className="trips-panel-title">
<PlusCircle size={20} style={{ color: 'var(--color-primary)' }} />
Create New Trip
</h2>
<span className={`trips-badge ${isEditingMode ? 'trips-badge--disabled' : 'trips-badge--new'}`}>
{isEditingMode ? 'Form Paused' : 'New Trip Form'}
</span>
</div>
{isEditingMode && (
<div style={{ background: 'rgba(139, 92, 246, 0.15)', border: '1px solid rgba(139, 92, 246, 0.3)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: '16px', fontSize: 'var(--font-size-xs)', color: 'var(--color-accent-violet)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Edit3 size={14} />
Editing in progress on right panel New trip creation is currently disabled.
</div>
)}
<form onSubmit={handleCreateTrip} className="trips-form">
<div className="trips-form-row">
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="new-emr-id">EMR ID</label>
<input
id="new-emr-id"
type="number"
className="trips-form-input"
placeholder="e.g. 1042"
value={newTripForm.emrID}
onChange={(e) => setNewTripForm({ ...newTripForm, emrID: e.target.value })}
disabled={isEditingMode || submitting}
/>
</div>
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="new-patient-name">Traveller Name</label>
<input
id="new-patient-name"
type="text"
className="trips-form-input"
placeholder="e.g. John Doe"
value={newTripForm.name}
onChange={(e) => setNewTripForm({ ...newTripForm, name: e.target.value })}
disabled={isEditingMode || submitting}
required
/>
</div>
</div>
<div className="trips-form-field">
<label className="trips-form-label">Destination Country(ies)</label>
<CountrySelect
selectedISOs={newTripForm.countriesISO}
onChange={(updatedISOs, updatedLocationStr) => {
setNewTripForm({
...newTripForm,
countriesISO: updatedISOs,
location: updatedLocationStr,
})
}}
disabled={isEditingMode || submitting}
placeholder="Search country (e.g. Spain, France)..."
/>
</div>
<div className="trips-form-row">
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="new-departure">Departure Date</label>
<input
id="new-departure"
type="date"
className="trips-form-input"
value={newTripForm.departure}
onChange={(e) => setNewTripForm({ ...newTripForm, departure: e.target.value })}
disabled={isEditingMode || submitting}
/>
</div>
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="new-arrival">Return / Arrival Date</label>
<input
id="new-arrival"
type="date"
className="trips-form-input"
value={newTripForm.arrival}
onChange={(e) => setNewTripForm({ ...newTripForm, arrival: e.target.value })}
disabled={isEditingMode || submitting}
/>
</div>
</div>
<div className="trips-form-actions">
<button
type="submit"
className="btn-primary"
disabled={isEditingMode || submitting}
>
<PlusCircle size={16} />
{submitting ? 'Creating...' : 'Create Trip'}
</button>
</div>
</form>
</section>
{/* ── Right Panel: Live Preview Card or Editable Card ────────────── */}
<section className={`trips-panel-card ${isEditingMode ? 'trips-panel-card--editing' : ''}`}>
<div className="trips-panel-header">
<h2 className="trips-panel-title">
{isEditingMode ? (
<>
<Edit3 size={20} style={{ color: 'var(--color-accent-violet)' }} />
Editing Trip ({editingTrip.id})
</>
) : (
<>
<Briefcase size={20} style={{ color: 'var(--color-primary)' }} />
Live Itinerary Preview
</>
)}
</h2>
<span className={`trips-badge ${isEditingMode ? 'trips-badge--edit' : 'trips-badge--new'}`}>
{isEditingMode ? 'Edit Mode Active' : 'Real-time Preview'}
</span>
</div>
{/* Mode 1: Create Preview Card (Non-editing mode) */}
{!isEditingMode && (
<div>
<div className="trips-preview-card">
<div className="log-card-top">
<div className="log-card-left">
<span className="log-patient-id" style={{ background: 'rgba(59, 158, 255, 0.15)', color: 'var(--color-primary)', border: '1px solid rgba(59, 158, 255, 0.3)' }}>
{newDraftPatient.emrID ? `EMR: ${newDraftPatient.emrID}` : 'NEW-TRIP'}
</span>
<h3 className="log-patient-name" style={{ marginTop: '4px', fontSize: 'var(--font-size-base)' }}>
{newDraftPatient.name}
</h3>
</div>
<span className={`status-pill ${newDraftMeta.className}`}>
{newDraftMeta.label}
</span>
</div>
<div className="log-card-details" style={{ marginTop: '8px', borderTop: '1px dashed var(--color-border)', paddingTop: '12px' }}>
<div className="log-detail-item">
<span className="log-detail-label">Location</span>
<span className="log-detail-val" style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
📍 {newDraftPatient.location}
</span>
</div>
<div className="log-detail-item" style={{ marginTop: '8px' }}>
<span className="log-detail-label">Destinations (ISOs)</span>
<div className="destination-chips-container">
{newDraftISOs.length > 0 ? (
newDraftISOs.map((iso) => (
<span key={iso} className="destination-chip">📍 {iso}</span>
))
) : (
<span className="log-detail-val" style={{ fontStyle: 'italic', color: 'var(--color-text-muted)' }}>None specified</span>
)}
</div>
</div>
<div className="log-detail-item" style={{ marginTop: '8px' }}>
<span className="log-detail-label">Travel Window</span>
<span className="log-detail-val">
<Calendar size={13} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '4px' }} />
{formatDate(newDraftPatient.departure)} {formatDate(newDraftPatient.arrival)}
</span>
</div>
</div>
</div>
<p className="trips-form-hint" style={{ marginTop: '12px', textAlign: 'center' }}>
This card reflects how the new trip will appear in the Live Tracking dashboard.
</p>
</div>
)}
{/* Mode 2: Editable Preview Form (Editing mode) */}
{isEditingMode && (
<form onSubmit={handleUpdateTrip} className="trips-form">
<div className="trips-preview-card trips-preview-card--editing" style={{ background: 'var(--color-bg-subtle)' }}>
<div className="log-card-top">
<div className="log-card-left">
<span className="log-patient-id" style={{ background: 'rgba(139, 92, 246, 0.2)', color: 'var(--color-accent-violet)', border: '1px solid rgba(139, 92, 246, 0.4)' }}>
{editTripForm.emrID ? `EMR: ${editTripForm.emrID}` : 'NO-EMR'}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)', marginLeft: '8px' }}>
DB ID: {editingTrip.id} | Created: {formatDate(editingTrip.created)}
</span>
</div>
<span className={`status-pill ${editDraftMeta.className}`}>
{editDraftMeta.label}
</span>
</div>
<div className="trips-form" style={{ marginTop: '8px' }}>
<div className="trips-form-row">
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-emr-id">EMR ID</label>
<input
id="edit-emr-id"
type="number"
className="trips-form-input"
placeholder="e.g. 1042"
value={editTripForm.emrID}
onChange={(e) => setEditTripForm({ ...editTripForm, emrID: e.target.value })}
disabled={submitting}
/>
</div>
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-patient-name">Patient / Traveller Name *</label>
<input
id="edit-patient-name"
type="text"
className="trips-form-input"
value={editTripForm.name}
onChange={(e) => setEditTripForm({ ...editTripForm, name: e.target.value })}
disabled={submitting}
required
/>
</div>
</div>
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-location">Primary Location</label>
<input
id="edit-location"
type="text"
className="trips-form-input"
value={editTripForm.location}
onChange={(e) => setEditTripForm({ ...editTripForm, location: e.target.value })}
disabled={submitting}
/>
</div>
<div className="trips-form-field">
<label className="trips-form-label">Destination Country(ies)</label>
<CountrySelect
selectedISOs={editTripForm.countriesISO}
onChange={(updatedISOs, updatedLocationStr) => {
setEditTripForm({
...editTripForm,
countriesISO: updatedISOs,
location: updatedLocationStr,
})
}}
disabled={submitting}
placeholder="Search country..."
/>
</div>
<div className="trips-form-row">
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-departure">Departure Date</label>
<input
id="edit-departure"
type="date"
className="trips-form-input"
value={editTripForm.departure}
onChange={(e) => setEditTripForm({ ...editTripForm, departure: e.target.value })}
disabled={submitting}
/>
</div>
<div className="trips-form-field">
<label className="trips-form-label" htmlFor="edit-arrival">Return / Arrival Date</label>
<input
id="edit-arrival"
type="date"
className="trips-form-input"
value={editTripForm.arrival}
onChange={(e) => setEditTripForm({ ...editTripForm, arrival: e.target.value })}
disabled={submitting}
/>
</div>
</div>
</div>
{/* Live chips preview for edit form */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }}>
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-muted)' }}>ISO Preview:</span>
{editDraftISOs.length > 0 ? (
editDraftISOs.map((iso) => (
<span key={iso} className="destination-chip">📍 {iso}</span>
))
) : (
<span style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>None</span>
)}
</div>
</div>
<div className="trips-form-actions">
<button
type="submit"
className="btn-primary"
style={{ background: 'var(--color-accent-violet)' }}
disabled={submitting}
>
<Check size={16} />
{submitting ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
className="btn-secondary"
onClick={cancelEditing}
disabled={submitting}
>
<X size={16} />
Cancel Edit
</button>
</div>
</form>
)}
</section>
</div>
{/* ── BOTTOM HALF: Trips Database List ────────────────────────────────── */}
<section className="journey-panel" style={{ marginTop: 'var(--space-4)' }}>
<div className="trips-bottom-panel-header" style={{ padding: 'var(--space-5) var(--space-6)', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 className="bottom-panel-title" style={{ margin: 0, fontSize: 'var(--font-size-lg)', fontWeight: 700 }}>
All Active Trips List
</h2>
</div>
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 600, color: 'var(--color-primary)', background: 'var(--color-primary-glow)', padding: '4px 12px', borderRadius: '999px', border: '1px solid var(--color-border-active)' }}>
Total Records: {travellers.length}
</div>
</div>
<div className="journey-table-container">
<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>
<th scope="col">Status</th>
<th scope="col" style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{travellers.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) => {
const meta = STATUS_META[p.status] || STATUS_META['pre-travel']
const isBeingEdited = editingTrip?.id === p.id
const isos = getPatientISOs(p)
return (
<tr
key={p.id}
id={`trip-row-${p.id}`}
style={{
background: isBeingEdited ? 'rgba(139, 92, 246, 0.08)' : undefined,
transition: 'background var(--transition-fast)'
}}
>
<td>
<span className="log-patient-id" style={{ opacity: isBeingEdited ? 1 : 0.85 }}>
{p.emrID ? p.emrID : '—'}
</span>
</td>
<td className="patient-name">{p.name}</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span>📍 {p.location || 'N/A'}</span>
{isos.length > 0 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{isos.map((iso) => (
<span key={iso} className="destination-chip" style={{ fontSize: '10px', padding: '1px 6px' }}>
{iso}
</span>
))}
</div>
)}
</div>
</td>
<td>{formatDate(p.departure)}</td>
<td>{formatDate(p.arrival)}</td>
<td>
<span className={`status-pill ${meta.className}`}>
{meta.label}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div className="trips-list-actions" style={{ justifyContent: 'flex-end' }}>
<button
type="button"
className="btn-table-edit"
onClick={() => startEditing(p)}
disabled={submitting}
title={`Edit trip ${p.id}`}
>
<Edit3 size={13} />
{isBeingEdited ? 'Editing...' : 'Edit'}
</button>
<button
type="button"
className="btn-danger"
onClick={() => handleDeleteTrip(p.id, p.name)}
disabled={submitting}
title={`Delete trip ${p.id}`}
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</section>
</div>
)
}