New top-level tab positioned after Physical Exam Guide. Per-user
directory of hospital phone extensions and pagers — grouped by location
then type, searchable, soft-deleted.
Data:
- New table user_phone_extensions (id, user_id, location, name, number,
type CHECK (extension|pager), notes, trashed_at, timestamps).
Partial indexes on active vs trashed rows for fast filtering.
- Not PHI — hospital internal phone directory. Plaintext.
API (all user-scoped, all params validated):
- GET /api/extensions?trash=1&q=text — list active or trash, optional search
- POST /api/extensions — create
- PUT /api/extensions/:id — update (requires all three core fields)
- DELETE /api/extensions/:id — soft-delete (sets trashed_at)
- POST /api/extensions/:id/restore — un-trash
- DELETE /api/extensions/:id/purge — hard-delete (only if trashed)
All :id params parsed + validated (positive integer) before query.
All queries parameterized, every WHERE includes user_id scoping.
UI (public/js/extensions.js + components/extensions.html):
- Search bar with 200ms debounce, server-side LIKE on location/name/number/notes
- Add button expands inline form — location (with datalist of existing
locations for autocomplete), name/dept, number, type, optional notes
- Each entry renders as a card: big monospace number, dept, type badge,
edit + delete inline
- Grouped by location → type (Extensions / Pagers subheaders)
- Trash view: toggle shows trashed items with Restore + Purge actions
- Trash count badge on the Trash button updates after every delete/restore
- Delete requires confirm() dialog, then soft-delete (easy to undo)
- Purge from trash requires a second confirm() ("cannot be undone")
- Esc closes the form; form resets between Add and Edit
295 lines
13 KiB
JavaScript
295 lines
13 KiB
JavaScript
// ============================================================
|
|
// PAGERS & EXTENSIONS — personal directory with async search + soft delete
|
|
// ============================================================
|
|
(function () {
|
|
var _inited = false;
|
|
var _items = [];
|
|
var _mode = 'active'; // 'active' | 'trash'
|
|
var _editId = null; // null = creating, non-null = editing
|
|
var _searchDebounce = null;
|
|
|
|
document.addEventListener('tabChanged', function (e) {
|
|
if (e.detail.tab !== 'extensions' || _inited) return;
|
|
_inited = true;
|
|
init();
|
|
});
|
|
|
|
function init() {
|
|
document.getElementById('ext-add-btn').addEventListener('click', onAddClick);
|
|
document.getElementById('ext-cancel-btn').addEventListener('click', hideForm);
|
|
document.getElementById('ext-save-btn').addEventListener('click', saveItem);
|
|
document.getElementById('ext-trash-btn').addEventListener('click', toggleTrashMode);
|
|
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
|
|
|
|
var searchInp = document.getElementById('ext-search');
|
|
searchInp.addEventListener('input', function () {
|
|
clearTimeout(_searchDebounce);
|
|
_searchDebounce = setTimeout(load, 200);
|
|
});
|
|
|
|
// Keyboard: Esc cancels form
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape' && !document.getElementById('ext-form-wrap').classList.contains('hidden')) {
|
|
hideForm();
|
|
}
|
|
});
|
|
|
|
load();
|
|
loadTrashCount();
|
|
}
|
|
|
|
function loadTrashCount() {
|
|
fetch('/api/extensions?trash=1', { headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (d.success) {
|
|
var n = (d.items || []).length;
|
|
document.getElementById('ext-trash-count').textContent = n ? '(' + n + ')' : '';
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function load() {
|
|
var q = document.getElementById('ext-search').value.trim();
|
|
var url = '/api/extensions?' + (_mode === 'trash' ? 'trash=1&' : '') + (q ? 'q=' + encodeURIComponent(q) : '');
|
|
var listEl = document.getElementById('ext-list');
|
|
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:30px;">Loading...</p>';
|
|
|
|
fetch(url, { headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { listEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:20px;">' + esc(d.error || 'Load failed') + '</p>'; return; }
|
|
_items = d.items || [];
|
|
render();
|
|
refreshLocationList();
|
|
})
|
|
.catch(function (err) {
|
|
listEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:20px;">Load failed: ' + esc(err.message) + '</p>';
|
|
});
|
|
}
|
|
|
|
function refreshLocationList() {
|
|
// Populate datalist with existing unique locations for autocomplete
|
|
var dl = document.getElementById('ext-location-list');
|
|
if (!dl) return;
|
|
var seen = {};
|
|
var opts = [];
|
|
_items.forEach(function (x) {
|
|
if (!seen[x.location]) { seen[x.location] = true; opts.push(x.location); }
|
|
});
|
|
dl.innerHTML = opts.map(function (l) { return '<option value="' + esc(l) + '">'; }).join('');
|
|
}
|
|
|
|
function render() {
|
|
var listEl = document.getElementById('ext-list');
|
|
if (_items.length === 0) {
|
|
var msg = _mode === 'trash' ? 'Trash is empty.' : 'No entries yet. Click Add to create your first one.';
|
|
var q = document.getElementById('ext-search').value.trim();
|
|
if (q) msg = 'No matches for "' + esc(q) + '".';
|
|
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">' + msg + '</p>';
|
|
return;
|
|
}
|
|
|
|
// Group by location → type
|
|
var byLoc = {};
|
|
_items.forEach(function (x) {
|
|
if (!byLoc[x.location]) byLoc[x.location] = { extension: [], pager: [] };
|
|
(byLoc[x.location][x.type] || byLoc[x.location].extension).push(x);
|
|
});
|
|
|
|
var html = '';
|
|
Object.keys(byLoc).sort().forEach(function (loc) {
|
|
html += '<div class="ext-loc-group" style="margin-bottom:18px;">';
|
|
html += '<h3 style="font-size:14px;color:var(--g700);margin:12px 0 8px;display:flex;align-items:center;gap:8px;"><i class="fas fa-map-marker-alt" style="color:var(--g400);"></i> ' + esc(loc) + '</h3>';
|
|
|
|
['extension', 'pager'].forEach(function (type) {
|
|
var arr = byLoc[loc][type];
|
|
if (!arr || arr.length === 0) return;
|
|
html += '<div style="margin-left:4px;">';
|
|
html += '<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--g500);font-weight:600;margin:6px 0 4px;">' + (type === 'pager' ? '📟 Pagers' : '☎️ Extensions') + '</div>';
|
|
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;">';
|
|
arr.forEach(function (x) { html += renderCard(x); });
|
|
html += '</div></div>';
|
|
});
|
|
|
|
html += '</div>';
|
|
});
|
|
listEl.innerHTML = html;
|
|
|
|
// Wire per-card events
|
|
listEl.querySelectorAll('[data-ext-action]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var action = btn.dataset.extAction;
|
|
var id = parseInt(btn.dataset.extId, 10);
|
|
if (!Number.isFinite(id) || id <= 0) return;
|
|
if (action === 'edit') return startEdit(id);
|
|
if (action === 'delete') return confirmDelete(id);
|
|
if (action === 'restore') return restoreItem(id);
|
|
if (action === 'purge') return confirmPurge(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderCard(x) {
|
|
var typeColor = x.type === 'pager' ? '#7c3aed' : '#2563eb';
|
|
var typeLabel = x.type === 'pager' ? 'Pager' : 'Ext';
|
|
var trashed = x.trashed_at != null;
|
|
var bg = trashed ? '#fafaf9' : 'var(--white, #fff)';
|
|
var border = trashed ? 'var(--g300)' : 'var(--g200)';
|
|
|
|
var actions;
|
|
if (trashed) {
|
|
actions =
|
|
'<button class="btn-sm btn-ghost" data-ext-action="restore" data-ext-id="' + x.id + '" title="Restore"><i class="fas fa-rotate-left"></i></button>' +
|
|
'<button class="btn-sm btn-ghost" data-ext-action="purge" data-ext-id="' + x.id + '" title="Delete permanently" style="color:var(--red);"><i class="fas fa-xmark"></i></button>';
|
|
} else {
|
|
actions =
|
|
'<button class="btn-sm btn-ghost" data-ext-action="edit" data-ext-id="' + x.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
|
|
'<button class="btn-sm btn-ghost" data-ext-action="delete" data-ext-id="' + x.id + '" title="Move to trash"><i class="fas fa-trash-can"></i></button>';
|
|
}
|
|
|
|
var html = '';
|
|
html += '<div class="ext-card" style="background:' + bg + ';border:1px solid ' + border + ';border-radius:10px;padding:12px;display:flex;gap:12px;align-items:center;transition:border-color 0.15s,box-shadow 0.15s;">';
|
|
html += ' <div style="flex:1;min-width:0;">';
|
|
html += ' <div style="display:flex;align-items:baseline;gap:8px;">';
|
|
html += ' <div style="font-size:22px;font-weight:700;color:' + typeColor + ';font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:0.5px;">' + esc(x.number) + '</div>';
|
|
html += ' <span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:6px;background:' + typeColor + '22;color:' + typeColor + ';text-transform:uppercase;letter-spacing:0.5px;">' + typeLabel + '</span>';
|
|
html += ' </div>';
|
|
html += ' <div style="font-size:13px;color:var(--g700);margin-top:2px;">' + esc(x.name) + '</div>';
|
|
if (x.notes) html += ' <div style="font-size:11px;color:var(--g500);margin-top:2px;">' + esc(x.notes) + '</div>';
|
|
html += ' </div>';
|
|
html += ' <div style="display:flex;gap:2px;flex-shrink:0;">' + actions + '</div>';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function onAddClick() {
|
|
_editId = null;
|
|
document.getElementById('ext-location').value = '';
|
|
document.getElementById('ext-name').value = '';
|
|
document.getElementById('ext-number').value = '';
|
|
document.getElementById('ext-type').value = 'extension';
|
|
document.getElementById('ext-notes').value = '';
|
|
document.getElementById('ext-form-status').textContent = '';
|
|
document.getElementById('ext-save-btn').innerHTML = '<i class="fas fa-floppy-disk"></i> Save';
|
|
document.getElementById('ext-form-wrap').classList.remove('hidden');
|
|
document.getElementById('ext-location').focus();
|
|
}
|
|
|
|
function hideForm() {
|
|
document.getElementById('ext-form-wrap').classList.add('hidden');
|
|
_editId = null;
|
|
}
|
|
|
|
function startEdit(id) {
|
|
var item = _items.filter(function (x) { return x.id === id; })[0];
|
|
if (!item) return;
|
|
_editId = id;
|
|
document.getElementById('ext-location').value = item.location;
|
|
document.getElementById('ext-name').value = item.name;
|
|
document.getElementById('ext-number').value = item.number;
|
|
document.getElementById('ext-type').value = item.type;
|
|
document.getElementById('ext-notes').value = item.notes || '';
|
|
document.getElementById('ext-form-status').textContent = '';
|
|
document.getElementById('ext-save-btn').innerHTML = '<i class="fas fa-check"></i> Update';
|
|
document.getElementById('ext-form-wrap').classList.remove('hidden');
|
|
document.getElementById('ext-location').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function saveItem() {
|
|
var body = {
|
|
location: document.getElementById('ext-location').value.trim(),
|
|
name: document.getElementById('ext-name').value.trim(),
|
|
number: document.getElementById('ext-number').value.trim(),
|
|
type: document.getElementById('ext-type').value,
|
|
notes: document.getElementById('ext-notes').value.trim()
|
|
};
|
|
if (!body.location || !body.name || !body.number) {
|
|
document.getElementById('ext-form-status').textContent = 'Location, name, and number are required.';
|
|
document.getElementById('ext-form-status').style.color = 'var(--red)';
|
|
return;
|
|
}
|
|
var url = _editId ? ('/api/extensions/' + _editId) : '/api/extensions';
|
|
var method = _editId ? 'PUT' : 'POST';
|
|
document.getElementById('ext-save-btn').disabled = true;
|
|
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(body) })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Save failed', 'error'); return; }
|
|
hideForm();
|
|
showToast(_editId ? 'Updated' : 'Added', 'success');
|
|
load();
|
|
})
|
|
.catch(function () { showToast('Save failed', 'error'); })
|
|
.finally(function () { document.getElementById('ext-save-btn').disabled = false; });
|
|
}
|
|
|
|
function confirmDelete(id) {
|
|
var item = _items.filter(function (x) { return x.id === id; })[0];
|
|
if (!item) return;
|
|
if (!confirm('Move "' + item.name + ' (' + item.number + ')" to trash?\n\nYou can restore it later from the trash view.')) return;
|
|
fetch('/api/extensions/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Delete failed', 'error'); return; }
|
|
showToast('Moved to trash', 'success');
|
|
load();
|
|
loadTrashCount();
|
|
})
|
|
.catch(function () { showToast('Delete failed', 'error'); });
|
|
}
|
|
|
|
function restoreItem(id) {
|
|
fetch('/api/extensions/' + id + '/restore', { method: 'POST', headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Restore failed', 'error'); return; }
|
|
showToast('Restored', 'success');
|
|
load();
|
|
loadTrashCount();
|
|
})
|
|
.catch(function () { showToast('Restore failed', 'error'); });
|
|
}
|
|
|
|
function confirmPurge(id) {
|
|
var item = _items.filter(function (x) { return x.id === id; })[0];
|
|
if (!item) return;
|
|
if (!confirm('Permanently delete "' + item.name + ' (' + item.number + ')"?\n\nThis cannot be undone.')) return;
|
|
fetch('/api/extensions/' + id + '/purge', { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Delete failed', 'error'); return; }
|
|
showToast('Permanently deleted', 'success');
|
|
load();
|
|
loadTrashCount();
|
|
})
|
|
.catch(function () { showToast('Delete failed', 'error'); });
|
|
}
|
|
|
|
function toggleTrashMode() {
|
|
_mode = (_mode === 'trash') ? 'active' : 'trash';
|
|
updateModeBanner();
|
|
load();
|
|
}
|
|
function updateModeBanner() {
|
|
var banner = document.getElementById('ext-mode-banner');
|
|
var trashBtn = document.getElementById('ext-trash-btn');
|
|
if (_mode === 'trash') {
|
|
banner.classList.remove('hidden');
|
|
trashBtn.innerHTML = '<i class="fas fa-list"></i> Active items';
|
|
document.getElementById('ext-add-btn').style.display = 'none';
|
|
} else {
|
|
banner.classList.add('hidden');
|
|
trashBtn.innerHTML = '<i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span>';
|
|
document.getElementById('ext-add-btn').style.display = '';
|
|
loadTrashCount();
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
})();
|