// ── Admin page logic ────────────────────────────────────────────────────────── // ── Toast ───────────────────────────────────────────────────────────────────── function showToast(msg, type = 'success') { const el = document.createElement('div'); el.className = `toast align-items-center text-bg-${type} border-0 show`; el.innerHTML = `
${msg}
`; document.getElementById('toastContainer').appendChild(el); setTimeout(() => el.remove(), 4000); } // ══ USERS TAB ════════════════════════════════════════════════════════════════ async function loadUsers() { try { const users = await apiGet('/users/admin/'); document.getElementById('usersSpinner').classList.add('d-none'); document.getElementById('usersTableWrap').classList.remove('d-none'); renderUsers(users); } catch(e) { document.getElementById('usersSpinner').innerHTML = '

Failed to load users.

'; } } function renderUsers(users) { document.getElementById('usersBody').innerHTML = users.map(u => ` ${u.username} ${u.email} ${[u.first_name, u.last_name].filter(Boolean).join(' ') || '—'} ${u.is_staff ? 'Staff' : ''}
${u.date_joined ? u.date_joined.slice(0,10) : '—'} `).join(''); } async function toggleUserActive(id, active) { try { await apiPatch(`/users/admin/${id}/`, { is_active: active }); showToast(`User ${active ? 'activated' : 'deactivated'}.`); } catch(e) { showToast('Failed to update user.', 'danger'); loadUsers(); } } async function toggleUserStaff(id, staff) { if (!confirm(`${staff ? 'Grant' : 'Revoke'} staff rights for this user?`)) return; try { await apiPatch(`/users/admin/${id}/`, { is_staff: staff }); showToast(`Staff rights ${staff ? 'granted' : 'revoked'}.`); loadUsers(); } catch(e) { showToast('Failed to update user.', 'danger'); } } // ══ GEAR CATALOG TAB ═════════════════════════════════════════════════════════ const GEAR_ENDPOINTS = { firearm: '/gears/firearms/', scope: '/gears/scopes/', suppressor: '/gears/suppressors/', bipod: '/gears/bipods/', magazine: '/gears/magazines/', }; const CATALOG_PAGE_SIZE = 50; let allCatalogItems = []; let catalogTotalCount = 0; let catalogSearchTerm = ''; let catalogPage = 1; let catalogHasNext = false; async function loadCatalog(status, page) { if (status === undefined) status = document.getElementById('catalogStatusFilter').value; if (page === undefined) page = 1; catalogPage = page; const spinnerEl = document.getElementById('catalogSpinner'); spinnerEl.innerHTML = '
'; spinnerEl.classList.remove('d-none'); document.getElementById('catalogTableWrap').classList.add('d-none'); const statusParam = (status && status !== 'all') ? `&status=${status}` : ''; const searchParam = catalogSearchTerm ? `&search=${encodeURIComponent(catalogSearchTerm)}` : ''; const pageParam = page > 1 ? `&page=${page}` : ''; try { const results = await Promise.all( Object.entries(GEAR_ENDPOINTS).map(([type, url]) => apiFetch(`${url}?page_size=${CATALOG_PAGE_SIZE}${statusParam}${searchParam}${pageParam}`) .then(r => { if (!r.ok) throw new Error(r.status); return r.json(); }) .then(data => ({ type, items: (data.results || []).map(g => ({ ...g, gear_type: type })), count: data.count || 0, hasNext: !!data.next, })) ) ); allCatalogItems = results.flatMap(r => r.items); catalogTotalCount = results.reduce((s, r) => s + r.count, 0); catalogHasNext = results.some(r => r.hasNext); spinnerEl.classList.add('d-none'); document.getElementById('catalogTableWrap').classList.remove('d-none'); renderCatalog(); } catch(e) { spinnerEl.innerHTML = `

Failed to load catalog: ${e.message}

