543 lines
24 KiB
JavaScript
543 lines
24 KiB
JavaScript
// ── 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();
|