Files
ShooterHub/frontend/js/reloads.js
2026-04-02 11:24:30 +02:00

543 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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();