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