feat: add operational database schema and live queue walk-in flow
Build Curio HMS / build-and-deploy (push) Successful in 57s
Details
Build Curio HMS / build-and-deploy (push) Successful in 57s
Details
This commit is contained in:
parent
ba88e9094d
commit
2ef518f30e
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()">×</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>
|
||||
|
|
|
|||
167
dashboard.js
167
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 = `<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
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue