Files
ShooterHub/frontend/js/reloads.js

543 lines
24 KiB
JavaScript
Raw Normal View History

// ── Reloads page logic ────────────────────────────────────────────────────────
let recipes = [];
let components = { primers: [], brass: [], bullets: [], powders: [] };
let currentRecipe = null;
// showToast, renderPublicToggle → utils.js
// ── Load components (for selects) ─────────────────────────────────────────────
async function loadComponents() {
const errOption = name => `<option value="">Failed to load ${name} — check console</option>`;
let primers = [], brass = [], bullets = [], powders = [];
try {
[primers, brass, bullets, powders] = await Promise.all([
apiGetAll('/gears/components/primers/?page_size=1000'),
apiGetAll('/gears/components/brass/?page_size=1000'),
apiGetAll('/gears/components/bullets/?page_size=1000'),
apiGetAll('/gears/components/powders/?page_size=1000'),
]);
} catch(e) {
console.error('loadComponents failed:', e);
['recipePrimer','recipeBrass','recipeBullet','batchPowder'].forEach(id => {
document.getElementById(id).innerHTML = errOption(id);
});
showToast('Failed to load components. Are the component tables populated?', 'danger');
return;
}
components = { primers, brass, bullets, powders };
// Populate recipe selects
document.getElementById('recipePrimer').innerHTML =
'<option value="">Select primer…</option>' +
(primers.length ? primers.map(p => `<option value="${p.id}">${p.brand} ${p.name} (${p.size})</option>`).join('')
: '<option disabled>No primers in database</option>');
document.getElementById('recipeBrass').innerHTML =
'<option value="">Select brass…</option>' +
(brass.length ? brass.map(b => `<option value="${b.id}">${b.brand} ${b.caliber_detail?.name || '?'}</option>`).join('')
: '<option disabled>No brass in database</option>');
document.getElementById('recipeBullet').innerHTML =
'<option value="">Select bullet…</option>' +
(bullets.length ? bullets.map(b => `<option value="${b.id}">${b.brand} ${b.model_name} ${b.weight_gr}gr (${b.bullet_type})</option>`).join('')
: '<option disabled>No bullets in database</option>');
// Populate batch powder select
document.getElementById('batchPowder').innerHTML =
'<option value="">Select powder…</option>' +
powders.map(p => `<option value="${p.id}">${p.brand} ${p.name}${p.powder_type ? ' ' + p.powder_type : ''}</option>`).join('');
}
// ── Recipes sidebar ───────────────────────────────────────────────────────────
function renderRecipesList() {
const list = document.getElementById('recipesList');
const empty = document.getElementById('recipesEmpty');
const spinner = document.getElementById('recipesSpinner');
spinner.classList.add('d-none');
if (!recipes.length) {
empty.classList.remove('d-none');
list.innerHTML = '';
return;
}
empty.classList.add('d-none');
list.innerHTML = recipes.map(r => `
<div class="recipe-item p-2 rounded mb-1 ${currentRecipe?.id === r.id ? 'active' : ''}"
onclick="selectRecipe(${r.id})">
<div class="fw-semibold small">${r.name}</div>
<div class="text-muted" style="font-size:.75rem;">
${r.caliber_detail?.name || '—'} · ${r.batches.length} batch${r.batches.length === 1 ? '' : 'es'}
</div>
</div>
`).join('');
}
async function loadRecipes() {
try {
recipes = await apiGetAll('/reloading/recipes/');
renderRecipesList();
} catch(e) {
document.getElementById('recipesSpinner').classList.add('d-none');
showToast('Failed to load recipes', 'danger');
}
}
function selectRecipe(id) {
currentRecipe = recipes.find(r => r.id === id);
if (!currentRecipe) return;
renderRecipesList();
renderRecipeDetail();
loadBatches(id);
}
function renderRecipeDetail() {
const r = currentRecipe;
document.getElementById('noRecipeMsg').classList.add('d-none');
document.getElementById('recipeDetail').classList.remove('d-none');
document.getElementById('detailName').textContent = r.name;
document.getElementById('detailCaliber').textContent = r.caliber_detail?.name || '—';
document.getElementById('detailPrimer').textContent =
`${r.primer_detail.brand} ${r.primer_detail.name} (${r.primer_detail.size})`;
document.getElementById('detailBrass').textContent =
`${r.brass_detail.brand} ${r.brass_detail.caliber_detail?.name || '?'}`;
document.getElementById('detailBullet').textContent =
`${r.bullet_detail.brand} ${r.bullet_detail.model_name} ${r.bullet_detail.weight_gr}gr`;
document.getElementById('detailNotes').textContent = r.notes || '—';
renderPublicToggle(r.is_public);
}
// renderPublicToggle → utils.js
document.getElementById('togglePublicBtn').addEventListener('click', async () => {
if (!currentRecipe) return;
const newVal = !currentRecipe.is_public;
try {
await apiPatch(`/reloading/recipes/${currentRecipe.id}/`, { is_public: newVal });
currentRecipe.is_public = newVal;
const idx = recipes.findIndex(r => r.id === currentRecipe.id);
if (idx >= 0) recipes[idx].is_public = newVal;
renderPublicToggle(newVal);
showToast(newVal ? 'Recipe is now public.' : 'Recipe is now private.');
} catch(e) {
showToast('Failed to update visibility.', 'danger');
}
});
// ── Edit / delete recipe ──────────────────────────────────────────────────────
const recipeModal = new bootstrap.Modal('#recipeModal');
document.getElementById('newRecipeBtn').addEventListener('click', async () => {
document.getElementById('recipeId').value = '';
document.getElementById('recipeName').value = '';
document.getElementById('recipeNotes').value = '';
document.getElementById('recipePrimer').value = '';
document.getElementById('recipeBrass').value = '';
document.getElementById('recipeBullet').value = '';
document.getElementById('recipeModalTitle').textContent = 'New recipe';
document.getElementById('recipeModalAlert').classList.add('d-none');
const calSel = document.getElementById('recipeCaliber');
if (calSel && (!calSel.options.length || calSel.options[0].text === 'Loading…')) {
await loadCalibersIntoSelect(calSel);
}
recipeModal.show();
});
document.getElementById('editRecipeBtn').addEventListener('click', async () => {
if (!currentRecipe) return;
const r = currentRecipe;
document.getElementById('recipeId').value = r.id;
document.getElementById('recipeName').value = r.name;
document.getElementById('recipeNotes').value = r.notes || '';
document.getElementById('recipePrimer').value = r.primer;
document.getElementById('recipeBrass').value = r.brass;
document.getElementById('recipeBullet').value = r.bullet;
document.getElementById('recipeModalTitle').textContent = 'Edit recipe';
document.getElementById('recipeModalAlert').classList.add('d-none');
const calSel = document.getElementById('recipeCaliber');
if (calSel && (!calSel.options.length || calSel.options[0].text === 'Loading…')) {
await loadCalibersIntoSelect(calSel);
}
// Set after options are loaded
calSel.value = r.caliber || '';
recipeModal.show();
});
document.getElementById('saveRecipeBtn').addEventListener('click', async () => {
const id = document.getElementById('recipeId').value;
const alertEl = document.getElementById('recipeModalAlert');
alertEl.classList.add('d-none');
const calVal = document.getElementById('recipeCaliber').value;
const payload = {
name: document.getElementById('recipeName').value.trim(),
caliber: calVal ? parseInt(calVal) : null,
primer: parseInt(document.getElementById('recipePrimer').value),
brass: parseInt(document.getElementById('recipeBrass').value),
bullet: parseInt(document.getElementById('recipeBullet').value),
notes: document.getElementById('recipeNotes').value.trim(),
};
if (!payload.name || !payload.caliber || !payload.primer || !payload.brass || !payload.bullet) {
alertEl.textContent = 'Name, caliber, primer, brass and bullet are required.';
alertEl.classList.remove('d-none');
return;
}
try {
if (id) {
const updated = await apiPatch(`/reloading/recipes/${id}/`, {
name: payload.name, caliber: payload.caliber || null, notes: payload.notes,
});
const idx = recipes.findIndex(r => r.id === parseInt(id));
if (idx >= 0) recipes[idx] = { ...recipes[idx], ...updated };
currentRecipe = recipes[idx];
renderRecipeDetail();
} else {
const created = await apiPost('/reloading/recipes/', payload);
recipes.push(created);
currentRecipe = created;
renderRecipeDetail();
document.getElementById('batchesBody').innerHTML = '';
document.getElementById('batchesEmpty').classList.remove('d-none');
document.getElementById('batchesTableWrap').classList.add('d-none');
document.getElementById('noRecipeMsg').classList.add('d-none');
document.getElementById('recipeDetail').classList.remove('d-none');
}
renderRecipesList();
recipeModal.hide();
showToast(id ? 'Recipe updated!' : 'Recipe created!');
} catch(e) {
alertEl.textContent = formatErrors(e.data);
alertEl.classList.remove('d-none');
}
});
document.getElementById('deleteRecipeBtn').addEventListener('click', async () => {
if (!currentRecipe) return;
if (!confirm(`Delete recipe "${currentRecipe.name}"? All its batches will be deleted too.`)) return;
try {
await apiDelete(`/reloading/recipes/${currentRecipe.id}/`);
recipes = recipes.filter(r => r.id !== currentRecipe.id);
currentRecipe = null;
renderRecipesList();
document.getElementById('recipeDetail').classList.add('d-none');
document.getElementById('noRecipeMsg').classList.remove('d-none');
showToast('Recipe deleted.');
} catch(e) {
showToast('Failed to delete recipe.', 'danger');
}
});
// ── Batches ───────────────────────────────────────────────────────────────────
let batches = [];
function renderBatches() {
const tbody = document.getElementById('batchesBody');
const wrap = document.getElementById('batchesTableWrap');
const empty = document.getElementById('batchesEmpty');
document.getElementById('batchesSpinner').classList.add('d-none');
if (!batches.length) {
empty.classList.remove('d-none');
wrap.classList.add('d-none');
return;
}
empty.classList.add('d-none');
wrap.classList.remove('d-none');
tbody.innerHTML = batches.map(b => `
<tr>
<td>${b.powder_detail.brand} ${b.powder_detail.name}</td>
<td><strong>${b.powder_charge_gr}</strong></td>
<td>${b.quantity ?? '—'}</td>
<td>${b.oal_mm ?? '—'}</td>
<td><span class="badge bg-secondary">${b.crimp}</span></td>
<td>${b.loaded_at ?? '—'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditBatch(${b.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteBatch(${b.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadBatches(recipeId) {
const spinner = document.getElementById('batchesSpinner');
spinner.classList.remove('d-none');
document.getElementById('batchesEmpty').classList.add('d-none');
document.getElementById('batchesTableWrap').classList.add('d-none');
try {
batches = await apiGet(`/reloading/batches/?recipe=${recipeId}`);
renderBatches();
} catch(e) {
spinner.classList.add('d-none');
showToast('Failed to load batches', 'danger');
}
}
// Create / Edit Batch modal
const batchModal = new bootstrap.Modal('#batchModal');
document.getElementById('newBatchBtn').addEventListener('click', () => {
document.getElementById('batchId').value = '';
document.getElementById('batchCharge').value = '';
document.getElementById('batchQty').value = '';
document.getElementById('batchOal').value = '';
document.getElementById('batchCoal').value = '';
document.getElementById('batchCrimp').value = 'NONE';
document.getElementById('batchLoaded').value = '';
document.getElementById('batchCasePrep').value = '';
document.getElementById('batchNotes').value = '';
document.getElementById('batchPowder').value = '';
document.getElementById('batchModalTitle').textContent = 'New batch';
document.getElementById('batchModalAlert').classList.add('d-none');
batchModal.show();
});
function openEditBatch(id) {
const b = batches.find(x => x.id === id);
if (!b) return;
document.getElementById('batchId').value = id;
document.getElementById('batchPowder').value = b.powder;
document.getElementById('batchCharge').value = b.powder_charge_gr;
document.getElementById('batchQty').value = b.quantity ?? '';
document.getElementById('batchOal').value = b.oal_mm ?? '';
document.getElementById('batchCoal').value = b.coal_mm ?? '';
document.getElementById('batchCrimp').value = b.crimp || 'NONE';
document.getElementById('batchLoaded').value = b.loaded_at ?? '';
document.getElementById('batchCasePrep').value = b.case_prep_notes ?? '';
document.getElementById('batchNotes').value = b.notes ?? '';
document.getElementById('batchModalTitle').textContent = 'Edit batch';
document.getElementById('batchModalAlert').classList.add('d-none');
batchModal.show();
}
document.getElementById('saveBatchBtn').addEventListener('click', async () => {
if (!currentRecipe) return;
const id = document.getElementById('batchId').value;
const alertEl = document.getElementById('batchModalAlert');
alertEl.classList.add('d-none');
const payload = {
recipe: currentRecipe.id,
powder: parseInt(document.getElementById('batchPowder').value),
powder_charge_gr: document.getElementById('batchCharge').value,
crimp: document.getElementById('batchCrimp').value,
};
const qty = document.getElementById('batchQty').value;
const oal = document.getElementById('batchOal').value;
const coal = document.getElementById('batchCoal').value;
const loaded = document.getElementById('batchLoaded').value;
const casePrep = document.getElementById('batchCasePrep').value.trim();
const notes = document.getElementById('batchNotes').value.trim();
if (qty) payload.quantity = parseInt(qty);
if (oal) payload.oal_mm = oal;
if (coal) payload.coal_mm = coal;
if (loaded) payload.loaded_at = loaded;
if (casePrep) payload.case_prep_notes = casePrep;
if (notes) payload.notes = notes;
if (!payload.powder || !payload.powder_charge_gr) {
alertEl.textContent = 'Powder and charge are required.';
alertEl.classList.remove('d-none');
return;
}
try {
if (id) {
const updated = await apiPatch(`/reloading/batches/${id}/`, payload);
const idx = batches.findIndex(b => b.id === parseInt(id));
if (idx >= 0) batches[idx] = updated;
} else {
const created = await apiPost('/reloading/batches/', payload);
batches.push(created);
// Update recipe batch count
const ri = recipes.findIndex(r => r.id === currentRecipe.id);
if (ri >= 0) recipes[ri].batches = batches;
renderRecipesList();
}
renderBatches();
batchModal.hide();
showToast(id ? 'Batch updated!' : 'Batch created!');
} catch(e) {
alertEl.textContent = formatErrors(e.data);
alertEl.classList.remove('d-none');
}
});
async function deleteBatch(id) {
if (!confirm('Delete this batch?')) return;
try {
await apiDelete(`/reloading/batches/${id}/`);
batches = batches.filter(b => b.id !== id);
renderBatches();
// Update recipe batch count
const ri = recipes.findIndex(r => r.id === currentRecipe?.id);
if (ri >= 0) recipes[ri].batches = batches;
renderRecipesList();
showToast('Batch deleted.');
} catch(e) {
showToast('Failed to delete batch.', 'danger');
}
}
// ── Suggest component (user submission) ──────────────────────────────────────
const COMP_SUGGEST_FORMS = {
primers: {
endpoint: '/gears/components/primers/',
selectId: 'recipePrimer',
html: `
<div class="card bg-light border-0 p-2 small">
<p class="text-muted mb-1"><i class="bi bi-info-circle me-1"></i>Pending admin approval. Usable by you immediately.</p>
<input type="text" class="form-control form-control-sm mb-1" data-field="brand" placeholder="Brand *">
<input type="text" class="form-control form-control-sm mb-1" data-field="name" placeholder="Name *">
<select class="form-select form-select-sm mb-1" data-field="size">
<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 class="comp-suggest-alert alert alert-danger d-none py-1 mb-1"></div>
<button class="btn btn-xs btn-primary btn-sm w-100 comp-suggest-submit">Submit</button>
</div>`,
payload: (box) => ({
brand: box.querySelector('[data-field=brand]').value.trim(),
name: box.querySelector('[data-field=name]').value.trim(),
size: box.querySelector('[data-field=size]').value,
}),
label: (c) => `${c.brand} ${c.name} (${c.size}) [pending]`,
},
brass: {
endpoint: '/gears/components/brass/',
selectId: 'recipeBrass',
html: `
<div class="card bg-light border-0 p-2 small">
<p class="text-muted mb-1"><i class="bi bi-info-circle me-1"></i>Pending admin approval. Usable by you immediately.</p>
<input type="text" class="form-control form-control-sm mb-1" data-field="brand" placeholder="Brand *">
<select class="form-select form-select-sm mb-1" data-field="caliber"><option value="">Loading calibers</option></select>
<div class="comp-suggest-alert alert alert-danger d-none py-1 mb-1"></div>
<button class="btn btn-sm btn-primary w-100 comp-suggest-submit">Submit</button>
</div>`,
payload: (box) => {
const cal = box.querySelector('[data-field=caliber]').value;
return { brand: box.querySelector('[data-field=brand]').value.trim(), caliber: cal ? parseInt(cal) : undefined };
},
label: (c) => `${c.brand} ${c.caliber_detail?.name || '?'} [pending]`,
},
bullets: {
endpoint: '/gears/components/bullets/',
selectId: 'recipeBullet',
html: `
<div class="card bg-light border-0 p-2 small">
<p class="text-muted mb-1"><i class="bi bi-info-circle me-1"></i>Pending admin approval. Usable by you immediately.</p>
<input type="text" class="form-control form-control-sm mb-1" data-field="brand" placeholder="Brand *">
<input type="text" class="form-control form-control-sm mb-1" data-field="model_name" placeholder="Model name *">
<input type="number" class="form-control form-control-sm mb-1" data-field="weight_gr" placeholder="Weight (gr) *" step="0.1">
<select class="form-select form-select-sm mb-1" data-field="bullet_type">
<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 class="comp-suggest-alert alert alert-danger d-none py-1 mb-1"></div>
<button class="btn btn-sm btn-primary w-100 comp-suggest-submit">Submit</button>
</div>`,
payload: (box) => ({
brand: box.querySelector('[data-field=brand]').value.trim(),
model_name: box.querySelector('[data-field=model_name]').value.trim(),
weight_gr: box.querySelector('[data-field=weight_gr]').value,
bullet_type: box.querySelector('[data-field=bullet_type]').value,
}),
label: (c) => `${c.brand} ${c.model_name} ${c.weight_gr}gr (${c.bullet_type}) [pending]`,
},
powders: {
endpoint: '/gears/components/powders/',
selectId: 'batchPowder',
html: `
<div class="card bg-light border-0 p-2 small">
<p class="text-muted mb-1"><i class="bi bi-info-circle me-1"></i>Pending admin approval. Usable by you immediately.</p>
<input type="text" class="form-control form-control-sm mb-1" data-field="brand" placeholder="Brand *">
<input type="text" class="form-control form-control-sm mb-1" data-field="name" placeholder="Name *">
<select class="form-select form-select-sm mb-1" data-field="powder_type">
<option value="">Type (optional)</option>
<option value="BALL">Ball/Spherical</option>
<option value="EXTRUDED">Extruded/Stick</option>
<option value="FLAKE">Flake</option>
</select>
<div class="comp-suggest-alert alert alert-danger d-none py-1 mb-1"></div>
<button class="btn btn-sm btn-primary w-100 comp-suggest-submit">Submit</button>
</div>`,
payload: (box) => {
const p = {
brand: box.querySelector('[data-field=brand]').value.trim(),
name: box.querySelector('[data-field=name]').value.trim(),
};
const t = box.querySelector('[data-field=powder_type]').value;
if (t) p.powder_type = t;
return p;
},
label: (c) => `${c.brand} ${c.name}${c.powder_type ? ' ' + c.powder_type : ''} [pending]`,
},
};
// Attach suggest-link handlers (works for both recipe modal and batch modal)
document.addEventListener('click', async e => {
const link = e.target.closest('.suggest-comp-link');
if (!link) return;
e.preventDefault();
const compType = link.dataset.comp;
const box = link.closest('.col-md-4, .col-md-6').querySelector('.suggest-comp-form');
const cfg = COMP_SUGGEST_FORMS[compType];
if (!box || !cfg) return;
if (box.innerHTML) { box.classList.toggle('d-none'); return; }
box.innerHTML = cfg.html;
box.classList.remove('d-none');
// If brass, load calibers into its select
if (compType === 'brass') {
const calSel = box.querySelector('[data-field=caliber]');
if (calSel) loadCalibersIntoSelect(calSel);
}
box.querySelector('.comp-suggest-submit').addEventListener('click', async () => {
const alertEl = box.querySelector('.comp-suggest-alert');
alertEl.classList.add('d-none');
const payload = cfg.payload(box);
if (!payload.brand || !payload.name && !payload.caliber && !payload.model_name) {
alertEl.textContent = 'Required fields are missing.';
alertEl.classList.remove('d-none');
return;
}
try {
const created = await apiPost(cfg.endpoint, payload);
// Add to the corresponding select and auto-select it
const sel = document.getElementById(cfg.selectId);
const opt = new Option(cfg.label(created), created.id, true, true);
sel.appendChild(opt);
// Add to components cache too
if (components[compType]) components[compType].push(created);
box.classList.add('d-none');
showToast('Component submitted! It is now available in the selector.');
} catch(err) {
alertEl.textContent = formatErrors(err.data) || 'Submission failed.';
alertEl.classList.remove('d-none');
}
});
});
// ── Init ──────────────────────────────────────────────────────────────────────
loadComponents();
loadRecipes();