782 lines
37 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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);
|
|
}
|