738 lines
31 KiB
JavaScript
738 lines
31 KiB
JavaScript
// ── 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 = `<div class="d-flex"><div class="toast-body">${msg}</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
|
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 =
|
|
'<p class="text-danger">Failed to load users.</p>';
|
|
}
|
|
}
|
|
|
|
function renderUsers(users) {
|
|
document.getElementById('usersBody').innerHTML = users.map(u => `
|
|
<tr>
|
|
<td class="fw-semibold">${u.username}</td>
|
|
<td>${u.email}</td>
|
|
<td>${[u.first_name, u.last_name].filter(Boolean).join(' ') || '—'}</td>
|
|
<td>${u.is_staff ? '<span class="badge bg-warning text-dark">Staff</span>' : ''}</td>
|
|
<td>
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" ${u.is_active ? 'checked' : ''}
|
|
onchange="toggleUserActive(${u.id}, this.checked)" title="Active">
|
|
</div>
|
|
</td>
|
|
<td class="text-muted small">${u.date_joined ? u.date_joined.slice(0,10) : '—'}</td>
|
|
<td>
|
|
<button class="btn btn-sm ${u.is_staff ? 'btn-outline-secondary' : 'btn-outline-warning'}"
|
|
onclick="toggleUserStaff(${u.id}, ${!u.is_staff})" title="${u.is_staff ? 'Remove staff' : 'Make staff'}">
|
|
<i class="bi bi-${u.is_staff ? 'person-dash' : 'person-up'}"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).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 = '<div class="spinner-border text-primary"></div>';
|
|
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 = `<p class="text-danger">Failed to load catalog: ${e.message}</p>`;
|
|
}
|
|
}
|
|
|
|
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: '<span class="badge bg-warning text-dark">Pending</span>',
|
|
VERIFIED: '<span class="badge bg-success">Verified</span>',
|
|
REJECTED: '<span class="badge bg-danger">Rejected</span>',
|
|
}[s] || `<span class="badge bg-secondary">${s}</span>`);
|
|
|
|
// 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 => `
|
|
<tr>
|
|
<td><span class="badge bg-secondary">${g.gear_type}</span></td>
|
|
<td>${esc(g.brand)}</td>
|
|
<td>${esc(g.model_name)}</td>
|
|
<td>${esc(g.caliber_detail?.name || '—')}</td>
|
|
<td>${statusBadge(g.status)}</td>
|
|
<td class="text-end">
|
|
${g.status === 'PENDING' ? `
|
|
<button class="btn btn-sm btn-outline-success me-1"
|
|
onclick="verifyGear('${g.gear_type}', ${g.id})" title="Verify">
|
|
<i class="bi bi-check-lg"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger me-1"
|
|
onclick="rejectGear('${g.gear_type}', ${g.id})" title="Reject">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>` : ''}
|
|
<button class="btn btn-sm btn-outline-danger"
|
|
onclick="deleteGear('${g.gear_type}', ${g.id})" title="Delete">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="6" class="text-muted text-center">No items.</td></tr>';
|
|
|
|
// Pagination controls
|
|
document.getElementById('catalogPagination').innerHTML = (catalogPage > 1 || catalogHasNext) ? `
|
|
<nav class="d-flex justify-content-between align-items-center mt-2">
|
|
<button class="btn btn-sm btn-outline-secondary" ${catalogPage <= 1 ? 'disabled' : ''}
|
|
onclick="loadCatalog(undefined, ${catalogPage - 1})">
|
|
<i class="bi bi-chevron-left"></i> Prev
|
|
</button>
|
|
<span class="small text-muted">Page ${catalogPage}</span>
|
|
<button class="btn btn-sm btn-outline-secondary" ${!catalogHasNext ? 'disabled' : ''}
|
|
onclick="loadCatalog(undefined, ${catalogPage + 1})">
|
|
Next <i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
</nav>` : '';
|
|
}
|
|
|
|
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' ? '<span class="badge bg-warning text-dark">Pending</span>'
|
|
: s === 'REJECTED' ? '<span class="badge bg-danger">Rejected</span>'
|
|
: '<span class="badge bg-success">Verified</span>';
|
|
}
|
|
|
|
const COMP_CONFIG = {
|
|
primers: {
|
|
title: 'primer',
|
|
endpoint: '/gears/components/primers/',
|
|
headers: ['Brand', 'Name', 'Size', 'Status', ''],
|
|
row: c => `<td>${c.brand}</td><td>${c.name}</td><td>${c.size}</td><td>${compStatusBadge(c.status)}</td>`,
|
|
form: () => `
|
|
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cBrand" placeholder="Brand *"></div>
|
|
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cName" placeholder="Name *"></div>
|
|
<div class="mb-2">
|
|
<select class="form-select form-select-sm" id="cSize">
|
|
<option value="SP">Small Pistol</option><option value="LP">Large Pistol</option>
|
|
<option value="SR">Small Rifle</option><option value="LR" selected>Large Rifle</option>
|
|
<option value="LRM">Large Rifle Magnum</option>
|
|
</select>
|
|
</div>`,
|
|
payload: () => ({ brand: v('cBrand'), name: v('cName'), size: v('cSize') }),
|
|
},
|
|
brass: {
|
|
title: 'brass',
|
|
endpoint: '/gears/components/brass/',
|
|
headers: ['Brand', 'Caliber', 'Pocket', 'Status', ''],
|
|
row: c => `<td>${c.brand}</td><td>${c.caliber_detail?.name || '—'}</td><td>${c.primer_pocket || '—'}</td><td>${compStatusBadge(c.status)}</td>`,
|
|
form: () => `
|
|
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cBrand" placeholder="Brand *"></div>
|
|
<div class="mb-2">
|
|
<select class="form-select form-select-sm" id="cCaliber"><option value="">Loading calibers…</option></select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<select class="form-select form-select-sm" id="cPocket">
|
|
<option value="">Primer pocket (optional)</option>
|
|
<option value="SP">Small Pistol</option><option value="LP">Large Pistol</option>
|
|
<option value="SR">Small Rifle</option><option value="LR">Large Rifle</option>
|
|
<option value="LRM">Large Rifle Magnum</option>
|
|
</select>
|
|
</div>`,
|
|
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 => `<td>${c.brand}</td><td>${c.model_name}</td><td>${c.weight_gr} gr</td><td>${c.bullet_type}</td><td>${compStatusBadge(c.status)}</td>`,
|
|
form: () => `
|
|
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cBrand" placeholder="Brand *"></div>
|
|
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cModel" placeholder="Model name *"></div>
|
|
<div class="mb-2"><input type="number" class="form-control form-control-sm" id="cWeight" placeholder="Weight (gr) *" step="0.1"></div>
|
|
<div class="mb-2">
|
|
<select class="form-select form-select-sm" id="cBType">
|
|
<option value="FMJ">FMJ</option><option value="HP">HP</option>
|
|
<option value="BTHP">BTHP</option><option value="SP">SP</option>
|
|
<option value="HPBT" selected>HPBT</option><option value="SMK">SMK</option>
|
|
<option value="A_TIP">A-Tip</option><option value="MONO">Monolithic</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-2"><input type="number" class="form-control form-control-sm" id="cDiam" placeholder="Diameter mm (opt)" step="0.001"></div>`,
|
|
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 => `<td>${c.brand}</td><td>${c.name}</td><td>${c.powder_type || '—'}</td><td>${c.burn_rate_index ?? '—'}</td><td>${compStatusBadge(c.status)}</td>`,
|
|
form: () => `
|
|
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cBrand" placeholder="Brand *"></div>
|
|
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cName" placeholder="Name *"></div>
|
|
<div class="mb-2">
|
|
<select class="form-select form-select-sm" id="cPType">
|
|
<option value="">Type (optional)</option>
|
|
<option value="BALL">Ball/Spherical</option>
|
|
<option value="EXTRUDED">Extruded/Stick</option>
|
|
<option value="FLAKE">Flake</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-2"><input type="number" class="form-control form-control-sm" id="cBurn" placeholder="Burn rate index (opt)"></div>`,
|
|
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 = '<p class="text-danger">Failed to load.</p>';
|
|
}
|
|
}
|
|
|
|
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 => `<th>${h}</th>`).join('');
|
|
|
|
document.getElementById('compBody').innerHTML = items.length
|
|
? items.map(c => `
|
|
<tr>
|
|
${cfg.row(c)}
|
|
<td class="text-end">
|
|
${c.status === 'PENDING' ? `
|
|
<button class="btn btn-sm btn-outline-success me-1"
|
|
onclick="verifyComp('${type}', ${c.id})" title="Verify">
|
|
<i class="bi bi-check-lg"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-warning me-1"
|
|
onclick="rejectComp('${type}', ${c.id})" title="Reject">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>` : ''}
|
|
<button class="btn btn-sm btn-outline-danger"
|
|
onclick="deleteComp('${type}', ${c.id})">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>`).join('')
|
|
: `<tr><td colspan="${cfg.headers.length}" class="text-muted text-center">No entries.</td></tr>`;
|
|
}
|
|
|
|
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 = '<p class="text-danger">Failed to load.</p>';
|
|
}
|
|
}
|
|
|
|
function renderCalibersAdmin() {
|
|
const statusBadge = s => ({
|
|
PENDING: '<span class="badge bg-warning text-dark">Pending</span>',
|
|
VERIFIED: '<span class="badge bg-success">Verified</span>',
|
|
REJECTED: '<span class="badge bg-danger">Rejected</span>',
|
|
}[s] || `<span class="badge bg-secondary">${s}</span>`);
|
|
|
|
document.getElementById('calibersBody').innerHTML = caliberData.length
|
|
? caliberData.map(c => `
|
|
<tr>
|
|
<td><strong>${c.name}</strong></td>
|
|
<td class="text-muted">${c.short_name || '—'}</td>
|
|
<td>${c.case_length_mm ?? '—'}</td>
|
|
<td>${c.max_pressure_mpa ?? '—'}</td>
|
|
<td>${statusBadge(c.status)}</td>
|
|
<td class="text-end">
|
|
${c.status === 'PENDING' ? `
|
|
<button class="btn btn-sm btn-outline-success me-1" onclick="verifyCaliber(${c.id})" title="Verify">
|
|
<i class="bi bi-check-lg"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger me-1" onclick="rejectCaliber(${c.id})" title="Reject">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>` : ''}
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteCaliber(${c.id})" title="Delete">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>`).join('')
|
|
: '<tr><td colspan="6" class="text-muted text-center">No calibers yet.</td></tr>';
|
|
}
|
|
|
|
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();
|