Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,412 @@
/**
* AI Help Widget
* Floating chat assistant for Powder Coating Logix.
*
* Persistence: panel open/closed state, conversation history, rendered messages,
* and panel height are all stored in sessionStorage so they survive page navigation.
* Everything resets when the browser tab/session ends.
*/
(function () {
'use strict';
const CHAT_ENDPOINT = '/AiHelp/Chat';
const STORAGE_KEY = 'aiHelpState';
const MIN_HEIGHT = 300;
const MAX_HEIGHT_VH = 0.85; // 85vh
// Conversation history for API: [{role:'user'|'assistant', content:'...'}]
let history = [];
// Rendered message store for restoring across navigation: [{role, content, html}]
let renderedMessages = [];
let isOpen = false;
let isSending = false;
let startersShown = true;
let panelHeight = 520;
// DOM refs
let btn, panel, messagesEl, inputEl, sendBtn, clearBtn, closeBtn,
typingEl, startersEl, tokenEl, resizeHandle;
// ── Init ─────────────────────────────────────────────────────────────────
function init() {
btn = document.getElementById('ai-help-btn');
panel = document.getElementById('ai-help-panel');
messagesEl = document.getElementById('ai-help-messages');
inputEl = document.getElementById('ai-help-input');
sendBtn = document.getElementById('ai-help-send');
clearBtn = document.getElementById('ai-help-clear');
closeBtn = document.getElementById('ai-help-close');
typingEl = document.getElementById('ai-help-typing');
startersEl = document.getElementById('ai-help-starters');
tokenEl = document.getElementById('ai-help-token');
resizeHandle = document.getElementById('ai-help-resize');
if (!btn || !panel) return;
btn.addEventListener('click', togglePanel);
closeBtn.addEventListener('click', closePanel);
clearBtn.addEventListener('click', clearChat);
sendBtn.addEventListener('click', sendMessage);
inputEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
document.querySelectorAll('.ai-help-starter-btn').forEach(function (b) {
b.addEventListener('click', function () {
inputEl.value = b.dataset.q;
sendMessage();
});
});
// Intercept help links inside the widget — navigate without closing
messagesEl.addEventListener('click', function (e) {
const link = e.target.closest('[data-help-link]');
if (link) {
e.preventDefault();
const href = link.getAttribute('href');
if (href) window.location.href = href;
}
});
// Save accurate open state right before any navigation so the new page
// can restore correctly. This runs after any click handlers that might
// have changed isOpen, giving us the true final state.
window.addEventListener('beforeunload', saveState);
initResize();
restoreState();
}
// ── Resize ───────────────────────────────────────────────────────────────
function initResize() {
if (!resizeHandle) return;
let startY = 0;
let startH = 0;
let dragging = false;
resizeHandle.addEventListener('mousedown', function (e) {
e.preventDefault();
dragging = true;
startY = e.clientY;
startH = panel.offsetHeight;
resizeHandle.classList.add('dragging');
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
// Panel grows upward: dragging mouse UP increases height
const delta = startY - e.clientY;
const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH);
panelHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startH + delta));
panel.style.height = panelHeight + 'px';
});
document.addEventListener('mouseup', function () {
if (!dragging) return;
dragging = false;
resizeHandle.classList.remove('dragging');
document.body.style.userSelect = '';
saveState();
});
// Touch support
resizeHandle.addEventListener('touchstart', function (e) {
const touch = e.touches[0];
startY = touch.clientY;
startH = panel.offsetHeight;
dragging = true;
}, { passive: true });
document.addEventListener('touchmove', function (e) {
if (!dragging) return;
const touch = e.touches[0];
const delta = startY - touch.clientY;
const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH);
panelHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startH + delta));
panel.style.height = panelHeight + 'px';
}, { passive: true });
document.addEventListener('touchend', function () {
if (!dragging) return;
dragging = false;
saveState();
});
}
// ── Panel open/close ─────────────────────────────────────────────────────
function togglePanel() {
if (isOpen) { closePanel(); } else { openPanel(); }
}
function openPanel() {
panel.hidden = false;
panel.style.height = panelHeight + 'px';
isOpen = true;
btn.setAttribute('aria-expanded', 'true');
inputEl.focus();
scrollToBottom();
saveState();
}
function closePanel() {
panel.hidden = true;
isOpen = false;
btn.setAttribute('aria-expanded', 'false');
saveState();
}
// ── Clear chat ───────────────────────────────────────────────────────────
function clearChat() {
history = [];
renderedMessages = [];
startersShown = true;
// Remove all messages except the static welcome message (first child)
const msgs = Array.from(messagesEl.children);
msgs.slice(1).forEach(function (el) { el.remove(); });
startersEl.hidden = false;
inputEl.value = '';
inputEl.focus();
saveState();
}
// ── Send message ─────────────────────────────────────────────────────────
async function sendMessage() {
if (isSending) return;
const text = inputEl.value.trim();
if (!text) return;
startersEl.hidden = true;
startersShown = false;
inputEl.value = '';
appendMessage('user', text, escapeHtml(text));
scrollToBottom();
saveState();
isSending = true;
sendBtn.disabled = true;
typingEl.classList.remove('d-none');
scrollToBottom();
try {
const token = tokenEl ? tokenEl.value : '';
// Send history BEFORE appending the current user message
// (appendMessage already pushed it, so slice off the last entry)
const payload = {
message: text,
history: history.slice(0, -1)
};
const res = await fetch(CHAT_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify(payload)
});
typingEl.classList.add('d-none');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const msg = err.error || 'Something went wrong. Please try again.';
appendMessage('assistant', msg, renderMarkdown(msg));
} else {
const data = await res.json();
const reply = data.response || 'No response received.';
appendMessage('assistant', reply, renderMarkdown(reply));
}
} catch (err) {
typingEl.classList.add('d-none');
const msg = 'Unable to connect. Please check your connection and try again.';
appendMessage('assistant', msg, renderMarkdown(msg));
} finally {
isSending = false;
sendBtn.disabled = false;
scrollToBottom();
saveState();
inputEl.focus();
}
}
// ── Append a message bubble ───────────────────────────────────────────────
function appendMessage(role, content, html) {
history.push({ role: role, content: content });
renderedMessages.push({ role: role, content: content, html: html });
const wrapper = document.createElement('div');
wrapper.className = 'ai-help-msg ai-help-msg--' + role;
const bubble = document.createElement('div');
bubble.className = 'ai-help-msg-bubble';
bubble.innerHTML = html;
wrapper.appendChild(bubble);
messagesEl.appendChild(wrapper);
}
// ── Restore bubble from stored HTML (safe — came from our own renderer) ──
function restoreMessage(role, html) {
const wrapper = document.createElement('div');
wrapper.className = 'ai-help-msg ai-help-msg--' + role;
const bubble = document.createElement('div');
bubble.className = 'ai-help-msg-bubble';
bubble.innerHTML = html;
wrapper.appendChild(bubble);
messagesEl.appendChild(wrapper);
}
// ── sessionStorage persistence ────────────────────────────────────────────
function saveState() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
open: isOpen,
history: history,
renderedMessages: renderedMessages,
startersShown: startersShown,
panelHeight: panelHeight
}));
} catch (_) { /* sessionStorage full or unavailable — ignore */ }
}
function restoreState() {
let saved;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return;
saved = JSON.parse(raw);
} catch (_) { return; }
if (!saved) return;
// Restore height first (before showing panel to avoid flicker)
if (saved.panelHeight && saved.panelHeight >= MIN_HEIGHT) {
panelHeight = saved.panelHeight;
panel.style.height = panelHeight + 'px';
}
// Restore messages (skip if only the welcome message was ever shown)
if (saved.renderedMessages && saved.renderedMessages.length > 0) {
history = saved.history || [];
renderedMessages = saved.renderedMessages || [];
saved.renderedMessages.forEach(function (m) {
restoreMessage(m.role, m.html);
});
if (!saved.startersShown) {
startersEl.hidden = true;
startersShown = false;
}
}
// Restore open state last
if (saved.open) {
openPanel();
}
}
// ── Minimal markdown renderer ─────────────────────────────────────────────
function renderMarkdown(text) {
let html = escapeHtml(text);
// Fenced code blocks
html = html.replace(/```[\s\S]*?```/g, function (match) {
const code = match.slice(3, -3).replace(/^\n/, '');
return '<pre style="font-size:0.8rem;overflow-x:auto;"><code>' + code + '</code></pre>';
});
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Markdown links — only /relative or https:// allowed
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, linkText, url) {
const safe = url.startsWith('/') || url.startsWith('https://') || url.startsWith('http://');
if (!safe) return escapeHtml(linkText);
return '<a href="' + escapeAttr(url) + '" data-help-link="true">' + linkText + '</a>';
});
// Build line-by-line for lists
const lines = html.split('\n');
const result = [];
let inUl = false, inOl = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const ulMatch = line.match(/^(\s*)[-*]\s+(.+)/);
const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/);
if (ulMatch) {
if (!inUl) { if (inOl) { result.push('</ol>'); inOl = false; } result.push('<ul>'); inUl = true; }
result.push('<li>' + ulMatch[2] + '</li>');
} else if (olMatch) {
if (!inOl) { if (inUl) { result.push('</ul>'); inUl = false; } result.push('<ol>'); inOl = true; }
result.push('<li>' + olMatch[2] + '</li>');
} else {
if (inUl) { result.push('</ul>'); inUl = false; }
if (inOl) { result.push('</ol>'); inOl = false; }
result.push(line.trim() === '' ? '<br>' : '<p>' + line + '</p>');
}
}
if (inUl) result.push('</ul>');
if (inOl) result.push('</ol>');
return result.join('\n')
.replace(/(<br>\s*){2,}/g, '<br>')
.replace(/<p>\s*<\/p>/g, '');
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(str) {
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function scrollToBottom() {
requestAnimationFrame(function () {
messagesEl.scrollTop = messagesEl.scrollHeight;
});
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
@@ -0,0 +1,815 @@
/**
* Appointment Calendar / Schedule Module
* Handles day, week and month calendar views for appointments, jobs, and maintenance
*/
const appointmentCalendar = {
currentView: 'week',
currentDate: new Date(),
events: [],
unscheduledJobs: [],
sidebarCollapsed: false,
// ──────────────────────────────────────────────────────────────────────────
// Init
// ──────────────────────────────────────────────────────────────────────────
init: function(view = 'week', date = new Date()) {
this.currentView = view;
this.currentDate = new Date(date);
this.updateViewButtons();
this.attachEventListeners();
this.loadAndRender();
this.loadUnscheduledJobs();
this.setupSidebarToggle();
this.setupSidebarDropZone();
},
attachEventListeners: function() {
document.getElementById('btnPrevious').addEventListener('click', () => this.goToPrevious());
document.getElementById('btnNext').addEventListener('click', () => this.goToNext());
document.getElementById('btnToday').addEventListener('click', () => this.goToToday());
document.getElementById('btnDayView').addEventListener('click', () => this.switchView('day'));
document.getElementById('btnWeekView').addEventListener('click', () => this.switchView('week'));
document.getElementById('btnMonthView').addEventListener('click', () => this.switchView('month'));
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => this.renderCalendar(), 250);
});
},
// ──────────────────────────────────────────────────────────────────────────
// Load & Render
// ──────────────────────────────────────────────────────────────────────────
loadAndRender: async function() {
const { start, end } = this.getDateRange();
await this.loadEvents(start, end);
this.renderCalendar();
this.updateCurrentDateDisplay();
this.updateURL();
},
loadEvents: async function(startDate, endDate) {
try {
const url = `/Appointments/GetCalendarEvents?start=${startDate.toISOString()}&end=${endDate.toISOString()}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load events');
this.events = await response.json();
} catch (error) {
console.error('Error loading events:', error);
this.events = [];
}
},
renderCalendar: function() {
const container = document.getElementById('calendarContainer');
if (this.currentView === 'day') {
container.innerHTML = this.renderDayView();
} else if (this.currentView === 'week') {
container.innerHTML = this.renderWeekView();
} else {
container.innerHTML = this.renderMonthView();
}
this.attachEventClickHandlers();
this.attachDropHandlers();
},
// ──────────────────────────────────────────────────────────────────────────
// Unscheduled Jobs Sidebar
// ──────────────────────────────────────────────────────────────────────────
loadUnscheduledJobs: async function() {
try {
const resp = await fetch('/Appointments/GetUnscheduledJobs');
this.unscheduledJobs = await resp.json();
} catch (e) {
this.unscheduledJobs = [];
}
this.renderUnscheduledPanel();
},
renderUnscheduledPanel: function() {
const panel = document.getElementById('unscheduledJobsPanel');
const countEl = document.getElementById('unscheduledCount');
if (!panel) return;
const count = this.unscheduledJobs.length;
if (countEl) countEl.textContent = count > 0 ? `${count} job${count === 1 ? '' : 's'}` : '';
if (count === 0) {
panel.innerHTML = '<div class="text-muted small text-center py-3"><i class="bi bi-check-circle me-1"></i>All jobs scheduled</div>';
return;
}
let html = '';
this.unscheduledJobs.forEach(job => {
let dueLine = '';
if (job.dueDate) {
const cls = job.isOverdue ? 'text-danger fw-semibold' : 'text-muted';
dueLine = `<div class="sj-due ${cls}"><i class="bi bi-calendar-event me-1"></i>${this.formatDateShort(job.dueDate)}${job.isOverdue ? ' ⚠' : ''}</div>`;
}
const previewData = JSON.stringify({
jobNumber: job.jobNumber,
customerName: job.customerName,
statusName: job.statusName,
color: job.color,
dueDate: job.dueDate,
isOverdue: job.isOverdue,
quotedPrice: job.quotedPrice,
specialInstructions: job.specialInstructions,
items: job.items || [],
itemCount: job.itemCount || 0
});
html += `
<div class="sj-card" draggable="true"
data-job-id="${job.id}"
data-preview="${encodeURIComponent(previewData)}"
style="border-left:3px solid ${this.escapeHtml(job.color)}">
<div class="sj-number">${this.escapeHtml(job.jobNumber)}</div>
<div class="sj-customer">${this.escapeHtml(job.customerName)}</div>
<div class="sj-status" style="color:${this.escapeHtml(job.color)}">${this.escapeHtml(job.statusName)}</div>
${dueLine}
</div>`;
});
panel.innerHTML = html;
this.attachSidebarDragHandlers();
this.attachHoverPreviewHandlers();
},
attachHoverPreviewHandlers: function() {
const popout = document.getElementById('sjPreviewCard');
if (!popout) return;
document.querySelectorAll('.sj-card').forEach(card => {
card.addEventListener('mouseenter', (e) => {
let data;
try { data = JSON.parse(decodeURIComponent(card.dataset.preview)); } catch { return; }
popout.innerHTML = this.buildPreviewHtml(data);
popout.style.display = 'block';
this.positionPreview(e);
});
card.addEventListener('mousemove', (e) => this.positionPreview(e));
card.addEventListener('mouseleave', () => { popout.style.display = 'none'; });
card.addEventListener('dragstart', () => { popout.style.display = 'none'; });
});
},
positionPreview: function(e) {
const popout = document.getElementById('sjPreviewCard');
if (!popout) return;
const offset = 14;
const vw = window.innerWidth, vh = window.innerHeight;
const pw = popout.offsetWidth || 240, ph = popout.offsetHeight || 160;
let left = e.clientX + offset;
let top = e.clientY + offset;
if (left + pw > vw - 8) left = e.clientX - pw - offset;
if (top + ph > vh - 8) top = e.clientY - ph - offset;
popout.style.left = left + 'px';
popout.style.top = top + 'px';
},
buildPreviewHtml: function(d) {
const dueLine = d.dueDate
? `<div class="sjp-row ${d.isOverdue ? 'sjp-overdue' : ''}">
<i class="bi bi-calendar-event"></i>
Due ${this.formatDateShort(d.dueDate)}${d.isOverdue ? ' — Overdue' : ''}
</div>` : '';
const priceLine = d.quotedPrice > 0
? `<div class="sjp-row"><i class="bi bi-currency-dollar"></i>Quoted $${Number(d.quotedPrice).toFixed(2)}</div>` : '';
const notesLine = d.specialInstructions
? `<div class="sjp-notes">${this.escapeHtml(d.specialInstructions)}</div>` : '';
let itemsHtml = '';
if (d.items && d.items.length > 0) {
itemsHtml = '<div class="sjp-items">';
d.items.forEach(item => {
const colorDot = item.colorName
? `<span class="sjp-color-dot" title="${this.escapeHtml(item.colorName)}"></span><span class="sjp-color">${this.escapeHtml(item.colorName)}</span>`
: '';
itemsHtml += `<div class="sjp-item"><span class="sjp-qty">×${item.quantity}</span><span class="sjp-item-desc">${this.escapeHtml(item.description)}</span>${colorDot ? colorDot : ''}</div>`;
});
if (d.itemCount > d.items.length) {
itemsHtml += `<div class="sjp-item sjp-more">+ ${d.itemCount - d.items.length} more item${d.itemCount - d.items.length > 1 ? 's' : ''}</div>`;
}
itemsHtml += '</div>';
}
return `<div class="sjp-card">
<div class="sjp-status-bar" style="background:${this.escapeHtml(d.color)}">${this.escapeHtml(d.statusName)}</div>
<div class="sjp-header" style="border-left:3px solid ${this.escapeHtml(d.color)}">
<span class="sjp-job">${this.escapeHtml(d.jobNumber)}</span>
</div>
<div class="sjp-customer">${this.escapeHtml(d.customerName)}</div>
${dueLine}${priceLine}${notesLine}
${itemsHtml}
</div>`;
},
attachSidebarDragHandlers: function() {
document.querySelectorAll('.sj-card').forEach(card => {
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'job',
jobId: card.dataset.jobId,
fromDate: null
}));
e.dataTransfer.effectAllowed = 'move';
card.classList.add('sj-dragging');
});
card.addEventListener('dragend', () => card.classList.remove('sj-dragging'));
});
},
setupSidebarDropZone: function() {
const panel = document.getElementById('unscheduledJobsPanel');
if (!panel) return;
const sidebar = document.getElementById('unscheduledSidebar');
if (!sidebar) return;
[panel, sidebar].forEach(el => {
el.addEventListener('dragover', (e) => {
e.preventDefault();
sidebar.classList.add('sidebar-drag-over');
});
el.addEventListener('dragleave', () => sidebar.classList.remove('sidebar-drag-over'));
el.addEventListener('drop', (e) => {
e.preventDefault();
sidebar.classList.remove('sidebar-drag-over');
let data;
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
if (data.type === 'job' && data.fromDate) {
this.scheduleJob(data.jobId, null);
}
});
});
},
setupSidebarToggle: function() {
const btn = document.getElementById('btnCollapseSidebar');
if (!btn) return;
btn.addEventListener('click', () => {
this.sidebarCollapsed = !this.sidebarCollapsed;
const sidebar = document.getElementById('unscheduledSidebar');
const chevron = document.getElementById('sidebarChevron');
const body = document.getElementById('unscheduledJobsPanel');
const countEl = document.getElementById('unscheduledCount');
const titleEl = document.getElementById('sidebarTitleText');
if (this.sidebarCollapsed) {
sidebar.classList.add('sidebar-collapsed');
if (chevron) { chevron.classList.remove('bi-chevron-left'); chevron.classList.add('bi-chevron-right'); }
if (body) body.style.display = 'none';
if (countEl) countEl.style.display = 'none';
if (titleEl) titleEl.style.display = 'none';
} else {
sidebar.classList.remove('sidebar-collapsed');
if (chevron) { chevron.classList.remove('bi-chevron-right'); chevron.classList.add('bi-chevron-left'); }
if (body) body.style.display = '';
if (countEl) countEl.style.display = '';
if (titleEl) titleEl.style.display = '';
}
});
},
// ──────────────────────────────────────────────────────────────────────────
// Drag & Drop — Drop Handlers
// ──────────────────────────────────────────────────────────────────────────
attachDropHandlers: function() {
// Week view: day header columns
document.querySelectorAll('.calendar-day-header[data-date]').forEach(cell => {
this.makeDayDropTarget(cell);
});
// Month view: month cells
document.querySelectorAll('.calendar-month-cell[data-date]').forEach(cell => {
this.makeDayDropTarget(cell);
});
// Day view: single day header
const dayHeader = document.querySelector('.calendar-day-header-single[data-date]');
if (dayHeader) this.makeDayDropTarget(dayHeader);
},
makeDayDropTarget: function(cell) {
cell.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
cell.classList.add('calendar-drag-over');
});
cell.addEventListener('dragleave', (e) => {
if (!cell.contains(e.relatedTarget)) {
cell.classList.remove('calendar-drag-over');
}
});
cell.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
cell.classList.remove('calendar-drag-over');
let data;
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
if (data.type !== 'job') return;
const dateStr = cell.dataset.date;
if (!dateStr) return;
this.scheduleJob(data.jobId, new Date(dateStr));
});
},
scheduleJob: async function(jobId, date) {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
const params = new URLSearchParams({ id: jobId });
if (date) params.append('date', date.toISOString().split('T')[0]);
if (token) params.append('__RequestVerificationToken', token);
try {
const resp = await fetch('/Appointments/ScheduleJob', { method: 'POST', body: params });
const result = await resp.json();
if (result.success) {
await this.loadAndRender();
await this.loadUnscheduledJobs();
if (result.removedFromBatch) {
this.showToast(`Job scheduled. Removed from oven batch ${this.escapeHtml(result.removedFromBatch)}.`, 'warning');
} else if (date) {
this.showToast('Job scheduled.', 'success');
} else {
this.showToast('Job unscheduled.', 'info');
}
} else {
this.showToast(result.message || 'Failed to update job.', 'danger');
}
} catch (e) {
this.showToast('Failed to update job.', 'danger');
}
},
showToast: function(message, type = 'success') {
const container = document.getElementById('scheduleToastContainer');
if (!container) return;
const id = 'toast-' + Date.now();
const bgMap = { success: 'bg-success', warning: 'bg-warning text-dark', info: 'bg-info text-dark', danger: 'bg-danger' };
const cls = bgMap[type] || 'bg-secondary';
container.insertAdjacentHTML('beforeend', `
<div id="${id}" class="toast align-items-center ${cls} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`);
const el = document.getElementById(id);
if (el && window.bootstrap) new bootstrap.Toast(el, { delay: 4000 }).show();
},
// ──────────────────────────────────────────────────────────────────────────
// Day View
// ──────────────────────────────────────────────────────────────────────────
renderDayView: function() {
const day = this.currentDate;
const isToday = this.isToday(day);
const allDayEvents = this.getAllDayEventsForDay(day);
const totalEvents = this.getEventsForDay(day).length;
let html = '<div class="calendar-day-view">';
html += '<div class="calendar-day-view-header">';
html += `<div class="calendar-day-header-single ${isToday ? 'today' : ''}" data-date="${day.toISOString()}">`;
html += '<div class="day-header-top">';
html += `<div class="day-name-long">${this.getDayName(day, 'long')}</div>`;
html += `<div class="day-number-large">${day.getDate()}</div>`;
html += `<div class="day-month-year">${this.getMonthName(day)} ${day.getFullYear()}</div>`;
if (totalEvents > 0) {
html += `<div class="day-event-count ms-auto"><span class="badge bg-primary">${totalEvents} event${totalEvents === 1 ? '' : 's'}</span></div>`;
}
html += '</div>';
if (allDayEvents.length > 0) {
html += '<div class="day-header-events">';
allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header'));
html += '</div>';
}
html += '</div></div>';
html += '<div class="calendar-day-grid">';
for (let hour = 6; hour < 21; hour++) {
const hourEvents = this.getEventsForDayHour(day, hour);
html += `<div class="calendar-day-time-row ${hourEvents.length > 0 ? 'has-events' : ''}">`;
html += `<div class="time-label">${this.formatHour(hour)}</div>`;
html += '<div class="calendar-day-time-cell">';
hourEvents.forEach(event => html += this.renderEventCard(event, 'day'));
html += '</div></div>';
}
html += '</div></div>';
return html;
},
// ──────────────────────────────────────────────────────────────────────────
// Week View
// ──────────────────────────────────────────────────────────────────────────
renderWeekView: function() {
const { start } = this.getDateRange();
const days = [];
for (let i = 0; i < 7; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
days.push(day);
}
let html = '<div class="calendar-week-view">';
html += '<div class="calendar-week-header">';
html += '<div class="calendar-week-header-spacer"></div>';
days.forEach(day => {
const isToday = this.isToday(day);
const allDayEvents = this.getAllDayEventsForDay(day);
html += `<div class="calendar-day-header ${isToday ? 'today' : ''}" data-date="${day.toISOString()}">`;
html += '<div class="day-header-top">';
html += `<div class="day-name">${this.getDayName(day, 'short')}</div>`;
html += `<div class="day-number day-drill" data-day-date="${day.toISOString()}" title="View ${this.getDayName(day, 'long')} ${day.getDate()}">${day.getDate()}</div>`;
html += '</div>';
if (allDayEvents.length > 0) {
html += '<div class="day-header-events">';
allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header'));
html += '</div>';
}
html += '</div>';
});
html += '</div>';
html += '<div class="calendar-week-grid">';
for (let hour = 6; hour < 20; hour++) {
html += '<div class="calendar-time-row">';
html += `<div class="time-label">${this.formatHour(hour)}</div>`;
days.forEach(day => {
const dayEvents = this.getEventsForDayHour(day, hour);
html += `<div class="calendar-time-cell" data-date="${day.toISOString()}" data-hour="${hour}">`;
dayEvents.forEach(event => html += this.renderEventCard(event, 'week'));
html += '</div>';
});
html += '</div>';
}
html += '</div></div>';
return html;
},
// ──────────────────────────────────────────────────────────────────────────
// Month View
// ──────────────────────────────────────────────────────────────────────────
renderMonthView: function() {
const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
const calendarStart = new Date(firstDay);
calendarStart.setDate(calendarStart.getDate() - ((calendarStart.getDay() + 6) % 7));
let html = '<div class="calendar-month-view">';
html += '<div class="calendar-month-header">';
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(name => {
html += `<div class="calendar-day-name">${name}</div>`;
});
html += '</div>';
html += '<div class="calendar-month-grid">';
let currentDay = new Date(calendarStart);
for (let week = 0; week < 6; week++) {
html += '<div class="calendar-week-row">';
for (let day = 0; day < 7; day++) {
const isCurrentMonth = currentDay.getMonth() === this.currentDate.getMonth();
const isToday = this.isToday(currentDay);
const allEvents = this.getEventsForDay(currentDay);
const dayIso = currentDay.toISOString();
const allDayEvents = allEvents.filter(e => e.allDay);
const timedEvents = allEvents.filter(e => !e.allDay);
html += `<div class="calendar-month-cell ${isCurrentMonth ? '' : 'other-month'} ${isToday ? 'today' : ''}" data-date="${dayIso}">`;
html += `<div class="cell-date day-drill" data-day-date="${dayIso}" title="View day">${currentDay.getDate()}</div>`;
html += '<div class="cell-events">';
allDayEvents.forEach(event => html += this.renderEventCard(event, 'month'));
const maxEvents = 3;
const remainingSlots = maxEvents - allDayEvents.length;
timedEvents.slice(0, remainingSlots).forEach(event => html += this.renderEventCard(event, 'month'));
const totalEvents = allEvents.length;
if (totalEvents > maxEvents) {
html += `<div class="event-more day-drill" data-day-date="${dayIso}">+${totalEvents - maxEvents} more</div>`;
}
html += '</div></div>';
currentDay.setDate(currentDay.getDate() + 1);
}
html += '</div>';
}
html += '</div></div>';
return html;
},
// ──────────────────────────────────────────────────────────────────────────
// Event Card Rendering
// ──────────────────────────────────────────────────────────────────────────
renderEventCard: function(event, viewType) {
const colorClass = this.getColorClass(event.backgroundColor);
const time = event.allDay ? 'All Day' : new Date(event.start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
const eventType = event.eventType || 'Appointment';
const allDayClass = event.allDay ? 'all-day-event' : '';
const isJob = eventType === 'Job';
const dragAttr = isJob ? `draggable="true" data-job-id="${event.id}"` : '';
const fallbackAttr = isJob && event.isFallbackDate ? 'data-fallback="true"' : '';
const fallbackStyle = isJob && event.isFallbackDate ? 'opacity:0.75;border-style:dashed;' : '';
const jobIcon = isJob ? '<i class="bi bi-briefcase me-1" style="font-size:0.65rem"></i>' : '';
const fallbackIcon = isJob && event.isFallbackDate ? ' <i class="bi bi-calendar-x" title="No scheduled date \u2014 showing on due date" style="font-size:0.65rem"></i>' : '';
if (viewType === 'week-header') {
return `<div class="calendar-event-header ${colorClass} ${allDayClass}"
data-event-id="${event.id}" data-event-type="${eventType}"
${dragAttr} ${fallbackAttr}
style="${fallbackStyle}"
title="${this.escapeHtml(event.title)}">
${jobIcon}<span class="event-text">${this.escapeHtml(isJob ? (event.jobNumber || event.title) : event.title)}</span>${fallbackIcon}
</div>`;
} else if (viewType === 'day') {
const locationHtml = event.location
? `<div class="event-location"><i class="bi bi-geo-alt me-1"></i>${this.escapeHtml(event.location)}</div>` : '';
return `<div class="calendar-event calendar-event-day ${colorClass} ${allDayClass}"
data-event-id="${event.id}" data-event-type="${eventType}"
${dragAttr} ${fallbackAttr}
style="${fallbackStyle}"
title="${this.escapeHtml(event.title)}">
<div class="event-time">${jobIcon}${time}</div>
<div class="event-title">${this.escapeHtml(event.title)}</div>
<div class="event-customer">${this.escapeHtml(event.customerName || '')}</div>
${locationHtml}
</div>`;
} else if (viewType === 'week') {
return `<div class="calendar-event ${colorClass} ${allDayClass}"
data-event-id="${event.id}" data-event-type="${eventType}"
${dragAttr} ${fallbackAttr}
style="${fallbackStyle}"
title="${this.escapeHtml(event.title)} \u2014 ${this.escapeHtml(event.customerName)}">
<div class="event-time">${jobIcon}${time}</div>
<div class="event-title">${this.escapeHtml(event.title)}</div>
<div class="event-customer">${this.escapeHtml(event.customerName)}</div>
</div>`;
} else {
// Month view
return `<div class="calendar-event-month ${colorClass} ${allDayClass}"
data-event-id="${event.id}" data-event-type="${eventType}"
${dragAttr} ${fallbackAttr}
title="${time} \u2014 ${this.escapeHtml(event.title)}">
<span class="event-dot"></span>
<span class="event-text">${jobIcon}${isJob ? this.escapeHtml(event.jobNumber || event.title) : (time + ' ' + this.escapeHtml(event.title))}</span>
</div>`;
}
},
// ──────────────────────────────────────────────────────────────────────────
// Event Helpers
// ──────────────────────────────────────────────────────────────────────────
getEventsForDay: function(day) {
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
return this.events.filter(event => {
const eventStart = new Date(event.start);
return eventStart >= dayStart && eventStart <= dayEnd;
});
},
getAllDayEventsForDay: function(day) {
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
return this.events.filter(event => {
if (!event.allDay) return false;
const eventStart = new Date(event.start);
return eventStart >= dayStart && eventStart <= dayEnd;
});
},
getEventsForDayHour: function(day, hour) {
const hourStart = new Date(day); hourStart.setHours(hour, 0, 0, 0);
const hourEnd = new Date(day); hourEnd.setHours(hour, 59, 59, 999);
return this.events.filter(event => {
if (event.allDay) return false;
const eventStart = new Date(event.start);
return eventStart >= hourStart && eventStart <= hourEnd;
});
},
// ──────────────────────────────────────────────────────────────────────────
// Click Handlers
// ──────────────────────────────────────────────────────────────────────────
attachEventClickHandlers: function() {
document.querySelectorAll('.calendar-event, .calendar-event-month, .calendar-event-header').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const eventId = el.dataset.eventId;
const eventType = el.dataset.eventType || 'Appointment';
if (eventType === 'Maintenance') {
window.location.href = `/Maintenance/Details/${eventId}`;
} else if (eventType === 'Job') {
window.location.href = `/Jobs/Details/${eventId}`;
} else {
window.location.href = `/Appointments/Details/${eventId}`;
}
});
});
document.querySelectorAll('.day-drill').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const date = new Date(el.dataset.dayDate);
this.switchToDay(date);
});
});
// Job event drag handlers (calendar → calendar reschedule)
document.querySelectorAll('[data-event-type="Job"]').forEach(el => {
el.setAttribute('draggable', 'true');
el.addEventListener('dragstart', (e) => {
e.stopPropagation();
const cell = el.closest('[data-date]');
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'job',
jobId: el.dataset.eventId,
fromDate: cell?.dataset.date || null
}));
e.dataTransfer.effectAllowed = 'move';
el.classList.add('event-dragging');
});
el.addEventListener('dragend', () => el.classList.remove('event-dragging'));
});
},
// ──────────────────────────────────────────────────────────────────────────
// Date Ranges & Navigation
// ──────────────────────────────────────────────────────────────────────────
getDateRange: function() {
if (this.currentView === 'day') return this.getDayRange(this.currentDate);
if (this.currentView === 'week') return this.getWeekRange(this.currentDate);
return this.getMonthRange(this.currentDate);
},
getDayRange: function(date) {
const start = new Date(date); start.setHours(0, 0, 0, 0);
const end = new Date(date); end.setHours(23, 59, 59, 999);
return { start, end };
},
getWeekRange: function(date) {
const start = new Date(date);
const day = start.getDay();
const diff = start.getDate() - day + (day === 0 ? -6 : 1);
start.setDate(diff);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(start.getDate() + 7);
end.setHours(23, 59, 59, 999);
return { start, end };
},
getMonthRange: function(date) {
const start = new Date(date.getFullYear(), date.getMonth(), 1);
start.setHours(0, 0, 0, 0);
const end = new Date(date.getFullYear(), date.getMonth() + 1, 0);
end.setHours(23, 59, 59, 999);
return { start, end };
},
goToPrevious: function() {
if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() - 1);
else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() - 7);
else this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.loadAndRender();
},
goToNext: function() {
if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() + 1);
else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() + 7);
else this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.loadAndRender();
},
goToToday: function() {
this.currentDate = new Date();
this.loadAndRender();
},
switchView: function(view) {
this.currentView = view;
this.updateViewButtons();
this.loadAndRender();
},
switchToDay: function(date) {
this.currentDate = new Date(date);
this.currentView = 'day';
this.updateViewButtons();
this.loadAndRender();
},
// ──────────────────────────────────────────────────────────────────────────
// UI State
// ──────────────────────────────────────────────────────────────────────────
updateViewButtons: function() {
document.getElementById('btnDayView').classList.toggle('active', this.currentView === 'day');
document.getElementById('btnWeekView').classList.toggle('active', this.currentView === 'week');
document.getElementById('btnMonthView').classList.toggle('active', this.currentView === 'month');
},
updateCurrentDateDisplay: function() {
const display = document.getElementById('currentDateDisplay');
if (this.currentView === 'day') {
display.textContent = `${this.getDayName(this.currentDate, 'long')}, ${this.getMonthName(this.currentDate)} ${this.currentDate.getDate()}, ${this.currentDate.getFullYear()}`;
} else if (this.currentView === 'week') {
const { start, end } = this.getWeekRange(this.currentDate);
const endDisplay = new Date(end);
endDisplay.setDate(endDisplay.getDate() - 1);
if (start.getMonth() === endDisplay.getMonth()) {
display.textContent = `${this.getMonthName(start)} ${start.getDate()}\u2013${endDisplay.getDate()}, ${start.getFullYear()}`;
} else {
display.textContent = `${this.getMonthName(start)} ${start.getDate()} \u2013 ${this.getMonthName(endDisplay)} ${endDisplay.getDate()}, ${start.getFullYear()}`;
}
} else {
display.textContent = `${this.getMonthName(this.currentDate)} ${this.currentDate.getFullYear()}`;
}
},
updateURL: function() {
const url = new URL(window.location);
url.searchParams.set('view', this.currentView);
url.searchParams.set('date', this.currentDate.toISOString().split('T')[0]);
window.history.replaceState({}, '', url);
},
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
isToday: function(date) {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
},
getDayName: function(date, format = 'long') {
return date.toLocaleDateString('en-US', { weekday: format });
},
getMonthName: function(date) {
return date.toLocaleDateString('en-US', { month: 'long' });
},
formatHour: function(hour) {
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
return `${displayHour} ${period}`;
},
formatDateShort: function(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
},
getColorClass: function(bgColor) {
if (bgColor && bgColor.startsWith('#')) {
const colorMap = {
'#6f42c1': 'calendar-event-purple',
'#198754': 'calendar-event-green',
'#0d6efd': 'calendar-event-blue',
'#fd7e14': 'calendar-event-orange',
'#d63384': 'calendar-event-pink',
'#0dcaf0': 'calendar-event-cyan',
'#20c997': 'calendar-event-teal',
'#6610f2': 'calendar-event-indigo',
'#84cc16': 'calendar-event-lime',
'#795548': 'calendar-event-brown',
'#dc3545': 'calendar-event-red',
'#ffc107': 'calendar-event-yellow',
'#6c757d': 'calendar-event-gray',
'#adb5bd': 'calendar-event-gray',
'#212529': 'calendar-event-dark'
};
return colorMap[bgColor] || 'calendar-event-blue';
}
return bgColor ? `calendar-event-${bgColor}` : 'calendar-event-blue';
},
escapeHtml: function(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
};
window.appointmentCalendar = appointmentCalendar;
window.loadCalendarEvents = () => appointmentCalendar.loadAndRender();
@@ -0,0 +1,204 @@
// CSV Bulk Import JavaScript
// Handles file upload with AJAX, progress indicators, and result display
document.addEventListener('DOMContentLoaded', function () {
setupCsvImportForm('csvImportCustomersForm', 'csvCustomersFile', 'csvImportCustomersBtn', '/Tools/CsvImportCustomers', 'csvCustomersResults');
setupCsvImportForm('csvImportCatalogForm', 'csvCatalogFile', 'csvImportCatalogBtn', '/Tools/CsvImportCatalogItems', 'csvCatalogResults');
setupCsvImportForm('csvImportInventoryForm', 'csvInventoryFile', 'csvImportInventoryBtn', '/Tools/CsvImportInventoryItems', 'csvInventoryResults');
setupCsvImportForm('csvImportQuotesForm', 'csvQuotesFile', 'csvImportQuotesBtn', '/Tools/CsvImportQuotes', 'csvQuotesResults');
setupCsvImportForm('csvImportJobsForm', 'csvJobsFile', 'csvImportJobsBtn', '/Tools/CsvImportJobs', 'csvJobsResults');
setupCsvImportForm('csvImportAppointmentsForm', 'csvAppointmentsFile', 'csvImportAppointmentsBtn', '/Tools/CsvImportAppointments', 'csvAppointmentsResults');
setupCsvImportForm('csvImportEquipmentForm', 'csvEquipmentFile', 'csvImportEquipmentBtn', '/Tools/CsvImportEquipment', 'csvEquipmentResults');
setupCsvImportForm('csvImportMaintenanceForm', 'csvMaintenanceFile', 'csvImportMaintenanceBtn', '/Tools/CsvImportMaintenance', 'csvMaintenanceResults');
setupCsvImportForm('csvImportSettingsForm', 'csvSettingsFile', 'csvImportSettingsBtn', '/Tools/CsvImportCompanySettings', 'csvSettingsResults');
setupCsvImportForm('csvImportVendorsForm', 'csvVendorsFile', 'csvImportVendorsBtn', '/Tools/CsvImportVendors', 'csvVendorsResults');
setupCsvImportForm('csvImportShopWorkersForm', 'csvShopWorkersFile', 'csvImportShopWorkersBtn', '/Tools/CsvImportShopWorkers', 'csvShopWorkersResults');
setupCsvImportForm('csvImportPrepServicesForm', 'csvPrepServicesFile', 'csvImportPrepServicesBtn', '/Tools/CsvImportPrepServices', 'csvPrepServicesResults');
});
function setupCsvImportForm(formId, fileInputId, submitBtnId, actionUrl, resultsId) {
const form = document.getElementById(formId);
if (!form) return;
form.addEventListener('submit', async function (e) {
e.preventDefault();
const fileInput = document.getElementById(fileInputId);
const submitBtn = document.getElementById(submitBtnId);
const resultsDiv = document.getElementById(resultsId);
const spinner = submitBtn.querySelector('.spinner-border');
// Validate file selection
if (!fileInput.files || fileInput.files.length === 0) {
showToast('Please select a CSV file to import.', 'error');
return;
}
const file = fileInput.files[0];
// Validate file extension
if (!file.name.toLowerCase().endsWith('.csv')) {
showToast('Please select a valid CSV file.', 'error');
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
showToast('File size must be less than 10MB.', 'error');
return;
}
// Show loading state
spinner.classList.remove('d-none');
submitBtn.disabled = true;
resultsDiv.classList.add('d-none');
resultsDiv.innerHTML = '';
// Prepare form data (use the form element so all fields, including account selects, are included)
const formData = new FormData(form);
// Get anti-forgery token
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
try {
const response = await fetch(actionUrl, {
method: 'POST',
body: formData,
headers: {
'RequestVerificationToken': token
}
});
const result = await response.json();
// Display results
displayImportResults(result, resultsDiv);
// Show toast notification
if (result.success) {
showToast(`Import completed: ${result.successCount} records imported successfully!`, 'success');
// Clear file input
fileInput.value = '';
} else {
showToast('Import completed with errors. Please review the details below.', 'error');
}
} catch (error) {
console.error('Import error:', error);
showToast('An error occurred during import: ' + error.message, 'error');
displayImportResults({ success: false, message: error.message, errors: [error.toString()] }, resultsDiv);
} finally {
// Hide loading state
spinner.classList.add('d-none');
submitBtn.disabled = false;
}
});
}
function displayImportResults(result, resultsDiv) {
resultsDiv.classList.remove('d-none');
const cardClass = result.success ? 'border-success' : 'border-danger';
const headerClass = result.success ? 'bg-success' : 'bg-danger';
const icon = result.success ? 'check-circle' : 'exclamation-triangle';
const skipped = result.skippedCount || 0;
const colSize = skipped > 0 ? 'col-md-3' : 'col-md-4';
let html = `
<div class="card ${cardClass}">
<div class="card-header ${headerClass} text-white">
<h6 class="mb-0"><i class="bi bi-${icon}"></i> Import Results</h6>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="${colSize}">
<div class="text-center">
<div class="display-6 text-success">${result.successCount || 0}</div>
<small class="text-muted">Imported</small>
</div>
</div>
${skipped > 0 ? `
<div class="${colSize}">
<div class="text-center">
<div class="display-6 text-warning">${skipped}</div>
<small class="text-muted">Skipped (already exist)</small>
</div>
</div>` : ''}
<div class="${colSize}">
<div class="text-center">
<div class="display-6 text-danger">${result.errorCount || 0}</div>
<small class="text-muted">Errors</small>
</div>
</div>
<div class="${colSize}">
<div class="text-center">
<div class="display-6 text-info">${result.totalRows || 0}</div>
<small class="text-muted">Total Rows</small>
</div>
</div>
</div>
<p class="mb-2"><strong>${result.message || 'Import completed.'}</strong></p>
`;
// Display errors
if (result.errors && result.errors.length > 0) {
html += `
<div class="alert alert-danger mt-3">
<h6><i class="bi bi-exclamation-triangle"></i> Errors:</h6>
<ul class="mb-0 small">
`;
result.errors.forEach(error => {
html += `<li>${escapeHtml(error)}</li>`;
});
html += `
</ul>
</div>
`;
}
// Display warnings
if (result.warnings && result.warnings.length > 0) {
html += `
<div class="alert alert-warning mt-3">
<h6><i class="bi bi-info-circle"></i> Warnings:</h6>
<ul class="mb-0 small">
`;
result.warnings.forEach(warning => {
html += `<li>${escapeHtml(warning)}</li>`;
});
html += `
</ul>
</div>
`;
}
html += `
</div>
</div>
`;
resultsDiv.innerHTML = html;
}
function showToast(message, type = 'success') {
const toastId = type === 'success' ? 'successToast' : 'errorToast';
const toastElement = document.getElementById(toastId);
if (!toastElement) return;
const toastBody = toastElement.querySelector('.toast-body');
toastBody.textContent = message;
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
@@ -0,0 +1,21 @@
/**
* Saves accordion open/closed state on toggle. The initial state restoration is
* handled by an inline script in the view that runs synchronously before Bootstrap
* initializes, preventing any visible flash.
*
* Key format: pcl_catalog_acc_{collapseId} → "1" (open) or "0" (closed)
*/
(function () {
const PREFIX = 'pcl_catalog_acc_';
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.catalog-tree .collapse').forEach(function (el) {
el.addEventListener('show.bs.collapse', function () {
localStorage.setItem(PREFIX + el.id, '1');
});
el.addEventListener('hide.bs.collapse', function () {
localStorage.setItem(PREFIX + el.id, '0');
});
});
});
}());
@@ -0,0 +1,588 @@
// Modal Management for Lookup Tables
// This file contains modal-specific functions that replace the old prompt() dialogs
// Include this AFTER company-settings-lookups.js
(function() {
'use strict';
// ====================
// JOB STATUS MODAL
// ====================
window.showJobStatusModal = function(item) {
const modal = new bootstrap.Modal(document.getElementById('jobStatusModal'));
const form = document.getElementById('jobStatusForm');
// Reset form
form.reset();
document.getElementById('jobStatusId').value = '';
if (item) {
// Edit mode
document.getElementById('jobStatusModalTitle').textContent = 'Edit Job Status';
document.getElementById('jobStatusId').value = item.id;
document.getElementById('jobStatusCode').value = item.statusCode;
document.getElementById('jobStatusCode').disabled = true; // Cannot change code
document.getElementById('jobStatusDisplayName').value = item.displayName;
document.getElementById('jobStatusColorClass').value = item.colorClass;
document.getElementById('jobStatusCategory').value = item.workflowCategory || '';
document.getElementById('jobStatusIsTerminal').checked = item.isTerminalStatus;
document.getElementById('jobStatusIsWIP').checked = item.isWorkInProgressStatus;
document.getElementById('jobStatusDescription').value = item.description || '';
} else {
// Add mode
document.getElementById('jobStatusModalTitle').textContent = 'Add Job Status';
document.getElementById('jobStatusCode').disabled = false;
}
modal.show();
};
document.getElementById('saveJobStatusBtn').addEventListener('click', function() {
const form = document.getElementById('jobStatusForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const id = document.getElementById('jobStatusId').value;
const data = {
statusCode: document.getElementById('jobStatusCode').value.toUpperCase(),
displayName: document.getElementById('jobStatusDisplayName').value,
colorClass: document.getElementById('jobStatusColorClass').value,
workflowCategory: document.getElementById('jobStatusCategory').value || null,
isTerminalStatus: document.getElementById('jobStatusIsTerminal').checked,
isWorkInProgressStatus: document.getElementById('jobStatusIsWIP').checked,
description: document.getElementById('jobStatusDescription').value || null,
displayOrder: 999 // Will be set by server
};
if (id) {
// Update
data.id = parseInt(id);
data.isActive = true;
$.ajax({
url: '/CompanySettings/UpdateJobStatus',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('jobStatusModal')).hide();
window.showToast('success', response.message);
window.loadJobStatuses();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to update job status');
}
});
} else {
// Create
$.ajax({
url: '/CompanySettings/CreateJobStatus',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('jobStatusModal')).hide();
window.showToast('success', response.message);
window.loadJobStatuses();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to create job status');
}
});
}
});
// ====================
// JOB PRIORITY MODAL
// ====================
window.showJobPriorityModal = function(item) {
const modal = new bootstrap.Modal(document.getElementById('jobPriorityModal'));
const form = document.getElementById('jobPriorityForm');
// Reset form
form.reset();
document.getElementById('jobPriorityId').value = '';
if (item) {
// Edit mode
document.getElementById('jobPriorityModalTitle').textContent = 'Edit Job Priority';
document.getElementById('jobPriorityId').value = item.id;
document.getElementById('jobPriorityCode').value = item.priorityCode;
document.getElementById('jobPriorityCode').disabled = true; // Cannot change code
document.getElementById('jobPriorityDisplayName').value = item.displayName;
document.getElementById('jobPriorityColorClass').value = item.colorClass;
document.getElementById('jobPriorityDescription').value = item.description || '';
} else {
// Add mode
document.getElementById('jobPriorityModalTitle').textContent = 'Add Job Priority';
document.getElementById('jobPriorityCode').disabled = false;
}
modal.show();
};
document.getElementById('saveJobPriorityBtn').addEventListener('click', function() {
const form = document.getElementById('jobPriorityForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const id = document.getElementById('jobPriorityId').value;
const data = {
priorityCode: document.getElementById('jobPriorityCode').value.toUpperCase(),
displayName: document.getElementById('jobPriorityDisplayName').value,
colorClass: document.getElementById('jobPriorityColorClass').value,
description: document.getElementById('jobPriorityDescription').value || null,
displayOrder: 999 // Will be set by server
};
if (id) {
// Update
data.id = parseInt(id);
data.isActive = true;
$.ajax({
url: '/CompanySettings/UpdateJobPriority',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('jobPriorityModal')).hide();
window.showToast('success', response.message);
window.loadJobPriorities();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to update job priority');
}
});
} else {
// Create
$.ajax({
url: '/CompanySettings/CreateJobPriority',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('jobPriorityModal')).hide();
window.showToast('success', response.message);
window.loadJobPriorities();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to create job priority');
}
});
}
});
// ====================
// QUOTE STATUS MODAL
// ====================
window.showQuoteStatusModal = function(item) {
const modal = new bootstrap.Modal(document.getElementById('quoteStatusModal'));
const form = document.getElementById('quoteStatusForm');
// Reset form
form.reset();
document.getElementById('quoteStatusId').value = '';
if (item) {
// Edit mode
document.getElementById('quoteStatusModalTitle').textContent = 'Edit Quote Status';
document.getElementById('quoteStatusId').value = item.id;
document.getElementById('quoteStatusCode').value = item.statusCode;
document.getElementById('quoteStatusCode').disabled = true; // Cannot change code
document.getElementById('quoteStatusDisplayName').value = item.displayName;
document.getElementById('quoteStatusColorClass').value = item.colorClass;
document.getElementById('quoteStatusIsApproved').checked = item.isApprovedStatus;
document.getElementById('quoteStatusIsConverted').checked = item.isConvertedStatus;
document.getElementById('quoteStatusIsDraft').checked = item.isDraftStatus;
document.getElementById('quoteStatusDescription').value = item.description || '';
} else {
// Add mode
document.getElementById('quoteStatusModalTitle').textContent = 'Add Quote Status';
document.getElementById('quoteStatusCode').disabled = false;
}
modal.show();
};
document.getElementById('saveQuoteStatusBtn').addEventListener('click', function() {
const form = document.getElementById('quoteStatusForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const id = document.getElementById('quoteStatusId').value;
const data = {
statusCode: document.getElementById('quoteStatusCode').value.toUpperCase(),
displayName: document.getElementById('quoteStatusDisplayName').value,
colorClass: document.getElementById('quoteStatusColorClass').value,
isApprovedStatus: document.getElementById('quoteStatusIsApproved').checked,
isConvertedStatus: document.getElementById('quoteStatusIsConverted').checked,
isDraftStatus: document.getElementById('quoteStatusIsDraft').checked,
description: document.getElementById('quoteStatusDescription').value || null,
displayOrder: 999 // Will be set by server
};
if (id) {
// Update
data.id = parseInt(id);
data.isActive = true;
$.ajax({
url: '/CompanySettings/UpdateQuoteStatus',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('quoteStatusModal')).hide();
window.showToast('success', response.message);
window.loadQuoteStatuses();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to update quote status');
}
});
} else {
// Create
$.ajax({
url: '/CompanySettings/CreateQuoteStatus',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('quoteStatusModal')).hide();
window.showToast('success', response.message);
window.loadQuoteStatuses();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to create quote status');
}
});
}
});
// ====================
// INVENTORY CATEGORY MODAL
// ====================
window.showInventoryCategoryModal = function(item) {
const modal = new bootstrap.Modal(document.getElementById('inventoryCategoryModal'));
const form = document.getElementById('inventoryCategoryForm');
// Reset form
form.reset();
document.getElementById('inventoryCategoryId').value = '';
if (item) {
// Edit mode
document.getElementById('inventoryCategoryModalTitle').textContent = 'Edit Inventory Category';
document.getElementById('inventoryCategoryId').value = item.id;
document.getElementById('inventoryCategoryCode').value = item.categoryCode;
document.getElementById('inventoryCategoryCode').disabled = true; // Cannot change code
document.getElementById('inventoryCategoryDisplayName').value = item.displayName;
document.getElementById('inventoryCategoryIsCoating').checked = item.isCoating;
document.getElementById('inventoryCategoryDescription').value = item.description || '';
} else {
// Add mode
document.getElementById('inventoryCategoryModalTitle').textContent = 'Add Inventory Category';
document.getElementById('inventoryCategoryCode').disabled = false;
}
modal.show();
};
document.getElementById('saveInventoryCategoryBtn').addEventListener('click', function() {
const form = document.getElementById('inventoryCategoryForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const id = document.getElementById('inventoryCategoryId').value;
const data = {
categoryCode: document.getElementById('inventoryCategoryCode').value.toUpperCase(),
displayName: document.getElementById('inventoryCategoryDisplayName').value,
isCoating: document.getElementById('inventoryCategoryIsCoating').checked,
description: document.getElementById('inventoryCategoryDescription').value || null,
displayOrder: 999 // Will be set by server
};
if (id) {
// Update
data.id = parseInt(id);
data.isActive = true;
$.ajax({
url: '/CompanySettings/UpdateInventoryCategory',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('inventoryCategoryModal')).hide();
window.showToast('success', response.message);
window.loadInventoryCategories();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to update inventory category');
}
});
} else {
// Create
$.ajax({
url: '/CompanySettings/CreateInventoryCategory',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('inventoryCategoryModal')).hide();
window.showToast('success', response.message);
window.loadInventoryCategories();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to create inventory category');
}
});
}
});
// Update the window-level edit functions to use the new modals
const originalEditJobStatus = window.editJobStatus;
window.editJobStatus = function(id) {
// Find the item from the global arrays
const item = window.jobStatuses ? window.jobStatuses.find(s => s.id === id) : null;
if (item) window.showJobStatusModal(item);
};
const originalEditJobPriority = window.editJobPriority;
window.editJobPriority = function(id) {
const item = window.jobPriorities ? window.jobPriorities.find(p => p.id === id) : null;
if (item) window.showJobPriorityModal(item);
};
const originalEditQuoteStatus = window.editQuoteStatus;
window.editQuoteStatus = function(id) {
const item = window.quoteStatuses ? window.quoteStatuses.find(s => s.id === id) : null;
if (item) window.showQuoteStatusModal(item);
};
const originalEditInventoryCategory = window.editInventoryCategory;
window.editInventoryCategory = function(id) {
const item = window.inventoryCategories ? window.inventoryCategories.find(c => c.id === id) : null;
if (item) window.showInventoryCategoryModal(item);
};
// ====================
// APPOINTMENT TYPE MODAL
// ====================
window.showAppointmentTypeModal = function(item) {
const modal = new bootstrap.Modal(document.getElementById('appointmentTypeModal'));
const form = document.getElementById('appointmentTypeForm');
// Reset form
form.reset();
document.getElementById('appointmentTypeId').value = '';
document.getElementById('appointmentTypeActiveField').style.display = 'none';
if (item) {
// Edit mode
document.getElementById('appointmentTypeModalTitle').textContent = 'Edit Appointment Type';
document.getElementById('appointmentTypeId').value = item.id;
document.getElementById('appointmentTypeCode').value = item.typeCode;
document.getElementById('appointmentTypeCode').disabled = true; // Cannot change code
document.getElementById('appointmentTypeDisplayName').value = item.displayName;
document.getElementById('appointmentTypeColorClass').value = item.colorClass;
document.getElementById('appointmentTypeIconClass').value = item.iconClass || '';
document.getElementById('appointmentTypeRequiresJob').checked = item.requiresJobLink;
document.getElementById('appointmentTypeIsActive').checked = item.isActive;
document.getElementById('appointmentTypeDescription').value = item.description || '';
document.getElementById('appointmentTypeActiveField').style.display = 'block';
} else {
// Add mode
document.getElementById('appointmentTypeModalTitle').textContent = 'Add Appointment Type';
document.getElementById('appointmentTypeCode').disabled = false;
document.getElementById('appointmentTypeIsActive').checked = true;
}
modal.show();
// Update color preview after modal is shown
window.updateAppointmentTypeColorPreview();
};
// Update the appointment type color preview badge
window.updateAppointmentTypeColorPreview = function() {
const colorSelect = document.getElementById('appointmentTypeColorClass');
const previewBadge = document.getElementById('appointmentTypeColorPreview');
if (!colorSelect || !previewBadge) return;
const selectedColor = colorSelect.value;
// Remove all existing Bootstrap color classes
const colorClasses = [
'bg-purple', 'bg-green', 'bg-blue', 'bg-orange', 'bg-red', 'bg-yellow',
'bg-pink', 'bg-cyan', 'bg-teal', 'bg-indigo', 'bg-lime', 'bg-brown', 'bg-gray',
'bg-success', 'bg-danger', 'bg-warning', 'bg-info', 'bg-primary', 'bg-secondary', 'bg-dark',
'text-dark', 'text-white'
];
previewBadge.classList.remove(...colorClasses);
// Add the selected color class
previewBadge.classList.add('bg-' + selectedColor);
// Add text-dark for warning (yellow) to ensure visibility
if (selectedColor === 'warning' || selectedColor === 'yellow' || selectedColor === 'lime') {
previewBadge.classList.add('text-dark');
} else {
previewBadge.classList.add('text-white');
}
};
document.getElementById('saveAppointmentTypeBtn').addEventListener('click', function() {
const form = document.getElementById('appointmentTypeForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const id = document.getElementById('appointmentTypeId').value;
const data = {
typeCode: document.getElementById('appointmentTypeCode').value.toUpperCase(),
displayName: document.getElementById('appointmentTypeDisplayName').value,
colorClass: document.getElementById('appointmentTypeColorClass').value,
iconClass: document.getElementById('appointmentTypeIconClass').value || null,
requiresJobLink: document.getElementById('appointmentTypeRequiresJob').checked,
description: document.getElementById('appointmentTypeDescription').value || null,
displayOrder: 999 // Will be set by server
};
if (id) {
// Update
data.id = parseInt(id);
data.isActive = document.getElementById('appointmentTypeIsActive').checked;
$.ajax({
url: '/CompanySettings/UpdateAppointmentType',
type: 'POST',
contentType: 'application/json',
headers: {
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('appointmentTypeModal')).hide();
window.showToast('success', response.message);
window.loadAppointmentTypes();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to update appointment type');
}
});
} else {
// Create
$.ajax({
url: '/CompanySettings/CreateAppointmentType',
type: 'POST',
contentType: 'application/json',
headers: {
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
bootstrap.Modal.getInstance(document.getElementById('appointmentTypeModal')).hide();
window.showToast('success', response.message);
window.loadAppointmentTypes();
} else {
window.showToast('error', response.message);
}
},
error: function() {
window.showToast('error', 'Failed to create appointment type');
}
});
}
});
// Override the editAppointmentType function to use modal
window.editAppointmentType = function(id) {
const item = window.appointmentTypes ? window.appointmentTypes.find(t => t.id === id) : null;
if (item) window.showAppointmentTypeModal(item);
};
// ====================
// AUTO-DERIVE CODE FROM DISPLAY NAME
// When adding a new lookup item, auto-populate the Code field from the Display Name:
// "In Progress" → "IN_PROGRESS". Also auto-uppercases direct code field edits.
// Only fires when the code field is enabled (add mode — disabled in edit mode).
// ====================
function deriveCode(displayName) {
return displayName.trim().toUpperCase().replace(/\s+/g, '_').replace(/[^A-Z0-9_]/g, '');
}
function wireCodeAutoFill(nameFieldId, codeFieldId) {
const nameField = document.getElementById(nameFieldId);
const codeField = document.getElementById(codeFieldId);
if (!nameField || !codeField) return;
nameField.addEventListener('input', function() {
if (!codeField.disabled) {
codeField.value = deriveCode(this.value);
}
});
codeField.addEventListener('input', function() {
const pos = this.selectionStart;
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
this.setSelectionRange(pos, pos);
});
}
wireCodeAutoFill('jobStatusDisplayName', 'jobStatusCode');
wireCodeAutoFill('jobPriorityDisplayName', 'jobPriorityCode');
wireCodeAutoFill('quoteStatusDisplayName', 'quoteStatusCode');
wireCodeAutoFill('inventoryCategoryDisplayName','inventoryCategoryCode');
wireCodeAutoFill('appointmentTypeDisplayName', 'appointmentTypeCode');
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
// Equipment Management JavaScript
// File Upload with Drag & Drop
function initializeFileUpload() {
const fileInput = document.getElementById('manualFile');
const uploadForm = document.getElementById('uploadManualForm');
if (!fileInput || !uploadForm) return;
// Drag and drop handlers
const dropZone = uploadForm.querySelector('.file-upload-zone');
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
uploadForm.dispatchEvent(new Event('submit', { bubbles: true }));
}
});
}
}
// Client-side Cost Calculation
function initializeCostCalculation() {
const laborCostInput = document.querySelector('input[name="LaborCost"]');
const partsCostInput = document.querySelector('input[name="PartsCost"]');
const totalCostDisplay = document.getElementById('totalCostDisplay');
if (!laborCostInput || !partsCostInput) return;
function calculateTotal() {
const laborCost = parseFloat(laborCostInput.value) || 0;
const partsCost = parseFloat(partsCostInput.value) || 0;
const totalCost = laborCost + partsCost;
if (totalCostDisplay) {
totalCostDisplay.textContent = totalCost.toLocaleString('en-US', {
style: 'currency',
currency: 'USD'
});
}
}
laborCostInput.addEventListener('input', calculateTotal);
partsCostInput.addEventListener('input', calculateTotal);
// Calculate on page load
calculateTotal();
}
// Equipment Status Change Handler
function initializeStatusChangeHandler() {
const statusSelect = document.querySelector('select[name="Status"]');
if (!statusSelect) return;
statusSelect.addEventListener('change', function() {
const selectedStatus = this.value;
// Add visual feedback based on status
const card = this.closest('.card');
if (card) {
card.classList.remove('border-success', 'border-warning', 'border-danger', 'border-info');
switch(selectedStatus) {
case 'Operational':
card.classList.add('border-success');
break;
case 'NeedsMaintenance':
card.classList.add('border-warning');
break;
case 'UnderMaintenance':
card.classList.add('border-info');
break;
case 'OutOfService':
card.classList.add('border-danger');
break;
}
}
});
}
// Maintenance Due Reminder
function checkMaintenanceDue() {
const daysUntilMaintenance = document.querySelector('[data-days-until-maintenance]');
if (!daysUntilMaintenance) return;
const days = parseInt(daysUntilMaintenance.dataset.daysUntilMaintenance);
if (days !== null && days < 7 && days >= 0) {
showToast(`Maintenance due in ${days} days!`, 'warning');
} else if (days < 0) {
showToast(`Maintenance is overdue by ${Math.abs(days)} days!`, 'danger');
}
}
// Show Toast Notification
function showToast(message, type = 'info') {
const toastHTML = `
<div class="toast align-items-center text-white bg-${type} border-0 position-fixed bottom-0 end-0 m-3" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', toastHTML);
const toastElement = document.querySelector('.toast:last-child');
const toast = new bootstrap.Toast(toastElement);
toast.show();
// Remove from DOM after hidden
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
// Confirm Delete with Modal
function confirmDelete(event, itemName, itemType) {
if (!confirm(`Are you sure you want to delete this ${itemType}: ${itemName}?\n\nThis action cannot be undone.`)) {
event.preventDefault();
return false;
}
return true;
}
// Search and Filter Tables
function initializeTableSearch(inputId, tableId) {
const searchInput = document.getElementById(inputId);
const table = document.getElementById(tableId);
if (!searchInput || !table) return;
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
}
// Initialize all features on page load
document.addEventListener('DOMContentLoaded', function() {
initializeFileUpload();
initializeCostCalculation();
initializeStatusChangeHandler();
checkMaintenanceDue();
// Initialize table searches if present
initializeTableSearch('searchInput', 'equipmentTable');
initializeTableSearch('searchInput', 'maintenanceTable');
});
// Export functions for use in other scripts
window.EquipmentManagement = {
showToast,
confirmDelete,
initializeFileUpload,
initializeCostCalculation
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
// Job Photos JavaScript
const jobPhotoModule = {
jobId: null,
allPhotos: [],
currentPhotoIndex: 0,
_tagApi: null,
init: function(jobId, tagSuggestions) {
this.jobId = jobId;
this._tagSuggestions = tagSuggestions || [];
this.loadJobPhotos();
this.setupDragDrop();
this.setupFileInput();
// Initialise the tag widget each time the upload modal opens so it's always fresh
const uploadModal = document.getElementById('uploadPhotoModal');
if (uploadModal) {
uploadModal.addEventListener('show.bs.modal', () => {
this._tagApi = initTagInput('photoTagsHidden', 'photoTagsContainer', {
suggestions: this._tagSuggestions
});
});
}
},
loadJobPhotos: function() {
console.log('loadJobPhotos called');
// Add cache-busting parameter to prevent browser from using cached data
fetch(`/Jobs/GetJobPhotos?jobId=${this.jobId}&_t=${Date.now()}`, {
cache: 'no-cache',
headers: {
'Cache-Control': 'no-cache'
}
})
.then(response => response.json())
.then(data => {
console.log('GetJobPhotos response:', data);
if (data.success) {
this.allPhotos = data.photos;
this.renderPhotoGallery(data.photos);
document.getElementById('photoCount').textContent = data.photos.length;
}
})
.catch(error => console.error('Error loading photos:', error));
},
renderPhotoGallery: function(photos) {
const gallery = document.getElementById('photoGallery');
if (!gallery) {
console.error('photoGallery element not found in DOM');
return;
}
// Clear gallery
gallery.innerHTML = '';
if (photos.length === 0) {
// Show "no photos" message
gallery.innerHTML = `
<div class="col-12 text-center py-5" id="noPhotosMessage">
<i class="bi bi-camera" style="font-size: 3rem; opacity: 0.2;"></i>
<p class="text-muted mt-2 mb-0">No photos uploaded yet</p>
<small class="text-muted">Click "Upload Photo" to add photos</small>
</div>
`;
return;
}
// Add photo cards
photos.forEach((photo, index) => {
const col = document.createElement('div');
col.className = 'col-md-4 col-sm-6';
col.innerHTML = `
<div class="photo-card" onclick="jobPhotoModule.viewPhoto(${index})">
<img src="/Jobs/GetPhoto/${photo.id}" alt="${this.escapeHtml(photo.caption || 'Job photo')}" class="photo-thumbnail">
<div class="photo-overlay">
<div class="photo-type-badge badge bg-${this.getPhotoTypeBadgeClass(photo.photoType)}">
${photo.photoTypeDisplay}
</div>
${photo.caption ? `<p class="photo-caption">${this.escapeHtml(photo.caption)}</p>` : ''}
</div>
</div>
`;
gallery.appendChild(col);
});
},
viewPhoto: function(index, isNavigating = false) {
this.currentPhotoIndex = index;
const photo = this.allPhotos[index];
document.getElementById('viewPhotoImage').src = `/Jobs/GetPhoto/${photo.id}`;
document.getElementById('viewPhotoTitle').textContent = photo.photoTypeDisplay + ' Photo';
document.getElementById('photoPosition').textContent = `Photo ${index + 1} of ${this.allPhotos.length}`;
document.getElementById('photoDetailCaption').textContent = photo.caption || 'No caption';
document.getElementById('photoDetailType').textContent = photo.photoTypeDisplay;
document.getElementById('photoDetailDate').textContent = new Date(photo.uploadedDate).toLocaleDateString();
document.getElementById('photoDetailUploader').textContent = photo.uploadedByName;
// Tags
const tagsRow = document.getElementById('photoDetailTagsRow');
const tagsSpan = document.getElementById('photoDetailTags');
if (photo.tagsList && photo.tagsList.length > 0) {
tagsSpan.innerHTML = photo.tagsList.map(t =>
`<span class="badge bg-primary bg-opacity-10 text-primary me-1">${this.escapeHtml(t)}</span>`
).join('');
tagsRow.style.display = '';
} else {
tagsRow.style.display = 'none';
}
// Only show modal if not navigating (prevents backdrop duplication)
if (!isNavigating) {
const modalElement = document.getElementById('viewPhotoModal');
const modal = bootstrap.Modal.getInstance(modalElement) || new bootstrap.Modal(modalElement);
modal.show();
}
},
navigatePhoto: function(direction) {
this.currentPhotoIndex += direction;
if (this.currentPhotoIndex < 0) this.currentPhotoIndex = this.allPhotos.length - 1;
if (this.currentPhotoIndex >= this.allPhotos.length) this.currentPhotoIndex = 0;
// Pass true to indicate we're navigating (don't re-show modal)
this.viewPhoto(this.currentPhotoIndex, true);
},
uploadPhoto: function() {
const fileInput = document.getElementById('photoFile');
const caption = document.getElementById('photoCaption').value;
const photoType = document.getElementById('photoType').value;
if (!fileInput.files || fileInput.files.length === 0) {
showWarning('Please select a photo to upload', 'No File Selected');
return;
}
const formData = new FormData();
formData.append('jobId', this.jobId);
formData.append('photo', fileInput.files[0]);
formData.append('caption', caption);
formData.append('photoType', photoType);
formData.append('tags', document.getElementById('photoTagsHidden').value);
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
fetch('/Jobs/UploadPhoto', {
method: 'POST',
headers: {
'RequestVerificationToken': token
},
body: formData
})
.then(response => response.json())
.then(data => {
console.log('Upload response:', data);
if (data.success) {
const modalElement = document.getElementById('uploadPhotoModal');
const modal = bootstrap.Modal.getInstance(modalElement);
modal.hide();
// Wait for modal animation to complete
setTimeout(() => {
console.log('Reloading photos after upload...');
this.loadJobPhotos();
this.clearPhotoSelection();
this.showToast('Photo uploaded successfully', 'success');
}, 400);
} else {
this.showToast(data.message || 'Error uploading photo', 'danger');
}
})
.catch(error => {
console.error('Error:', error);
this.showToast('An error occurred while uploading', 'danger');
});
},
deletePhoto: function() {
if (!confirm('Are you sure you want to delete this photo?')) return;
const photo = this.allPhotos[this.currentPhotoIndex];
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
fetch('/Jobs/DeletePhoto?id=' + photo.id, {
method: 'POST',
headers: {
'RequestVerificationToken': token,
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
console.log('Delete response:', data);
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('viewPhotoModal')).hide();
// Wait for modal animation to complete
setTimeout(() => {
console.log('Reloading photos after delete...');
this.loadJobPhotos();
this.showToast('Photo deleted successfully', 'success');
}, 400);
} else {
this.showToast(data.message || 'Error deleting photo', 'danger');
}
})
.catch(error => {
console.error('Error:', error);
this.showToast('An error occurred while deleting', 'danger');
});
},
setupDragDrop: function() {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('photoFile');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('drag-over');
}, false);
});
dropZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
fileInput.files = files;
this.handleFileSelect({ target: fileInput });
}, false);
},
setupFileInput: function() {
document.getElementById('photoFile').addEventListener('change', (e) => this.handleFileSelect(e));
},
handleFileSelect: function(e) {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showError('Please select an image file', 'Invalid File Type');
return;
}
if (file.size > 10 * 1024 * 1024) {
showError('File size must be less than 10 MB', 'File Too Large');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
document.getElementById('previewImage').src = e.target.result;
document.getElementById('photoPreview').classList.remove('d-none');
document.getElementById('dropZone').style.display = 'none';
};
reader.readAsDataURL(file);
},
clearPhotoSelection: function() {
document.getElementById('photoFile').value = '';
document.getElementById('photoCaption').value = '';
document.getElementById('photoType').value = '1';
document.getElementById('photoPreview').classList.add('d-none');
document.getElementById('dropZone').style.display = 'block';
if (this._tagApi) this._tagApi.clear();
},
getPhotoTypeBadgeClass: function(type) {
const classes = {
0: 'info', // Before
1: 'primary', // Progress
2: 'success', // After
3: 'warning', // Quality Check
4: 'danger', // Issue
5: 'success' // Completed
};
return classes[type] || 'secondary';
},
escapeHtml: function(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
showToast: function(message, type) {
const alertClass = `alert-${type}`;
const alert = document.createElement('div');
alert.className = `alert ${alertClass} alert-dismissible fade show position-fixed top-0 end-0 m-3`;
alert.style.zIndex = '9999';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 3000);
}
};
@@ -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, '&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);
}
@@ -0,0 +1,309 @@
/**
* purchase-orders.js
* Handles dynamic line-item management for Purchase Order Create/Edit forms.
* A single searchable text input per row: type to filter inventory items or
* enter a free-text description. Selecting an inventory item stores its ID in
* a hidden field; typing something custom leaves the hidden field empty.
*/
'use strict';
let inventoryData = [];
let itemIndex = 0;
document.addEventListener('DOMContentLoaded', () => {
const dataEl = document.getElementById('inventoryItemsData');
if (dataEl) {
try { inventoryData = JSON.parse(dataEl.textContent); } catch (e) { inventoryData = []; }
}
const tbody = document.getElementById('lineItemsBody');
if (tbody) {
itemIndex = tbody.querySelectorAll('tr[data-index]').length;
}
updateTotals();
});
// ─── Add / Remove rows ────────────────────────────────────────────────────────
function addItem() {
const tbody = document.getElementById('lineItemsBody');
if (!tbody) return;
const i = itemIndex++;
const tr = document.createElement('tr');
tr.setAttribute('data-index', i);
tr.innerHTML = buildRowHtml(i, {});
tbody.appendChild(tr);
toggleEmptyMessage();
updateTotals();
tr.querySelector('.item-search-input')?.focus();
}
function removeItem(btn) {
const tr = btn.closest('tr');
if (tr) {
tr.remove();
reindexRows();
updateTotals();
toggleEmptyMessage();
}
}
function reindexRows() {
const rows = document.querySelectorAll('#lineItemsBody tr[data-index]');
rows.forEach((tr, newIndex) => {
tr.setAttribute('data-index', newIndex);
tr.querySelectorAll('[name]').forEach(el => {
el.name = el.name.replace(/Items\[\d+\]/, `Items[${newIndex}]`);
});
const searchInput = tr.querySelector('.item-search-input');
if (searchInput) {
searchInput.setAttribute('data-row-index', newIndex);
searchInput.setAttribute('oninput', `onItemSearch(this, ${newIndex})`);
}
});
itemIndex = rows.length;
}
// ─── Row HTML builder ─────────────────────────────────────────────────────────
/**
* opts: { type: 'inventory'|'custom', selectedId, description, qty, cost, notes }
*/
function buildRowHtml(i, opts = {}) {
const isInventory = opts.type === 'inventory' && opts.selectedId;
const qty = opts.qty ?? 1;
const cost = opts.cost ?? 0;
const notes = opts.notes ?? '';
const lineTotal = (qty * cost).toFixed(2);
let displayText = '';
if (isInventory) {
const inv = inventoryData.find(x => String(x.value) === String(opts.selectedId));
displayText = inv ? inv.text : (opts.description || '');
} else {
displayText = opts.description || '';
}
return `
<td style="min-width:240px">
<input type="text"
class="form-control form-control-sm item-search-input"
name="Items[${i}].Description"
value="${escHtml(displayText)}"
placeholder="Search inventory or enter description…"
autocomplete="off"
oninput="onItemSearch(this, ${i})"
onblur="hideAutocomplete()"
data-row-index="${i}" />
<input type="hidden"
name="Items[${i}].InventoryItemId"
class="inventory-id-field"
value="${isInventory ? (opts.selectedId || '') : ''}" />
</td>
<td>
<input type="number" name="Items[${i}].QuantityOrdered"
class="form-control form-control-sm"
value="${qty}" min="0.001" step="0.001" style="width:85px"
oninput="updateLineTotals()" required />
</td>
<td>
<input type="number" name="Items[${i}].UnitCost"
class="form-control form-control-sm"
value="${cost}" min="0" step="0.01" style="width:105px"
oninput="updateLineTotals()" />
</td>
<td class="item-line-total text-end fw-semibold align-middle">$${lineTotal}</td>
<td>
<input type="text" name="Items[${i}].Notes"
class="form-control form-control-sm"
value="${escHtml(notes)}" placeholder="Optional" />
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeItem(this)" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>`;
}
// ─── Autocomplete ─────────────────────────────────────────────────────────────
// Uses a single body-level dropdown so it's never clipped by table overflow.
let _activeSearchInput = null;
function getAutocompleteDropdown() {
let el = document.getElementById('po-autocomplete');
if (!el) {
el = document.createElement('ul');
el.id = 'po-autocomplete';
el.className = 'list-group shadow';
el.style.cssText = 'position:fixed;z-index:9999;max-height:220px;overflow-y:auto;'
+ 'margin:0;padding:0;border-radius:.35rem;min-width:180px;display:none;';
document.body.appendChild(el);
}
return el;
}
function onItemSearch(input, rowIndex) {
_activeSearchInput = input;
// Clear previously selected inventory item when user edits the field
const tr = input.closest('tr');
const idField = tr?.querySelector('.inventory-id-field');
if (idField) idField.value = '';
const query = input.value.trim().toLowerCase();
const dropdown = getAutocompleteDropdown();
if (!query) {
dropdown.style.display = 'none';
return;
}
const matches = inventoryData
.filter(item => item.text.toLowerCase().includes(query))
.slice(0, 12);
if (matches.length === 0) {
dropdown.style.display = 'none';
return;
}
dropdown.innerHTML = matches.map(item => `
<li class="list-group-item list-group-item-action py-1 px-2 small"
style="cursor:pointer"
data-value="${item.value}"
data-cost="${item.cost}"
data-text="${escHtml(item.text)}"
onmousedown="selectInventoryItem(this, ${rowIndex})">
${escHtml(item.text)}
</li>`).join('');
// Position below the input
const rect = input.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 2) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = rect.width + 'px';
dropdown.style.display = '';
}
function selectInventoryItem(li, rowIndex) {
const tr = document.querySelector(`#lineItemsBody tr[data-index="${rowIndex}"]`);
if (!tr) return;
const searchInput = tr.querySelector('.item-search-input');
const idField = tr.querySelector('.inventory-id-field');
const costInput = tr.querySelector(`[name="Items[${rowIndex}].UnitCost"]`);
if (searchInput) searchInput.value = li.dataset.text;
if (idField) idField.value = li.dataset.value;
if (costInput) {
const cost = parseFloat(li.dataset.cost);
if (cost > 0) costInput.value = cost.toFixed(2);
}
getAutocompleteDropdown().style.display = 'none';
_activeSearchInput = null;
updateLineTotals();
}
function hideAutocomplete() {
// Delay so mousedown on a list item fires before blur hides the dropdown
setTimeout(() => {
getAutocompleteDropdown().style.display = 'none';
}, 150);
}
// Reposition on scroll/resize so the dropdown tracks the input
window.addEventListener('scroll', () => {
if (!_activeSearchInput) return;
const rect = _activeSearchInput.getBoundingClientRect();
const dd = document.getElementById('po-autocomplete');
if (dd && dd.style.display !== 'none') {
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
}
}, true);
// ─── Totals ───────────────────────────────────────────────────────────────────
function updateLineTotals() {
const rows = document.querySelectorAll('#lineItemsBody tr[data-index]');
let subTotal = 0;
rows.forEach(tr => {
const i = tr.getAttribute('data-index');
const qty = parseFloat(tr.querySelector(`[name="Items[${i}].QuantityOrdered"]`)?.value) || 0;
const cost = parseFloat(tr.querySelector(`[name="Items[${i}].UnitCost"]`)?.value) || 0;
const lineTotal = qty * cost;
subTotal += lineTotal;
const cell = tr.querySelector('.item-line-total');
if (cell) cell.textContent = '$' + lineTotal.toFixed(2);
});
updateTotals(subTotal);
}
function updateTotals(subTotal) {
if (subTotal === undefined) {
subTotal = 0;
document.querySelectorAll('#lineItemsBody tr[data-index]').forEach(tr => {
const i = tr.getAttribute('data-index');
const qty = parseFloat(tr.querySelector(`[name="Items[${i}].QuantityOrdered"]`)?.value) || 0;
const cost = parseFloat(tr.querySelector(`[name="Items[${i}].UnitCost"]`)?.value) || 0;
subTotal += qty * cost;
});
}
const shipping = parseFloat(document.getElementById('shippingCostInput')?.value) || 0;
const grandTotal = subTotal + shipping;
setText('subTotalDisplay', '$' + subTotal.toFixed(2));
setText('grandTotalDisplay', '$' + grandTotal.toFixed(2));
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function toggleEmptyMessage() {
const tbody = document.getElementById('lineItemsBody');
const msg = document.getElementById('emptyItemsMessage');
const header = document.getElementById('lineItemsHeader');
if (!tbody || !msg) return;
const hasRows = tbody.querySelectorAll('tr[data-index]').length > 0;
msg.style.display = hasRows ? 'none' : 'block';
if (header) header.style.display = hasRows ? '' : 'none';
}
// ─── Receive form helpers ─────────────────────────────────────────────────────
function receiveAll() {
document.querySelectorAll('.receive-qty-input').forEach(input => {
input.value = parseFloat(input.dataset.remaining) || 0;
});
}
function clearAll() {
document.querySelectorAll('.receive-qty-input').forEach(input => {
input.value = 0;
});
}
// ─── Utilities ────────────────────────────────────────────────────────────────
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
@@ -0,0 +1,643 @@
/**
* QuickBooks Migration Wizard — qb-migration-wizard.js
* Embedded in Setup Wizard Step 2.
* Manages a 9-step modal that walks the user through all QB Desktop data imports.
*/
(function () {
'use strict';
// ── Step Definitions ──────────────────────────────────────────────────────
const STEPS = [
{
id: 1, key: 'chartOfAccounts', title: 'Chart of Accounts',
icon: 'bi-diagram-3', fileAccept: '.iif',
endpoint: '/Tools/ImportChartOfAccounts',
deps: [],
intro: 'Your Chart of Accounts defines the financial structure of your books. Import this first so vendor bills and other financial data can link to the correct accounts.',
instructions: [
'In QuickBooks Desktop, go to <strong>Lists → Chart of Accounts</strong>.',
'Click the <strong>Account</strong> button at the bottom of the list.',
'Choose <strong>Import/Export → Export Chart of Accounts</strong> (or use <em>File → Utilities → Export → Lists to IIF Files → Chart of Accounts</em>).',
'Save the <code>.iif</code> file and upload it here.'
]
},
{
id: 2, key: 'customers', title: 'Customers',
icon: 'bi-people', fileAccept: '.iif',
endpoint: '/Tools/ImportCustomers',
deps: [],
intro: 'Import your customer list so that historical invoices and payments can be matched to existing customer records.',
instructions: [
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
'Check <strong>Customer List</strong> and click OK.',
'Save the <code>.iif</code> file and upload it here.'
]
},
{
id: 3, key: 'vendors', title: 'Vendors',
icon: 'bi-truck', fileAccept: '.iif',
endpoint: '/Tools/ImportVendors',
deps: [],
intro: 'Import your vendor list. Vendors are required before importing inventory stock levels and vendor bills.',
instructions: [
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
'Check <strong>Vendor List</strong> and click OK.',
'Save the <code>.iif</code> file and upload it here.'
]
},
{
id: 4, key: 'catalogItems', title: 'Catalog Items',
icon: 'bi-grid', fileAccept: '.iif',
endpoint: '/Tools/ImportCatalogItems',
deps: [],
intro: 'Import service items from QuickBooks to populate your catalog with pre-priced services.',
instructions: [
'In QuickBooks Desktop, go to <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
'Check <strong>Item List</strong> and click OK.',
'Save the <code>.iif</code> file and upload it here.'
]
},
{
id: 5, key: 'inventory', title: 'Inventory',
icon: 'bi-boxes', fileAccept: '.csv',
endpoint: '/Tools/ImportQbInventoryValuation',
deps: [3],
intro: 'Import current stock levels from an Inventory Valuation Summary report. Vendors must be imported first for preferred vendor matching.',
instructions: [
'In QuickBooks Desktop, go to <strong>Reports → Inventory → Inventory Valuation Summary</strong>.',
'Click <strong>Customize Report</strong>, open the <strong>Display</strong> tab, and add the <strong>Preferred Vendor</strong> column.',
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
'Upload the CSV file here.'
]
},
{
id: 6, key: 'invoices', title: 'Invoices',
icon: 'bi-receipt', fileAccept: '.csv',
endpoint: '/Tools/ImportQbInvoices',
deps: [2, 4],
intro: 'Import historical invoices from a Customer Balance Detail report. Customers and catalog items should be imported first.',
instructions: [
'In QuickBooks Desktop, go to <strong>Reports → Customers &amp; Receivables → Customer Balance Detail</strong>.',
'Set the date range to <strong>All</strong> to cover your full history.',
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
'Upload the CSV file here.'
]
},
{
id: 7, key: 'transactions', title: 'Customer Payments',
icon: 'bi-cash-coin', fileAccept: '.csv',
endpoint: '/Tools/ImportQbTransactions',
deps: [6],
intro: 'Import payment records to update invoice balances. Invoices must be imported first.',
instructions: [
'In QuickBooks Desktop, go to <strong>Reports → Customers &amp; Receivables → Transaction List by Customer</strong>.',
'Set the date range to <strong>All</strong> to cover your full history.',
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
'Upload the CSV file here.'
]
},
{
id: 8, key: 'bills', title: 'Vendor Bills & Payments',
icon: 'bi-file-earmark-text', fileAccept: '.csv',
endpoint: '/Tools/ImportQbBillsAndPayments',
deps: [1, 3],
intro: 'Import vendor bills and payment history in a single step. The same Vendor Balance Detail file contains both — bills are imported first, then payments are matched against them.',
instructions: [
'In QuickBooks Desktop, go to <strong>Reports → Vendors &amp; Payables → Vendor Balance Detail</strong>.',
'Set the date range to <strong>All</strong> to cover your full history.',
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> and save.',
'Upload the CSV file here — bills and payments will both be processed automatically.'
]
}
];
// ── QuickBooks Online Step Definitions ────────────────────────────────────
const QBO_STEPS = [
{
id: 1, key: 'qbo_coa', title: 'Chart of Accounts',
icon: 'bi-diagram-3', fileAccept: '.xlsx,.xls',
endpoint: '/Tools/ImportQboChartOfAccounts',
deps: [],
intro: 'Your Chart of Accounts defines the financial structure of your books. Import this first so financial data can link to the correct accounts.',
instructions: [
'In QuickBooks Online, go to <strong>Accounting → Chart of Accounts</strong>.',
'Click the <strong>Run Report</strong> button at the top right.',
'Click the <strong>Export</strong> icon (spreadsheet icon) and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> file and upload it here.'
]
},
{
id: 2, key: 'qbo_customers', title: 'Customers',
icon: 'bi-people', fileAccept: '.xlsx,.xls',
endpoint: '/Tools/ImportQboCustomers',
deps: [],
intro: 'Import your customer list so invoices and payments can be matched to existing customer records.',
instructions: [
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Customer Contact List</strong>.',
'Click <strong>Customize</strong> if you want to include additional fields (address, terms, balance).',
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> file and upload it here.'
]
},
{
id: 3, key: 'qbo_vendors', title: 'Vendors',
icon: 'bi-truck', fileAccept: '.xlsx,.xls',
endpoint: '/Tools/ImportQboVendors',
deps: [],
intro: 'Import your vendor list before importing purchase orders or inventory.',
instructions: [
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Vendor Contact List</strong>.',
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> file and upload it here.'
]
},
{
id: 4, key: 'qbo_products', title: 'Products & Services',
icon: 'bi-grid', fileAccept: '.xlsx,.xls',
endpoint: '/Tools/ImportQboCatalogItems',
deps: [],
intro: 'Import your products and services. Inventory-type items will create inventory records; all others become catalog items.',
instructions: [
'In QuickBooks Online, go to <strong>Sales → Products and Services</strong>.',
'Click the <strong>More</strong> (⋮) button at the top right.',
'Choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> file and upload it here.'
]
},
{
id: 5, key: 'qbo_invoices', title: 'Invoices',
icon: 'bi-receipt', fileAccept: '.xlsx,.xls',
endpoint: '/Tools/ImportQboInvoices',
deps: [2],
intro: 'Import historical invoices. Customers should be imported first for matching.',
instructions: [
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Invoice List</strong>.',
'Set the date range to cover all of your history.',
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> file and upload it here.'
]
},
{
id: 6, key: 'qbo_transactions', title: 'Payments',
icon: 'bi-cash-stack', fileAccept: '.xlsx,.xls',
endpoint: '/Tools/ImportQboTransactions',
deps: [5],
intro: 'Import payment history to mark invoices as paid. Invoices must be imported first.',
instructions: [
'In QuickBooks Online, go to <strong>Reports</strong> and search for <strong>Transaction List by Date</strong>.',
'Set the date range to cover all of your history.',
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> file and upload it here.'
]
}
];
// ── Active step set (Desktop or Online) ───────────────────────────────────
let activeSteps = STEPS; // default to Desktop
let activeSource = 'desktop';
// Statuses: pending | complete | skipped | error | blocked
let currentStepIdx = 0; // index into activeSteps
let stepState = {}; // key -> { status, result }
// ── Init ──────────────────────────────────────────────────────────────────
function init() {
// Default all steps to pending
activeSteps.forEach(s => {
stepState[s.key] = stepState[s.key] || { status: 'pending', result: null };
});
refreshBlocked();
}
function refreshBlocked() {
activeSteps.forEach(s => {
if (s.deps.length === 0) return;
const current = stepState[s.key].status;
if (current === 'complete' || current === 'skipped') return;
const anyDepUnresolved = s.deps.some(depId => {
const depStep = activeSteps.find(x => x.id === depId);
const depStatus = depStep ? stepState[depStep.key].status : 'pending';
return depStatus === 'pending' || depStatus === 'blocked' || depStatus === 'error';
});
stepState[s.key].status = anyDepUnresolved ? 'blocked' : 'pending';
});
}
// ── Open Modal ────────────────────────────────────────────────────────────
window.openQbWizard = async function (source) {
activeSteps = (source === 'online') ? QBO_STEPS : STEPS;
activeSource = source;
stepState = {}; // reset state when switching source
init();
// Load persisted state from server
try {
const resp = await fetch('/SetupWizard/GetQbMigrationState');
const data = await resp.json();
if (data.state) {
const saved = JSON.parse(data.state);
// Only restore state if same source
if (saved && saved.source === source && saved.steps) {
Object.assign(stepState, saved.steps);
}
// Resume at first incomplete step
const firstIncomplete = activeSteps.findIndex(s => {
const st = stepState[s.key]?.status;
return st !== 'complete' && st !== 'skipped';
});
currentStepIdx = firstIncomplete >= 0 ? firstIncomplete : activeSteps.length - 1;
}
} catch (e) {
// Non-fatal — start from beginning
}
refreshBlocked();
// Update modal title to reflect source
var titleEl = document.getElementById('qbMigrationWizardLabel');
if (titleEl) {
titleEl.textContent = source === 'online'
? 'QuickBooks Online Migration Wizard'
: 'QuickBooks Desktop Migration Wizard';
}
renderWizard();
const modal = new bootstrap.Modal(document.getElementById('qbMigrationWizard'));
modal.show();
};
// ── Render ────────────────────────────────────────────────────────────────
function renderWizard() {
renderStepIndicator();
renderProgressBar();
renderStepContent();
renderFooterButtons();
}
function renderStepIndicator() {
const container = document.getElementById('qbwStepIndicator');
if (!container) return;
container.innerHTML = activeSteps.map((s, idx) => {
const st = stepState[s.key]?.status || 'pending';
const isActive = idx === currentStepIdx;
let statusClass = isActive ? 'active' : st;
let icon = '';
if (st === 'complete') icon = '<i class="bi bi-check-lg"></i>';
else if (st === 'skipped') icon = '<i class="bi bi-skip-forward-fill"></i>';
else if (st === 'error') icon = '<i class="bi bi-exclamation-triangle-fill"></i>';
else if (st === 'blocked') icon = '<i class="bi bi-lock-fill"></i>';
else icon = s.id;
return `<div class="qbw-dot ${statusClass}" onclick="qbwGoTo(${idx})" title="${s.title}">
<div class="qbw-dot-circle">${icon}</div>
<div class="qbw-dot-label">${s.title}</div>
</div>`;
}).join('');
// Update badge
const badge = document.getElementById('qbwStepBadge');
if (badge) badge.textContent = `Step ${currentStepIdx + 1} of ${activeSteps.length}`;
}
function renderProgressBar() {
const completed = Object.values(stepState).filter(s => s.status === 'complete' || s.status === 'skipped').length;
const pct = Math.round((completed / activeSteps.length) * 100);
const bar = document.getElementById('qbwProgressBar');
if (bar) bar.style.width = pct + '%';
}
function renderStepContent() {
const container = document.getElementById('qbwStepContent');
if (!container) return;
const step = activeSteps[currentStepIdx];
const st = stepState[step.key];
container.innerHTML = buildStepHtml(step, st);
}
function buildStepHtml(step, st) {
const isBlocked = st.status === 'blocked';
const blockedNames = step.deps.map(depId => {
const depStep = activeSteps.find(x => x.id === depId);
return depStep ? depStep.title : '';
}).filter(Boolean).join(', ');
let resultHtml = '';
if (st.result) {
resultHtml = buildResultHtml(st.result, st.status);
}
const instructionItems = step.instructions.map(i => `<li class="mb-1">${i}</li>`).join('');
return `
<div class="row g-4">
<div class="col-12">
<div class="d-flex align-items-start gap-3 mb-3">
<div class="rounded-3 p-3 d-flex align-items-center justify-content-center flex-shrink-0"
style="background:var(--bs-primary-bg-subtle); color:var(--bs-primary); width:60px; height:60px;">
<i class="bi ${step.icon} fs-3"></i>
</div>
<div>
<h5 class="mb-1">${step.title}</h5>
<p class="text-secondary mb-0">${step.intro}</p>
</div>
</div>
${isBlocked ? `
<div class="alert alert-warning d-flex gap-2 mb-3">
<i class="bi bi-lock-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Step locked.</strong>
This step requires the following to be completed first:
<strong>${blockedNames}</strong>.
Go back and complete those steps, then return here.
</div>
</div>` : ''}
<div class="accordion mb-3" id="qbwInstructions-${step.id}">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button ${st.status === 'complete' ? 'collapsed' : ''}" type="button"
data-bs-toggle="collapse"
data-bs-target="#qbwInstructionsBody-${step.id}"
aria-expanded="${st.status === 'complete' ? 'false' : 'true'}">
<i class="bi bi-info-circle me-2"></i>How to export from QuickBooks Desktop
</button>
</h2>
<div id="qbwInstructionsBody-${step.id}" class="accordion-collapse collapse ${st.status === 'complete' ? '' : 'show'}">
<div class="accordion-body pb-2">
<ol class="mb-0">${instructionItems}</ol>
</div>
</div>
</div>
</div>
${!isBlocked ? `
<div class="qbw-upload-zone mb-3">
<label class="form-label fw-medium mb-2" for="qbwFile-${step.id}">
<i class="bi bi-cloud-upload me-1"></i>Select file to import
<span class="text-secondary fw-normal">(${step.fileAccept})</span>
</label>
<input type="file" class="form-control" id="qbwFile-${step.id}" accept="${step.fileAccept}">
</div>
<button class="btn btn-primary" id="qbwImportBtn-${step.id}"
onclick="qbwRunImport(${currentStepIdx})"
${isBlocked ? 'disabled' : ''}>
<i class="bi bi-cloud-upload me-1"></i>Import Now
</button>
<div id="qbwImportSpinner-${step.id}" class="d-none d-inline-flex align-items-center gap-2 ms-3 text-secondary">
<span class="spinner-border spinner-border-sm"></span> Importing&hellip;
</div>` : ''}
${resultHtml ? `<div class="mt-3 qbw-result-box">${resultHtml}</div>` : ''}
<div id="qbwResultContainer-${step.id}" class="mt-3 qbw-result-box"></div>
</div>
</div>`;
}
function buildResultHtml(result, status) {
const alertClass = status === 'error' ? 'alert-danger' : status === 'complete' ? 'alert-success' : 'alert-warning';
const icon = status === 'error' ? 'bi-x-circle-fill' : status === 'complete' ? 'bi-check-circle-fill' : 'bi-exclamation-triangle-fill';
let html = `<div class="alert ${alertClass} mb-0">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi ${icon}"></i>
<strong>${status === 'complete' ? 'Import successful' : status === 'error' ? 'Import failed' : 'Import completed with issues'}</strong>
</div>
<div class="row g-2 text-center mb-2">
${(result.billsImported != null || result.paymentsImported != null) ? `
<div class="col-auto"><span class="badge bg-success"><i class="bi bi-file-earmark-text me-1"></i>${result.billsImported ?? 0} Bills Imported</span></div>
<div class="col-auto"><span class="badge bg-info"><i class="bi bi-credit-card me-1"></i>${result.paymentsImported ?? 0} Payments Applied</span></div>
` : `
<div class="col-auto"><span class="badge bg-secondary">${result.totalRecords ?? 0} Total</span></div>
<div class="col-auto"><span class="badge bg-success">${result.importedCount ?? 0} Imported</span></div>
<div class="col-auto"><span class="badge bg-info">${result.updatedCount ?? 0} Updated</span></div>
`}
${(result.skippedCount ?? 0) > 0 ? `<div class="col-auto"><span class="badge bg-secondary">${result.skippedCount} Skipped</span></div>` : ''}
${(result.alreadyRecordedCount ?? 0) > 0 ? `<div class="col-auto"><span class="badge bg-secondary">${result.alreadyRecordedCount} Already Recorded</span></div>` : ''}
<div class="col-auto"><span class="badge bg-danger">${result.errorCount ?? 0} Errors</span></div>
</div>`;
if (result.errors && result.errors.length > 0) {
html += `<details><summary class="small fw-medium">${result.errors.length} error message(s) — click to expand</summary>
<ul class="small mt-1 mb-0">
${result.errors.slice(0, 20).map(e => `<li>${escHtml(e)}</li>`).join('')}
${result.errors.length > 20 ? `<li>… and ${result.errors.length - 20} more</li>` : ''}
</ul></details>`;
}
if (result.warnings && result.warnings.length > 0) {
html += `<details><summary class="small fw-medium">${result.warnings.length} warning(s) — click to expand</summary>
<ul class="small mt-1 mb-0">
${result.warnings.slice(0, 10).map(w => `<li>${escHtml(w)}</li>`).join('')}
</ul></details>`;
}
html += '</div>';
return html;
}
function renderFooterButtons() {
const btnBack = document.getElementById('qbwBtnBack');
const btnSkip = document.getElementById('qbwBtnSkip');
const btnNext = document.getElementById('qbwBtnNext');
const btnFinish = document.getElementById('qbwBtnFinish');
if (!btnBack) return;
const isLast = currentStepIdx === activeSteps.length - 1;
const st = stepState[activeSteps[currentStepIdx].key];
btnBack.classList.toggle('d-none', currentStepIdx === 0);
btnSkip.classList.toggle('d-none', isLast || st.status === 'complete');
btnNext.classList.toggle('d-none', isLast);
btnFinish.classList.toggle('d-none', !isLast);
// Disable Next if current step is still pending (not complete/skipped)
const canAdvance = st.status === 'complete' || st.status === 'skipped' || st.status === 'blocked';
btnNext.disabled = !canAdvance;
if (isLast) {
btnFinish.disabled = false;
}
}
// ── Navigation ────────────────────────────────────────────────────────────
window.qbwGoTo = function (idx) {
if (idx < 0 || idx >= activeSteps.length) return;
currentStepIdx = idx;
renderWizard();
};
window.qbwBack = function () {
if (currentStepIdx > 0) {
currentStepIdx--;
renderWizard();
}
};
window.qbwNext = function () {
if (currentStepIdx < activeSteps.length - 1) {
currentStepIdx++;
renderWizard();
}
};
window.qbwSkip = function () {
const step = activeSteps[currentStepIdx];
stepState[step.key].status = 'skipped';
refreshBlocked();
saveState();
window.qbwNext();
};
window.qbwFinish = function () {
saveState();
// Show completion message on Step 2 page if present
const completionMsg = document.getElementById('qbWizardCompletionAlert');
if (completionMsg) completionMsg.classList.remove('d-none');
};
// ── Import ────────────────────────────────────────────────────────────────
window.qbwRunImport = async function (stepIdx) {
const step = activeSteps[stepIdx];
const fileInput = document.getElementById(`qbwFile-${step.id}`);
const importBtn = document.getElementById(`qbwImportBtn-${step.id}`);
const spinner = document.getElementById(`qbwImportSpinner-${step.id}`);
const resultContainer = document.getElementById(`qbwResultContainer-${step.id}`);
if (!fileInput || !fileInput.files.length) {
showToast('Please select a file to import.', 'warning');
return;
}
importBtn.disabled = true;
spinner?.classList.remove('d-none');
resultContainer.innerHTML = '';
try {
const formData = new FormData();
formData.append('file', fileInput.files[0]);
// Include anti-forgery token if present on page
const aft = document.querySelector('input[name="__RequestVerificationToken"]');
if (aft) formData.append('__RequestVerificationToken', aft.value);
const resp = await fetch(step.endpoint, {
method: 'POST',
body: formData
});
if (!resp.ok) {
throw new Error(`Server returned ${resp.status}`);
}
const data = await resp.json();
const result = data.result || data;
// Errors array contains ImportErrorDto objects — split into hard errors vs warnings by severity
const allErrors = result.errors ?? result.Errors ?? [];
const toStr = e => (typeof e === 'string') ? e : (e.displayMessage ?? e.errorMessage ?? JSON.stringify(e));
const hardErrors = allErrors.filter(e => !e.severity || e.severity === 'Error');
const warnings = allErrors.filter(e => e.severity === 'Warning' || e.severity === 'Skipped');
const normalised = {
totalRecords: result.totalRecords ?? result.TotalRecords ?? 0,
importedCount: result.importedCount ?? result.ImportedCount ?? 0,
updatedCount: result.updatedCount ?? result.UpdatedCount ?? 0,
skippedCount: result.skippedCount ?? result.SkippedCount ?? 0,
alreadyRecordedCount: result.alreadyRecordedCount ?? result.AlreadyRecordedCount ?? 0,
billsImported: result.billsImported ?? result.BillsImported ?? null,
paymentsImported: result.paymentsImported ?? result.PaymentsImported ?? null,
errorCount: hardErrors.length,
errors: hardErrors.map(toStr),
warnings: warnings.map(toStr)
};
// Hard failure = server returned success:false AND nothing was imported at all
const hasHardError = result.success === false
&& normalised.importedCount === 0
&& normalised.updatedCount === 0;
const tooManyErrors = false; // covered by hasHardError logic above
if (hasHardError || tooManyErrors) {
stepState[step.key] = { status: 'error', result: normalised };
// Prompt: continue anyway?
const proceed = confirm(
`Import completed with ${normalised.errorCount} error(s) out of ${normalised.totalRecords} records.\n\n` +
'Do you want to mark this step as done and continue anyway?\n' +
'Click OK to continue, or Cancel to stay on this step and review the errors.'
);
if (proceed) {
stepState[step.key].status = 'complete';
}
} else if (normalised.errorCount > 0) {
// Partial errors — still counts as complete but show warning
stepState[step.key] = { status: 'complete', result: normalised };
showToast(`${step.title} imported with ${normalised.errorCount} errors. Review the details below.`, 'warning');
} else {
stepState[step.key] = { status: 'complete', result: normalised };
showToast(`${step.title} imported successfully!`, 'success');
}
refreshBlocked();
saveState();
renderWizard();
} catch (err) {
const errResult = { totalRecords: 0, importedCount: 0, updatedCount: 0, skippedCount: 0, errorCount: 1, errors: [err.message], warnings: [] };
stepState[step.key] = { status: 'error', result: errResult };
refreshBlocked();
saveState();
renderWizard();
showToast('Import failed: ' + err.message, 'danger');
} finally {
// Re-enable button (in case re-render didn't)
const btn2 = document.getElementById(`qbwImportBtn-${step.id}`);
if (btn2) btn2.disabled = false;
const sp2 = document.getElementById(`qbwImportSpinner-${step.id}`);
if (sp2) sp2.classList.add('d-none');
}
};
// ── Persistence ───────────────────────────────────────────────────────────
async function saveState() {
try {
const payload = JSON.stringify({ source: activeSource, steps: stepState });
await fetch('/SetupWizard/SaveQbMigrationState', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: payload })
});
} catch (e) {
// Non-fatal
}
}
// ── Toast Helper ──────────────────────────────────────────────────────────
function showToast(message, type) {
// Use existing site toast infrastructure if available
if (typeof window.showNotification === 'function') {
window.showNotification(message, type);
return;
}
// Fallback: simple Bootstrap toast
const container = document.getElementById('toastContainer') || createToastContainer();
const id = 'qbwToast-' + Date.now();
const bgClass = type === 'success' ? 'bg-success' : type === 'warning' ? 'bg-warning' : type === 'danger' ? 'bg-danger' : 'bg-primary';
const textClass = type === 'warning' ? 'text-dark' : 'text-white';
const html = `<div id="${id}" class="toast align-items-center ${bgClass} ${textClass} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${escHtml(message)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`;
container.insertAdjacentHTML('beforeend', html);
const el = document.getElementById(id);
if (el) new bootstrap.Toast(el, { delay: 5000 }).show();
}
function createToastContainer() {
const div = document.createElement('div');
div.id = 'toastContainer';
div.className = 'toast-container position-fixed bottom-0 end-0 p-3';
div.style.zIndex = '9999';
document.body.appendChild(div);
return div;
}
function escHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})();
@@ -0,0 +1,218 @@
/**
* quick-add.js — Generic inline-form quick-add modal.
*
* Usage: add these attributes to any <select>:
* data-quick-add-url="/Controller/Create" — GET loads the inline partial; POST saves
* data-quick-add-title="Add New Vendor" — optional modal title override
*
* Add a sentinel option at the top:
* <option value="__new__">+ Add New Vendor…</option>
*
* When the user picks "__new__", the modal opens, loads the Create partial (GET ?inline=true),
* intercepts the form submit (POST ?inline=true → JSON {success, id, name}), adds the new option
* to the originating select, selects it, and closes the modal.
*/
(function () {
'use strict';
const modalEl = document.getElementById('quickAddModal');
const modalBody = document.getElementById('quickAddModalBody');
const modalErrors = document.getElementById('quickAddModalErrors');
const modalErrorList = document.getElementById('quickAddModalErrorList');
const modalTitle = document.getElementById('quickAddModalLabel');
if (!modalEl) return;
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
let _originSelect = null; // the <select> that triggered the modal
let _submitBtn = null; // the submit button inside the loaded form
let _saving = false;
// ── Wire every matching select ────────────────────────────────────────────
function wireSelect(sel) {
sel.addEventListener('change', function () {
if (this.value !== '__new__') return;
// Reset to blank so the select isn't stuck on __new__ if user cancels
this.value = '';
openQuickAdd(this);
});
}
document.querySelectorAll('select[data-quick-add-url]').forEach(wireSelect);
// Support selects injected after DOMContentLoaded (e.g. in modals)
window.quickAddWire = wireSelect;
// ── Open ─────────────────────────────────────────────────────────────────
function openQuickAdd(selectEl) {
_originSelect = selectEl;
_saving = false;
const url = selectEl.dataset.quickAddUrl;
const title = selectEl.dataset.quickAddTitle || 'Add New';
modalTitle.textContent = title;
modalBody.innerHTML = `
<div class="d-flex align-items-center justify-content-center py-5">
<div class="spinner-border text-primary me-3" role="status"></div>
<span class="text-muted">Loading\u2026</span>
</div>`;
modalErrors.classList.add('d-none');
modalErrorList.innerHTML = '';
bsModal.show();
const inlineUrl = url.includes('?') ? url + '&inline=true' : url + '?inline=true';
fetch(inlineUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(r => r.text())
.then(html => {
modalBody.innerHTML = html;
// Execute any <script> tags in the injected HTML — innerHTML assignment doesn't run them
modalBody.querySelectorAll('script').forEach(old => {
const s = document.createElement('script');
Array.from(old.attributes).forEach(a => s.setAttribute(a.name, a.value));
s.textContent = old.textContent;
old.replaceWith(s);
});
// Re-initialise Bootstrap popovers and validation inside the loaded fragment
modalBody.querySelectorAll('[data-bs-toggle="popover"]').forEach(el => {
new bootstrap.Popover(el, { html: true, trigger: 'focus' });
});
if (window.jQuery && $.validator) {
const form = modalBody.querySelector('form');
if (form) $.validator.unobtrusive.parse(form);
}
// Hide the Back/Cancel navigation inside the partial — not needed in a modal
modalBody.querySelectorAll('.d-flex.justify-content-end a.btn-outline-secondary').forEach(a => {
if (a.textContent.trim().startsWith('Back') || a.textContent.trim() === 'Cancel') {
a.style.display = 'none';
}
});
wireFormSubmit();
})
.catch(() => {
modalBody.innerHTML = '<div class="alert alert-danger alert-permanent m-3">Failed to load form. Please try again.</div>';
});
}
// ── Intercept the form inside the loaded partial ──────────────────────────
function wireFormSubmit() {
const form = modalBody.querySelector('form');
if (!form) return;
// Replace the form's action to include ?inline=true
const action = form.getAttribute('action') || '';
if (!action.includes('inline=true')) {
const sep = action.includes('?') ? '&' : '?';
form.setAttribute('action', action + sep + 'inline=true');
}
// Add a footer Save button to the modal (keeps modal-footer pattern consistent)
_submitBtn = document.createElement('button');
_submitBtn.type = 'button';
_submitBtn.className = 'btn btn-primary px-4';
_submitBtn.innerHTML = '<i class="bi bi-check-circle me-2"></i>Save';
_submitBtn.addEventListener('click', () => form.requestSubmit());
// Also hide the form's own submit button to avoid duplication
form.querySelectorAll('[type="submit"]').forEach(b => b.style.display = 'none');
// Inject footer
const footer = document.createElement('div');
footer.className = 'modal-footer border-top';
footer.innerHTML = '<button type="button" class="btn btn-outline-secondary px-4" data-bs-dismiss="modal">Cancel</button>';
footer.appendChild(_submitBtn);
modalEl.querySelector('.modal-content').appendChild(footer);
form.addEventListener('submit', handleSubmit);
}
// ── Submit handler ────────────────────────────────────────────────────────
async function handleSubmit(e) {
e.preventDefault();
if (_saving) return;
const form = e.target;
// jQuery unobtrusive validation
if (window.jQuery && $(form).valid && !$(form).valid()) return;
_saving = true;
if (_submitBtn) {
_submitBtn.disabled = true;
_submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving\u2026';
}
modalErrors.classList.add('d-none');
try {
const data = new FormData(form);
const resp = await fetch(form.action, { method: 'POST', body: data });
const json = await resp.json();
if (json.success) {
addOptionAndSelect(json.id, json.name);
// Remove the injected footer before hiding so it doesn't stack on re-open
const injectedFooter = modalEl.querySelector('.modal-footer.border-top');
if (injectedFooter) injectedFooter.remove();
bsModal.hide();
} else {
const msgs = (json.errors || ['An unknown error occurred.']).join('<br>');
modalErrorList.innerHTML = msgs;
modalErrors.classList.remove('d-none');
}
} catch {
modalErrorList.innerHTML = 'A network error occurred. Please try again.';
modalErrors.classList.remove('d-none');
} finally {
_saving = false;
if (_submitBtn) {
_submitBtn.disabled = false;
_submitBtn.innerHTML = '<i class="bi bi-check-circle me-2"></i>Save';
}
}
}
// ── Add the new option to the originating select and select it ────────────
function addOptionAndSelect(id, name) {
if (!_originSelect) return;
// Remove sentinel option temporarily so we can check for duplicates
const existing = _originSelect.querySelector(`option[value="${id}"]`);
if (!existing) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = name;
// Insert in alphabetical order before the sentinel (value="__new__")
const sentinel = _originSelect.querySelector('option[value="__new__"]');
const options = Array.from(_originSelect.options).filter(o => o.value && o.value !== '__new__');
const after = options.find(o => o.textContent.toLowerCase() > name.toLowerCase());
if (after) {
_originSelect.insertBefore(opt, after);
} else if (sentinel) {
_originSelect.insertBefore(opt, sentinel);
} else {
_originSelect.appendChild(opt);
}
}
_originSelect.value = id;
_originSelect.dispatchEvent(new Event('change'));
}
// ── Clean up injected footer on modal close ───────────────────────────────
modalEl.addEventListener('hidden.bs.modal', function () {
const injectedFooter = modalEl.querySelector('.modal-footer.border-top');
if (injectedFooter) injectedFooter.remove();
_saving = false;
_submitBtn = null;
});
})();
@@ -0,0 +1,15 @@
// Suppress the quoting calibration nudge banner for the rest of the browser session
// once the user dismisses it, so it doesn't reappear on every page visit.
(function () {
var nudge = document.getElementById('quotingCalibrationNudge');
if (!nudge) return;
if (sessionStorage.getItem('quotingCalibrationDismissed')) {
nudge.remove();
return;
}
nudge.addEventListener('close.bs.alert', function () {
sessionStorage.setItem('quotingCalibrationDismissed', '1');
});
})();
@@ -0,0 +1,420 @@
// Randomizer Wheel Easter Egg
// A fun decision-making tool hidden in the Tools page
let wheel = {
canvas: null,
ctx: null,
spinning: false,
rotation: 0,
targetRotation: 0,
spinSpeed: 0,
spinStartTime: 0,
options: [],
colors: [],
currentPreset: 'decisions'
};
// Preset configurations
const presets = {
decisions: {
name: 'Decisions',
options: ['Yes!', 'No Way', 'Maybe', 'Ask Again', 'Definitely', 'Not Now', 'Go For It!', 'Wait'],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']
},
lunch: {
name: 'Lunch Ideas',
options: ['Pizza 🍕', 'Burgers 🍔', 'Sushi 🍣', 'Tacos 🌮', 'Salad 🥗', 'Pasta 🍝', 'Chinese 🥡', 'Sandwiches 🥪'],
colors: ['#E74C3C', '#F39C12', '#3498DB', '#2ECC71', '#9B59B6', '#1ABC9C', '#E67E22', '#34495E']
},
tasks: {
name: 'Task Priority',
options: ['Do It Now!', 'Schedule It', 'Delegate', 'Skip It', 'High Priority', 'Low Priority', 'Break Time!', 'Focus Time'],
colors: ['#FF4757', '#FFA502', '#2ED573', '#1E90FF', '#5F27CD', '#00D2D3', '#FF6348', '#C23616']
},
colors: {
name: 'Powder Colors',
options: ['Gloss Black', 'Candy Red', 'Chrome Silver', 'Matte White', 'Metallic Blue', 'Neon Green', 'Rose Gold', 'Deep Purple'],
colors: ['#000000', '#DC143C', '#C0C0C0', '#F5F5F5', '#4169E1', '#39FF14', '#B76E79', '#663399']
}
};
// Initialize the wheel when page loads
document.addEventListener('DOMContentLoaded', function() {
wheel.canvas = document.getElementById('wheelCanvas');
if (wheel.canvas) {
wheel.ctx = wheel.canvas.getContext('2d');
setWheelPreset('decisions');
// Add click event to Tools header
const header = document.getElementById('toolsHeader');
if (header) {
header.addEventListener('click', function() {
const modal = new bootstrap.Modal(document.getElementById('randomizerModal'));
modal.show();
// Redraw wheel when modal opens (in case it was resized)
setTimeout(() => drawWheel(), 100);
});
}
}
});
// Set wheel to a preset configuration
function setWheelPreset(presetName) {
const preset = presets[presetName];
if (!preset) return;
wheel.options = [...preset.options];
wheel.colors = [...preset.colors];
wheel.currentPreset = presetName;
// Update button states
const buttons = document.querySelectorAll('.btn-group .btn');
buttons.forEach((btn, index) => {
btn.classList.remove('active');
// Set active based on preset name
const btnText = btn.textContent.toLowerCase();
if (btnText.includes(presetName)) {
btn.classList.add('active');
}
});
// Clear result
const resultDiv = document.getElementById('result');
if (resultDiv) {
resultDiv.innerHTML = '';
}
// Redraw wheel
drawWheel();
}
// Load shop workers from the server
async function loadShopWorkers() {
try {
// Show loading state
const resultDiv = document.getElementById('result');
if (resultDiv) {
resultDiv.innerHTML = '<div class="spinner-border spinner-border-sm text-primary" role="status"><span class="visually-hidden">Loading workers...</span></div>';
}
const response = await fetch('/Tools/GetShopWorkers');
const data = await response.json();
if (data.success && data.workers && data.workers.length > 0) {
wheel.options = data.workers;
wheel.colors = generateRainbowColors(data.workers.length);
wheel.currentPreset = 'workers';
// Update button states
const buttons = document.querySelectorAll('.btn-group .btn');
buttons.forEach(btn => {
btn.classList.remove('active');
if (btn.textContent.includes('Shop Workers')) {
btn.classList.add('active');
}
});
// Clear result and redraw
if (resultDiv) {
resultDiv.innerHTML = `<small class="text-success"><i class="bi bi-check-circle me-1"></i>Loaded ${data.workers.length} active shop workers</small>`;
}
drawWheel();
} else {
if (resultDiv) {
resultDiv.innerHTML = '<small class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>No active shop workers found</small>';
}
}
} catch (error) {
console.error('Error loading shop workers:', error);
const resultDiv = document.getElementById('result');
if (resultDiv) {
resultDiv.innerHTML = '<small class="text-danger"><i class="bi bi-x-circle me-1"></i>Error loading shop workers</small>';
}
}
}
// Set custom options from textarea
function setCustomOptions() {
const input = document.getElementById('customOptionsInput');
const options = input.value
.split('\n')
.map(opt => opt.trim())
.filter(opt => opt.length > 0);
if (options.length < 2) {
showWarning('Please enter at least 2 options!', 'Invalid Input');
return;
}
wheel.options = options;
// Generate rainbow colors for custom options
wheel.colors = generateRainbowColors(options.length);
// Collapse the custom options panel
const collapse = bootstrap.Collapse.getInstance(document.getElementById('customOptions'));
if (collapse) collapse.hide();
// Clear result and redraw
document.getElementById('result').innerHTML = '';
drawWheel();
}
// Generate rainbow colors
function generateRainbowColors(count) {
const colors = [];
for (let i = 0; i < count; i++) {
const hue = (i * 360) / count;
colors.push(`hsl(${hue}, 70%, 60%)`);
}
return colors;
}
// Draw the wheel
function drawWheel() {
if (!wheel.ctx) return;
const canvas = wheel.canvas;
const ctx = wheel.ctx;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const radius = Math.min(centerX, centerY) - 10;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Save context
ctx.save();
// Rotate canvas
ctx.translate(centerX, centerY);
ctx.rotate(wheel.rotation);
ctx.translate(-centerX, -centerY);
const numOptions = wheel.options.length;
const anglePerOption = (2 * Math.PI) / numOptions;
// Draw each segment
for (let i = 0; i < numOptions; i++) {
const startAngle = i * anglePerOption - Math.PI / 2;
const endAngle = (i + 1) * anglePerOption - Math.PI / 2;
// Draw segment
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = wheel.colors[i % wheel.colors.length];
ctx.fill();
// Draw border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3;
ctx.stroke();
// Draw text
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(startAngle + anglePerOption / 2);
ctx.textAlign = 'right';
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px Arial';
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.shadowBlur = 3;
ctx.fillText(wheel.options[i], radius - 20, 8);
ctx.restore();
}
// Draw center circle
ctx.beginPath();
ctx.arc(centerX, centerY, 30, 0, 2 * Math.PI);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.strokeStyle = '#333333';
ctx.lineWidth = 3;
ctx.stroke();
// Draw center star
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('★', centerX, centerY);
// Restore context
ctx.restore();
}
// Spin the wheel
function spinWheel() {
if (wheel.spinning) return;
wheel.spinning = true;
wheel.spinStartTime = Date.now();
document.getElementById('spinBtn').disabled = true;
document.getElementById('result').innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Spinning...</span></div>';
// Random spin: 5-8 full rotations + random position
const fullRotations = 5 + Math.random() * 3;
const randomAngle = Math.random() * 2 * Math.PI;
wheel.targetRotation = wheel.rotation + (fullRotations * 2 * Math.PI) + randomAngle;
wheel.spinSpeed = 0.3;
animateSpin();
}
// Animate the spin
function animateSpin() {
if (!wheel.spinning) return;
// Safety timeout: stop after 10 seconds
const elapsed = Date.now() - wheel.spinStartTime;
if (elapsed > 10000) {
console.warn('Spin animation timed out, forcing stop');
wheel.rotation = wheel.targetRotation;
finishSpin();
return;
}
// Ease out
const distance = wheel.targetRotation - wheel.rotation;
// Stop if we're very close OR if the speed is too slow to make progress
if (Math.abs(distance) < 0.01 || wheel.spinSpeed < 0.001) {
wheel.rotation = wheel.targetRotation;
finishSpin();
return;
}
wheel.rotation += distance * wheel.spinSpeed;
wheel.spinSpeed *= 0.97; // Deceleration
drawWheel();
requestAnimationFrame(animateSpin);
}
// Finish the spin and show result
function finishSpin() {
// Normalize rotation to 0-2π for next spin
wheel.rotation = wheel.rotation % (2 * Math.PI);
if (wheel.rotation < 0) wheel.rotation += 2 * Math.PI;
wheel.spinning = false;
document.getElementById('spinBtn').disabled = false;
showResult();
}
// Show the result
function showResult() {
const numOptions = wheel.options.length;
const anglePerOption = (2 * Math.PI) / numOptions;
// Normalize rotation to 0-2π
let normalizedRotation = wheel.rotation % (2 * Math.PI);
if (normalizedRotation < 0) normalizedRotation += 2 * Math.PI;
// Calculate which segment is at the pointer (top of wheel)
// Canvas rotation is counterclockwise, so we need to reverse the calculation
const rawIndex = (2 * Math.PI - normalizedRotation) / anglePerOption;
const selectedIndex = Math.floor(rawIndex) % numOptions;
const winner = wheel.options[selectedIndex];
const winnerColor = wheel.colors[selectedIndex % wheel.colors.length];
// Display result with animation
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = `
<div class="alert alert-success border-3" style="border-color: ${winnerColor} !important; animation: fadeInScale 0.5s ease;">
<h4 class="mb-2">
<i class="bi bi-trophy-fill text-warning me-2"></i>Winner!
</h4>
<h3 class="mb-0" style="color: ${winnerColor}; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.1);">
${winner}
</h3>
</div>
`;
// Add confetti effect
createConfetti();
}
// Create confetti effect
function createConfetti() {
const duration = 2000;
const animationEnd = Date.now() + duration;
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F'];
(function frame() {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) return;
const particleCount = 2;
for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div');
particle.style.position = 'fixed';
particle.style.width = '10px';
particle.style.height = '10px';
particle.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
particle.style.borderRadius = '50%';
particle.style.pointerEvents = 'none';
particle.style.zIndex = '9999';
const startX = Math.random() * window.innerWidth;
const startY = -20;
particle.style.left = startX + 'px';
particle.style.top = startY + 'px';
document.body.appendChild(particle);
const angle = Math.random() * Math.PI * 2;
const velocity = 2 + Math.random() * 2;
const vx = Math.cos(angle) * velocity;
const vy = Math.sin(angle) * velocity + 3;
let x = startX;
let y = startY;
let opacity = 1;
const animate = () => {
y += vy;
x += vx;
opacity -= 0.02;
particle.style.top = y + 'px';
particle.style.left = x + 'px';
particle.style.opacity = opacity;
if (opacity > 0 && y < window.innerHeight) {
requestAnimationFrame(animate);
} else {
particle.remove();
}
};
animate();
}
requestAnimationFrame(frame);
})();
}
// Add CSS animation for result
const style = document.createElement('style');
style.textContent = `
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
`;
document.head.appendChild(style);
@@ -0,0 +1,64 @@
document.addEventListener('DOMContentLoaded', function () {
function getCsrfToken() {
var el = document.querySelector('input[name="__RequestVerificationToken"]');
return el ? el.value : '';
}
document.body.addEventListener('click', function (e) {
var target = e.target;
var btn = target.closest ? target.closest('.btn-toggle-panel') : null;
if (!btn) return;
e.stopPropagation();
var itemId = btn.getAttribute('data-item-id');
var hasPanel = btn.getAttribute('data-has-panel');
var row = btn.closest ? btn.closest('tr') : null;
var csrf = getCsrfToken();
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
fetch('/Inventory/ToggleSamplePanel', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': csrf
},
body: 'id=' + itemId + '&hasPanel=' + hasPanel + '&__RequestVerificationToken=' + encodeURIComponent(csrf)
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
if (row) {
row.style.transition = 'opacity .25s';
row.style.opacity = '0';
setTimeout(function () { location.reload(); }, 300);
} else {
location.reload();
}
} else {
btn.disabled = false;
alert(data.message || 'An error occurred.');
}
})
.catch(function () { btn.disabled = false; });
});
var printBtn = document.getElementById('btnPrintList');
if (printBtn) {
printBtn.addEventListener('click', function () {
var printArea = document.getElementById('printArea');
var content = printArea ? printArea.innerHTML : '';
var win = window.open('', '_blank', 'width=900,height=700');
win.document.open();
win.document.write('<!DOCTYPE html><html><head><title>Need to Order</title>');
win.document.write('<style>body{font-family:sans-serif;padding:20px}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccc;padding:6px 10px;text-align:left}th{background:#f0f0f0}</style>');
win.document.write('</head><body>');
win.document.write(content);
win.document.write('</body></html>');
win.document.close();
win.focus();
setTimeout(function () { win.print(); }, 500);
});
}
});
+15
View File
@@ -0,0 +1,15 @@
// Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.
/**
* Change page size for pagination
* @param {number} newSize - The new page size
*/
function changePageSize(newSize) {
const url = new URL(window.location);
url.searchParams.set('pageSize', newSize);
url.searchParams.set('pageNumber', '1'); // Reset to page 1 when changing page size
window.location.href = url.toString();
}
@@ -0,0 +1,116 @@
// Tag chip input widget
// Usage: call initTagInput('hiddenFieldId', 'containerId') after DOM ready
// The hidden field stores comma-separated tags.
// The container receives: a normal form-control input + a chips area below it.
// Optional: pass { suggestions: ['tag1', 'tag2'] } as third argument to show clickable suggestion chips.
// Returns: { addTag, clear } for external control.
function initTagInput(hiddenId, containerId, options) {
const hidden = document.getElementById(hiddenId);
const container = document.getElementById(containerId);
if (!hidden || !container) return { addTag: function() {}, clear: function() {} };
// Clear any previous render
container.innerHTML = '';
// Normal fixed-height text input
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control';
input.placeholder = 'Add tag\u2026';
input.autocomplete = 'off';
// Chips live below the input — does not affect input height
const chipsArea = document.createElement('div');
chipsArea.className = 'tag-chips-area';
container.appendChild(input);
container.appendChild(chipsArea);
let tags = hidden.value
? hidden.value.split(',').map(t => t.trim()).filter(Boolean)
: [];
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderTags() {
chipsArea.innerHTML = '';
tags.forEach((tag, i) => {
const chip = document.createElement('span');
chip.className = 'badge rounded-pill bg-info text-dark tag-chip';
chip.innerHTML =
`${escapeHtml(tag)}` +
`<span class="tag-remove ms-1" data-index="${i}" aria-label="Remove" role="button">&times;</span>`;
chipsArea.appendChild(chip);
});
hidden.value = tags.join(',');
}
function addTag(val) {
const trimmed = val.trim().replace(/,/g, '').slice(0, 50);
if (trimmed && !tags.includes(trimmed)) {
tags.push(trimmed);
renderTags();
}
}
input.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(input.value);
input.value = '';
} else if (e.key === 'Backspace' && input.value === '' && tags.length > 0) {
tags.pop();
renderTags();
}
});
input.addEventListener('blur', () => {
if (input.value.trim()) {
addTag(input.value);
input.value = '';
}
});
chipsArea.addEventListener('click', e => {
if (e.target.classList.contains('tag-remove')) {
const idx = parseInt(e.target.dataset.index, 10);
tags.splice(idx, 1);
renderTags();
}
});
// Render suggestion chips if provided
if (options && options.suggestions && options.suggestions.length > 0) {
const suggestionsRow = document.createElement('div');
suggestionsRow.className = 'd-flex flex-wrap align-items-center gap-1 mt-2';
suggestionsRow.innerHTML = '<small class="text-muted">Suggested:</small>';
options.suggestions.forEach(s => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-secondary btn-sm py-0 px-2';
btn.style.fontSize = '0.78rem';
btn.textContent = s;
btn.addEventListener('click', () => addTag(s));
suggestionsRow.appendChild(btn);
});
container.appendChild(suggestionsRow);
}
renderTags();
return {
addTag: addTag,
clear: function() {
tags = [];
input.value = '';
renderTags();
}
};
}
@@ -0,0 +1,74 @@
(function () {
'use strict';
function syncUI(surface) {
document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) {
btn.setAttribute('aria-pressed', surface === 'ink' ? 'true' : 'false');
var icon = btn.querySelector('i');
if (icon) icon.className = surface === 'ink' ? 'bi bi-sun' : 'bi bi-moon';
});
}
function persistServerSide(surface) {
var body = new URLSearchParams({ surface: surface });
fetch('/Theme/Set', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(function (r) {
console.log('[theme] POST /Theme/Set → ' + r.status + ' (surface=' + surface + ')');
})
.catch(function (err) {
console.warn('[theme] POST /Theme/Set failed', err);
});
}
function apply(surface) {
surface = (surface === 'ink') ? 'ink' : 'paper';
console.log('[theme] apply(' + surface + ')');
document.documentElement.setAttribute('data-surface', surface);
document.documentElement.setAttribute('data-bs-theme', surface === 'ink' ? 'dark' : 'light');
syncUI(surface);
persistServerSide(surface);
}
// Expose globally so Profile page radio and other pages can call it
window.pclApplyTheme = apply;
function bindToggles() {
document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) {
// Remove any previously-bound handler to avoid doubles on re-bind
btn.removeEventListener('click', btn._pclToggleHandler);
btn._pclToggleHandler = function (e) {
e.preventDefault();
e.stopPropagation();
var current = document.documentElement.getAttribute('data-surface') || 'paper';
console.log('[theme] toggle clicked, current=' + current);
apply(current === 'ink' ? 'paper' : 'ink');
};
btn.addEventListener('click', btn._pclToggleHandler);
});
}
// Follow OS preference only when no cookie is set yet
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
var hasCookie = document.cookie.indexOf('pcl_surface=') !== -1;
if (!hasCookie) apply(e.matches ? 'ink' : 'paper');
});
}
function onReady() {
var surface = document.documentElement.getAttribute('data-surface') || 'paper';
console.log('[theme] onReady, data-surface=' + surface);
syncUI(surface);
bindToggles();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onReady);
} else {
onReady();
}
})();
@@ -0,0 +1,174 @@
/**
* Toast Notification System
* Provides consistent, user-friendly notifications across the application
* Uses Toastr library with Bootstrap 5 theming
*/
// Configure Toastr global options
toastr.options = {
"closeButton": true,
"debug": false,
"newestOnTop": true,
"progressBar": true,
"positionClass": "toast-top-right",
"preventDuplicates": true,
"onclick": null,
"showDuration": "300",
"hideDuration": "1000",
"timeOut": "5000",
"extendedTimeOut": "1000",
"showEasing": "swing",
"hideEasing": "linear",
"showMethod": "fadeIn",
"hideMethod": "fadeOut"
};
/**
* Show success toast notification
* @param {string} message - The success message to display
* @param {string} title - Optional title for the toast
*/
function showSuccess(message, title = 'Success') {
toastr.success(message, title);
}
/**
* Show error toast notification
* @param {string} message - The error message to display
* @param {string} title - Optional title for the toast
*/
function showError(message, title = 'Error') {
toastr.error(message, title);
}
/**
* Show warning toast notification
* @param {string} message - The warning message to display
* @param {string} title - Optional title for the toast
*/
function showWarning(message, title = 'Warning') {
toastr.warning(message, title);
}
/**
* Show info toast notification
* @param {string} message - The info message to display
* @param {string} title - Optional title for the toast
*/
function showInfo(message, title = 'Info') {
toastr.info(message, title);
}
/**
* Show validation errors from ModelState
* @param {Array<string>} errors - Array of validation error messages
*/
function showValidationErrors(errors) {
if (!errors || errors.length === 0) return;
// If single error, show it directly
if (errors.length === 1) {
showError(errors[0], 'Validation Error');
return;
}
// Multiple errors - show as a list
const errorList = '<ul class="mb-0 ps-3">' +
errors.map(err => `<li>${escapeHtml(err)}</li>`).join('') +
'</ul>';
toastr.error(errorList, `${errors.length} Validation Errors`, {
timeOut: 8000,
extendedTimeOut: 2000
});
}
/**
* Display TempData messages automatically on page load
* Expects TempData keys: Success, Error, Warning, Info
*/
function displayTempDataMessages() {
// Success message
const successMsg = document.getElementById('tempdata-success-message');
if (successMsg && successMsg.textContent.trim()) {
showSuccess(successMsg.textContent.trim());
}
// Error message
const errorMsg = document.getElementById('tempdata-error-message');
if (errorMsg && errorMsg.textContent.trim()) {
showError(errorMsg.textContent.trim());
}
// Permanent success — no auto-dismiss
const successPerm = document.getElementById('tempdata-success-permanent-message');
if (successPerm && successPerm.textContent.trim()) {
toastr.success(successPerm.textContent.trim(), 'Success', { timeOut: 0, extendedTimeOut: 0 });
}
// Permanent error — no auto-dismiss
const errorPerm = document.getElementById('tempdata-error-permanent-message');
if (errorPerm && errorPerm.textContent.trim()) {
toastr.error(errorPerm.textContent.trim(), 'Error', { timeOut: 0, extendedTimeOut: 0 });
}
// Warning message
const warningMsg = document.getElementById('tempdata-warning-message');
if (warningMsg && warningMsg.textContent.trim()) {
showWarning(warningMsg.textContent.trim());
}
// Info message
const infoMsg = document.getElementById('tempdata-info-message');
if (infoMsg && infoMsg.textContent.trim()) {
showInfo(infoMsg.textContent.trim());
}
}
/**
* Display ModelState validation errors on page load
* Expects a hidden div with id 'modelstate-errors' containing JSON array of errors
*/
function displayModelStateErrors() {
const errorContainer = document.getElementById('modelstate-errors');
if (errorContainer && errorContainer.textContent.trim()) {
try {
const errors = JSON.parse(errorContainer.textContent);
showValidationErrors(errors);
} catch (e) {
console.error('Failed to parse ModelState errors:', e);
}
}
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} - Escaped text
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Clear all toasts
*/
function clearAllToasts() {
toastr.clear();
}
// Auto-initialize on page load
document.addEventListener('DOMContentLoaded', function() {
displayTempDataMessages();
displayModelStateErrors();
});
// Make functions globally available
window.showSuccess = showSuccess;
window.showError = showError;
window.showWarning = showWarning;
window.showInfo = showInfo;
window.showValidationErrors = showValidationErrors;
window.clearAllToasts = clearAllToasts;
@@ -0,0 +1,885 @@
// Tools Import/Export Wizard
(function () {
'use strict';
// ── Anti-forgery token ────────────────────────────────────────────────────
const tokenInput = document.querySelector('input[name="__RequestVerificationToken"]');
if (!tokenInput) { console.error('[Tools] Anti-forgery token not found'); return; }
const token = tokenInput.value;
// ── Account select data (embedded by Razor as JSON) ───────────────────────
let accountData = { revenueAccounts: [], cogsAccounts: [], inventoryAccounts: [] };
try {
const el = document.getElementById('toolsAccountData');
if (el) accountData = JSON.parse(el.textContent);
} catch (e) { console.warn('[Tools] Could not parse account data'); }
// ── Wizard state ──────────────────────────────────────────────────────────
let wDir = null; // 'import' | 'export'
let wFmt = null; // 'csv' | 'qb-desktop' | 'qb-online'
let wItem = null; // selected item object
let wStep = 1;
// ── Item catalog ──────────────────────────────────────────────────────────
const ITEMS = [
// ── CSV Import ──────────────────────────────────────────────────────
{ key: 'csv-customers',
label: 'Customers', icon: 'bi-people', color: '#2563eb',
desc: 'Contact info, addresses, and balances',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportCustomers', accept: '.csv',
template: '/Tools/DownloadCustomerTemplate',
tips: ['Download the CSV template to see the expected columns', 'One customer per row — existing records matched by company name are updated'] },
{ key: 'csv-vendors',
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
desc: 'Supplier records and contact info',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportVendors', accept: '.csv',
template: '/Tools/DownloadVendorTemplate',
tips: ['Download the CSV template to see the expected columns', 'One vendor per row — existing records matched by company name are updated'] },
{ key: 'csv-catalog',
label: 'Catalog Items', icon: 'bi-box-seam', color: '#059669',
desc: 'Pre-priced service catalog entries',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportCatalogItems', accept: '.csv',
template: '/Tools/DownloadCatalogTemplate',
extraFields: [
{ name: 'revenueAccountId', label: 'Revenue Account', hint: 'Optional', accountKey: 'revenueAccounts' },
{ name: 'cogsAccountId', label: 'COGS Account', hint: 'Optional', accountKey: 'cogsAccounts' },
],
tips: ['Download the CSV template', 'Optionally map to GL accounts before uploading'] },
{ key: 'csv-inventory',
label: 'Inventory', icon: 'bi-boxes', color: '#0891b2',
desc: 'Stock items, quantities, and unit costs',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportInventoryItems', accept: '.csv',
template: '/Tools/DownloadInventoryTemplate',
extraFields: [
{ name: 'inventoryAccountId', label: 'Inventory Asset Account', hint: 'Optional', accountKey: 'inventoryAccounts' },
{ name: 'cogsAccountId', label: 'COGS Account', hint: 'Optional', accountKey: 'cogsAccounts' },
],
tips: ['Download the CSV template', 'Optionally map to GL accounts before uploading'] },
{ key: 'csv-quotes',
label: 'Quotes', icon: 'bi-file-earmark-text', color: '#d97706',
desc: 'Quote records with statuses and totals',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportQuotes', accept: '.csv',
template: '/Tools/DownloadQuoteTemplate',
tips: ['Download the CSV template', 'One quote per row'] },
{ key: 'csv-jobs',
label: 'Jobs', icon: 'bi-briefcase', color: '#059669',
desc: 'Job records with statuses and priorities',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportJobs', accept: '.csv',
template: '/Tools/DownloadJobTemplate',
tips: ['Download the CSV template', 'One job per row'] },
{ key: 'csv-invoices',
label: 'Invoices', icon: 'bi-receipt', color: '#0891b2',
desc: 'Invoice headers, amounts, and payment status',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportInvoices', accept: '.csv',
template: '/Tools/DownloadInvoiceTemplate',
tips: ['Download the CSV template to see the expected columns',
'Customers must exist before importing — matched by CustomerEmail then Customer name',
'Existing invoices matched by InvoiceNumber are updated; new ones are created',
'Line items are not part of the CSV — this imports invoice headers and totals only'] },
{ key: 'csv-appointments',
label: 'Appointments', icon: 'bi-calendar-check', color: '#2563eb',
desc: 'Scheduled customer appointments',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportAppointments', accept: '.csv',
template: '/Tools/DownloadAppointmentTemplate',
tips: ['Download the CSV template', 'One appointment per row'] },
{ key: 'csv-equipment',
label: 'Equipment', icon: 'bi-tools', color: '#dc2626',
desc: 'Equipment records and status',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportEquipment', accept: '.csv',
template: '/Tools/DownloadEquipmentTemplate',
tips: ['Download the CSV template', 'One equipment item per row'] },
{ key: 'csv-maintenance',
label: 'Maintenance Records', icon: 'bi-wrench', color: '#6b7280',
desc: 'Scheduled and completed maintenance',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportMaintenance', accept: '.csv',
template: '/Tools/DownloadMaintenanceTemplate',
tips: ['Download the CSV template', 'One maintenance record per row'] },
{ key: 'csv-prepservices',
label: 'Prep Services', icon: 'bi-hammer', color: '#7c3aed',
desc: 'Sandblasting, masking, and prep options',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportPrepServices', accept: '.csv',
template: '/Tools/DownloadPrepServiceTemplate',
tips: ['Existing services matched by name are updated'] },
{ key: 'csv-coa',
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
desc: 'GL accounts — import first (required for expenses and bills)',
badge: 'Import first',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportChartOfAccounts', accept: '.csv',
template: '/Tools/DownloadChartOfAccountsTemplate',
tips: ['Download the CSV template to see required columns',
'Valid AccountType values: Asset, Liability, Equity, Revenue, CostOfGoods, Expense',
'Existing accounts matched by AccountNumber are updated; system accounts are never modified'] },
{ key: 'csv-expenses',
label: 'Expenses', icon: 'bi-receipt-cutoff', color: '#dc2626',
desc: 'Direct expenses with account, vendor, and job links',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportExpenses', accept: '.csv',
template: '/Tools/DownloadExpenseTemplate',
tips: ['Download the CSV template to see required columns',
'ExpenseAccountNumber and PaymentAccountNumber must match account numbers in your Chart of Accounts',
'VendorName and JobNumber are optional — leave blank if not applicable',
'Valid PaymentMethod values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment'] },
{ key: 'csv-payments',
label: 'Payments', icon: 'bi-cash-coin', color: '#059669',
desc: 'Invoice payment records with method and reference',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportPayments', accept: '.csv',
template: '/Tools/DownloadPaymentTemplate',
tips: ['Download the CSV template to see the expected columns',
'Invoices must exist before importing — matched by InvoiceNumber',
'Duplicate payments (same invoice + date + amount) are automatically skipped',
'Valid PaymentMethod values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment',
'Invoice AmountPaid and status are updated automatically after each payment'] },
{ key: 'csv-purchaseorders',
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
desc: 'Purchase order headers with vendor, status, and totals',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportPurchaseOrders', accept: '.csv',
template: '/Tools/DownloadPurchaseOrderTemplate',
tips: ['Download the CSV template to see the expected columns',
'Vendors must exist before importing — matched by company name',
'Existing POs matched by PoNumber are updated; new ones are created',
'Valid Status values: Draft, Submitted, PartiallyReceived, Received, Cancelled',
'Line items are not part of the CSV — this imports PO headers and totals only'] },
{ key: 'csv-settings',
label: 'Company Settings', icon: 'bi-gear', color: '#d97706',
desc: 'Operating costs and configuration',
dir: ['import'], fmt: ['csv'],
endpoint: '/Tools/CsvImportCompanySettings', accept: '.csv',
template: '/Tools/DownloadCompanySettingsTemplate',
warning: 'This will overwrite your current company settings. Download a backup first.',
tips: ['Download a backup of your current settings first', 'Modify the CSV, then upload it below'] },
// ── CSV Export ──────────────────────────────────────────────────────
{ key: 'exp-customers',
label: 'Customers', icon: 'bi-people', color: '#2563eb',
desc: 'Contact info, addresses, and balances',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportCustomersCsv' },
{ key: 'exp-vendors',
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
desc: 'Supplier records, contact info, and payment terms',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportVendorsCsv' },
{ key: 'exp-quotes',
label: 'Quotes', icon: 'bi-file-earmark-text', color: '#0891b2',
desc: 'Status, dates, totals, and customer info',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportQuotesCsv' },
{ key: 'exp-jobs',
label: 'Jobs', icon: 'bi-briefcase', color: '#059669',
desc: 'Status, priority, dates, and completion data',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportJobsCsv' },
{ key: 'exp-invoices',
label: 'Invoices', icon: 'bi-receipt', color: '#0891b2',
desc: 'Invoice headers, amounts, status, and customer info',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInvoicesCsv' },
{ key: 'exp-payments',
label: 'Payments', icon: 'bi-cash-coin', color: '#059669',
desc: 'Invoice payment records with method and reference',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPaymentsCsv' },
{ key: 'exp-appointments',
label: 'Appointments', icon: 'bi-calendar-check', color: '#d97706',
desc: 'Customer, type, status, and scheduling details',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportAppointmentsCsv' },
{ key: 'exp-catalog',
label: 'Catalog Items', icon: 'bi-box-seam', color: '#6b7280',
desc: 'SKU, pricing, categories, and descriptions',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportCatalogCsv' },
{ key: 'exp-inventory',
label: 'Inventory', icon: 'bi-boxes', color: '#1f2937',
desc: 'Quantities, costs, and location details',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportInventoryCsv' },
{ key: 'exp-equipment',
label: 'Equipment', icon: 'bi-tools', color: '#dc2626',
desc: 'Details, purchase info, and current status',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportEquipmentCsv' },
{ key: 'exp-maintenance',
label: 'Maintenance Records', icon: 'bi-wrench', color: '#6b7280',
desc: 'Scheduled and completed maintenance with equipment and costs',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportMaintenanceCsv' },
{ key: 'exp-prepservices',
label: 'Prep Services', icon: 'bi-tools', color: '#7c3aed',
desc: 'Preparation service catalog (sandblasting, stripping, etc.)',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPrepServicesCsv' },
{ key: 'exp-purchaseorders',
label: 'Purchase Orders', icon: 'bi-cart', color: '#6b7280',
desc: 'Vendor, status, dates, and totals',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportPurchaseOrdersCsv' },
{ key: 'exp-coa',
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
desc: 'Full GL account list with types, balances, and account numbers',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportChartOfAccountsCsv' },
{ key: 'exp-expenses',
label: 'Expenses', icon: 'bi-receipt-cutoff', color: '#dc2626',
desc: 'Direct expenses with dates, accounts, vendors, and amounts',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportExpensesCsv' },
{ key: 'exp-settings',
label: 'Company Settings', icon: 'bi-gear-fill', color: '#d97706',
desc: 'Company info, operating costs, and preferences',
dir: ['export'], fmt: ['csv'], exportUrl: '/Tools/ExportCompanySettingsCsv' },
// ── QB Desktop Import ───────────────────────────────────────────────
{ key: 'qbd-coa',
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
desc: 'Account list — import this first (required for bills)',
badge: 'Import first',
dir: ['import'], fmt: ['qb-desktop'],
endpoint: '/Tools/ImportChartOfAccounts', accept: '.iif,.txt',
tips: ['Lists → Chart of Accounts → right-click → Export → save as .iif', 'Upload the .iif file below'] },
{ key: 'qbd-customers',
label: 'Customers', icon: 'bi-people', color: '#2563eb',
desc: 'Customer list from QB Desktop',
dir: ['import'], fmt: ['qb-desktop'],
endpoint: '/Tools/ImportCustomers', accept: '.iif,.txt',
tips: ['File → Utilities → Export → Lists to IIF Files → select Customers', 'Upload the .iif file below'] },
{ key: 'qbd-vendors',
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
desc: 'Vendor list from QB Desktop',
dir: ['import'], fmt: ['qb-desktop'],
endpoint: '/Tools/ImportVendors', accept: '.iif,.txt',
tips: ['File → Utilities → Export → Lists to IIF Files → select Vendors', 'Upload the .iif file below'] },
{ key: 'qbd-catalog',
label: 'Catalog Items', icon: 'bi-box-seam', color: '#059669',
desc: 'Service items from QB Desktop',
dir: ['import'], fmt: ['qb-desktop'],
endpoint: '/Tools/ImportCatalogItems', accept: '.iif,.txt',
tips: ['File → Utilities → Export → Lists to IIF Files → select Items', 'Upload the .iif file below'] },
{ key: 'qbd-inventory',
label: 'Inventory Stock', icon: 'bi-boxes', color: '#0891b2',
desc: 'Inventory Valuation Summary — include Pref Vendor column',
dir: ['import'], fmt: ['qb-desktop'],
endpoint: '/Tools/ImportQbInventoryValuation', accept: '.csv',
tips: [
'Reports → Inventory → Inventory Valuation Summary',
'Customize Report → Display tab → check <strong>Pref Vendor</strong>',
'Excel → Create New Worksheet → Comma Separated Values (.csv)',
'Upload the saved .csv file below'
] },
{ key: 'qbd-invoices',
label: 'Invoices', icon: 'bi-receipt', color: '#2563eb',
desc: 'Customer Balance Detail report',
dir: ['import'], fmt: ['qb-desktop'],
endpoint: '/Tools/ImportQbInvoices', accept: '.csv',
tips: ['Reports → Customers & Receivables → Customer Balance Detail', 'Set date range to <strong>All</strong>', 'Excel → Create New Worksheet → Comma Separated Values (.csv)', 'Upload the saved .csv file below'] },
{ key: 'qbd-transactions',
label: 'Transactions', icon: 'bi-arrow-left-right', color: '#059669',
desc: 'Transaction List by Customer report',
dir: ['import'], fmt: ['qb-desktop'],
endpoint: '/Tools/ImportQbTransactions', accept: '.csv',
tips: ['Reports → Customers & Receivables → Transaction List by Customer', 'Set date range to <strong>All</strong>', 'Excel → Create New Worksheet → Comma Separated Values (.csv)', 'Upload the saved .csv file below'] },
{ key: 'qbd-bills',
label: 'Bills & Payments', icon: 'bi-file-earmark-minus', color: '#dc2626',
desc: 'Vendor Balance Detail report — imports bills and payments in one pass',
dir: ['import'], fmt: ['qb-desktop'],
endpoint: '/Tools/ImportQbBillsAndPayments', accept: '.csv',
tips: [
'Reports → Vendors &amp; Payables → Vendor Balance Detail',
'Set date range to <strong>All</strong>',
'Click <strong>Customize Report</strong> → <strong>Display</strong> tab → check <strong>Memo</strong> to include bill and payment descriptions',
'Excel → Create New Worksheet → Comma Separated Values (.csv)',
'Upload the file — bills are imported first, then payments are matched against them automatically'
] },
// ── QB Online Import ────────────────────────────────────────────────
{ key: 'qbo-coa',
label: 'Chart of Accounts', icon: 'bi-journal-text', color: '#374151',
desc: 'Account List — import this first (required for invoices)',
badge: 'Import first',
dir: ['import'], fmt: ['qb-online'],
endpoint: '/Tools/ImportQboChartOfAccounts', accept: '.xlsx,.xls',
tips: [
'Accounting (left nav) → Chart of Accounts → Download/Export button',
'<strong>Or:</strong> Reports → search "Account List" → Export to Excel',
'Upload the .xlsx file below'
] },
{ key: 'qbo-customers',
label: 'Customers', icon: 'bi-people', color: '#2563eb',
desc: 'Customer Contact List from QuickBooks Online',
dir: ['import'], fmt: ['qb-online'],
endpoint: '/Tools/ImportQboCustomers', accept: '.xlsx,.xls',
tips: [
'Reports → search "Customer Contact List" → Customize → add all desired columns → Export to Excel',
'<strong>Tip:</strong> Add Billing Street, City, State, Zip columns via Customize → Rows/Columns for best results',
'Upload the .xlsx file below'
] },
{ key: 'qbo-vendors',
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
desc: 'Vendor Contact List from QuickBooks Online',
dir: ['import'], fmt: ['qb-online'],
endpoint: '/Tools/ImportQboVendors', accept: '.xlsx,.xls',
tips: [
'Reports → search "Vendor Contact List" → Customize → add all desired columns → Export to Excel',
'<strong>Or:</strong> Expenses → Vendors → Export icon (box with arrow) → Export to Excel',
'Upload the .xlsx file below'
] },
{ key: 'qbo-products',
label: 'Products &amp; Services', icon: 'bi-box-seam', color: '#059669',
desc: 'Product/service catalog from QuickBooks Online',
dir: ['import'], fmt: ['qb-online'],
endpoint: '/Tools/ImportQboCatalogItems', accept: '.xlsx,.xls',
tips: [
'Sales → Products and Services → Export to Excel icon (top right)',
'Inventory-type items are imported as Inventory; all others as Catalog Items',
'Items named <em>Category:Item Name</em> will have the category prefix stripped automatically',
'Upload the .xlsx file below'
] },
{ key: 'qbo-invoices',
label: 'Invoices', icon: 'bi-receipt', color: '#2563eb',
desc: 'Invoice List report from QuickBooks Online',
dir: ['import'], fmt: ['qb-online'],
endpoint: '/Tools/ImportQboInvoices', accept: '.xlsx,.xls',
tips: [
'Reports → search "Invoice List" → set your date range → Export to Excel',
'Customers must be imported first so invoices can be linked',
'Invoice totals and open balances are imported; line item detail is not available in this report',
'Upload the .xlsx file below'
] },
{ key: 'qbo-transactions',
label: 'Transactions', icon: 'bi-arrow-left-right', color: '#059669',
desc: 'Transaction List — applies payments to imported invoices',
dir: ['import'], fmt: ['qb-online'],
endpoint: '/Tools/ImportQboTransactions', accept: '.xlsx,.xls',
tips: [
'Reports → search "Transaction List by Date" → set All Dates → Export to Excel',
'Only Payment/Receipt rows are processed; Invoice rows are skipped',
'Invoices must be imported first so payments can be matched by reference number',
'Upload the .xlsx file below'
] },
// ── QB Export ───────────────────────────────────────────────────────
{ key: 'qb-exp-customers',
label: 'Customers', icon: 'bi-people', color: '#2563eb',
desc: 'Export all active customers',
dir: ['export'], fmt: ['qb-desktop', 'qb-online'],
exportUrlDesktop: '/Tools/ExportCustomers?format=desktop',
exportUrlOnline: '/Tools/ExportCustomers?format=online' },
{ key: 'qb-exp-vendors',
label: 'Vendors', icon: 'bi-truck', color: '#d97706',
desc: 'Export all active vendors to IIF format',
dir: ['export'], fmt: ['qb-desktop'],
exportUrl: '/Tools/ExportVendors' },
{ key: 'qb-exp-catalog',
label: 'Catalog Items', icon: 'bi-box-seam', color: '#059669',
desc: 'Export active catalog items as service items',
dir: ['export'], fmt: ['qb-desktop', 'qb-online'],
exportUrlDesktop: '/Tools/ExportCatalogItems?format=desktop',
exportUrlOnline: '/Tools/ExportCatalogItems?format=online' },
];
// ── Helpers ───────────────────────────────────────────────────────────────
function getExportUrl(item) {
if (item.exportUrl) return item.exportUrl;
if (wFmt === 'qb-online' && item.exportUrlOnline) return item.exportUrlOnline;
if (item.exportUrlDesktop) return item.exportUrlDesktop;
return '#';
}
function filteredItems() {
return ITEMS.filter(it => it.dir.includes(wDir) && it.fmt.includes(wFmt));
}
// ── Wizard Navigation ─────────────────────────────────────────────────────
window.wizardSetDirection = function (dir) {
wDir = dir;
wStep = 2;
setBreadcrumb(2, dir === 'import' ? 'Import' : 'Export');
document.getElementById('step2-heading').textContent =
dir === 'import' ? 'Import from which format?' : 'Export to which format?';
showStep(2);
document.getElementById('wizardBackBtn').classList.remove('d-none');
};
window.wizardSetFormat = function (fmt) {
wFmt = fmt;
wStep = 3;
const labels = { csv: 'CSV', 'qb-desktop': 'QB Desktop', 'qb-online': 'QB Online' };
setBreadcrumb(3, labels[fmt] || fmt);
renderStep3();
showStep(3);
};
window.wizardBack = function () {
if (wStep === 4) {
wItem = null;
wStep = 3;
resetBreadcrumb(4, wDir === 'import' ? 'Import' : 'Export');
renderStep3();
showStep(3);
} else if (wStep === 3) {
wFmt = null;
wStep = 2;
resetBreadcrumb(3, 'Select');
showStep(2);
} else if (wStep === 2) {
wDir = null;
wStep = 1;
resetBreadcrumb(2, 'Format');
resetBreadcrumb(1, 'Direction');
showStep(1);
document.getElementById('wizardBackBtn').classList.add('d-none');
}
};
window.wizardSelectItem = function (key) {
wItem = ITEMS.find(it => it.key === key);
if (!wItem) return;
wStep = 4;
setBreadcrumb(4, wItem.label);
renderStep4();
showStep(4);
};
function showStep(n) {
[1, 2, 3, 4].forEach(function (s) {
const el = document.getElementById('wizard-step-' + s);
if (el) el.classList.toggle('d-none', s !== n);
});
updateBreadcrumbActive(n);
}
function setBreadcrumb(step, label) {
const lbl = document.getElementById('bc-' + step + '-label');
const bdg = document.getElementById('bc-' + step + '-badge');
if (lbl) { lbl.textContent = label; lbl.className = 'small fw-semibold'; }
if (bdg) bdg.className = 'badge rounded-pill bg-primary';
}
function resetBreadcrumb(step, label) {
const lbl = document.getElementById('bc-' + step + '-label');
const bdg = document.getElementById('bc-' + step + '-badge');
if (lbl) { lbl.textContent = label; lbl.className = 'small text-muted'; }
if (bdg) bdg.className = 'badge rounded-pill bg-secondary';
}
function updateBreadcrumbActive(currentStep) {
for (let s = 1; s <= 4; s++) {
const badge = document.getElementById('bc-' + s + '-badge');
if (badge && s < currentStep && badge.className.includes('primary')) {
badge.className = 'badge rounded-pill bg-success';
}
}
}
// ── Step 3: item selection grid ───────────────────────────────────────────
function renderStep3() {
const heading = document.getElementById('step3-heading');
const grid = document.getElementById('step3-grid');
if (!heading || !grid) return;
heading.textContent = wDir === 'import' ? 'What would you like to import?' : 'What would you like to export?';
let html = '';
// CSV Export: "Export All" shortcut
if (wDir === 'export' && wFmt === 'csv') {
html += `<div class="col-12 text-center mb-1">
<a href="/Tools/ExportAllCsv" class="btn btn-primary px-4">
<i class="bi bi-file-earmark-zip me-1"></i>Export All as ZIP
</a>
<p class="text-muted small mt-2 mb-0">Or pick a specific data set below.</p>
</div>`;
}
// QB Desktop Import: recommended order callout
if (wDir === 'import' && wFmt === 'qb-desktop') {
html += `<div class="col-12 mb-1">
<div class="alert alert-info py-2 mb-0 small">
<i class="bi bi-list-ol me-1"></i>
<strong>Recommended order:</strong>
Chart of Accounts &rarr; Customers &rarr; Vendors &rarr; Catalog Items &rarr; Inventory &rarr; Invoices &rarr; Transactions &rarr; Bills &amp; Payments
</div>
</div>`;
}
filteredItems().forEach(function (item) {
const badgeHtml = item.badge
? ` <span class="badge ms-1" style="background:#374151;font-size:0.68rem;vertical-align:middle">${item.badge}</span>`
: '';
html += `
<div class="col-sm-6 col-lg-4">
<div class="card h-100 border-2" role="button"
onclick="wizardSelectItem('${item.key}')"
style="cursor:pointer;border-color:${item.color}!important;transition:transform .1s"
onmouseover="this.style.transform='scale(1.02)'"
onmouseout="this.style.transform=''">
<div class="card-body py-3 px-3">
<div class="d-flex align-items-start gap-3">
<div style="width:42px;height:42px;border-radius:10px;background:${item.color}18;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<i class="bi ${item.icon}" style="font-size:1.3rem;color:${item.color}"></i>
</div>
<div>
<div class="fw-semibold" style="font-size:0.93rem">${item.label}${badgeHtml}</div>
<div class="text-muted" style="font-size:0.8rem">${item.desc}</div>
</div>
</div>
</div>
</div>
</div>`;
});
if (!filteredItems().length) {
html += `<div class="col-12 text-center text-muted py-5">
<i class="bi bi-inbox" style="font-size:2.5rem"></i>
<p class="mt-2 mb-0">No options available for this selection.</p>
</div>`;
}
grid.innerHTML = html;
}
// ── Step 4: upload form or download button ────────────────────────────────
async function renderStep4() {
const container = document.getElementById('step4-content');
if (!container || !wItem) return;
const item = wItem;
const isImport = wDir === 'import';
// Refresh account data from server if this card has account dropdowns,
// so accounts imported earlier in the same page session are available.
if (item.extraFields && item.extraFields.length) {
try {
const resp = await fetch('/Tools/GetImportAccounts');
if (resp.ok) accountData = await resp.json();
} catch (e) { /* keep whatever was loaded at page render */ }
}
// Tips list
const tipsHtml = (item.tips || []).length
? `<ol class="small text-muted ps-3 mb-0">${item.tips.map(t => `<li>${t}</li>`).join('')}</ol>`
: '';
// Warning
const warningHtml = item.warning
? `<div class="alert alert-warning py-2 mb-3 small"><i class="bi bi-exclamation-triangle-fill me-1"></i>${item.warning}</div>`
: '';
// Template download button
const templateHtml = item.template
? `<div class="mb-4">
<a href="${item.template}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-file-earmark-arrow-down me-1"></i>Download CSV Template
</a>
</div>`
: '';
// Extra account dropdowns
let extraFieldsHtml = '';
(item.extraFields || []).forEach(function (f) {
const opts = (accountData[f.accountKey] || [])
.map(o => `<option value="${o.value}">${o.text}</option>`)
.join('');
extraFieldsHtml += `
<div class="mb-3">
<label class="form-label small fw-semibold mb-1">${f.label}
<span class="text-muted fw-normal">${f.hint ? '— ' + f.hint : ''}</span>
</label>
<select name="${f.name}" class="form-select form-select-sm">
<option value="">(none)</option>
${opts}
</select>
</div>`;
});
let actionHtml;
if (isImport) {
actionHtml = `
${warningHtml}
${templateHtml}
<form id="step4-form" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label small fw-semibold mb-1">
Select file <span class="text-muted fw-normal">${item.accept}</span>
</label>
<input type="file" class="form-control" id="step4-file" name="file" accept="${item.accept}" required />
</div>
${extraFieldsHtml}
<button type="submit" class="btn btn-primary" id="step4-btn">
<span class="spinner-border spinner-border-sm d-none me-1" id="step4-spinner" role="status"></span>
<i class="bi bi-upload me-1"></i>Import ${item.label}
</button>
</form>
<div id="step4-results" class="mt-4 d-none">
<hr>
<div id="step4-summary" class="mb-2"></div>
<div id="step4-errors"></div>
</div>`;
} else {
const exportUrl = getExportUrl(item);
const qbHelpHtml = (wFmt === 'qb-desktop' || wFmt === 'qb-online')
? `<div class="alert alert-info small mt-3 mb-0">
<i class="bi bi-info-circle me-1"></i>
<strong>Importing into QuickBooks${wFmt === 'qb-online' ? ' Online' : ' Desktop'}:</strong>
${wFmt === 'qb-desktop'
? 'File → Utilities → Import → IIF Files → select the downloaded file.'
: 'Settings → Import Data → select the data type, then upload the file.'}
</div>`
: '';
actionHtml = `
<a href="${exportUrl}" class="btn btn-success btn-lg">
<i class="bi bi-download me-2"></i>Download ${item.label}
</a>
${qbHelpHtml}`;
}
container.innerHTML = `
<div class="row g-4">
<div class="col-md-4">
<div class="d-flex align-items-center gap-2 mb-3">
<div style="width:40px;height:40px;border-radius:8px;background:${item.color}18;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<i class="bi ${item.icon}" style="color:${item.color};font-size:1.2rem"></i>
</div>
<div>
<div class="fw-bold">${item.label}</div>
<div class="text-muted small">${item.desc}</div>
</div>
</div>
${tipsHtml}
</div>
<div class="col-md-8">
${actionHtml}
</div>
</div>`;
if (isImport) {
document.getElementById('step4-form').addEventListener('submit', runImport);
}
}
// ── Import runner ─────────────────────────────────────────────────────────
async function runImport(e) {
e.preventDefault();
const item = wItem;
const fileInput = document.getElementById('step4-file');
const btn = document.getElementById('step4-btn');
const spinner = document.getElementById('step4-spinner');
const resultsDiv = document.getElementById('step4-results');
const summaryDiv = document.getElementById('step4-summary');
const errorsDiv = document.getElementById('step4-errors');
if (!fileInput || !fileInput.files.length) {
if (typeof showWarning === 'function') showWarning('Please select a file first');
return;
}
btn.disabled = true;
if (spinner) spinner.classList.remove('d-none');
resultsDiv.classList.add('d-none');
const formData = new FormData(document.getElementById('step4-form'));
formData.append('__RequestVerificationToken', token);
try {
const response = await fetch(item.endpoint, { method: 'POST', body: formData });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const ct = response.headers.get('content-type') || '';
if (!ct.includes('application/json')) throw new Error('Unexpected response from server — check the browser console');
const result = await response.json();
displaySuccess(summaryDiv, errorsDiv, resultsDiv, result);
if (result.success) {
if (typeof showSuccess === 'function') showSuccess(result.message || 'Import completed');
} else {
if (typeof showError === 'function') showError(result.message || 'Import completed with errors');
}
} catch (err) {
console.error('[Tools] Import error:', err);
displayError(summaryDiv, errorsDiv, resultsDiv, err.message, err.stack);
if (typeof showError === 'function') showError(err.message);
} finally {
btn.disabled = false;
if (spinner) spinner.classList.add('d-none');
}
}
// ── Result display ────────────────────────────────────────────────────────
function displayError(summaryDiv, errorsDiv, resultsDiv, msg, detail) {
if (summaryDiv) {
summaryDiv.innerHTML = `<div class="alert alert-danger mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Import Failed:</strong> ${msg}
</div>`;
}
if (errorsDiv) {
errorsDiv.innerHTML = detail
? `<details class="mt-2" open>
<summary class="text-danger"><strong>Error Details</strong></summary>
<pre class="mt-2 small bg-light p-2 rounded" style="max-height:300px;overflow-y:auto">${detail}</pre>
</details>`
: '';
}
if (resultsDiv) resultsDiv.classList.remove('d-none');
}
function displaySuccess(summaryDiv, errorsDiv, resultsDiv, result) {
// Normalise both QB-format (importedCount/totalRecords) and
// CSV bulk-import format (successCount/totalRows) so this function
// works regardless of which endpoint was called.
const totalRecords = result.totalRecords ?? result.totalRows ?? 0;
const importedCount = result.importedCount ?? result.successCount ?? 0;
const updatedCount = result.updatedCount ?? 0;
const skippedCount = result.skippedCount ?? 0;
// QB format puts everything in result.errors with a severity field.
// CSV format puts error strings in result.errors and warning strings
// in a separate result.warnings array. Normalise both into one list.
const allMessages = [
...(result.errors || []).map(function (e) {
return typeof e === 'string' ? { severity: 'Error', displayMessage: e } : e;
}),
...(result.warnings || []).map(function (w) {
return typeof w === 'string' ? { severity: 'Warning', displayMessage: w } : w;
}),
];
const errors = allMessages.filter(function (e) { return (e.severity || 'Error') === 'Error'; });
const warnings = allMessages.filter(function (e) { return e.severity === 'Warning'; });
const skipped = allMessages.filter(function (e) { return e.severity === 'Skipped'; });
if (summaryDiv) {
const icon = errors.length > 0
? '<i class="bi bi-exclamation-triangle text-warning me-1"></i>'
: '<i class="bi bi-check-circle text-success me-1"></i>';
summaryDiv.innerHTML = `
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
${icon}<strong>Results</strong> &nbsp;
Total: <strong>${totalRecords}</strong> &nbsp;&nbsp;
Imported: <span class="text-success fw-bold">${importedCount}</span> &nbsp;&nbsp;
${updatedCount > 0 ? `Updated: <span class="text-info fw-bold">${updatedCount}</span> &nbsp;&nbsp;` : ''}
Skipped: <span class="text-warning fw-bold">${skippedCount}</span>
${errors.length > 0 ? ` &nbsp;&nbsp; Errors: <span class="text-danger fw-bold">${errors.length}</span>` : ''}
</div>
<button class="btn btn-outline-secondary btn-sm flex-shrink-0"
onclick="downloadImportReport(this)"
data-result='${JSON.stringify(result).replace(/'/g, '&#39;')}'>
<i class="bi bi-download me-1"></i>Download Report
</button>
</div>`;
}
if (errorsDiv) {
let html = '';
if (errors.length) {
html += `<details class="mt-2" open>
<summary class="text-danger"><strong>${errors.length} Error(s)</strong></summary>
<ul class="mt-1 small mb-0">${errors.map(function (e) {
return '<li>' + (e.displayMessage || e.errorMessage || JSON.stringify(e)) + '</li>';
}).join('')}</ul>
</details>`;
}
if (warnings.length) {
html += `<details class="mt-2">
<summary class="text-warning"><strong>${warnings.length} Warning(s)</strong></summary>
<ul class="mt-1 small mb-0">${warnings.map(function (e) {
return '<li>' + (e.displayMessage || e.errorMessage) + '</li>';
}).join('')}</ul>
</details>`;
}
if (skipped.length) {
html += `<details class="mt-2">
<summary class="text-secondary"><strong>${skipped.length} Skipped</strong></summary>
<ul class="mt-1 small mb-0">${skipped.map(function (e) {
return '<li>' + (e.recordName || '') + (e.errorMessage ? ' — ' + e.errorMessage : '') + '</li>';
}).join('')}</ul>
</details>`;
}
errorsDiv.innerHTML = html;
}
if (resultsDiv) resultsDiv.classList.remove('d-none');
}
// ── Download report (called from inline onclick) ──────────────────────────
window.downloadImportReport = function (btn) {
try {
const result = JSON.parse(btn.getAttribute('data-result'));
const importedCount = result.importedCount ?? result.successCount ?? 0;
const updatedCount = result.updatedCount ?? 0;
const skippedCount = result.skippedCount ?? 0;
const rows = [['Status', 'Record', 'Field', 'Message']];
rows.push(['Imported', importedCount, '', '']);
if (updatedCount > 0) rows.push(['Updated', updatedCount, '', '']);
rows.push(['Skipped', skippedCount, '', '']);
// Normalise errors: QB format = objects, CSV format = plain strings
const allErrors = [
...(result.errors || []).map(function (e) {
return typeof e === 'string' ? { severity: 'Error', recordName: '', fieldName: '', errorMessage: e } : e;
}),
...(result.warnings || []).map(function (w) {
return typeof w === 'string' ? { severity: 'Warning', recordName: '', fieldName: '', errorMessage: w } : w;
}),
];
allErrors.forEach(function (e) {
rows.push([e.severity || 'Error', e.recordName || '', e.fieldName || '', e.errorMessage || '']);
});
const csv = rows.map(function (r) {
return r.map(function (c) { return '"' + String(c).replace(/"/g, '""') + '"'; }).join(',');
}).join('\r\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = 'import-report-' + new Date().toISOString().slice(0, 10) + '.csv';
a.click();
URL.revokeObjectURL(a.href);
} catch (err) {
console.error('[Tools] Report error:', err);
}
};
})();