492 lines
19 KiB
JavaScript
492 lines
19 KiB
JavaScript
import React, { useState, useRef } from 'react';
|
|
import { AlertCircle, CheckCircle, Clock, User } from 'lucide-react';
|
|
import Keyboard from "react-simple-keyboard";
|
|
import "react-simple-keyboard/build/css/index.css";
|
|
|
|
const API_BASE = `http://${window.location.hostname}:8000`;
|
|
|
|
function App() {
|
|
const [step, setStep] = useState('welcome');
|
|
const [formData, setFormData] = useState({
|
|
firstName: '',
|
|
lastName: '',
|
|
dob: '',
|
|
symptoms: [],
|
|
severity: 'moderate'
|
|
});
|
|
const [dobDisplay, setDobDisplay] = useState('');
|
|
const [assignedBand, setAssignedBand] = useState(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const [showKeyboard, setShowKeyboard] = useState(false);
|
|
const [activeField, setActiveField] = useState(null);
|
|
const [layoutName, setLayoutName] = useState("default");
|
|
const keyboard = useRef();
|
|
|
|
const symptoms = [
|
|
'Chest Pain', 'Difficulty Breathing', 'Severe Headache',
|
|
'Abdominal Pain', 'Fever', 'Nausea/Vomiting',
|
|
'Dizziness', 'Injury/Trauma', 'Other'
|
|
];
|
|
|
|
const fieldOrder = ['firstName', 'lastName', 'dob'];
|
|
|
|
const handleSymptomToggle = (symptom) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
symptoms: prev.symptoms.includes(symptom)
|
|
? prev.symptoms.filter(s => s !== symptom)
|
|
: [...prev.symptoms, symptom]
|
|
}));
|
|
};
|
|
|
|
const formatDateUS = (digits) => {
|
|
// Only keep digits, max 8
|
|
const cleaned = digits.replace(/\D/g, '').slice(0, 8);
|
|
|
|
let formatted = '';
|
|
|
|
// Add slashes as user types: MM/DD/YYYY
|
|
if (cleaned.length >= 1) {
|
|
formatted = cleaned.slice(0, 2); // MM
|
|
}
|
|
if (cleaned.length >= 3) {
|
|
formatted += '/' + cleaned.slice(2, 4); // DD
|
|
}
|
|
if (cleaned.length >= 5) {
|
|
formatted += '/' + cleaned.slice(4, 8); // YYYY
|
|
}
|
|
|
|
return formatted;
|
|
};
|
|
|
|
const convertToISODate = (usDate) => {
|
|
// Convert MM/DD/YYYY to YYYY-MM-DD for backend
|
|
const match = usDate.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
|
if (match) {
|
|
const [_, month, day, year] = match;
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const getDatePreview = (usDate) => {
|
|
const match = usDate.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
|
if (match) {
|
|
const [_, month, day, year] = match;
|
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
const monthIdx = parseInt(month) - 1;
|
|
if (monthIdx >= 0 && monthIdx < 12) {
|
|
const monthName = months[monthIdx];
|
|
return `${monthName} ${parseInt(day)}, ${year}`;
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const onKeyboardChange = (input) => {
|
|
if (activeField === 'dob') {
|
|
// Keep only the raw digits the user types
|
|
const digitsOnly = input.replace(/\D/g, '');
|
|
const formatted = formatDateUS(digitsOnly);
|
|
|
|
setDobDisplay(formatted);
|
|
setFormData(prev => ({
|
|
...prev,
|
|
dob: convertToISODate(formatted)
|
|
}));
|
|
} else if (activeField) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
[activeField]: input
|
|
}));
|
|
}
|
|
};
|
|
|
|
const onKeyPress = (button) => {
|
|
if (button === "{shift}" || button === "{lock}") {
|
|
setLayoutName(layoutName === "default" ? "shift" : "default");
|
|
}
|
|
|
|
if (button === "{tab}") {
|
|
const currentIndex = fieldOrder.indexOf(activeField);
|
|
const nextIndex = (currentIndex + 1) % fieldOrder.length;
|
|
const nextField = fieldOrder[nextIndex];
|
|
|
|
setActiveField(nextField);
|
|
setLayoutName(nextField === 'dob' ? 'numbers' : 'default');
|
|
|
|
setTimeout(() => {
|
|
if (keyboard.current) {
|
|
const value = nextField === 'dob' ? dobDisplay.replace(/\//g, '') : formData[nextField] || '';
|
|
keyboard.current.setInput(value);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
if (button === "{enter}") {
|
|
setShowKeyboard(false);
|
|
setActiveField(null);
|
|
}
|
|
};
|
|
|
|
const handleInputFocus = (fieldName) => {
|
|
setActiveField(fieldName);
|
|
setShowKeyboard(true);
|
|
setLayoutName(fieldName === 'dob' ? 'numbers' : 'default');
|
|
|
|
setTimeout(() => {
|
|
if (keyboard.current) {
|
|
// For date field, show only digits (no slashes) in keyboard input
|
|
const value = fieldName === 'dob' ? dobDisplay.replace(/\//g, '') : formData[fieldName] || '';
|
|
keyboard.current.setInput(value);
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/api/checkin`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(formData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
setAssignedBand({
|
|
patientId: data.patient_id,
|
|
bandId: data.band_id,
|
|
station: Math.floor(Math.random() * 8) + 1
|
|
});
|
|
|
|
setStep('complete');
|
|
setShowKeyboard(false);
|
|
} catch (error) {
|
|
setError(error.message);
|
|
alert(`Failed to check in: ${error.message}`);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (step === 'welcome') {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-2xl shadow-2xl p-12 max-w-2xl w-full text-center">
|
|
<div className="mb-8">
|
|
<div className="bg-blue-600 rounded-full w-24 h-24 flex items-center justify-center mx-auto mb-6">
|
|
<User className="w-12 h-12 text-white" />
|
|
</div>
|
|
<h1 className="text-4xl font-bold text-gray-800 mb-3">Welcome to VitalLink</h1>
|
|
<p className="text-xl text-gray-600">Emergency Room Check-In</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 mb-8 text-left bg-blue-50 p-6 rounded-xl">
|
|
<h2 className="font-semibold text-lg text-gray-800 mb-3">What to expect:</h2>
|
|
<div className="flex items-start gap-3">
|
|
<CheckCircle className="w-5 h-5 text-green-600 mt-1 flex-shrink-0" />
|
|
<p className="text-gray-700 text-lg">Answer a few questions about your condition</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<CheckCircle className="w-5 h-5 text-green-600 mt-1 flex-shrink-0" />
|
|
<p className="text-gray-700 text-lg">Receive a smart wristband to monitor your vitals</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<CheckCircle className="w-5 h-5 text-green-600 mt-1 flex-shrink-0" />
|
|
<p className="text-gray-700 text-lg">Wait comfortably while we track your condition</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setStep('form')}
|
|
className="bg-blue-600 text-white px-16 py-6 rounded-xl text-2xl font-bold hover:bg-blue-700 transition-colors shadow-lg active:scale-95"
|
|
>
|
|
Start Check-In
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (step === 'form') {
|
|
const datePreview = getDatePreview(dobDisplay);
|
|
|
|
return (
|
|
<div className={`min-h-screen bg-gradient-to-br from-blue-50 to-blue-100 p-4 transition-all ${showKeyboard ? 'pb-[550px]' : ''}`}>
|
|
<div className="max-w-3xl mx-auto pt-8">
|
|
<div className="bg-white rounded-2xl shadow-2xl p-8">
|
|
<h2 className="text-3xl font-bold text-gray-800 mb-6">Patient Information</h2>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border-2 border-red-300 text-red-800 p-4 rounded-lg mb-6">
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="w-5 h-5" />
|
|
<p className="font-semibold">Error: {error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
First Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.firstName}
|
|
onFocus={() => handleInputFocus('firstName')}
|
|
onChange={(e) => setFormData({...formData, firstName: e.target.value})}
|
|
className="w-full px-6 py-4 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none text-xl font-semibold cursor-pointer"
|
|
placeholder="Tap to type"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Last Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.lastName}
|
|
onFocus={() => handleInputFocus('lastName')}
|
|
onChange={(e) => setFormData({...formData, lastName: e.target.value})}
|
|
className="w-full px-6 py-4 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none text-xl font-semibold cursor-pointer"
|
|
placeholder="Tap to type"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Date of Birth *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={dobDisplay}
|
|
onFocus={() => handleInputFocus('dob')}
|
|
readOnly
|
|
className="w-full px-6 py-4 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none text-xl font-semibold cursor-pointer"
|
|
placeholder="MM/DD/YYYY"
|
|
/>
|
|
{datePreview && (
|
|
<p className="mt-2 text-green-600 font-semibold text-lg flex items-center gap-2">
|
|
<CheckCircle className="w-5 h-5" />
|
|
{datePreview}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
Select Your Symptoms *
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{symptoms.map((symptom) => (
|
|
<button
|
|
key={symptom}
|
|
onClick={() => handleSymptomToggle(symptom)}
|
|
type="button"
|
|
className={`px-6 py-5 rounded-lg border-2 transition-all text-left font-semibold text-lg active:scale-95 ${
|
|
formData.symptoms.includes(symptom)
|
|
? 'bg-blue-100 border-blue-500 text-blue-700'
|
|
: 'bg-white border-gray-300 text-gray-700 hover:border-blue-300'
|
|
}`}
|
|
>
|
|
{symptom}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
How severe are your symptoms? *
|
|
</label>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{['mild', 'moderate', 'severe'].map((level) => (
|
|
<button
|
|
key={level}
|
|
onClick={() => setFormData({...formData, severity: level})}
|
|
type="button"
|
|
className={`px-8 py-6 rounded-lg border-2 transition-all font-bold capitalize text-xl active:scale-95 ${
|
|
formData.severity === level
|
|
? level === 'severe'
|
|
? 'bg-red-100 border-red-500 text-red-700'
|
|
: level === 'moderate'
|
|
? 'bg-yellow-100 border-yellow-500 text-yellow-700'
|
|
: 'bg-green-100 border-green-500 text-green-700'
|
|
: 'bg-white border-gray-300 text-gray-700 hover:border-gray-400'
|
|
}`}
|
|
>
|
|
{level}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-4 mt-8">
|
|
<button
|
|
onClick={() => setStep('welcome')}
|
|
type="button"
|
|
className="flex-1 px-8 py-5 border-2 border-gray-300 text-gray-700 rounded-xl text-xl font-bold hover:bg-gray-50 transition-colors active:scale-95"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!formData.firstName || !formData.lastName || !formData.dob || formData.symptoms.length === 0 || isSubmitting}
|
|
type="button"
|
|
className="flex-1 px-8 py-5 bg-blue-600 text-white rounded-xl text-xl font-bold hover:bg-blue-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed active:scale-95"
|
|
>
|
|
{isSubmitting ? 'Checking In...' : 'Complete Check-In'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Large Touchscreen Keyboard */}
|
|
{showKeyboard && (
|
|
<div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-gray-800 to-gray-700 border-t-4 border-blue-500 shadow-2xl p-6 z-50">
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="text-center mb-4">
|
|
<p className="text-white text-2xl font-bold mb-2">
|
|
{activeField === 'firstName' ? '👤 First Name' :
|
|
activeField === 'lastName' ? '👤 Last Name' :
|
|
'📅 Date of Birth'}
|
|
</p>
|
|
{activeField === 'dob' && (
|
|
<div className="bg-blue-600 text-white px-6 py-3 rounded-lg inline-block">
|
|
<p className="text-lg">
|
|
Type: <span className="font-mono text-2xl">{dobDisplay || 'MM/DD/YYYY'}</span>
|
|
</p>
|
|
{datePreview && (
|
|
<p className="text-sm text-blue-200 mt-1">= {datePreview}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Keyboard
|
|
keyboardRef={r => (keyboard.current = r)}
|
|
layoutName={activeField === 'dob' ? 'numbers' : layoutName}
|
|
onChange={onKeyboardChange}
|
|
onKeyPress={onKeyPress}
|
|
theme="hg-theme-default hg-layout-default kiosk-keyboard"
|
|
display={{
|
|
'{bksp}': '⌫ Delete',
|
|
'{enter}': '✓ Done',
|
|
'{tab}': '→ Next',
|
|
'{shift}': '⬆',
|
|
'{space}': '_____ Space _____',
|
|
}}
|
|
layout={activeField === 'dob' ? {
|
|
numbers: [
|
|
"1 2 3",
|
|
"4 5 6",
|
|
"7 8 9",
|
|
"{bksp} 0 {tab}",
|
|
"{enter}"
|
|
]
|
|
} : {
|
|
default: [
|
|
"Q W E R T Y U I O P {bksp}",
|
|
"A S D F G H J K L",
|
|
"{shift} Z X C V B N M {shift}",
|
|
"{tab} {space} {enter}"
|
|
],
|
|
shift: [
|
|
"Q W E R T Y U I O P {bksp}",
|
|
"A S D F G H J K L",
|
|
"{shift} Z X C V B N M {shift}",
|
|
"{tab} {space} {enter}"
|
|
]
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (step === 'complete') {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-green-50 to-green-100 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-2xl shadow-2xl p-12 max-w-2xl w-full text-center">
|
|
<div className="mb-6">
|
|
<div className="bg-green-600 rounded-full w-24 h-24 flex items-center justify-center mx-auto mb-6">
|
|
<CheckCircle className="w-12 h-12 text-white" />
|
|
</div>
|
|
<h1 className="text-4xl font-bold text-gray-800 mb-3">Check-In Complete!</h1>
|
|
<p className="text-xl text-gray-600">Your wristband has been assigned</p>
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-8 mb-8 text-white">
|
|
<p className="text-sm uppercase tracking-wide mb-2 opacity-90">Your Patient ID</p>
|
|
<p className="text-3xl font-bold mb-4">{assignedBand?.patientId}</p>
|
|
<div className="border-t border-white/30 pt-4">
|
|
<p className="text-sm uppercase tracking-wide mb-2 opacity-90">Wristband ID</p>
|
|
<p className="text-2xl font-semibold">{assignedBand?.bandId}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 text-left bg-blue-50 p-6 rounded-xl mb-8">
|
|
<h2 className="font-bold text-lg text-gray-800 mb-3">Next Steps:</h2>
|
|
<div className="flex items-start gap-3">
|
|
<div className="bg-blue-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold flex-shrink-0">1</div>
|
|
<p className="text-gray-700 pt-1 text-lg">
|
|
<strong>Pick up your wristband</strong> from Station {assignedBand?.station}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<div className="bg-blue-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold flex-shrink-0">2</div>
|
|
<p className="text-gray-700 pt-1 text-lg">
|
|
<strong>Wear it on your wrist</strong> - make sure it's snug but comfortable
|
|
</p>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<div className="bg-blue-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold flex-shrink-0">3</div>
|
|
<p className="text-gray-700 pt-1 text-lg">
|
|
<strong>Take a seat in the waiting area</strong> - your vitals are being monitored
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center gap-2 text-gray-600 mb-6">
|
|
<Clock className="w-6 h-6" />
|
|
<p className="text-lg">A nurse will call you when it's your turn</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setStep('welcome');
|
|
setFormData({ firstName: '', lastName: '', dob: '', symptoms: [], severity: 'moderate' });
|
|
setDobDisplay('');
|
|
setAssignedBand(null);
|
|
setError(null);
|
|
}}
|
|
className="text-blue-600 text-xl font-bold hover:underline"
|
|
>
|
|
Return to Start
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export default App;
|