/* 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 = 'Drop more here'; } 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 ? `` : ''; coatEl.innerHTML = `
${colorDot} ${escHtml(q.coatName)} — ${escHtml(q.itemDescription)}
Pass ${q.coatPassNumber} · ${(+q.surfaceAreaSqFt).toFixed(1)} sqft ${q.colorName ? `· ${escHtml(q.colorName)}` : ''}
`; // 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 ? `
Due ${formatDate(q.dueDate)} ${q.isOverdue ? 'Overdue' : ''}
` : ''; 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 = `
${escHtml(q.jobNumber)} ${escHtml(q.priority)}
${escHtml(q.customerName)}
${dueDateHtml}
`; 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 = 'Drop items here or click + to create a batch'; 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 => `
${escHtml(w)}
` ).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 = '
No batches suggested. The queue may be empty or all coats are already scheduled.
'; } else { suggestion.batches.forEach((batch, idx) => { const capPct = batch.capacityUtilization > 0 ? Math.round(batch.capacityUtilization * 100) : null; const capBar = capPct !== null ? `
Utilization ${capPct}%
` : ''; const items = batch.items.map(i => `
Pass ${i.coatPassNumber}
${escHtml(i.jobNumber)} ${escHtml(i.description)}
${i.surfaceAreaSqFt.toFixed(1)} sqft
` ).join(''); listEl.innerHTML += `
${escHtml(batch.batchName)} ${escHtml(batch.ovenName)}
${batch.suggestedStartTime ? `${batch.suggestedStartTime}` : ''} ${batch.primaryColorName ? `${escHtml(batch.primaryColorName)}` : ''} ${batch.cureTemperatureF ? `${batch.cureTemperatureF}°F` : ''} ${batch.estimatedSqFt.toFixed(1)} sqft ${batch.estimatedCycleMinutes} min
${capBar}
${items}
${escHtml(batch.rationale)}
`; }); } showAiState('results'); } async function acceptAllBatches() { if (!AI_SUGGESTION_DATA.batches?.length) return; document.getElementById('btnAcceptAll').disabled = true; document.getElementById('btnAcceptAll').innerHTML = '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 = '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 = `
Pass ${item.coatPassNumber} ${item.colorName ? `${escHtml(item.colorName)}` : ''} ${escHtml(item.itemDescription)}
${escHtml(item.jobNumber)}· ${(+item.surfaceAreaContribution).toFixed(1)} sqft
${withRemove ? `
` : ''} `; // 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, '"'); } 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); }