Initial commit
This commit is contained in:
@@ -0,0 +1,781 @@
|
||||
/* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user