yeswanth0212/token_system
1
1const { useState, useEffect, useRef } = React;2 3const API_URL = '/api';4 5function App() {6 const [counters, setCounters] = useState([]);7 const [waitingTokens, setWaitingTokens] = useState([]);8 const [patientName, setPatientName] = useState('');9 const [selectedDept, setSelectedDept] = useState('General');10 const [isPriority, setIsPriority] = useState(false);11 12 // Navigation & Filters13 const [activeTab, setActiveTab] = useState('dashboard'); // 'dashboard', 'staff', 'analytics'14 const [queueFilter, setQueueFilter] = useState('All');15 16 // Analytics Metrics17 const [analytics, setAnalytics] = useState({18 total_tokens_today: 0,19 total_completed_today: 0,20 total_skipped_today: 0,21 total_cancelled_today: 0,22 avg_wait_time_mins: 0.0,23 avg_serve_time_mins: 0.0,24 by_department: [],25 hourly_throughput: []26 });27 28 // Voice Announcement Ref tracking29 const prevServingRef = useRef({});30 const [speakingCounter, setSpeakingCounter] = useState(null);31 32 // Fetch live queue status33 const fetchQueue = async () => {34 try {35 const res = await fetch(`${API_URL}/queue`);36 if (!res.ok) throw new Error("Network response was not ok");37 const data = await res.json();38 39 // Check if any counter has called a new patient (for Voice Announcement!)40 data.counters.forEach(counter => {41 const prevToken = prevServingRef.current[counter.id];42 const currentToken = counter.current_token;43 44 if (currentToken && (!prevToken || prevToken.id !== currentToken.id)) {45 // Trigger announcement!46 announceCall(currentToken.token_number, counter.name, counter.id);47 }48 49 // Update cache50 prevServingRef.current[counter.id] = currentToken;51 });52 53 setCounters(data.counters);54 setWaitingTokens(data.waiting_tokens);55 } catch (error) {56 console.error("Error fetching queue:", error);57 }58 };59 60 // Fetch analytics data61 const fetchAnalytics = async () => {62 try {63 const res = await fetch(`${API_URL}/analytics`);64 if (!res.ok) throw new Error("Network response was not ok");65 const data = await res.json();66 setAnalytics(data);67 } catch (error) {68 console.error("Error fetching analytics:", error);69 }70 };71 72 // Voice Calling Announcer (Web Speech API)73 const announceCall = (tokenNum, counterName, counterId) => {74 if ('speechSynthesis' in window) {75 // Cancel any current utterances to play the newest one instantly76 window.speechSynthesis.cancel();77 78 const message = `Token ${tokenNum.split('-').join(' ')}, please proceed to ${counterName}`;79 const utterance = new SpeechSynthesisUtterance(message);80 utterance.rate = 0.95; // Slightly slower for clear hospital announcement81 utterance.pitch = 1.0;82 83 // Animate soundwave ring in UI84 utterance.onstart = () => setSpeakingCounter(counterId);85 utterance.onend = () => setSpeakingCounter(null);86 utterance.onerror = () => setSpeakingCounter(null);87 88 window.speechSynthesis.speak(utterance);89 }90 };91 92 // Polling effects93 useEffect(() => {94 fetchQueue();95 const interval = setInterval(fetchQueue, 3000); // Poll live queue every 3s96 return () => clearInterval(interval);97 }, []);98 99 useEffect(() => {100 if (activeTab === 'analytics') {101 fetchAnalytics();102 }103 }, [activeTab]);104 105 // Token Generation (Kiosk)106 const generateToken = async (e) => {107 e.preventDefault();108 if (!patientName.trim()) return;109 110 try {111 const res = await fetch(`${API_URL}/tokens`, {112 method: 'POST',113 headers: { 'Content-Type': 'application/json' },114 body: JSON.stringify({ 115 patient_name: patientName,116 department: selectedDept,117 priority: isPriority ? 1 : 0118 })119 });120 if (res.ok) {121 setPatientName('');122 setIsPriority(false);123 fetchQueue();124 125 // Show standard notification alert or just refresh126 const data = await res.json();127 alert(`Token Generated successfully!\nNumber: ${data.token_number}\nPatient: ${data.patient_name}`);128 }129 } catch (error) {130 console.error("Error generating token:", error);131 }132 };133 134 // Staff Counter Actions135 const callNext = async (counterId) => {136 try {137 const res = await fetch(`${API_URL}/counters/${counterId}/call`, { method: 'PUT' });138 if (res.ok) {139 fetchQueue();140 } else {141 alert("No waiting patients match this counter's departments.");142 }143 } catch (error) {144 console.error("Error calling next token:", error);145 }146 };147 148 const completeCurrent = async (counterId) => {149 try {150 await fetch(`${API_URL}/counters/${counterId}/complete`, { method: 'PUT' });151 fetchQueue();152 } catch (error) {153 console.error("Error completing token:", error);154 }155 };156 157 const skipCurrent = async (counterId) => {158 try {159 await fetch(`${API_URL}/counters/${counterId}/skip`, { method: 'PUT' });160 fetchQueue();161 } catch (error) {162 console.error("Error skipping token:", error);163 }164 };165 166 // Toggle Counter Online/Paused Status167 const toggleCounterStatus = async (counterId, currentStatus, supportedDepts) => {168 const nextStatus = currentStatus === 'active' ? 'paused' : 'active';169 try {170 await fetch(`${API_URL}/counters/${counterId}/status`, {171 method: 'PUT',172 headers: { 'Content-Type': 'application/json' },173 body: JSON.stringify({ 174 status: nextStatus,175 supported_departments: supportedDepts176 })177 });178 fetchQueue();179 } catch (error) {180 console.error("Error toggling counter status:", error);181 }182 };183 184 // Reset database to initial test state185 const resetKioskDB = async () => {186 if (confirm("Are you sure you want to reset the database? This deletes all current tokens and restores standard counter rules.")) {187 try {188 await fetch(`${API_URL}/reset`, { method: 'POST' });189 prevServingRef.current = {};190 fetchQueue();191 if (activeTab === 'analytics') fetchAnalytics();192 alert("Database reset completed successfully!");193 } catch (error) {194 console.error("Error resetting database:", error);195 }196 }197 };198 199 // Filter queue list based on department chip selection200 const filteredWaitingTokens = waitingTokens.filter(t => 201 queueFilter === 'All' || t.department === queueFilter202 );203 204 // Get max hourly count for graph scaling205 const maxHourlyCount = Math.max(...analytics.hourly_throughput.map(h => h.count), 1);206 207 return (208 <>209 <header>210 <div className="brand-section">211 <div className="brand-logo">ๅ</div>212 <div>213 <h1>CareQueue</h1>214 <span style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Clinic Token Manager</span>215 </div>216 </div>217 218 <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>219 <div className="nav-tabs">220 <button 221 className={`tab-btn ${activeTab === 'dashboard' ? 'active' : ''}`}222 onClick={() => setActiveTab('dashboard')}223 >224 ๐ Live Board225 </button>226 <button 227 className={`tab-btn ${activeTab === 'staff' ? 'active' : ''}`}228 onClick={() => setActiveTab('staff')}229 >230 ๐งโโ๏ธ Staff Panel231 </button>232 <button 233 className={`tab-btn ${activeTab === 'analytics' ? 'active' : ''}`}234 onClick={() => setActiveTab('analytics')}235 >236 ๐ Analytics237 </button>238 </div>239 <button 240 className="tab-btn" 241 style={{ border: '1px solid rgba(244,63,94,0.3)', color: 'var(--accent-rose)' }} 242 onClick={resetKioskDB}243 >244 ๐ Reset245 </button>246 </div>247 </header>248 249 <main className="container">250 {/* 1. DASHBOARD VIEW */}251 {activeTab === 'dashboard' && (252 <div className="dashboard-layout">253 <div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>254 {/* Serving Section */}255 <section className="glass-panel">256 <div className="panel-header">257 <h2>Now Serving</h2>258 <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>259 <span className="counter-status online"></span>260 <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>Live Status</span>261 </div>262 </div>263 <div className="counters-grid">264 {counters.map(counter => {265 const isOnline = counter.status === 'active';266 const isServing = counter.current_token;267 return (268 <div 269 key={counter.id} 270 className={`counter-card ${isServing ? 'active-serving' : ''}`}271 style={{ opacity: counter.status === 'offline' ? 0.4 : 1 }}272 >273 <div className="counter-header">274 <span className="counter-name">{counter.name}</span>275 <span className={`counter-status ${isOnline ? 'online' : 'paused'}`}></span>276 </div>277 278 {isServing ? (279 <>280 <div className="current-token pulsing">281 {counter.current_token.token_number}282 </div>283 <div className="current-patient">284 {counter.current_token.patient_name}285 </div>286 <div style={{ marginTop: '0.5rem', display: 'flex', justifyContent: 'center', gap: '0.5rem' }}>287 <span className={`dept-badge dept-${counter.current_token.department.toLowerCase()}`}>288 {counter.current_token.department}289 </span>290 {counter.current_token.priority === 1 && (291 <span className="priority-badge">โญ VIP</span>292 )}293 </div>294 295 {speakingCounter === counter.id && (296 <div style={{ marginTop: '0.75rem', display: 'flex', justifyContent: 'center' }}>297 <div className="speech-pulse"></div>298 </div>299 )}300 </>301 ) : (302 <div className="empty-state">303 {counter.status === 'paused' ? 'Paused' : 'Available'}304 </div>305 )}306 307 <div className="supported-list" style={{ marginTop: '1rem', fontSize: '0.7rem' }}>308 Route: {counter.supported_departments}309 </div>310 </div>311 );312 })}313 </div>314 </section>315 316 {/* Waiting Queue List */}317 <section className="glass-panel">318 <div className="panel-header">319 <h2>Waiting Queue</h2>320 <span style={{ fontSize: '0.85rem', color: 'var(--accent-cyan)', fontWeight: '700' }}>321 {filteredWaitingTokens.length} Patients322 </span>323 </div>324 325 <div className="dept-filters">326 {['All', 'General', 'Pediatrics', 'Cardiology', 'Dental'].map(dept => (327 <div 328 key={dept} 329 className={`filter-chip ${queueFilter === dept ? 'active' : ''}`}330 onClick={() => setQueueFilter(dept)}331 >332 {dept}333 </div>334 ))}335 </div>336 337 {filteredWaitingTokens.length === 0 ? (338 <div className="empty-state" style={{ textAlign: 'center', padding: '3rem 0' }}>339 No patients waiting in {queueFilter === 'All' ? 'queue' : `${queueFilter} department`}.340 </div>341 ) : (342 <div className="waiting-list">343 {filteredWaitingTokens.map(token => (344 <div 345 key={token.id} 346 className={`waiting-item ${token.priority === 1 ? 'priority-item' : ''}`}347 >348 <div className="token-info">349 <div className="token-num">{token.token_number}</div>350 <div className="patient-name-container">351 <div className="patient-name">{token.patient_name}</div>352 <div>353 <span className={`dept-badge dept-${token.department.toLowerCase()}`}>354 {token.department}355 </span>356 </div>357 </div>358 </div>359 360 <div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem' }}>361 {token.priority === 1 && (362 <span className="priority-badge">โญ Senior/Priority</span>363 )}364 <div className="wait-time">365 <span>Est. Wait</span>366 <span>{token.estimated_wait_time_mins} mins</span>367 </div>368 </div>369 </div>370 ))}371 </div>372 )}373 </section>374 </div>375 376 {/* Patient Kiosk Form */}377 <div className="actions-panel">378 <section className="glass-panel kiosk-section">379 <h2 style={{ marginBottom: '1.25rem' }}>๐ซ Ticket Kiosk</h2>380 <form onSubmit={generateToken} className="kiosk-form">381 <div className="input-group">382 <label>Patient Name</label>383 <input 384 type="text" 385 placeholder="Enter full name" 386 value={patientName}387 onChange={(e) => setPatientName(e.target.value)}388 required389 />390 </div>391 392 <div className="input-group">393 <label>Select Specialty</label>394 <div className="kiosk-grid">395 {['General', 'Pediatrics', 'Cardiology', 'Dental'].map(dept => (396 <div 397 key={dept} 398 className={`dept-select-card ${selectedDept === dept ? 'selected' : ''}`}399 onClick={() => setSelectedDept(dept)}400 >401 <span>{dept}</span>402 </div>403 ))}404 </div>405 </div>406 407 <div className="input-group">408 <label className="checkbox-container">409 <input 410 type="checkbox" 411 checked={isPriority}412 onChange={(e) => setIsPriority(e.target.checked)}413 />414 Priority service (Senior Citizen / Urgency)415 </label>416 </div>417 418 <button type="submit" className="primary">419 Generate Token420 </button>421 </form>422 </section>423 </div>424 </div>425 )}426 427 {/* 2. STAFF WORKSPACE VIEW */}428 {activeTab === 'staff' && (429 <div className="staff-layout">430 <div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>431 <section className="glass-panel">432 <div className="panel-header">433 <h2>Staff Counters Control</h2>434 <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>Manage active services</span>435 </div>436 437 <div className="staff-counters-grid">438 {counters.map(counter => {439 const isOnline = counter.status === 'active';440 const isServing = counter.current_token;441 return (442 <div key={counter.id} className="staff-counter-card">443 <div className="staff-card-header">444 <div>445 <h3 style={{ fontSize: '1.15rem' }}>{counter.name}</h3>446 <span className="supported-list">447 Serves: {counter.supported_departments}448 </span>449 </div>450 <button 451 className={`filter-chip ${isOnline ? 'active' : ''}`}452 style={{ width: 'auto', padding: '0.25rem 0.75rem', fontSize: '0.75rem' }}453 onClick={() => toggleCounterStatus(counter.id, counter.status, counter.supported_departments)}454 >455 {isOnline ? '๐ข Online' : '๐ก Paused'}456 </button>457 </div>458 459 <div className="staff-serving-section">460 {isServing ? (461 <>462 <div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', textTransform: 'uppercase' }}>463 Serving Now464 </div>465 <div className="current-token" style={{ color: 'var(--accent-cyan)' }}>466 {counter.current_token.token_number}467 </div>468 <div className="current-patient" style={{ fontWeight: '700' }}>469 {counter.current_token.patient_name}470 </div>471 </>472 ) : (473 <div style={{ padding: '1rem 0', color: 'var(--text-secondary)', fontStyle: 'italic' }}>474 No patient called yet475 </div>476 )}477 </div>478 479 <div className="staff-actions">480 <button 481 className="success" 482 onClick={() => callNext(counter.id)}483 disabled={!isOnline}484 style={{ opacity: !isOnline ? 0.4 : 1, cursor: !isOnline ? 'not-allowed' : 'pointer' }}485 >486 Call Next487 </button>488 <button 489 className="warning" 490 onClick={() => skipCurrent(counter.id)}491 disabled={!isServing}492 style={{ opacity: !isServing ? 0.4 : 1, cursor: !isServing ? 'not-allowed' : 'pointer' }}493 >494 Skip Patient495 </button>496 <button 497 className="danger" 498 onClick={() => completeCurrent(counter.id)}499 disabled={!isServing}500 style={{ opacity: !isServing ? 0.4 : 1, cursor: !isServing ? 'not-allowed' : 'pointer', gridColumn: 'span 2' }}501 >502 Mark Completed503 </button>504 </div>505 </div>506 );507 })}508 </div>509 </section>510 </div>511 512 {/* Interactive Voice Test Utility */}513 <div className="actions-panel">514 <section className="glass-panel">515 <h3>๐ Audio Kiosk Tester</h3>516 <p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', margin: '0.75rem 0' }}>517 Staff can announce token calling using high-quality web voice synthesis. Click a sample to preview:518 </p>519 <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>520 <button className="secondary-btn" onClick={() => announceCall('GEN-001', 'Counter 1', 1)}>521 Call GEN-001 at Counter 1522 </button>523 <button className="secondary-btn" onClick={() => announceCall('PED-003', 'Counter 2', 2)}>524 Call PED-003 at Counter 2525 </button>526 <button className="secondary-btn" onClick={() => announceCall('CAR-002', 'Counter 3', 3)}>527 Call CAR-002 at Counter 3528 </button>529 </div>530 </section>531 </div>532 </div>533 )}534 535 {/* 3. ANALYTICS & REPORTS VIEW */}536 {activeTab === 'analytics' && (537 <div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>538 {/* KPI Metrics */}539 <section className="analytics-metrics">540 <div className="metric-card">541 <div className="metric-icon" style={{ background: 'rgba(6,182,212,0.12)', color: 'var(--accent-cyan)' }}>๐ซ</div>542 <div className="metric-details">543 <span className="metric-value">{analytics.total_tokens_today}</span>544 <span className="metric-label">Total Registered</span>545 </div>546 </div>547 <div className="metric-card">548 <div className="metric-icon" style={{ background: 'rgba(16,185,129,0.12)', color: 'var(--accent-emerald)' }}>โ
</div>549 <div className="metric-details">550 <span className="metric-value">{analytics.total_completed_today}</span>551 <span className="metric-label">Completed</span>552 </div>553 </div>554 <div className="metric-card">555 <div className="metric-icon" style={{ background: 'rgba(245,158,11,0.12)', color: 'var(--accent-amber)' }}>โณ</div>556 <div className="metric-details">557 <span className="metric-value">{analytics.avg_wait_time_mins}m</span>558 <span className="metric-label">Avg Wait Time</span>559 </div>560 </div>561 <div className="metric-card">562 <div className="metric-icon" style={{ background: 'rgba(244,63,94,0.12)', color: 'var(--accent-rose)' }}>๐ซ</div>563 <div className="metric-details">564 <span className="metric-value">565 {analytics.total_skipped_today + analytics.total_cancelled_today}566 </span>567 <span className="metric-label">Skipped/Cancelled</span>568 </div>569 </div>570 </section>571 572 {/* Chart Grid */}573 <section className="analytics-charts">574 {/* Hourly Flow Chart */}575 <div className="glass-panel chart-panel">576 <h2>Hourly Flow Throughput</h2>577 <div className="bar-chart-container">578 {analytics.hourly_throughput.map(item => {579 const percentageHeight = (item.count / maxHourlyCount) * 80; // scale to 80% max580 return (581 <div key={item.hour} className="bar-col">582 <div 583 className="bar-fill" 584 style={{ height: `${Math.max(percentageHeight, 4)}%` }}585 >586 <div className="bar-tooltip">{item.count} tickets</div>587 </div>588 <span className="bar-label">{item.hour}</span>589 </div>590 );591 })}592 </div>593 </div>594 595 {/* Department Distribution Chart */}596 <div className="glass-panel chart-panel">597 <h2>Department Routing Share</h2>598 <div className="pie-chart-list">599 {analytics.by_department.map(item => {600 const percentage = analytics.total_tokens_today > 0 601 ? Math.round((item.count / analytics.total_tokens_today) * 100) 602 : 0;603 604 const colorDot = {605 'General': 'var(--accent-cyan)',606 'Pediatrics': 'var(--accent-emerald)',607 'Cardiology': 'var(--accent-rose)',608 'Dental': 'var(--accent-amber)'609 }[item.department] || 'var(--text-secondary)';610 611 return (612 <div key={item.department} className="pie-chart-row">613 <div className="dept-name-wrapper">614 <span className="dept-color-dot" style={{ background: colorDot }}></span>615 <span style={{ fontWeight: '600' }}>{item.department}</span>616 </div>617 <div className="chart-bar-bg">618 <div 619 className="chart-bar-fill" 620 style={{ 621 width: `${percentage}%`, 622 background: colorDot 623 }}624 ></div>625 </div>626 <div style={{ minWidth: '60px', textAlign: 'right' }}>627 <span style={{ fontWeight: '700' }}>{item.count}</span>628 <span style={{ color: 'var(--text-secondary)', fontSize: '0.75rem', marginLeft: '0.25rem' }}>629 ({percentage}%)630 </span>631 </div>632 </div>633 );634 })}635 </div>636 </div>637 </section>638 639 {/* Export & Data Report Details */}640 <section className="glass-panel" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>641 <div>642 <h3>๐ Daily Logs Report</h3>643 <p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>644 Export all register records, serving times, department statistics, and wait estimations as a CSV file.645 </p>646 </div>647 <a 648 href={`${API_URL}/analytics/export`} 649 target="_blank"650 download651 className="tab-btn active"652 style={{ textDecoration: 'none', height: 'auto', display: 'inline-flex', alignItems: 'center', background: 'linear-gradient(135deg, var(--accent-cyan), var(--accent-emerald))', padding: '0.8rem 1.5rem', borderRadius: '0.75rem' }}653 >654 ๐ฅ Export CSV Report655 </a>656 </section>657 </div>658 )}659 </main>660 </>661 );662}663 664const root = ReactDOM.createRoot(document.getElementById('root'));665root.render(<App />);666 667 