From 2ef518f30e9cbe939522e62f6a786bcbe3a0b915 Mon Sep 17 00:00:00 2001 From: Deep Koluguri Date: Wed, 8 Jul 2026 23:57:55 -0400 Subject: [PATCH] feat: add operational database schema and live queue walk-in flow --- backend/db.js | 27 ++++++++ backend/server.js | 85 +++++++++++++++++++++++ dashboard.html | 97 ++++++++++++++------------- dashboard.js | 167 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 329 insertions(+), 47 deletions(-) diff --git a/backend/db.js b/backend/db.js index c73bd1c..c2d2503 100644 --- a/backend/db.js +++ b/backend/db.js @@ -97,6 +97,32 @@ CREATE TABLE IF NOT EXISTS prescriptions ( created_at TIMESTAMPTZ DEFAULT now() ); +CREATE TABLE IF NOT EXISTS patients ( + id SERIAL PRIMARY KEY, + tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE, + display_id TEXT NOT NULL, + name TEXT NOT NULL, + whatsapp_number TEXT NOT NULL, + age INTEGER, + gender TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(tenant_id, whatsapp_number) +); + +CREATE TABLE IF NOT EXISTS visits ( + id SERIAL PRIMARY KEY, + tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE, + patient_id INTEGER REFERENCES patients(id) ON DELETE CASCADE, + token_number TEXT NOT NULL, + status TEXT DEFAULT 'waiting' CHECK (status IN ('waiting', 'in-room', 'completed', 'cancelled')), + type TEXT DEFAULT 'walk-in' CHECK (type IN ('walk-in', 'online')), + triage_category TEXT DEFAULT 'Routine', + payment_status TEXT DEFAULT 'unpaid' CHECK (payment_status IN ('unpaid', 'paid')), + fee INTEGER DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); + CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_pending_status ON pending_registrations(status); @@ -139,6 +165,7 @@ async function seedDemoUsers() { const hash = await bcrypt.hash(defaultPassword, 10); const demoUsers = [ { email: 'doctor@curio.app', name: 'Dr. Sharma', role: 'DOCTOR', tenant_id: 'default' }, + { email: 'reception@curio.app', name: 'Front Desk', role: 'RECEPTIONIST', tenant_id: 'default' }, { email: 'pharmacy@curio.app', name: 'Pharmacist', role: 'PHARMACIST', tenant_id: 'default' }, ]; diff --git a/backend/server.js b/backend/server.js index e85087c..09fa40a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -499,6 +499,91 @@ app.patch('/api/clinic/agents/:type', requireAuth('OWNER'), async (req, res) => } }); +// ── CLINIC OPERATIONS (QUEUE & PATIENTS) ────────────────────────────────────── + +/** + * GET /api/clinic/queue + * Fetch the live queue for the tenant + */ +app.get('/api/clinic/queue', requireAuth(), async (req, res) => { + try { + const tenantId = req.user.tenantId; + const result = await query( + `SELECT v.*, p.name as patient_name, p.whatsapp_number, p.age, p.gender, p.display_id + FROM visits v + JOIN patients p ON v.patient_id = p.id + WHERE v.tenant_id = $1 AND v.status IN ('waiting', 'in-room') + ORDER BY v.created_at ASC`, + [tenantId] + ); + res.json(result.rows); + } catch (err) { + console.error('[Clinic Queue]', err); + res.status(500).json({ error: 'Failed to fetch queue' }); + } +}); + +/** + * POST /api/clinic/walk-in + * Register a new walk-in patient and add to queue + */ +app.post('/api/clinic/walk-in', requireAuth(), async (req, res) => { + const { name, whatsappNumber, age, gender, fee } = req.body; + const tenantId = req.user.tenantId; + + if (!name || !whatsappNumber) return res.status(400).json({ error: 'Name and WhatsApp number are required' }); + + try { + await query('BEGIN'); + + // Find or create patient + let patientResult = await query( + 'SELECT id FROM patients WHERE tenant_id = $1 AND whatsapp_number = $2', + [tenantId, whatsappNumber] + ); + + let patientId; + if (patientResult.rows.length > 0) { + patientId = patientResult.rows[0].id; + } else { + // Generate display ID + const countRes = await query('SELECT count(*) FROM patients WHERE tenant_id = $1', [tenantId]); + const nextNum = parseInt(countRes.rows[0].count) + 1001; + const displayId = `CF-${nextNum}`; + + const insertPatient = await query( + `INSERT INTO patients (tenant_id, display_id, name, whatsapp_number, age, gender) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [tenantId, displayId, name, whatsappNumber, age || null, gender || null] + ); + patientId = insertPatient.rows[0].id; + } + + // Generate Token Number (simple count-based for the day) + const todayStr = new Date().toISOString().split('T')[0]; + const tokenRes = await query( + `SELECT count(*) FROM visits WHERE tenant_id = $1 AND DATE(created_at) = $2`, + [tenantId, todayStr] + ); + const tokenNum = parseInt(tokenRes.rows[0].count) + 1; + const tokenStr = `#${tokenNum.toString().padStart(3, '0')}`; + + // Insert Visit + const insertVisit = await query( + `INSERT INTO visits (tenant_id, patient_id, token_number, type, status, fee) + VALUES ($1, $2, $3, 'walk-in', 'waiting', $4) RETURNING *`, + [tenantId, patientId, tokenStr, fee || 0] + ); + + await query('COMMIT'); + res.json({ success: true, visit: insertVisit.rows[0], token: tokenStr }); + } catch (err) { + await query('ROLLBACK'); + console.error('[Clinic Walk-in]', err); + res.status(500).json({ error: 'Failed to register walk-in' }); + } +}); + // ── CLINIC OWNER ROUTES ─────────────────────────────────────────────────────── /** diff --git a/dashboard.html b/dashboard.html index 399f2e0..af6c6b7 100644 --- a/dashboard.html +++ b/dashboard.html @@ -57,7 +57,7 @@ Reception Counter: 1 - + @@ -125,21 +125,8 @@

