Files
PowderCoatingLogix/src/PowderCoating.Web/wwwroot/js/oven-scheduler.js
T
2026-04-23 21:38:24 -04:00

782 lines
37 KiB
JavaScript

/* oven-scheduler.js — Batch scheduler with mouse-event drag (no HTML5 DnD API) */
'use strict';
// ──────────────────────────────────────────────────────────────────────────────
// Drag state
// ──────────────────────────────────────────────────────────────────────────────
let dragState = null;
/* dragState shape:
{
type: 'queue' | 'batch',
sourceEl: HTMLElement, // original row being dragged
ghost: HTMLElement, // floating clone following the cursor
offsetX, offsetY: number, // cursor offset within the source element
// queue-specific:
coatId, jobId, jobItemId, sqft, color, colorCode,
pass, coatName, jobNumber, customer, description, priority, dueDate
// batch-specific:
batchItemId, sourceBatchId, sqft
}
*/
// Currently highlighted batch card id (for visual feedback)
let dragOverBatchId = null;
// ──────────────────────────────────────────────────────────────────────────────
// Init — wire up mousedown on all draggable rows
// ──────────────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initDraggable();
});
function initDraggable() {
// Queue coat rows
document.querySelectorAll('[data-coat-id]').forEach(el => {
el.addEventListener('mousedown', onQueueMouseDown);
});
// Batch item rows (only those with draggable=true, i.e. editable batches)
document.querySelectorAll('[data-batch-item-id]').forEach(el => {
if (el.dataset.draggable === 'true' || el.getAttribute('draggable') === 'true') {
el.addEventListener('mousedown', onBatchItemMouseDown);
}
});
}
// ──────────────────────────────────────────────────────────────────────────────
// Mousedown handlers
// ──────────────────────────────────────────────────────────────────────────────
function onQueueMouseDown(e) {
if (e.button !== 0) return; // left button only
if (e.target.closest('button')) return; // don't intercept button clicks
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
dragState = {
type: 'queue',
sourceEl: el,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
coatId: +el.dataset.coatId,
jobId: +el.dataset.jobId,
jobItemId: +el.dataset.jobItemId,
sqft: +el.dataset.sqft,
color: el.dataset.color,
colorCode: el.dataset.colorCode,
pass: +el.dataset.pass,
coatName: el.dataset.coatName,
jobNumber: el.dataset.jobNumber,
customer: el.dataset.customer,
description: el.dataset.description,
priority: el.dataset.priority,
dueDate: el.dataset.dueDate
};
startDrag(el, e);
e.preventDefault();
}
function onBatchItemMouseDown(e) {
if (e.button !== 0) return;
if (e.target.closest('button')) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
dragState = {
type: 'batch',
sourceEl: el,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
batchItemId: +el.dataset.batchItemId,
sourceBatchId: +el.dataset.batchId,
sqft: +el.dataset.sqft
};
startDrag(el, e);
e.preventDefault();
}
// ──────────────────────────────────────────────────────────────────────────────
// Ghost element
// ──────────────────────────────────────────────────────────────────────────────
function startDrag(sourceEl, e) {
const rect = sourceEl.getBoundingClientRect();
const ghost = sourceEl.cloneNode(true);
ghost.style.cssText = `
position: fixed;
left: ${rect.left}px;
top: ${rect.top}px;
width: ${rect.width}px;
pointer-events: none;
z-index: 9999;
opacity: 0.85;
box-shadow: 0 8px 24px rgba(0,0,0,.25);
border-radius: 6px;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
padding: .3rem .5rem;
font-size: .82rem;
transition: none;
`;
document.body.appendChild(ghost);
dragState.ghost = ghost;
sourceEl.style.opacity = '0.35';
}
function moveGhost(e) {
if (!dragState?.ghost) return;
dragState.ghost.style.left = (e.clientX - dragState.offsetX) + 'px';
dragState.ghost.style.top = (e.clientY - dragState.offsetY) + 'px';
}
// ──────────────────────────────────────────────────────────────────────────────
// Document mousemove — move ghost and highlight drop targets
// ──────────────────────────────────────────────────────────────────────────────
document.addEventListener('mousemove', e => {
if (!dragState) return;
moveGhost(e);
// Hide ghost temporarily so elementFromPoint sees what's underneath
dragState.ghost.style.display = 'none';
const elUnder = document.elementFromPoint(e.clientX, e.clientY);
dragState.ghost.style.display = '';
const batchCard = elUnder?.closest('.batch-card');
const newId = batchCard ? +batchCard.dataset.batchId : null;
if (newId !== dragOverBatchId) {
// Remove old highlight
if (dragOverBatchId !== null) {
document.getElementById(`batch-${dragOverBatchId}`)?.classList.remove('drag-over');
document.getElementById(`empty-items-${dragOverBatchId}`)?.classList.remove('drag-over');
}
// Add new highlight
if (newId !== null) {
batchCard.classList.add('drag-over');
document.getElementById(`empty-items-${newId}`)?.classList.add('drag-over');
}
dragOverBatchId = newId;
}
// Highlight empty oven zone when a queue item hovers over it
const ovenZone = elUnder?.closest('[data-oven-id]');
document.querySelectorAll('.drop-zone-empty').forEach(z => z.classList.remove('drag-over'));
if (dragState.type === 'queue' && !batchCard && ovenZone) {
document.getElementById(`empty-${ovenZone.dataset.ovenId}`)?.classList.add('drag-over');
}
// Highlight queue when a batch item is being dragged (indicates it can be dropped there)
const queueContainer = document.getElementById('queueContainer');
const overQueue = elUnder?.closest('#queueContainer') || elUnder?.closest('.scheduler-queue');
if (dragState.type === 'batch') {
queueContainer?.classList.toggle('drag-over', !!overQueue);
} else {
queueContainer?.classList.remove('drag-over');
}
});
// ──────────────────────────────────────────────────────────────────────────────
// Document mouseup — commit the drop
// ──────────────────────────────────────────────────────────────────────────────
document.addEventListener('mouseup', e => {
if (!dragState) return;
if (e.button !== 0) return;
// Clear all highlights
if (dragOverBatchId !== null) {
document.getElementById(`batch-${dragOverBatchId}`)?.classList.remove('drag-over');
document.getElementById(`empty-items-${dragOverBatchId}`)?.classList.remove('drag-over');
}
document.getElementById('queueContainer')?.classList.remove('drag-over');
document.querySelectorAll('.drop-zone-empty').forEach(z => z.classList.remove('drag-over'));
// Restore source element
if (dragState.sourceEl) dragState.sourceEl.style.opacity = '';
// Remove ghost
dragState.ghost?.remove();
// Find batch card under cursor
dragState.ghost = null;
const elUnder = document.elementFromPoint(e.clientX, e.clientY);
const batchCard = elUnder?.closest('.batch-card');
const state = dragState;
dragState = null;
dragOverBatchId = null;
if (!batchCard) {
if (state.type === 'queue') {
// Dropped on empty oven zone — auto-create batch and add
const ovenZone = elUnder?.closest('[data-oven-id]');
if (ovenZone) autoCreateBatchAndAdd(+ovenZone.dataset.ovenId, state);
} else if (state.type === 'batch') {
// Dropped outside any batch card — return item to queue
removeFromBatch(state.batchItemId, state.sourceBatchId);
}
return;
}
const batchId = +batchCard.dataset.batchId;
if (state.type === 'queue') {
addCoatToBatch(state, batchId);
} else if (state.type === 'batch' && state.sourceBatchId !== batchId) {
moveToBatch(state.batchItemId, batchId, state.sourceBatchId, state.sqft);
}
});
// ──────────────────────────────────────────────────────────────────────────────
// Add coat from queue to batch
// ──────────────────────────────────────────────────────────────────────────────
async function addCoatToBatch(drag, batchId) {
const res = await apiPost(URLS.addToBatch, {
batchId,
jobItemCoatId: drag.coatId
});
if (!res.success) { showToast(res.error || 'Failed to add to batch', 'danger'); return; }
// Remove from queue
document.getElementById(`qcoat-${drag.coatId}`)?.remove();
// Add to batch items list
appendBatchItemRow(batchId, res.item, true);
// Update capacity display
updateBatchCapacity(batchId, res.totalSurfaceAreaSqFt, res.capacityPct);
// Update drop hint: switch from "Drag coats here" to "Drop more here"
const hint = document.getElementById(`empty-items-${batchId}`);
if (hint) {
hint.classList.remove('batch-drop-hint-empty');
hint.innerHTML = '<i class="bi bi-plus-circle me-1"></i><span>Drop more here</span>';
}
updateQueueCount(-1);
showToast('Added to batch', 'success');
}
// ──────────────────────────────────────────────────────────────────────────────
// Auto-create a batch then add the dragged coat (dropped on empty oven zone)
// ──────────────────────────────────────────────────────────────────────────────
async function autoCreateBatchAndAdd(ovenCostId, drag) {
const createRes = await apiPost(URLS.createBatch, {
ovenCostId,
scheduledDate: SCHEDULED_DATE,
scheduledStartTime: null
});
if (!createRes.success) { showToast(createRes.error || 'Failed to create batch', 'danger'); return; }
// Add the coat to the newly created batch before reloading
const addRes = await apiPost(URLS.addToBatch, {
batchId: createRes.batch.id,
jobItemCoatId: drag.coatId
});
if (!addRes.success) { showToast(addRes.error || 'Batch created but could not add coat', 'warning'); }
else { showToast('Batch created and item added!', 'success'); }
setTimeout(() => window.location.reload(), 600);
}
// ──────────────────────────────────────────────────────────────────────────────
// Move batch item between batches
// ──────────────────────────────────────────────────────────────────────────────
async function moveToBatch(batchItemId, targetBatchId, sourceBatchId, sqft) {
const res = await apiPost(URLS.moveToBatch, { batchItemId, targetBatchId });
if (!res.success) { showToast(res.error || 'Failed to move item', 'danger'); return; }
// Move DOM element
const itemEl = document.getElementById(`bitem-${batchItemId}`);
const targetList = document.getElementById(`items-${targetBatchId}`);
if (itemEl && targetList) {
itemEl.dataset.batchId = targetBatchId;
targetList.appendChild(itemEl);
document.getElementById(`empty-items-${targetBatchId}`)?.remove();
}
// Update capacity displays
const sourceBatchCard = document.getElementById(`batch-${sourceBatchId}`);
const maxSource = +(sourceBatchCard?.dataset.maxSqft || 0);
updateBatchCapacity(sourceBatchId, res.sourceBatchTotal,
maxSource > 0 ? Math.round(res.sourceBatchTotal / maxSource * 1000) / 10 : null);
const targetBatchCard = document.getElementById(`batch-${targetBatchId}`);
const maxTarget = +(targetBatchCard?.dataset.maxSqft || 0);
updateBatchCapacity(targetBatchId, res.targetBatchTotal,
maxTarget > 0 ? Math.round(res.targetBatchTotal / maxTarget * 1000) / 10 : null);
showToast('Item moved', 'success');
}
// ──────────────────────────────────────────────────────────────────────────────
// Remove coat from batch back to queue (no page reload)
// ──────────────────────────────────────────────────────────────────────────────
async function removeFromBatch(batchItemId, batchId) {
const res = await apiPost(URLS.removeFromBatch, { batchItemId });
if (!res.success) { showToast(res.error || 'Failed to remove', 'danger'); return; }
document.getElementById(`bitem-${batchItemId}`)?.remove();
const batchCard = document.getElementById(`batch-${batchId}`);
const maxSqft = +(batchCard?.dataset.maxSqft || 0);
const capPct = maxSqft > 0 ? Math.round(res.batchTotal / maxSqft * 1000) / 10 : null;
updateBatchCapacity(batchId, res.batchTotal, capPct);
if (res.queueItem) returnCoatToQueue(res.queueItem);
showToast('Removed — returned to queue', 'info');
}
function returnCoatToQueue(q) {
const coatElId = `qcoat-${q.jobItemCoatId}`;
if (document.getElementById(coatElId)) return;
const borderColors = { Rush: '#dc3545', Urgent: '#fd7e14', High: '#ffc107', Normal: '#0d6efd', Low: '#6c757d' };
const border = borderColors[q.priority] || '#6c757d';
const coatEl = document.createElement('div');
coatEl.className = 'batch-item-row d-flex align-items-center';
coatEl.id = coatElId;
coatEl.dataset.coatId = q.jobItemCoatId;
coatEl.dataset.jobId = q.jobId;
coatEl.dataset.jobItemId = q.jobItemId;
coatEl.dataset.sqft = q.surfaceAreaSqFt;
coatEl.dataset.color = q.colorName || '';
coatEl.dataset.colorCode = q.colorCode || '';
coatEl.dataset.pass = q.coatPassNumber;
coatEl.dataset.coatName = q.coatName;
coatEl.dataset.jobNumber = q.jobNumber;
coatEl.dataset.customer = q.customerName;
coatEl.dataset.description = q.itemDescription;
coatEl.dataset.priority = q.priority;
coatEl.dataset.dueDate = q.dueDate || '';
const colorDot = q.colorName
? `<span class="color-dot" style="background:#aaaaaa;"></span>`
: '';
coatEl.innerHTML = `
<div class="flex-grow-1 overflow-hidden">
<div class="text-truncate">
${colorDot}
<span class="fw-medium">${escHtml(q.coatName)}</span>
<span class="text-muted ms-1">— ${escHtml(q.itemDescription)}</span>
</div>
<div class="text-muted" style="font-size:.75rem;">
Pass ${q.coatPassNumber} · ${(+q.surfaceAreaSqFt).toFixed(1)} sqft
${q.colorName ? `· ${escHtml(q.colorName)}` : ''}
</div>
</div>
<i class="bi bi-grip-vertical text-muted ms-1"></i>
`;
// Wire drag via mousedown
coatEl.addEventListener('mousedown', onQueueMouseDown);
// Find or create the job card in the queue
const existingJobCard = document.getElementById(`qjob-${q.jobId}`);
if (existingJobCard) {
document.getElementById(`coats-job-${q.jobId}`)?.appendChild(coatEl);
} else {
const priorityBadgeClass = { Rush: 'bg-danger', Urgent: 'bg-warning text-dark', High: 'bg-primary', Normal: 'bg-secondary', Low: 'bg-light text-dark' }[q.priority] || 'bg-secondary';
const dueDateHtml = q.dueDate
? `<div class="small ${q.isOverdue ? 'text-danger fw-semibold' : 'text-muted'}">
<i class="bi bi-calendar-event me-1"></i>Due ${formatDate(q.dueDate)}
${q.isOverdue ? '<span class="badge bg-danger ms-1">Overdue</span>' : ''}
</div>`
: '';
const jobCard = document.createElement('div');
jobCard.className = 'queue-job-card card border-0 shadow-sm';
jobCard.id = `qjob-${q.jobId}`;
jobCard.dataset.priority = q.priority;
jobCard.style.borderLeft = `4px solid ${border}`;
jobCard.innerHTML = `
<div class="card-body p-2">
<div class="d-flex align-items-center mb-1">
<span class="fw-semibold small me-auto">${escHtml(q.jobNumber)}</span>
<span class="badge ${priorityBadgeClass} ms-1 small">${escHtml(q.priority)}</span>
</div>
<div class="text-muted small mb-1">${escHtml(q.customerName)}</div>
${dueDateHtml}
<div class="mt-2" id="coats-job-${q.jobId}"></div>
</div>
`;
const queueContainer = document.getElementById('queueContainer');
const cards = [...queueContainer.querySelectorAll('.queue-job-card')];
const insertBefore = cards.find(c => +(c.dataset.priorityId || 0) < +q.priorityId);
insertBefore ? queueContainer.insertBefore(jobCard, insertBefore) : queueContainer.appendChild(jobCard);
document.getElementById(`coats-job-${q.jobId}`)?.appendChild(coatEl);
}
updateQueueCount(1);
}
function updateQueueCount(delta) {
const badge = document.getElementById('queueCount');
if (badge) badge.textContent = Math.max(0, (+badge.textContent || 0) + delta);
}
function formatDate(iso) {
if (!iso) return '';
return new Date(iso + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// ──────────────────────────────────────────────────────────────────────────────
// Create an empty batch
// ──────────────────────────────────────────────────────────────────────────────
async function createBatch(ovenCostId, ovenName, date) {
const res = await apiPost(URLS.createBatch, { ovenCostId, scheduledDate: date, scheduledStartTime: null });
if (!res.success) { showToast(res.error || 'Failed to create batch', 'danger'); return; }
window.location.reload();
}
// ──────────────────────────────────────────────────────────────────────────────
// Batch lifecycle actions
// ──────────────────────────────────────────────────────────────────────────────
async function startBatch(batchId) {
if (!await showConfirm('Start this batch? All linked jobs will be updated to "In Oven" status.', 'Start Batch')) return;
const res = await apiPost(URLS.startBatch, { batchId });
if (!res.success) { showToast(res.error || 'Failed to start batch', 'danger'); return; }
showToast('Batch started!', 'success');
window.location.reload();
}
async function completeBatch(batchId) {
if (!await showConfirm('Mark this batch as complete? Job statuses will be updated to Curing or Coating.', 'Complete Batch')) return;
const res = await apiPost(URLS.completeBatch, { batchId });
if (!res.success) { showToast(res.error || 'Failed to complete batch', 'danger'); return; }
showToast('Batch completed!', 'success');
window.location.reload();
}
async function deleteBatch(batchId, batchNumber) {
if (!await showConfirm(`Delete batch ${batchNumber}? Items will be returned to the queue.`, 'Delete', true)) return;
const res = await apiPost(URLS.deleteBatch, { batchId });
if (!res.success) { showToast(res.error || 'Failed to delete batch', 'danger'); return; }
const card = document.getElementById(`batch-${batchId}`);
const ovenId = card?.dataset.ovenId;
card?.remove();
// If the oven column is now empty, restore the drop zone hint
const zone = document.getElementById(`zone-${ovenId}`);
if (zone && !zone.querySelector('.batch-card')) {
const hint = document.createElement('div');
hint.className = 'drop-zone-empty';
hint.id = `empty-${ovenId}`;
hint.innerHTML = '<span><i class="bi bi-fire me-1"></i>Drop items here or click + to create a batch</span>';
zone.appendChild(hint);
}
showToast('Batch deleted', 'info');
}
// ──────────────────────────────────────────────────────────────────────────────
// AI Suggestion Panel
// ──────────────────────────────────────────────────────────────────────────────
function openAiPanel() {
document.getElementById('aiPanel').classList.add('open');
document.getElementById('aiBackdrop').classList.add('open');
const goalLabels = {
maximize_throughput: 'Maximize Throughput',
minimize_lateness: 'Minimize Lateness',
minimize_color_changes: 'Minimize Color Changes'
};
document.getElementById('goalLabel').textContent = goalLabels[OPTIMIZATION_GOAL] || OPTIMIZATION_GOAL;
}
function closeAiPanel() {
document.getElementById('aiPanel').classList.remove('open');
document.getElementById('aiBackdrop').classList.remove('open');
}
async function runAiSuggest() {
showAiState('loading');
const res = await apiPost(URLS.suggest, { optimizationGoal: OPTIMIZATION_GOAL });
if (!res.success) {
document.getElementById('aiErrorText').textContent = res.error || 'AI error';
showAiState('error');
return;
}
const { suggestion } = res;
AI_SUGGESTION_DATA.batches = suggestion.batches;
document.getElementById('aiSummary').textContent = suggestion.summary;
const warnEl = document.getElementById('aiWarnings');
if (suggestion.warnings?.length > 0) {
warnEl.innerHTML = suggestion.warnings.map(w =>
`<div class="alert alert-warning py-1 px-2 mb-1 small"><i class="bi bi-exclamation-triangle me-1"></i>${escHtml(w)}</div>`
).join('');
warnEl.classList.remove('d-none');
} else {
warnEl.classList.add('d-none');
}
const listEl = document.getElementById('aiBatchList');
listEl.innerHTML = '';
if (!suggestion.batches?.length) {
listEl.innerHTML = '<div class="text-muted text-center py-3 small">No batches suggested. The queue may be empty or all coats are already scheduled.</div>';
} else {
suggestion.batches.forEach((batch, idx) => {
const capPct = batch.capacityUtilization > 0 ? Math.round(batch.capacityUtilization * 100) : null;
const capBar = capPct !== null
? `<div class="mt-1">
<div class="d-flex justify-content-between mb-1" style="font-size:.72rem;">
<span class="text-muted">Utilization</span>
<span class="fw-semibold">${capPct}%</span>
</div>
<div class="capacity-bar-wrap">
<div class="capacity-bar-fill ${capPct >= 100 ? 'cap-over' : capPct >= 80 ? 'cap-warn' : 'cap-ok'}"
style="width:${Math.min(capPct, 100)}%"></div>
</div>
</div>` : '';
const items = batch.items.map(i =>
`<div class="d-flex align-items-center gap-1 py-1 border-bottom" style="font-size:.78rem;">
<span class="badge bg-secondary" style="font-size:.68rem;">Pass ${i.coatPassNumber}</span>
<div class="flex-grow-1 text-truncate">
<span class="fw-medium">${escHtml(i.jobNumber)}</span>
<span class="text-muted ms-1">${escHtml(i.description)}</span>
</div>
<span class="text-muted">${i.surfaceAreaSqFt.toFixed(1)} sqft</span>
</div>`
).join('');
listEl.innerHTML +=
`<div class="card mb-3 shadow-sm" id="aibatch-${idx}">
<div class="card-header py-2 px-3 d-flex align-items-center gap-2"
style="background:linear-gradient(90deg,#6f42c1 0%,#0d6efd 100%);color:white;">
<i class="bi bi-fire"></i>
<span class="fw-semibold small flex-grow-1">${escHtml(batch.batchName)}</span>
<span class="badge bg-white text-dark" style="font-size:.7rem;">${escHtml(batch.ovenName)}</span>
</div>
<div class="card-body p-2">
<div class="d-flex flex-wrap gap-1 mb-2">
${batch.suggestedStartTime ? `<span class="badge bg-light text-dark border"><i class="bi bi-clock me-1"></i>${batch.suggestedStartTime}</span>` : ''}
${batch.primaryColorName ? `<span class="badge bg-secondary"><i class="bi bi-palette me-1"></i>${escHtml(batch.primaryColorName)}</span>` : ''}
${batch.cureTemperatureF ? `<span class="badge bg-danger">${batch.cureTemperatureF}°F</span>` : ''}
<span class="badge bg-light text-dark border">${batch.estimatedSqFt.toFixed(1)} sqft</span>
<span class="badge bg-light text-dark border">${batch.estimatedCycleMinutes} min</span>
</div>
${capBar}
<div class="mt-2">${items}</div>
<div class="mt-2 text-muted small">
<i class="bi bi-chat-left-text me-1" style="color:#6f42c1;"></i>
${escHtml(batch.rationale)}
</div>
</div>
</div>`;
});
}
showAiState('results');
}
async function acceptAllBatches() {
if (!AI_SUGGESTION_DATA.batches?.length) return;
document.getElementById('btnAcceptAll').disabled = true;
document.getElementById('btnAcceptAll').innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving...';
const res = await apiPost(URLS.acceptSuggestion, {
scheduledDate: SCHEDULED_DATE,
batches: AI_SUGGESTION_DATA.batches
});
if (!res.success) {
showToast(res.error || 'Failed to save batches', 'danger');
document.getElementById('btnAcceptAll').disabled = false;
document.getElementById('btnAcceptAll').innerHTML = '<i class="bi bi-check-all me-1"></i>Accept All Batches';
return;
}
showToast(`${res.batches?.length || 0} batch(es) created!`, 'success');
closeAiPanel();
setTimeout(() => window.location.reload(), 600);
}
function showAiState(state) {
['loading', 'error', 'results', 'initial'].forEach(s => {
const el = document.getElementById(`ai${s.charAt(0).toUpperCase() + s.slice(1)}`);
if (el) {
if (s === state) {
el.classList.remove('d-none');
if (s === 'results') el.classList.add('d-flex', 'flex-column');
} else {
el.classList.add('d-none');
if (s === 'results') el.classList.remove('d-flex', 'flex-column');
}
}
});
}
// ──────────────────────────────────────────────────────────────────────────────
// DOM helpers
// ──────────────────────────────────────────────────────────────────────────────
function appendBatchItemRow(batchId, item, withRemove) {
const list = document.getElementById(`items-${batchId}`);
if (!list) return;
const el = document.createElement('div');
el.className = 'batch-item-row d-flex align-items-center';
el.id = `bitem-${item.id}`;
el.dataset.batchItemId = item.id;
el.dataset.batchId = batchId;
el.dataset.sqft = item.surfaceAreaContribution;
el.setAttribute('draggable', 'true'); // kept for CSS selector compat
el.innerHTML = `
<div class="flex-grow-1 overflow-hidden">
<div class="d-flex align-items-center gap-1 text-truncate">
<span class="badge bg-light text-dark border" style="font-size:.7rem;">Pass ${item.coatPassNumber}</span>
${item.colorName ? `<span class="text-truncate fw-medium small">${escHtml(item.colorName)}</span>` : ''}
<span class="text-muted small text-truncate">${escHtml(item.itemDescription)}</span>
</div>
<div class="text-muted d-flex align-items-center gap-2" style="font-size:.73rem;">
<span>${escHtml(item.jobNumber)}</span><span>·</span>
<span>${(+item.surfaceAreaContribution).toFixed(1)} sqft</span>
</div>
</div>
${withRemove
? `<div class="d-flex align-items-center ms-1 gap-1">
<i class="bi bi-grip-vertical text-muted"></i>
<button class="btn btn-sm p-0 text-danger" style="line-height:1;"
onclick="removeFromBatch(${item.id}, ${batchId})" title="Remove">
<i class="bi bi-x"></i>
</button>
</div>`
: '<i class="bi bi-grip-vertical text-muted ms-1"></i>'}
`;
// Wire mouse-based drag
el.addEventListener('mousedown', onBatchItemMouseDown);
list.appendChild(el);
}
function updateBatchCapacity(batchId, totalSqFt, capPct) {
document.querySelectorAll(`.batch-sqft[data-batch-id="${batchId}"]`).forEach(el => {
const maxSqft = document.getElementById(`batch-${batchId}`)?.dataset.maxSqft;
el.innerHTML = maxSqft
? `${(+totalSqFt).toFixed(1)} / ${(+maxSqft).toFixed(0)} sqft`
: `${(+totalSqFt).toFixed(1)} sqft`;
});
document.querySelectorAll(`.batch-cap-bar[data-batch-id="${batchId}"]`).forEach(bar => {
if (capPct !== null) {
const pct = Math.min(+capPct, 100);
bar.style.width = pct + '%';
bar.className = `capacity-bar-fill ${pct >= 100 ? 'cap-over' : pct >= 80 ? 'cap-warn' : 'cap-ok'} batch-cap-bar`;
bar.dataset.batchId = batchId;
}
});
const card = document.getElementById(`batch-${batchId}`);
if (card) card.dataset.totalSqft = totalSqFt;
}
// ──────────────────────────────────────────────────────────────────────────────
// Utility
// ──────────────────────────────────────────────────────────────────────────────
async function apiPost(url, data) {
try {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|| document.querySelector('meta[name="csrf-token"]')?.content;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'RequestVerificationToken': token } : {})
},
body: JSON.stringify(data)
});
return await res.json();
} catch (e) {
return { success: false, error: e.message };
}
}
function escHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function showConfirm(message, confirmLabel = 'Confirm', danger = false) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed; inset:0; z-index:10000;
background:rgba(0,0,0,.45);
display:flex; align-items:center; justify-content:center;
`;
const box = document.createElement('div');
box.style.cssText = `
background:var(--bs-body-bg);
border-radius:12px;
padding:1.5rem 1.75rem;
max-width:360px; width:90%;
box-shadow:0 16px 48px rgba(0,0,0,.3);
text-align:center;
`;
const msg = document.createElement('p');
msg.style.cssText = 'margin:0 0 1.25rem; font-size:.95rem; line-height:1.5;';
msg.textContent = message;
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex; gap:.75rem; justify-content:center;';
const btnCancel = document.createElement('button');
btnCancel.className = 'btn btn-outline-secondary';
btnCancel.textContent = 'Cancel';
const btnOk = document.createElement('button');
btnOk.className = `btn ${danger ? 'btn-danger' : 'btn-primary'}`;
btnOk.textContent = confirmLabel;
btnRow.appendChild(btnCancel);
btnRow.appendChild(btnOk);
box.appendChild(msg);
box.appendChild(btnRow);
overlay.appendChild(box);
document.body.appendChild(overlay);
const close = result => { overlay.remove(); resolve(result); };
btnOk.addEventListener('click', () => close(true));
btnCancel.addEventListener('click', () => close(false));
overlay.addEventListener('click', e => { if (e.target === overlay) close(false); });
});
}
function showToast(message, type = 'info') {
const colors = { success: '#198754', danger: '#dc3545', info: '#0dcaf0', warning: '#ffc107' };
const toast = document.createElement('div');
toast.style.cssText = `
position:fixed; bottom:1.5rem; right:1.5rem; z-index:9999;
background:${colors[type] || '#333'}; color:white;
padding:.65rem 1.1rem; border-radius:8px;
font-size:.88rem; box-shadow:0 4px 16px rgba(0,0,0,.2);
transition:opacity .3s; max-width:320px;
`;
if (type === 'warning') toast.style.color = '#212529';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 2800);
}