Features: - Admin can add, edit, and delete developmental milestones via dashboard - Milestones stored in PostgreSQL (developmental_milestones table) - Client-side loads milestones from API instead of static file - Import script to migrate existing static data to database - Organized by age group and domain - Supports sorting and filtering Admin UI: - New section in Admin panel for milestone management - Filter by age group - Add/Edit modal with validation - Delete with confirmation - Auto-complete for age groups and domains API Endpoints: - GET /api/milestones-data - Public endpoint for authenticated users - GET /api/admin/milestones - List all milestones (admin only) - GET /api/admin/milestones/meta - Get age groups and domains - POST /api/admin/milestones - Create milestone - PUT /api/admin/milestones/:id - Update milestone - DELETE /api/admin/milestones/:id - Delete milestone - POST /api/admin/milestones/bulk-import - Bulk import Usage: 1. Run import script: node scripts/import-milestones.js 2. Access Admin dashboard → Developmental Milestones section 3. Add/Edit/Delete milestones as needed
251 lines
8.7 KiB
JavaScript
251 lines
8.7 KiB
JavaScript
// ============================================================
|
|
// ADMIN: DEVELOPMENTAL MILESTONES MANAGEMENT
|
|
// ============================================================
|
|
|
|
(function() {
|
|
var _inited = false;
|
|
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail.tab !== 'admin' || _inited) return;
|
|
_inited = true;
|
|
initMilestonesAdmin();
|
|
});
|
|
|
|
var milestones = [];
|
|
var ageGroups = [];
|
|
var domains = [];
|
|
var editingId = null;
|
|
|
|
function initMilestonesAdmin() {
|
|
console.log('[AdminMilestones] Initializing...');
|
|
|
|
// Buttons
|
|
document.getElementById('btn-refresh-milestones')?.addEventListener('click', loadMilestones);
|
|
document.getElementById('btn-add-milestone')?.addEventListener('click', function() {
|
|
editingId = null;
|
|
openMilestoneModal();
|
|
});
|
|
document.getElementById('btn-close-milestone-modal')?.addEventListener('click', closeMilestoneModal);
|
|
document.getElementById('btn-cancel-milestone-modal')?.addEventListener('click', closeMilestoneModal);
|
|
document.getElementById('btn-save-milestone')?.addEventListener('click', saveMilestone);
|
|
|
|
// Filter
|
|
document.getElementById('ms-filter-age')?.addEventListener('change', renderMilestones);
|
|
|
|
// Load data
|
|
loadMetadata();
|
|
loadMilestones();
|
|
}
|
|
|
|
function loadMetadata() {
|
|
fetch('/api/admin/milestones/meta', {
|
|
headers: getAuthHeaders()
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.success) return;
|
|
ageGroups = data.age_groups || [];
|
|
domains = data.domains || [];
|
|
|
|
// Populate filter dropdown
|
|
var filterSelect = document.getElementById('ms-filter-age');
|
|
if (filterSelect) {
|
|
filterSelect.innerHTML = '<option value="">All Age Groups</option>';
|
|
ageGroups.forEach(function(age) {
|
|
var opt = document.createElement('option');
|
|
opt.value = age;
|
|
opt.textContent = age;
|
|
filterSelect.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
// Populate datalists for modal
|
|
var ageDatalist = document.getElementById('ms-age-list');
|
|
if (ageDatalist) {
|
|
ageDatalist.innerHTML = '';
|
|
ageGroups.forEach(function(age) {
|
|
var opt = document.createElement('option');
|
|
opt.value = age;
|
|
ageDatalist.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
var domainDatalist = document.getElementById('ms-domain-list');
|
|
if (domainDatalist) {
|
|
domainDatalist.innerHTML = '';
|
|
domains.forEach(function(domain) {
|
|
var opt = document.createElement('option');
|
|
opt.value = domain;
|
|
domainDatalist.appendChild(opt);
|
|
});
|
|
}
|
|
})
|
|
.catch(err => console.error('[AdminMilestones] Failed to load metadata:', err));
|
|
}
|
|
|
|
function loadMilestones() {
|
|
var filterAge = document.getElementById('ms-filter-age')?.value;
|
|
var url = '/api/admin/milestones' + (filterAge ? '?age_group=' + encodeURIComponent(filterAge) : '');
|
|
|
|
fetch(url, {
|
|
headers: getAuthHeaders()
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.success) {
|
|
showToast(data.error || 'Failed to load milestones', 'error');
|
|
return;
|
|
}
|
|
milestones = data.milestones || [];
|
|
renderMilestones();
|
|
})
|
|
.catch(err => {
|
|
console.error('[AdminMilestones] Failed to load:', err);
|
|
showToast('Failed to load milestones: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function renderMilestones() {
|
|
var list = document.getElementById('milestones-list');
|
|
if (!list) return;
|
|
|
|
var filterAge = document.getElementById('ms-filter-age')?.value;
|
|
var filtered = filterAge ? milestones.filter(m => m.age_group === filterAge) : milestones;
|
|
|
|
if (filtered.length === 0) {
|
|
list.innerHTML = '<p style="text-align:center;color:var(--g400);padding:40px;">No milestones found. Click "Add Milestone" to create one.</p>';
|
|
return;
|
|
}
|
|
|
|
// Group by age_group and domain
|
|
var grouped = {};
|
|
filtered.forEach(function(m) {
|
|
if (!grouped[m.age_group]) grouped[m.age_group] = {};
|
|
if (!grouped[m.age_group][m.domain]) grouped[m.age_group][m.domain] = [];
|
|
grouped[m.age_group][m.domain].push(m);
|
|
});
|
|
|
|
var html = '';
|
|
Object.keys(grouped).forEach(function(age) {
|
|
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:12px;background:var(--g50);">';
|
|
html += '<h4 style="margin:0 0 12px 0;font-size:15px;color:var(--g700);">' + age + '</h4>';
|
|
|
|
Object.keys(grouped[age]).forEach(function(domain) {
|
|
html += '<div style="margin-bottom:10px;">';
|
|
html += '<div style="font-size:13px;font-weight:600;color:var(--g600);margin-bottom:6px;">' + domain + '</div>';
|
|
|
|
grouped[age][domain].forEach(function(m) {
|
|
html += '<div style="display:flex;align-items:center;gap:8px;padding:6px;border:1px solid var(--g200);border-radius:6px;background:white;margin-bottom:4px;">';
|
|
html += '<span style="flex:1;font-size:13px;color:var(--g700);">' + m.milestone_text + '</span>';
|
|
html += '<button class="btn-sm btn-ghost" style="padding:4px 8px;font-size:11px;" onclick="editMilestone(' + m.id + ')"><i class="fas fa-edit"></i></button>';
|
|
html += '<button class="btn-sm" style="padding:4px 8px;font-size:11px;background:var(--red);color:white;border:none;" onclick="deleteMilestone(' + m.id + ')"><i class="fas fa-trash"></i></button>';
|
|
html += '</div>';
|
|
});
|
|
|
|
html += '</div>';
|
|
});
|
|
|
|
html += '</div>';
|
|
});
|
|
|
|
list.innerHTML = html;
|
|
}
|
|
|
|
function openMilestoneModal(milestone) {
|
|
var modal = document.getElementById('milestone-modal');
|
|
var title = document.getElementById('milestone-modal-title');
|
|
|
|
if (!modal) return;
|
|
|
|
if (milestone) {
|
|
title.innerHTML = '<i class="fas fa-edit"></i> Edit Milestone';
|
|
document.getElementById('ms-edit-id').value = milestone.id;
|
|
document.getElementById('ms-edit-age').value = milestone.age_group;
|
|
document.getElementById('ms-edit-domain').value = milestone.domain;
|
|
document.getElementById('ms-edit-text').value = milestone.milestone_text;
|
|
document.getElementById('ms-edit-sort').value = milestone.sort_order;
|
|
} else {
|
|
title.innerHTML = '<i class="fas fa-plus"></i> Add Milestone';
|
|
document.getElementById('ms-edit-id').value = '';
|
|
document.getElementById('ms-edit-age').value = '';
|
|
document.getElementById('ms-edit-domain').value = '';
|
|
document.getElementById('ms-edit-text').value = '';
|
|
document.getElementById('ms-edit-sort').value = '0';
|
|
}
|
|
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
function closeMilestoneModal() {
|
|
var modal = document.getElementById('milestone-modal');
|
|
if (modal) modal.style.display = 'none';
|
|
}
|
|
|
|
function saveMilestone() {
|
|
var id = document.getElementById('ms-edit-id').value;
|
|
var age_group = document.getElementById('ms-edit-age').value.trim();
|
|
var domain = document.getElementById('ms-edit-domain').value.trim();
|
|
var milestone_text = document.getElementById('ms-edit-text').value.trim();
|
|
var sort_order = parseInt(document.getElementById('ms-edit-sort').value) || 0;
|
|
|
|
if (!age_group || !domain || !milestone_text) {
|
|
showToast('Please fill in all fields', 'error');
|
|
return;
|
|
}
|
|
|
|
var method = id ? 'PUT' : 'POST';
|
|
var url = id ? '/api/admin/milestones/' + id : '/api/admin/milestones';
|
|
|
|
fetch(url, {
|
|
method: method,
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ age_group, domain, milestone_text, sort_order })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.success) {
|
|
showToast(data.error || 'Failed to save', 'error');
|
|
return;
|
|
}
|
|
showToast(id ? 'Milestone updated' : 'Milestone added', 'success');
|
|
closeMilestoneModal();
|
|
loadMetadata();
|
|
loadMilestones();
|
|
})
|
|
.catch(err => {
|
|
console.error('[AdminMilestones] Save error:', err);
|
|
showToast('Failed to save: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
window.editMilestone = function(id) {
|
|
var milestone = milestones.find(m => m.id === id);
|
|
if (!milestone) return;
|
|
editingId = id;
|
|
openMilestoneModal(milestone);
|
|
};
|
|
|
|
window.deleteMilestone = function(id) {
|
|
if (!confirm('Delete this milestone? This action cannot be undone.')) return;
|
|
|
|
fetch('/api/admin/milestones/' + id, {
|
|
method: 'DELETE',
|
|
headers: getAuthHeaders()
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.success) {
|
|
showToast(data.error || 'Failed to delete', 'error');
|
|
return;
|
|
}
|
|
showToast('Milestone deleted', 'success');
|
|
loadMetadata();
|
|
loadMilestones();
|
|
})
|
|
.catch(err => {
|
|
console.error('[AdminMilestones] Delete error:', err);
|
|
showToast('Failed to delete: ' + err.message, 'error');
|
|
});
|
|
};
|
|
|
|
})();
|