Up Next (Ctrl+Q)

4 Waiting -
- -
-
#014
-
- Suresh Kumar - Back Pain -
-
+
+
@@ -306,37 +293,8 @@ Actions - - - #012 - Rahul Verma - Online - HI - High Fever / Cough - In Room - 09:15 AM - - - - #013 - Anjali Singh - Walk-in - TE - General Checkup - Unpaid (₹500) - 09:30 AM - - - - #014 - Suresh Kumar - Online - EN - Back Pain - Paid (Waiting) - 09:45 AM - - + +
@@ -572,6 +530,51 @@ + + + diff --git a/dashboard.js b/dashboard.js index abc5357..db70143 100644 --- a/dashboard.js +++ b/dashboard.js @@ -985,3 +985,170 @@ async function submitPrescription() { alert('An error occurred while creating the prescription.'); } } + +// ========================================== +// Live Queue & Walk-in UI Logic +// ========================================== + +let liveQueuePollingInterval = null; + +async function fetchLiveQueue() { + const token = localStorage.getItem("curio_token"); + if (!token) return; + + try { + const res = await fetch('/api/clinic/queue', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const queue = await res.json(); + renderLiveQueueTable(queue); + renderZenPipeline(queue); + updateQueueStats(queue); + } + } catch (err) { + console.error("Failed to fetch live queue:", err); + } +} + +function updateQueueStats(queue) { + const counterBadge = document.querySelector('.counter-badge'); + if (counterBadge) counterBadge.innerText = `Waiting: ${queue.filter(q => q.status === 'waiting').length}`; + + const waitingSpan = document.querySelector('.pipeline-header .badge'); + if (waitingSpan) waitingSpan.innerText = `${queue.filter(q => q.status === 'waiting').length} Waiting`; +} + +function renderLiveQueueTable(queue) { + const tbody = document.getElementById("liveQueueTbody"); + if (!tbody) return; + + if (queue.length === 0) { + tbody.innerHTML = `Queue is empty`; + return; + } + + tbody.innerHTML = queue.map(visit => { + const time = new Date(visit.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + const sourceClass = visit.type === 'online' ? 'online' : 'walkin'; + const sourceText = visit.type === 'online' ? 'Online' : 'Walk-in'; + + let statusHtml = ''; + if (visit.status === 'in-room') { + statusHtml = `In Room`; + } else if (visit.payment_status === 'unpaid') { + statusHtml = `Unpaid (₹${visit.fee})`; + } else { + statusHtml = `Paid (Waiting)`; + } + + let actionHtml = ''; + if (visit.status === 'in-room') { + actionHtml = ``; + } else if (visit.payment_status === 'unpaid') { + actionHtml = ``; + } else { + actionHtml = ``; + } + + return ` + + ${visit.token_number} + ${visit.patient_name}
${visit.display_id} + ${sourceText} + EN + ${visit.triage_category || 'Routine'} + ${statusHtml} + ${time} + ${actionHtml} + + `; + }).join(''); +} + +function renderZenPipeline(queue) { + const pipelineList = document.getElementById("livePipelineList"); + if (!pipelineList) return; + + if (queue.length === 0) { + pipelineList.innerHTML = `
No patients waiting
`; + return; + } + + pipelineList.innerHTML = queue.map((visit, index) => { + const isNext = index === 0 && visit.status !== 'in-room'; + const isActive = visit.status === 'in-room'; + const itemClass = isActive ? 'active' : (isNext ? 'next' : ''); + const triageClass = visit.triage_category === 'Urgent' ? 'urgent' : 'routine'; + + return ` +
+
${visit.token_number}
+
+ ${visit.patient_name} + ${visit.triage_category || 'Routine'} +
+
+ `; + }).join(''); +} + +// Walk-in Modal Handlers +window.openWalkinModal = () => { + document.getElementById('walkInModal')?.classList.add('open'); +}; + +window.closeWalkinModal = () => { + document.getElementById('walkInModal')?.classList.remove('open'); + document.getElementById('walkInForm')?.reset(); +}; + +window.submitWalkIn = async () => { + const btn = document.getElementById("submitWalkInBtn"); + const name = document.getElementById("walkInName").value.trim(); + const phone = document.getElementById("walkInPhone").value.trim(); + const age = document.getElementById("walkInAge").value; + const gender = document.getElementById("walkInGender").value; + const fee = document.getElementById("walkInFee").value; + + if (!name || !phone) { + alert("Name and WhatsApp Number are required."); + return; + } + + btn.disabled = true; + btn.innerText = "Registering..."; + + try { + const token = localStorage.getItem("curio_token"); + const res = await fetch('/api/clinic/walk-in', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name, whatsappNumber: phone, age, gender, fee: parseInt(fee) || 0 }) + }); + + const data = await res.json(); + if (data.success) { + closeWalkinModal(); + fetchLiveQueue(); // Instantly refresh + alert(`Token ${data.token} generated for ${name}!`); + } else { + alert("Error: " + data.error); + } + } catch (err) { + console.error("Walk-in error:", err); + alert("Failed to register walk-in."); + } finally { + btn.disabled = false; + btn.innerText = "Register & Print Token"; + } +}; + +// Start Polling when DOM loads +document.addEventListener('DOMContentLoaded', () => { + fetchLiveQueue(); + liveQueuePollingInterval = setInterval(fetchLiveQueue, 10000); // Poll every 10s +});