96de47be43
Added: Live source updater for RxNorm, DailyMed, and openFDA. Admin dashboard panel to refresh medicine sources. CLI updater for periodic jobs: [scripts/update_medicine_catalog.php](/Users/tyemeclifford/Documents/GH/workout/scripts/update_medicine_catalog.php) Update history table: medication_source_updates. Curl fallback for external fetches when PHP stream DNS fails. README instructions for periodic updates. I also ran the imports: Current medication catalog count: 6,002. Latest source imports:RxNorm: 3,875 inserted, 1,125 updated. openFDA: 912 inserted, 88 updated. DailyMed: 95 inserted. Periodic update command: /Users/tyemeclifford/frankenphp php-cli scripts/update_medicine_catalog.php 5000 all
294 lines
12 KiB
JavaScript
294 lines
12 KiB
JavaScript
const adminState = {
|
|
data: null,
|
|
userId: localStorage.getItem("nht:adminUserId") || localStorage.getItem("nht:userId") || "",
|
|
};
|
|
|
|
const admin$ = (selector, root = document) => root.querySelector(selector);
|
|
|
|
function adminEscape(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function adminNumber(value, digits = 0) {
|
|
return Number(value || 0).toLocaleString(undefined, {
|
|
maximumFractionDigits: digits,
|
|
minimumFractionDigits: digits,
|
|
});
|
|
}
|
|
|
|
function adminToast(message) {
|
|
const toast = admin$("#siteToast");
|
|
if (!toast) return;
|
|
toast.textContent = message;
|
|
toast.classList.add("show");
|
|
window.clearTimeout(adminToast.timer);
|
|
adminToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2400);
|
|
}
|
|
|
|
async function adminApi(action, payload = null, method = "POST") {
|
|
const options = { method };
|
|
if (payload) {
|
|
options.headers = { "Content-Type": "application/json" };
|
|
options.body = JSON.stringify(payload);
|
|
}
|
|
const response = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options);
|
|
const json = await response.json();
|
|
if (!response.ok || json.error) {
|
|
throw new Error(json.error || "Request failed.");
|
|
}
|
|
return json;
|
|
}
|
|
|
|
async function loadAdminState() {
|
|
const params = new URLSearchParams({
|
|
action: "state",
|
|
view: "weekly",
|
|
});
|
|
if (adminState.userId) {
|
|
params.set("user_id", adminState.userId);
|
|
}
|
|
|
|
const response = await fetch(`/api.php?${params.toString()}`);
|
|
const json = await response.json();
|
|
if (!response.ok || json.error) {
|
|
throw new Error(json.error || "Unable to load admin dashboard.");
|
|
}
|
|
|
|
adminState.data = json;
|
|
adminState.userId = String(json.currentUser.id);
|
|
localStorage.setItem("nht:adminUserId", adminState.userId);
|
|
renderAdminPage();
|
|
}
|
|
|
|
function renderAdminPage() {
|
|
const data = adminState.data;
|
|
const profileSelect = admin$("#adminProfileSelect");
|
|
profileSelect.innerHTML = data.users.map((user) => `
|
|
<option value="${user.id}" ${String(user.id) === String(data.currentUser.id) ? "selected" : ""}>
|
|
${adminEscape(user.name)} (${adminEscape(user.role)})
|
|
</option>
|
|
`).join("");
|
|
|
|
admin$("#adminPanel").hidden = !data.currentUserIsAdmin;
|
|
admin$("#adminDenied").hidden = data.currentUserIsAdmin;
|
|
if (!data.currentUserIsAdmin) {
|
|
return;
|
|
}
|
|
|
|
renderAdminStats();
|
|
renderAdminUsers();
|
|
}
|
|
|
|
function renderAdminStats() {
|
|
const summary = adminState.data.adminSummary || {};
|
|
const stats = [
|
|
["Total", summary.total_users || 0],
|
|
["Active", summary.active_users || 0],
|
|
["Inactive", summary.inactive_users || 0],
|
|
["Admins", summary.admins || 0],
|
|
["Members", summary.members || 0],
|
|
["Med logs", summary.medication_logs || 0],
|
|
["Vitals", summary.vital_logs || 0],
|
|
["Labs", summary.lab_results || 0],
|
|
["Visits", summary.appointments || 0],
|
|
];
|
|
|
|
admin$("#adminStats").innerHTML = stats.map(([label, value]) => `
|
|
<article class="admin-stat">
|
|
<span>${adminEscape(label)}</span>
|
|
<strong>${adminNumber(value)}</strong>
|
|
</article>
|
|
`).join("");
|
|
}
|
|
|
|
function renderAdminUsers() {
|
|
const currentId = Number(adminState.data.currentUser.id);
|
|
const rows = adminState.data.adminUsers || [];
|
|
admin$("#adminUserList").innerHTML = rows.length ? rows.map((user) => {
|
|
const isCurrent = Number(user.id) === currentId;
|
|
const nextStatus = user.status === "active" ? "inactive" : "active";
|
|
const height = Number(user.height_in || 0) > 0 ? ` / ${adminNumber(user.height_in, 1)} in` : "";
|
|
const training = user.training_level ? ` / ${user.training_level}` : "";
|
|
return `
|
|
<article class="admin-user-card">
|
|
<div class="admin-user-main">
|
|
<div>
|
|
<h3>${adminEscape(user.name)}${isCurrent ? " (current)" : ""}</h3>
|
|
<p class="details-line">${adminEscape(user.email || "No email")}</p>
|
|
<p class="details-line">${adminEscape(user.fitness_focus || "No care focus set")}</p>
|
|
<p class="timeline-meta">${adminEscape(user.phone || "No phone")}${adminEscape(training)}${adminEscape(height)}</p>
|
|
</div>
|
|
<div class="admin-badges">
|
|
<span class="badge ${adminEscape(user.role)}">${adminEscape(user.role)}</span>
|
|
<span class="badge ${adminEscape(user.status)}">${adminEscape(user.status)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="admin-user-metrics">
|
|
<span>${adminNumber(user.routine_count)} routines</span>
|
|
<span>${adminNumber(user.workout_count)} workouts</span>
|
|
<span>${adminNumber(user.nutrition_log_count)} nutrition logs</span>
|
|
<span>${adminNumber(user.supplement_log_count)} supplement logs</span>
|
|
<span>${adminNumber(user.metric_count)} metrics</span>
|
|
<span>${adminNumber(user.medication_log_count)} meds</span>
|
|
<span>${adminNumber(user.vital_log_count)} vitals</span>
|
|
<span>${adminNumber(user.lab_result_count)} labs</span>
|
|
<span>${adminNumber(user.appointment_count)} appointments</span>
|
|
</div>
|
|
<div class="status-row">
|
|
<button class="status-button" type="button" data-admin-action="edit" data-admin-user-id="${user.id}">Edit</button>
|
|
<button class="status-button" type="button" data-admin-action="status" data-admin-user-id="${user.id}" data-next-status="${nextStatus}">
|
|
${nextStatus === "active" ? "Activate" : "Deactivate"}
|
|
</button>
|
|
<button class="status-button danger-action" type="button" data-admin-action="delete" data-admin-user-id="${user.id}" ${isCurrent ? "disabled" : ""}>Delete</button>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join("") : `<div class="admin-denied">No users found.</div>`;
|
|
}
|
|
|
|
function resetAdminForm() {
|
|
const form = admin$("#adminUserForm");
|
|
form.reset();
|
|
form.elements.target_user_id.value = "";
|
|
form.elements.role.value = "member";
|
|
form.elements.status.value = "active";
|
|
form.elements.theme_mode.value = "dark";
|
|
form.elements.theme_accent.value = "green";
|
|
admin$("#adminUserFormTitle").textContent = "Create Profile";
|
|
}
|
|
|
|
function fillAdminForm(user) {
|
|
const form = admin$("#adminUserForm");
|
|
form.elements.target_user_id.value = user.id || "";
|
|
form.elements.name.value = user.name || "";
|
|
form.elements.email.value = user.email || "";
|
|
form.elements.role.value = user.role || "member";
|
|
form.elements.status.value = user.status || "active";
|
|
form.elements.phone.value = user.phone || "";
|
|
form.elements.birthday.value = user.birthday || "";
|
|
form.elements.height_in.value = Number(user.height_in || 0) > 0 ? user.height_in : "";
|
|
form.elements.training_level.value = user.training_level || "";
|
|
form.elements.theme_mode.value = user.theme_mode || "dark";
|
|
form.elements.theme_accent.value = user.theme_accent || "green";
|
|
form.elements.fitness_focus.value = user.fitness_focus || "";
|
|
form.elements.emergency_contact.value = user.emergency_contact || "";
|
|
admin$("#adminUserFormTitle").textContent = `Edit ${user.name}`;
|
|
}
|
|
|
|
function adminFormPayload(form) {
|
|
const payload = Object.fromEntries(new FormData(form).entries());
|
|
payload.acting_user_id = adminState.data.currentUser.id;
|
|
return payload;
|
|
}
|
|
|
|
function renderRefreshResult(result) {
|
|
const target = admin$("#medicineSourceRefreshResult");
|
|
if (!target) return;
|
|
const rows = (result.sources || []).map((source) => `
|
|
<article>
|
|
<strong>${adminEscape(source.source)}: ${adminEscape(source.status)}</strong>
|
|
<span>${adminNumber(source.inserted)} inserted / ${adminNumber(source.updated)} updated</span>
|
|
<small>${adminEscape(source.message || "")}</small>
|
|
</article>
|
|
`).join("");
|
|
target.innerHTML = `
|
|
<p>${adminNumber(result.catalog_count)} medicines in catalog after refresh.</p>
|
|
<div class="source-refresh-list">${rows}</div>
|
|
`;
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
admin$("#adminProfileSelect").addEventListener("change", async (event) => {
|
|
adminState.userId = event.target.value;
|
|
localStorage.setItem("nht:adminUserId", adminState.userId);
|
|
await loadAdminState();
|
|
});
|
|
|
|
admin$("#clearAdminUserFormButton").addEventListener("click", resetAdminForm);
|
|
|
|
admin$("#adminUserList").addEventListener("click", async (event) => {
|
|
const button = event.target.closest("[data-admin-action]");
|
|
if (!button) return;
|
|
const user = (adminState.data.adminUsers || []).find((item) => String(item.id) === button.dataset.adminUserId);
|
|
if (!user) return;
|
|
|
|
if (button.dataset.adminAction === "edit") {
|
|
fillAdminForm(user);
|
|
admin$("#adminUserForm").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (button.dataset.adminAction === "status") {
|
|
await adminApi("update_user", {
|
|
acting_user_id: adminState.data.currentUser.id,
|
|
target_user_id: user.id,
|
|
status: button.dataset.nextStatus,
|
|
});
|
|
adminToast("Profile status updated");
|
|
}
|
|
|
|
if (button.dataset.adminAction === "delete") {
|
|
const ok = window.confirm(`Delete ${user.name} and all tracker data attached to this profile?`);
|
|
if (!ok) return;
|
|
await adminApi("delete_user", {
|
|
acting_user_id: adminState.data.currentUser.id,
|
|
target_user_id: user.id,
|
|
});
|
|
adminToast("Profile deleted");
|
|
}
|
|
|
|
resetAdminForm();
|
|
await loadAdminState();
|
|
} catch (error) {
|
|
adminToast(error.message);
|
|
}
|
|
});
|
|
|
|
admin$("#adminUserForm").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const payload = adminFormPayload(event.currentTarget);
|
|
const action = payload.target_user_id ? "update_user" : "create_user";
|
|
try {
|
|
await adminApi(action, payload);
|
|
resetAdminForm();
|
|
await loadAdminState();
|
|
adminToast(action === "create_user" ? "Profile created" : "Profile updated");
|
|
} catch (error) {
|
|
adminToast(error.message);
|
|
}
|
|
});
|
|
|
|
const refreshForm = admin$("#medicineSourceRefreshForm");
|
|
if (refreshForm) {
|
|
refreshForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const payload = adminFormPayload(refreshForm);
|
|
const button = refreshForm.querySelector("button[type='submit']");
|
|
if (button) button.disabled = true;
|
|
admin$("#medicineSourceRefreshResult").textContent = "Refreshing medicine sources...";
|
|
try {
|
|
const result = await adminApi("refresh_medication_sources", payload);
|
|
renderRefreshResult(result);
|
|
adminToast("Medicine sources refreshed");
|
|
} catch (error) {
|
|
admin$("#medicineSourceRefreshResult").textContent = error.message;
|
|
adminToast(error.message);
|
|
} finally {
|
|
if (button) button.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
await loadAdminState();
|
|
} catch (error) {
|
|
adminToast(error.message);
|
|
}
|
|
});
|