feat: add operational database schema and live queue walk-in flow
Build Curio HMS / build-and-deploy (push) Successful in 57s Details

This commit is contained in:
Deep Koluguri 2026-07-08 23:57:55 -04:00
parent ba88e9094d
commit 2ef518f30e
4 changed files with 329 additions and 47 deletions

View File

@ -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' },
];

View File

@ -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 ───────────────────────────────────────────────────────
/**

View File

@ -57,7 +57,7 @@
<input type="date" id="queueDate" class="date-input" aria-label="Queue Date">
</div>
<span class="badge counter-badge">Reception Counter: 1</span>
<button class="btn-primary" id="newWalkinBtn">+ New Walk-in (Print Token)</button>
<button class="btn-primary" id="newWalkinBtn" onclick="openWalkinModal()">+ New Walk-in (Print Token)</button>
</div>
</header>
@ -125,21 +125,8 @@
<h3>Up Next <small>(Ctrl+Q)</small></h3>
<span class="badge">4 Waiting</span>
</div>
<div class="pipeline-list">
<div class="pipeline-item next" onclick="switchPatientContext('Anjali Singh', '#013', 'High Fever')">
<div class="p-token">#013</div>
<div class="p-info">
<strong>Anjali Singh</strong>
<span class="triage-pill urgent">High Fever</span>
</div>
</div>
<div class="pipeline-item" onclick="switchPatientContext('Suresh Kumar', '#014', 'Back Pain')">
<div class="p-token">#014</div>
<div class="p-info">
<strong>Suresh Kumar</strong>
<span class="triage-pill">Back Pain</span>
</div>
</div>
<div class="pipeline-list" id="livePipelineList">
<!-- Live queue injected here via JS -->
</div>
</aside>
@ -306,37 +293,8 @@
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr class="active-row">
<td>#012</td>
<td><strong>Rahul Verma</strong></td>
<td><span class="source-tag online">Online</span></td>
<td><span class="lang-tag">HI</span></td>
<td><span class="triage-pill urgent">High Fever / Cough</span></td>
<td><span class="status-pill in-room">In Room</span></td>
<td>09:15 AM</td>
<td><button class="btn-small">Prescribe</button></td>
</tr>
<tr>
<td>#013</td>
<td><strong>Anjali Singh</strong></td>
<td><span class="source-tag walkin">Walk-in</span></td>
<td><span class="lang-tag">TE</span></td>
<td><span class="triage-pill moderate">General Checkup</span></td>
<td><span class="status-pill pending-pay">Unpaid (₹500)</span></td>
<td>09:30 AM</td>
<td><button class="btn-small secondary">Collect Pay</button></td>
</tr>
<tr>
<td>#014</td>
<td><strong>Suresh Kumar</strong></td>
<td><span class="source-tag online">Online</span></td>
<td><span class="lang-tag">EN</span></td>
<td><span class="triage-pill routine">Back Pain</span></td>
<td><span class="status-pill waiting">Paid (Waiting)</span></td>
<td>09:45 AM</td>
<td><button class="btn-small secondary">Notify</button></td>
</tr>
<tbody id="liveQueueTbody">
<!-- Live queue rows injected here via JS -->
</tbody>
</table>
</div>
@ -572,6 +530,51 @@
</div>
</div>
<!-- Walk-in Modal -->
<div id="walkInModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<header class="modal-header">
<h2>New Walk-in Patient</h2>
<button type="button" class="close-modal" onclick="closeWalkinModal()">&times;</button>
</header>
<div class="modal-body">
<form id="walkInForm">
<div class="input-group">
<label>Patient Name *</label>
<input type="text" id="walkInName" required placeholder="e.g. Rahul Verma">
</div>
<div class="input-group">
<label>WhatsApp Number *</label>
<input type="tel" id="walkInPhone" required placeholder="+91">
</div>
<div style="display: flex; gap: 1rem;">
<div class="input-group" style="flex:1;">
<label>Age</label>
<input type="number" id="walkInAge" placeholder="e.g. 32">
</div>
<div class="input-group" style="flex:1;">
<label>Gender</label>
<select id="walkInGender">
<option value="">Select...</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</select>
</div>
</div>
<div class="input-group">
<label>Consultation Fee Collected (₹)</label>
<input type="number" id="walkInFee" value="500">
</div>
</form>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWalkinModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="submitWalkIn()" id="submitWalkInBtn">Register & Print Token</button>
</footer>
</div>
</div>
</main>
<script src="dashboard.js?v=100"></script>
</body>

View File

@ -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 = `<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">Queue is empty</td></tr>`;
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 = `<span class="status-pill in-room">In Room</span>`;
} else if (visit.payment_status === 'unpaid') {
statusHtml = `<span class="status-pill pending-pay">Unpaid (₹${visit.fee})</span>`;
} else {
statusHtml = `<span class="status-pill waiting">Paid (Waiting)</span>`;
}
let actionHtml = '';
if (visit.status === 'in-room') {
actionHtml = `<button class="btn-small" onclick="showSection('zen'); switchPatientContext('${visit.patient_name}', '${visit.token_number}', '${visit.triage_category}')">Consult</button>`;
} else if (visit.payment_status === 'unpaid') {
actionHtml = `<button class="btn-small secondary" onclick="alert('Collect Pay Flow...')">Collect Pay</button>`;
} else {
actionHtml = `<button class="btn-small secondary" onclick="alert('Notifying patient...')">Notify</button>`;
}
return `
<tr class="${visit.status === 'in-room' ? 'active-row' : ''}">
<td>${visit.token_number}</td>
<td><strong>${visit.patient_name}</strong><br><small class="text-muted">${visit.display_id}</small></td>
<td><span class="source-tag ${sourceClass}">${sourceText}</span></td>
<td><span class="lang-tag">EN</span></td>
<td><span class="triage-pill ${visit.triage_category === 'Urgent' ? 'urgent' : 'routine'}">${visit.triage_category || 'Routine'}</span></td>
<td>${statusHtml}</td>
<td>${time}</td>
<td>${actionHtml}</td>
</tr>
`;
}).join('');
}
function renderZenPipeline(queue) {
const pipelineList = document.getElementById("livePipelineList");
if (!pipelineList) return;
if (queue.length === 0) {
pipelineList.innerHTML = `<div style="padding: 1rem; color: var(--text-muted); text-align: center; font-size: 0.9rem;">No patients waiting</div>`;
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 `
<div class="pipeline-item ${itemClass}" onclick="switchPatientContext('${visit.patient_name}', '${visit.token_number}', '${visit.triage_category}')">
<div class="p-token">${visit.token_number}</div>
<div class="p-info">
<strong>${visit.patient_name}</strong>
<span class="triage-pill ${triageClass}">${visit.triage_category || 'Routine'}</span>
</div>
</div>
`;
}).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
});