`; } } function renderCatalog() { const typeFilter = document.getElementById('catalogTypeFilter').value; let items = allCatalogItems; if (typeFilter !== 'all') items = items.filter(g => g.gear_type === typeFilter); const statusBadge = s => ({ PENDING: 'Pending', VERIFIED: 'Verified', REJECTED: 'Rejected', }[s] || `${s}`); // Count line const showing = items.length; const countEl = document.getElementById('catalogCount'); if (countEl) { const pageDesc = catalogHasNext || catalogPage > 1 ? ` — page ${catalogPage}` : ''; countEl.textContent = `${showing} item${showing !== 1 ? 's' : ''} shown (${catalogTotalCount} total${pageDesc})`; } // Table rows document.getElementById('catalogBody').innerHTML = items.map(g => ` ${g.gear_type} ${esc(g.brand)} ${esc(g.model_name)} ${esc(g.caliber_detail?.name || '—')} ${statusBadge(g.status)} ${g.status === 'PENDING' ? ` ` : ''} `).join('') || 'No items.'; // Pagination controls document.getElementById('catalogPagination').innerHTML = (catalogPage > 1 || catalogHasNext) ? ` ` : ''; } document.getElementById('catalogTypeFilter').addEventListener('change', renderCatalog); document.getElementById('catalogStatusFilter').addEventListener('change', () => loadCatalog()); async function verifyGear(type, id) { try { await apiPost(`/gears/${type}s/${id}/verify/`, {}); const item = allCatalogItems.find(g => g.gear_type === type && g.id === id); if (item) item.status = 'VERIFIED'; renderCatalog(); showToast('Gear verified.'); } catch(e) { showToast('Failed to verify.', 'danger'); } } async function rejectGear(type, id) { try { await apiPost(`/gears/${type}s/${id}/reject/`, {}); const item = allCatalogItems.find(g => g.gear_type === type && g.id === id); if (item) item.status = 'REJECTED'; renderCatalog(); showToast('Gear rejected.'); } catch(e) { showToast('Failed to reject.', 'danger'); } } async function deleteGear(type, id) { if (!confirm('Delete this gear item from the catalog?')) return; try { await apiDelete(`/gears/${type}s/${id}/`); allCatalogItems = allCatalogItems.filter(g => !(g.gear_type === type && g.id === id)); renderCatalog(); showToast('Gear deleted.'); } catch(e) { showToast('Failed to delete.', 'danger'); } } // Add gear form — show/hide type-specific fields document.getElementById('addGearType').addEventListener('change', function() { ['Firearm','Scope','Suppressor','Bipod','Magazine'].forEach(t => document.getElementById(`fields${t}`).classList.add('d-none')); if (this.value) { const key = this.value.charAt(0).toUpperCase() + this.value.slice(1); document.getElementById(`fields${key}`)?.classList.remove('d-none'); document.getElementById('addGearSubmit').disabled = false; // Load calibers into the newly-visible caliber select const calSelectId = { firearm: 'addFirearmCaliber', suppressor: 'addSuppCaliber', magazine: 'addMagCaliber' }[this.value]; if (calSelectId) { const sel = document.getElementById(calSelectId); if (sel && (!sel.options.length || sel.options[0].text === 'Loading…')) { loadCalibersIntoSelect(sel); } } } else { document.getElementById('addGearSubmit').disabled = true; } }); document.getElementById('addGearSubmit').addEventListener('click', async () => { const type = document.getElementById('addGearType').value; const alertEl = document.getElementById('addGearAlert'); alertEl.classList.add('d-none'); if (!type) return; const payload = { brand: document.getElementById('addGearBrand').value.trim(), model_name: document.getElementById('addGearModel').value.trim(), description:document.getElementById('addGearDesc').value.trim(), status: 'VERIFIED', }; if (!payload.brand || !payload.model_name) { alertEl.textContent = 'Brand and model are required.'; alertEl.classList.remove('d-none'); return; } if (type === 'firearm') { payload.firearm_type = document.getElementById('addFirearmType').value; const fc = document.getElementById('addFirearmCaliber').value; if (fc) payload.caliber = parseInt(fc); const bl = document.getElementById('addFirearmBarrel').value; const mc = document.getElementById('addFirearmMag').value; if (bl) payload.barrel_length_mm = parseFloat(bl); if (mc) payload.magazine_capacity = parseInt(mc); } else if (type === 'scope') { const mn = document.getElementById('addScopeMagMin').value; const mx = document.getElementById('addScopeMagMax').value; const ob = document.getElementById('addScopeObj').value; const tu = document.getElementById('addScopeTube').value; const rt = document.getElementById('addScopeReticle').value; const au = document.getElementById('addScopeAdj').value; const fp = document.getElementById('addScopeFP').value; if (rt) payload.reticle_type = rt; if (mn) payload.magnification_min = parseFloat(mn); if (mx) payload.magnification_max = parseFloat(mx); if (ob) payload.objective_diameter_mm = parseFloat(ob); if (tu) payload.tube_diameter_mm = parseFloat(tu); if (au) payload.adjustment_unit = au; if (fp) payload.focal_plane = fp; } else if (type === 'suppressor') { const sc = document.getElementById('addSuppCaliber').value; if (sc) payload.max_caliber = parseInt(sc); payload.thread_pitch = document.getElementById('addSuppThread').value.trim(); const ln = document.getElementById('addSuppLength').value; const wt = document.getElementById('addSuppWeight').value; if (ln) payload.length_mm = parseFloat(ln); if (wt) payload.weight_g = parseFloat(wt); } else if (type === 'bipod') { const mn = document.getElementById('addBipodMin').value; const mx = document.getElementById('addBipodMax').value; payload.attachment_type = document.getElementById('addBipodAttach').value.trim(); if (mn) payload.min_height_mm = parseFloat(mn); if (mx) payload.max_height_mm = parseFloat(mx); } else if (type === 'magazine') { const mc = document.getElementById('addMagCaliber').value; if (mc) payload.caliber = parseInt(mc); const cap = document.getElementById('addMagCapacity').value; if (cap) payload.capacity = parseInt(cap); } try { const created = await apiPost(`/gears/${type}s/`, payload); allCatalogItems.push({ ...created, gear_type: type }); renderCatalog(); // Reset form ['addGearBrand','addGearModel','addGearDesc','addFirearmAction', 'addFirearmBarrel','addFirearmMag','addScopeMagMin','addScopeMagMax','addScopeObj', 'addScopeTube','addScopeReticle','addScopeAdj','addScopeFP', 'addSuppThread','addSuppLength', 'addSuppWeight','addBipodMin','addBipodMax','addBipodAttach', 'addMagCapacity'].forEach(id => { const el = document.getElementById(id); if(el) el.value=''; }); // Reset caliber selects to first option ['addFirearmCaliber','addSuppCaliber','addMagCaliber'].forEach(id => { const el = document.getElementById(id); if (el) el.selectedIndex = 0; }); showToast('Gear added to catalog!'); } catch(e) { alertEl.textContent = formatErrors(e.data) || 'Failed to add gear.'; alertEl.classList.remove('d-none'); } }); // ══ RELOAD COMPONENTS TAB ═════════════════════════════════════════════════════ function compStatusBadge(s) { return s === 'PENDING' ? 'Pending' : s === 'REJECTED' ? 'Rejected' : 'Verified'; } const COMP_CONFIG = { primers: { title: 'primer', endpoint: '/gears/components/primers/', headers: ['Brand', 'Name', 'Size', 'Status', ''], row: c => `${c.brand}${c.name}${c.size}${compStatusBadge(c.status)}`, form: () => `
`, payload: () => ({ brand: v('cBrand'), name: v('cName'), size: v('cSize') }), }, brass: { title: 'brass', endpoint: '/gears/components/brass/', headers: ['Brand', 'Caliber', 'Pocket', 'Status', ''], row: c => `${c.brand}${c.caliber_detail?.name || '—'}${c.primer_pocket || '—'}${compStatusBadge(c.status)}`, form: () => `
`, payload: () => { const cal = v('cCaliber'); return { brand: v('cBrand'), caliber: cal ? parseInt(cal) : undefined, primer_pocket: v('cPocket') || undefined }; }, }, bullets: { title: 'bullet', endpoint: '/gears/components/bullets/', headers: ['Brand', 'Model', 'Weight', 'Type', 'Status', ''], row: c => `${c.brand}${c.model_name}${c.weight_gr} gr${c.bullet_type}${compStatusBadge(c.status)}`, form: () => `
`, payload: () => { const p = { brand: v('cBrand'), model_name: v('cModel'), weight_gr: v('cWeight'), bullet_type: v('cBType') }; const d = v('cDiam'); if (d) p.diameter_mm = d; return p; }, }, powders: { title: 'powder', endpoint: '/gears/components/powders/', headers: ['Brand', 'Name', 'Type', 'Burn idx', 'Status', ''], row: c => `${c.brand}${c.name}${c.powder_type || '—'}${c.burn_rate_index ?? '—'}${compStatusBadge(c.status)}`, form: () => `
`, payload: () => { const p = { brand: v('cBrand'), name: v('cName') }; const t = v('cPType'); if (t) p.powder_type = t; const b = v('cBurn'); if (b) p.burn_rate_index = parseInt(b); return p; }, }, }; let currentComp = 'primers'; let compData = {}; function v(id) { return document.getElementById(id)?.value?.trim() || ''; } async function loadComp(type) { currentComp = type; const cfg = COMP_CONFIG[type]; const spinner = document.getElementById('compSpinner'); const wrap = document.getElementById('compTableWrap'); spinner.classList.remove('d-none'); wrap.classList.add('d-none'); document.getElementById('addCompTitle').textContent = `Add ${cfg.title}`; document.getElementById('addCompForm').innerHTML = cfg.form(); document.getElementById('addCompAlert').classList.add('d-none'); // Load calibers into brass form select if (type === 'brass') { const sel = document.getElementById('cCaliber'); if (sel) loadCalibersIntoSelect(sel); } try { compData[type] = await apiGetAll(cfg.endpoint + '?page_size=1000'); renderComp(type); } catch(e) { spinner.innerHTML = '

Failed to load.

'; } } function renderComp(type) { const cfg = COMP_CONFIG[type]; const items = compData[type] || []; document.getElementById('compSpinner').classList.add('d-none'); document.getElementById('compTableWrap').classList.remove('d-none'); document.getElementById('compTableHead').innerHTML = cfg.headers.map(h => `${h}`).join(''); document.getElementById('compBody').innerHTML = items.length ? items.map(c => ` ${cfg.row(c)} ${c.status === 'PENDING' ? ` ` : ''} `).join('') : `No entries.`; } document.getElementById('addCompSubmit').addEventListener('click', async () => { const cfg = COMP_CONFIG[currentComp]; const alertEl = document.getElementById('addCompAlert'); alertEl.classList.add('d-none'); try { const payload = cfg.payload(); const created = await apiPost(cfg.endpoint, payload); if (!compData[currentComp]) compData[currentComp] = []; compData[currentComp].push(created); renderComp(currentComp); document.getElementById('addCompForm').innerHTML = cfg.form(); if (currentComp === 'brass') { const sel = document.getElementById('cCaliber'); if (sel) loadCalibersIntoSelect(sel); } showToast(`${cfg.title} added!`); } catch(e) { alertEl.textContent = formatErrors(e.data) || 'Failed to add.'; alertEl.classList.remove('d-none'); } }); async function verifyComp(type, id) { const cfg = COMP_CONFIG[type]; try { await apiPost(`${cfg.endpoint}${id}/verify/`, {}); const item = (compData[type] || []).find(c => c.id === id); if (item) item.status = 'VERIFIED'; renderComp(type); showToast('Component verified.'); } catch(e) { showToast('Failed to verify.', 'danger'); } } async function rejectComp(type, id) { const cfg = COMP_CONFIG[type]; try { await apiPost(`${cfg.endpoint}${id}/reject/`, {}); const item = (compData[type] || []).find(c => c.id === id); if (item) item.status = 'REJECTED'; renderComp(type); showToast('Component rejected.'); } catch(e) { showToast('Failed to reject.', 'danger'); } } async function deleteComp(type, id) { if (!confirm('Delete this component?')) return; const cfg = COMP_CONFIG[type]; try { await apiDelete(`${cfg.endpoint}${id}/`); compData[type] = compData[type].filter(c => c.id !== id); renderComp(type); showToast('Deleted.'); } catch(e) { showToast('Failed to delete.', 'danger'); } } // Pill switcher document.getElementById('compPills').addEventListener('click', e => { const btn = e.target.closest('[data-comp]'); if (!btn) return; document.querySelectorAll('#compPills .nav-link').forEach(b => b.classList.remove('active')); btn.classList.add('active'); loadComp(btn.dataset.comp); }); // ── Tab activation → lazy load ───────────────────────────────────────────── document.querySelectorAll('#adminTabs button').forEach(btn => { btn.addEventListener('shown.bs.tab', e => { const target = e.target.dataset.bsTarget; if (target === '#usersTab' && !document.getElementById('usersBody').innerHTML) loadUsers(); if (target === '#catalogTab' && !allCatalogItems.length) loadCatalog(); if (target === '#componentsTab' && !compData.primers) loadComp('primers'); if (target === '#calibersTab' && !caliberData.length) loadCalibersAdmin(); }); }); document.addEventListener('DOMContentLoaded', () => { document.getElementById('catalogSearch')?.addEventListener('keydown', e => { if (e.key === 'Enter') { catalogSearchTerm = e.target.value.trim(); loadCatalog(undefined, 1); } }); }); // ══ CALIBERS TAB ═════════════════════════════════════════════════════════════ let caliberData = []; async function loadCalibersAdmin() { try { caliberData = await apiGetAll('/calibers/?page_size=1000'); document.getElementById('calibersSpinner').classList.add('d-none'); document.getElementById('calibersTableWrap').classList.remove('d-none'); renderCalibersAdmin(); } catch(e) { document.getElementById('calibersSpinner').innerHTML = '

Failed to load.

'; } } function renderCalibersAdmin() { const statusBadge = s => ({ PENDING: 'Pending', VERIFIED: 'Verified', REJECTED: 'Rejected', }[s] || `${s}`); document.getElementById('calibersBody').innerHTML = caliberData.length ? caliberData.map(c => ` ${c.name} ${c.short_name || '—'} ${c.case_length_mm ?? '—'} ${c.max_pressure_mpa ?? '—'} ${statusBadge(c.status)} ${c.status === 'PENDING' ? ` ` : ''} `).join('') : 'No calibers yet.'; } async function verifyCaliber(id) { try { await apiPost(`/calibers/${id}/verify/`, {}); const item = caliberData.find(c => c.id === id); if (item) item.status = 'VERIFIED'; renderCalibersAdmin(); showToast('Caliber verified.'); } catch(e) { showToast('Failed to verify.', 'danger'); } } async function rejectCaliber(id) { try { await apiPost(`/calibers/${id}/reject/`, {}); const item = caliberData.find(c => c.id === id); if (item) item.status = 'REJECTED'; renderCalibersAdmin(); showToast('Caliber rejected.'); } catch(e) { showToast('Failed to reject.', 'danger'); } } async function deleteCaliber(id) { if (!confirm('Delete this caliber?')) return; try { await apiDelete(`/calibers/${id}/`); caliberData = caliberData.filter(c => c.id !== id); renderCalibersAdmin(); showToast('Deleted.'); } catch(e) { showToast('Failed to delete.', 'danger'); } } document.getElementById('calibersAddBtn').addEventListener('click', async () => { const alertEl = document.getElementById('calibersAddAlert'); alertEl.classList.add('d-none'); const name = document.getElementById('calName').value.trim(); if (!name) { alertEl.textContent = 'Name is required.'; alertEl.classList.remove('d-none'); return; } const payload = { name }; const short = document.getElementById('calShort').value.trim(); if (short) payload.short_name = short; const caseLen = document.getElementById('calCase').value; const oal = document.getElementById('calOAL').value; const bulDia = document.getElementById('calBulletDia').value; const pressure = document.getElementById('calPressure').value; if (caseLen) payload.case_length_mm = parseFloat(caseLen); if (oal) payload.overall_length_mm = parseFloat(oal); if (bulDia) payload.bullet_diameter_mm = parseFloat(bulDia); if (pressure) payload.max_pressure_mpa = parseFloat(pressure); try { // Admin creates calibers directly as verified via the verify action after creation const created = await apiPost('/calibers/', payload); await apiPost(`/calibers/${created.id}/verify/`, {}); created.status = 'VERIFIED'; caliberData.push(created); renderCalibersAdmin(); ['calName','calShort','calCase','calOAL','calBulletDia','calPressure'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); showToast('Caliber added.'); } catch(e) { alertEl.textContent = formatErrors(e.data) || 'Failed to add.'; alertEl.classList.remove('d-none'); } }); // ══ CREATE USER ══════════════════════════════════════════════════════════════ document.getElementById('createUserBtn').addEventListener('click', () => { const panel = document.getElementById('createUserPanel'); panel.classList.toggle('d-none'); if (!panel.classList.contains('d-none')) { document.getElementById('newUsername').focus(); } }); document.getElementById('cancelCreateUserBtn').addEventListener('click', () => { document.getElementById('createUserPanel').classList.add('d-none'); document.getElementById('createUserAlert').classList.add('d-none'); }); document.getElementById('saveCreateUserBtn').addEventListener('click', async () => { const alertEl = document.getElementById('createUserAlert'); alertEl.classList.add('d-none'); const username = document.getElementById('newUsername').value.trim(); const email = document.getElementById('newEmail').value.trim(); const password = document.getElementById('newPassword').value; const first_name = document.getElementById('newFirstName').value.trim(); const last_name = document.getElementById('newLastName').value.trim(); const is_staff = document.getElementById('newIsStaff').checked; if (!username || !email || !password) { alertEl.textContent = 'Username, email and password are required.'; alertEl.classList.remove('d-none'); return; } try { const user = await apiPost('/users/admin/', { username, email, password, first_name, last_name, is_staff }); await loadUsers(); document.getElementById('createUserPanel').classList.add('d-none'); ['newUsername','newEmail','newPassword','newFirstName','newLastName'].forEach(id => { document.getElementById(id).value = ''; }); document.getElementById('newIsStaff').checked = false; showToast(`User "${user.username}" created.`); } catch(e) { alertEl.textContent = formatErrors(e.data) || 'Failed to create user.'; alertEl.classList.remove('d-none'); } }); // ── Init: load users tab (active by default) ────────────────────────────── loadUsers();