// ── 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 => ``; 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 = '' + (primers.length ? primers.map(p => ``).join('') : ''); document.getElementById('recipeBrass').innerHTML = '' + (brass.length ? brass.map(b => ``).join('') : ''); document.getElementById('recipeBullet').innerHTML = '' + (bullets.length ? bullets.map(b => ``).join('') : ''); // Populate batch powder select document.getElementById('batchPowder').innerHTML = '' + powders.map(p => ``).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 => `
${r.name}
${r.caliber_detail?.name || '—'} · ${r.batches.length} batch${r.batches.length === 1 ? '' : 'es'}
`).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 => ` ${b.powder_detail.brand} ${b.powder_detail.name} ${b.powder_charge_gr} ${b.quantity ?? '—'} ${b.oal_mm ?? '—'} ${b.crimp} ${b.loaded_at ?? '—'} `).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: `

Pending admin approval. Usable by you immediately.

`, 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: `

Pending admin approval. Usable by you immediately.

`, 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: `

Pending admin approval. Usable by you immediately.

`, 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: `

Pending admin approval. Usable by you immediately.

`, 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();