// ==========================================================================
// B2B Quotation and Takeoff Program - Frontend Controller (Auth, BLOB & Embedded PDFs)
// ==========================================================================
const API_BASE = '/api';
// Globally intercept fetch to bypass localtunnel reminder page
const originalFetch = window.fetch;
window.fetch = async function (url, options = {}) {
if (!options.headers) {
options.headers = {};
}
if (options.headers instanceof Headers) {
options.headers.set('bypass-tunnel-reminder', 'true');
} else {
options.headers['bypass-tunnel-reminder'] = 'true';
}
return originalFetch(url, options);
};
// Timezone-aware SQLite datetime parser (Thailand Timezone ICT +07:00 alignment)
function parseDbDate(dateStr) {
if (!dateStr) return null;
if (dateStr instanceof Date) return dateStr;
if (typeof dateStr === 'number') return new Date(dateStr);
const str = String(dateStr).trim();
// Check if it's date-only YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) {
const parts = str.split('-');
return new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
}
// If it already has Z or offset, parse directly
if (str.includes('Z') || str.includes('+') || (str.includes('-') && str.includes('T') && str.length > 20)) {
return new Date(str);
}
// For SQLite datetime like "YYYY-MM-DD HH:MM:SS" (stored in UTC), parse manually as UTC
const match = str.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(\.\d+)?$/);
if (match) {
return new Date(Date.UTC(
parseInt(match[1], 10),
parseInt(match[2], 10) - 1,
parseInt(match[3], 10),
parseInt(match[4], 10),
parseInt(match[5], 10),
parseInt(match[6], 10)
));
}
// For datetime-local inputs like "YYYY-MM-DDTHH:MM", parse as local time
const matchShort = str.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})$/);
if (matchShort) {
return new Date(
parseInt(matchShort[1], 10),
parseInt(matchShort[2], 10) - 1,
parseInt(matchShort[3], 10),
parseInt(matchShort[4], 10),
parseInt(matchShort[5], 10)
);
}
return new Date(str);
}
// Helper to escape special regex characters for message highlighting
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Test environment bypass for browser automation confirm dialogs
if (window.location.search.includes('test=true')) {
window.confirm = () => true;
window.prompt = (msg) => window.TEST_PROMPT_VAL || 'Drawings';
// Expose test helper for project documents upload simulation
window.TEST_UPLOAD_DOC = async (fileName) => {
const dummyBlob = new Blob(['dummy content'], { type: 'application/octet-stream' });
const dummyFile = new File([dummyBlob], fileName, { type: 'application/octet-stream' });
await handleDocsUpload([dummyFile]);
};
}
// Global Application State
let state = {
workspaceMode: 'estimate',
currentUser: null, // { id, username, role }
currentLanguage: localStorage.getItem('lang') || 'th', // Active language state
projects: [],
activeProjectId: null,
activeProject: null,
activeProjectSummary: null,
categories: [],
groups: [],
systems: [],
suppliers: [], // List of all suppliers
contractors: [], // List of all contractors
rfqList: [], // List of contractor RFQs for the active project
selectedSectionId: null, // target section when adding catalog items
isSelectingForTemplate: false,
editingCatalogProduct: null, // product object being edited in detail form
currentDocFolderPath: '/', // Current path inside Project Document Vault
currentFatReportItems: [], // Loaded items for the active FAT report
activeProjectDocs: { folders: [], files: [] }, // Folders & files list in active project
collapsedSections: new Set(), // Tracks IDs of collapsed sections
imageFileToUpload: null, // selected image file
pdfFileToUpload: null, // selected PDF specification sheet
quoteFileToUpload: null, // selected supplier quotation file
// Pagination states
explorerCurrentPage: 1,
explorerPageSize: 999999,
explorerProducts: [],
managerCurrentPage: 1,
managerPageSize: 999999,
managerProducts: [],
activityLogPage: 1,
activityLogLimit: 50,
activityLogTotal: 0,
availableReports: [],
chatConversations: [],
activeChatConversationId: null,
chatThreadPollInterval: null,
chatUnreadPollInterval: null,
chatUserDirectory: []
};
// Helper function to prepare headers for API requests
function getHeaders() {
const headers = {
'Content-Type': 'application/json'
};
const token = localStorage.getItem('authToken');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
// Helper to get auth header object (for FormData requests that can't use getHeaders)
function getAuthHeader() {
const token = localStorage.getItem('authToken');
if (token) {
return { 'Authorization': `Bearer ${token}` };
}
return {};
}
// Helper to handle fetch requests with auth headers
async function authFetch(url, options = {}) {
options.credentials = 'include';
if (!options.headers) {
options.headers = {};
}
// Add JWT Bearer token
const token = localStorage.getItem('authToken');
if (token) {
options.headers['Authorization'] = `Bearer ${token}`;
}
// Default Content-Type to JSON if body exists and is not FormData
if (options.body && !(options.body instanceof FormData) && !options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json';
}
const res = await fetch(url, options);
if (res.status === 401) {
// Token expired or invalid - force re-login
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
state.currentUser = null;
checkSession();
showToast('Session expired. Please log in again.', 'error');
throw new Error('Unauthorized');
}
if (res.status === 403) {
const errData = await res.json();
showToast(errData.error || t('toast_error_permission'), 'error');
throw new Error('Forbidden');
}
return res;
}
// Plain <img>/<a> tags can't send an Authorization header, so append the JWT
// as a query param for authenticated media-streaming URLs (server accepts both).
function withAuthToken(url) {
const token = localStorage.getItem('authToken');
if (!token) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${encodeURIComponent(token)}`;
}
// Helper to track modified project IDs during session
function markProjectModified(projectId) {
if (!projectId || !state.currentUser) return;
const userId = state.currentUser.id;
const key = `modifiedProjects_${userId}`;
let modified = [];
try {
modified = JSON.parse(localStorage.getItem(key)) || [];
} catch (e) {
modified = [];
}
if (!modified.includes(projectId)) {
modified.push(projectId);
localStorage.setItem(key, JSON.stringify(modified));
}
}
// ==========================================================================
// Initialization & Event Listeners
// ==========================================================================
document.addEventListener('DOMContentLoaded', () => {
// Restore sidebar collapse preference
const sidebarCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
if (sidebarCollapsed) {
document.body.classList.add('sidebar-collapsed');
}
// Restore header menu collapse preference
const headerMenuHidden = localStorage.getItem('headerMenuHidden') === 'true';
const menuGrid = document.querySelector('.header-menu-grid');
const toggleBtn = document.getElementById('btn-toggle-header-menu');
if (headerMenuHidden && menuGrid && toggleBtn) {
menuGrid.classList.add('menu-hidden');
toggleBtn.setAttribute('data-i18n', 'btn_show_menu');
const icon = toggleBtn.querySelector('i');
if (icon) icon.className = 'fa-solid fa-eye';
}
initLanguage();
const fatSelect = document.getElementById('fat-select-type');
if (fatSelect) {
const options = Array.from(fatSelect.options);
const placeholder = options.shift();
options.sort((a, b) => a.text.trim().localeCompare(b.text.trim()));
fatSelect.innerHTML = '';
if (placeholder) fatSelect.appendChild(placeholder);
options.forEach(opt => fatSelect.appendChild(opt));
}
checkSession();
setupEventListeners();
setupIdleTracker();
initPdfAnnotationEvents();
// Bind header menu toggle button click
if (toggleBtn && menuGrid) {
toggleBtn.addEventListener('click', () => {
const isHidden = menuGrid.classList.toggle('menu-hidden');
localStorage.setItem('headerMenuHidden', isHidden);
if (isHidden) {
toggleBtn.setAttribute('data-i18n', 'btn_show_menu');
const icon = toggleBtn.querySelector('i');
if (icon) icon.className = 'fa-solid fa-eye';
} else {
toggleBtn.setAttribute('data-i18n', 'btn_hide_menu');
const icon = toggleBtn.querySelector('i');
if (icon) icon.className = 'fa-solid fa-eye-slash';
}
applyLanguage();
});
}
});
// Localization Initialization & Logic
function initLanguage() {
state.currentLanguage = localStorage.getItem('lang') || 'th';
// Setup event listeners for the language switcher buttons
document.querySelectorAll('.btn-lang').forEach(btn => {
btn.addEventListener('click', (e) => {
const selectedLang = e.currentTarget.dataset.lang;
changeLanguage(selectedLang);
});
});
applyLanguage();
}
function changeLanguage(lang) {
state.currentLanguage = lang;
localStorage.setItem('lang', lang);
applyLanguage();
// Re-render user profile details
updateHeaderUserDisplay();
// Re-render dynamic components
fetchProjects();
if (state.activeProjectId) {
fetchActiveProjectDetails().then(() => {
renderWorkspace();
});
} else {
renderWorkspace();
}
}
function applyLanguage() {
const lang = state.currentLanguage || 'th';
// Switch HTML tag lang
document.documentElement.lang = lang;
// Update visual toggle buttons active classes
document.querySelectorAll('.btn-lang').forEach(btn => {
if (btn.dataset.lang === lang) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Translate static elements marked with data-i18n
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const translation = t(key);
if (translation !== key) {
// If the element contains an icon element, keep the icon
const icon = el.querySelector('i');
if (icon) {
el.innerHTML = icon.outerHTML + ' ' + translation;
} else {
el.textContent = translation;
}
}
});
// Translate form inputs placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
const translation = t(key);
if (translation !== key) {
el.placeholder = translation;
}
});
// Translate tooltip and titles
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
const translation = t(key);
if (translation !== key) {
el.title = translation;
}
});
}
function checkSession() {
const savedUser = localStorage.getItem('currentUser');
const loginContainer = document.getElementById('login-container');
if (savedUser) {
state.currentUser = JSON.parse(savedUser);
loginContainer.style.display = 'none';
// Show user details in Header
updateHeaderUserDisplay();
initApp();
// startAnnouncementPolling(); // Disabled in favor of announcements.js
if (typeof window.checkAnnouncements === 'function') {
window.checkAnnouncements();
}
} else {
state.currentUser = null;
loginContainer.style.display = 'flex';
document.body.classList.remove('hide-cost-columns');
if (state.chatUnreadPollInterval) {
clearInterval(state.chatUnreadPollInterval);
state.chatUnreadPollInterval = null;
}
if (state.announcementPollInterval) {
clearInterval(state.announcementPollInterval);
state.announcementPollInterval = null;
}
stopChatThreadPolling();
if (typeof window.checkAnnouncements === 'function') {
window.checkAnnouncements();
}
}
}
function updateHeaderUserDisplay() {
if (state.currentUser) {
const nameVal = state.currentUser.username;
const roleVal = translateRole(state.currentUser.role);
const elName = document.getElementById('logged-in-user-name');
if (elName) elName.textContent = nameVal;
const elRole = document.getElementById('logged-in-user-role');
if (elRole) elRole.textContent = roleVal;
const elNameDropdown = document.getElementById('logged-in-user-name-dropdown');
if (elNameDropdown) elNameDropdown.textContent = nameVal;
const elRoleDropdown = document.getElementById('logged-in-user-role-dropdown');
if (elRoleDropdown) elRoleDropdown.textContent = roleVal;
// Toggle cost columns visibility class
const allowedRoles = ['superadmin', 'admin', 'excusive', 'procurment', 'procurement_manager', 'account_manager'];
if (allowedRoles.includes(state.currentUser.role)) {
document.body.classList.remove('hide-cost-columns');
} else {
document.body.classList.add('hide-cost-columns');
}
}
}
function translateRole(role) {
const map = {
'admin': t('role_admin'),
'supervisor': t('role_supervisor'),
'estimate': t('role_estimate'),
'user': t('role_user'),
'sale': t('role_sale'),
'design': t('role_design'),
'production': t('role_production'),
'qc': t('role_qc'),
'qa': t('role_qa'),
'stock': t('role_stock'),
'procurment': t('role_procurment'),
'support': t('role_support'),
'excusive': t('role_excusive'),
'draft': t('role_draft'),
'sale_manager': t('role_sale_manager'),
'design_manager_mech': t('role_design_manager_mech'),
'design_manager_elec': t('role_design_manager_elec'),
'consultant_mech': t('role_consultant_mech'),
'consultant_elec': t('role_consultant_elec'),
'factory_manager': t('role_factory_manager'),
'account_manager': t('role_account_manager'),
'procurement_manager': t('role_procurement_manager'),
'contractor': t('role_contractor')
};
return map[role] || role;
}
function isReadOnlyRole(role) {
return !['admin', 'superadmin', 'supervisor', 'estimate'].includes(role);
}
async function checkPendingAnnouncements() {
if (state.currentUser && state.currentUser.role === 'contractor') {
return;
}
// If the modal is already active, don't query/overwrite
const modal = document.getElementById('modal-announcement');
if (modal && modal.classList.contains('active')) {
return;
}
try {
const res = await authFetch(`${API_BASE}/announcements/pending`);
const announcements = await res.json();
if (announcements.length > 0) {
state.pendingAnnouncementsQueue = announcements;
showNextPendingAnnouncement();
}
} catch (err) {
console.error('Failed to check pending announcements:', err);
}
}
function startAnnouncementPolling() {
if (state.currentUser && state.currentUser.role === 'contractor') {
if (state.announcementPollInterval) {
clearInterval(state.announcementPollInterval);
state.announcementPollInterval = null;
}
return;
}
if (state.announcementPollInterval) clearInterval(state.announcementPollInterval);
checkPendingAnnouncements();
state.announcementPollInterval = setInterval(checkPendingAnnouncements, 15000);
}
function showNextPendingAnnouncement() {
const modal = document.getElementById('modal-announcement');
if (!state.pendingAnnouncementsQueue || state.pendingAnnouncementsQueue.length === 0) {
modal.classList.remove('active');
return;
}
const announcement = state.pendingAnnouncementsQueue[0];
document.getElementById('announcement-id').value = announcement.id;
document.getElementById('announcement-title').textContent = announcement.title;
document.getElementById('announcement-body').textContent = announcement.body;
modal.classList.add('active');
}
async function handleAcknowledgeAnnouncement() {
const id = document.getElementById('announcement-id').value;
const btn = document.getElementById('btn-acknowledge-announcement');
btn.disabled = true;
try {
await authFetch(`${API_BASE}/announcements/${id}/ack`, { method: 'POST' });
} catch (err) {
console.error('Failed to acknowledge announcement:', err);
} finally {
btn.disabled = false;
}
state.pendingAnnouncementsQueue.shift();
showNextPendingAnnouncement();
}
async function initApp() {
await fetchProjects();
await fetchCategories();
await fetchGroups();
await fetchSystems();
await populateSystemDropdowns();
applyRoleBasedUI();
checkReportsButtonVisibility();
startChatUnreadPolling();
// Start background projects polling for contractor to monitor new RFQs
if (state.currentUser && state.currentUser.role === 'contractor') {
if (state.contractorProjectsPollInterval) clearInterval(state.contractorProjectsPollInterval);
state.contractorProjectsPollInterval = setInterval(fetchProjects, 30000);
}
// Auto-select first project if available
if (state.projects.length > 0) {
selectProject(state.projects[0].id);
} else {
renderWorkspace();
}
}
function applyRoleBasedUI() {
const role = state.currentUser ? state.currentUser.role : 'user';
// Reset all access masking classes on body
document.body.classList.remove('role-user');
document.body.classList.remove('role-confidential');
document.body.classList.remove('role-contractor');
if (role === 'superadmin' || role === 'excusive' || role === 'admin' || role === 'supervisor' || role === 'estimate') {
// Executive level or full estimators / administrators - sees all prices (costs and selling prices)
} else if (role === 'sale' || role === 'sale_manager') {
// Confidential level - sees selling prices, hides costs
document.body.classList.add('role-confidential');
} else {
// ระดับทั่วไป (General) and ระดับต่ำ (Low / contractor) - hides all pricing
document.body.classList.add('role-user');
if (role === 'contractor') {
document.body.classList.add('role-contractor');
}
}
// Toggle Admin Panel button
const adminPanelBtn = document.getElementById('btn-open-admin-panel');
if (adminPanelBtn) {
adminPanelBtn.style.display = (role === 'admin' || role === 'superadmin' || role === 'supervisor' || role === 'estimate') ? 'inline-flex' : 'none';
}
// Toggle Announcements button
const announcementsBtn = document.getElementById('btn-open-announcements');
if (announcementsBtn) {
announcementsBtn.style.display = (role === 'admin' || role === 'superadmin' || role === 'excusive') ? 'inline-flex' : 'none';
}
// Toggle Super Admin System Panel button
const systemPanelBtn = document.getElementById('btn-open-system-panel');
if (systemPanelBtn) {
systemPanelBtn.style.display = role === 'superadmin' ? 'inline-flex' : 'none';
}
// Toggle Procurement Panel button
const procurementPanelBtn = document.getElementById('btn-open-procurement-panel');
if (procurementPanelBtn) {
procurementPanelBtn.style.display = ['procurment', 'admin', 'superadmin', 'excusive', 'ee_manager', 'me_manager'].includes(role) ? 'inline-flex' : 'none';
}
// Toggle Stock Panel button
const stockPanelBtn = document.getElementById('btn-open-stock-panel');
if (stockPanelBtn) {
stockPanelBtn.style.display = ['stock', 'admin', 'superadmin'].includes(role) ? 'inline-flex' : 'none';
}
// Toggle BOM & PR button in project toolbar
const openProjectBomsBtn = document.getElementById('btn-open-project-boms');
if (openProjectBomsBtn) {
openProjectBomsBtn.style.display = (role !== 'contractor') ? 'inline-flex' : 'none';
}
// Only Admin or Super Admin can manage catalog, suppliers, and templates database
const openCatalogBtn = document.getElementById('btn-open-catalog-manager');
const openSupplierBtn = document.getElementById('btn-open-supplier-manager');
const openTemplateBtn = document.getElementById('btn-open-template-manager');
const addCatalogBtn = document.getElementById('btn-add-catalog-product');
const isCatalogWriter = (role === 'admin' || role === 'superadmin' || role === 'supervisor' || role === 'estimate');
if (openCatalogBtn) openCatalogBtn.style.display = isCatalogWriter ? 'inline-flex' : 'none';
if (openSupplierBtn) openSupplierBtn.style.display = isCatalogWriter ? 'inline-flex' : 'none';
if (openTemplateBtn) openTemplateBtn.style.display = isCatalogWriter ? 'inline-flex' : 'none';
if (addCatalogBtn) addCatalogBtn.style.display = isCatalogWriter ? 'inline-flex' : 'none';
// User Role (Viewer only) - disable all create/edit buttons on workspace
const projectActions = document.querySelector('.project-info-actions');
const addSectionBtn = document.getElementById('btn-add-section');
const createProjectBtn = document.getElementById('btn-create-project');
if (isReadOnlyRole(role)) {
if (projectActions) {
projectActions.style.display = 'flex';
document.getElementById('btn-approve-project').style.display = 'none';
document.getElementById('btn-edit-proposal-letter').style.display = 'none';
document.getElementById('btn-edit-project-active').style.display = 'none';
document.getElementById('btn-clone-project-active').style.display = 'none';
document.getElementById('btn-delete-project-active').style.display = 'none';
document.getElementById('btn-print-bom-trigger').style.display = 'none';
}
if (addSectionBtn) addSectionBtn.style.display = 'none';
if (createProjectBtn) createProjectBtn.style.display = 'none';
} else {
if (projectActions) {
projectActions.style.display = 'flex';
document.getElementById('btn-approve-project').style.display = 'inline-flex';
document.getElementById('btn-edit-proposal-letter').style.display = 'inline-flex';
document.getElementById('btn-edit-project-active').style.display = 'inline-flex';
document.getElementById('btn-clone-project-active').style.display = 'inline-flex';
document.getElementById('btn-print-bom-trigger').style.display = 'inline-flex';
// Supervisors can only delete projects they created
const deleteBtn = document.getElementById('btn-delete-project-active');
if (role === 'supervisor' || role === 'estimate') {
if (state.activeProjectSummary && state.activeProjectSummary.created_by === state.currentUser.id) {
deleteBtn.style.display = 'inline-flex';
} else {
deleteBtn.style.display = 'none';
}
} else {
deleteBtn.style.display = 'inline-flex';
}
}
if (addSectionBtn) addSectionBtn.style.display = 'inline-flex';
if (createProjectBtn) createProjectBtn.style.display = 'inline-flex';
}
// If active project is soft-deleted, override and hide editing/managing actions
if (state.activeProjectSummary && state.activeProjectSummary.is_deleted === 1) {
const projectActions = document.querySelector('.project-info-actions');
const addSectionBtn = document.getElementById('btn-add-section');
if (projectActions) {
document.getElementById('btn-approve-project').style.display = 'none';
document.getElementById('btn-edit-proposal-letter').style.display = 'none';
document.getElementById('btn-edit-project-active').style.display = 'none';
document.getElementById('btn-clone-project-active').style.display = 'none';
document.getElementById('btn-delete-project-active').style.display = 'none';
document.getElementById('btn-print-bom-trigger').style.display = 'none';
}
if (addSectionBtn) addSectionBtn.style.display = 'none';
}
// Contractor role adjustments
const isContractor = role === 'contractor';
const rfqDirectBtn = document.getElementById('btn-open-project-rfq-direct');
const contractorDropdown = document.getElementById('btn-contractor-dropdown-trigger')?.parentElement;
const exportDropdown = document.getElementById('btn-export-dropdown-trigger')?.parentElement;
const summaryCards = document.querySelector('.summary-cards-grid');
// Toggle app-header layout for contractor to align actions left
const appHeader = document.querySelector('.app-header');
if (appHeader) {
if (isContractor) {
appHeader.style.justifyContent = 'flex-start';
appHeader.style.gap = '32px';
} else {
appHeader.style.justifyContent = 'space-between';
appHeader.style.gap = '';
}
}
if (isContractor) {
if (rfqDirectBtn) rfqDirectBtn.style.display = 'none';
if (contractorDropdown) contractorDropdown.style.display = 'none';
if (exportDropdown) {
exportDropdown.style.display = 'none';
}
// Show contractor-specific buttons
const extraBtn = document.getElementById('btn-open-extra-work');
const deductBtn = document.getElementById('btn-open-deduct-work');
const billingBtn = document.getElementById('btn-open-billing');
const complaintsBtn = document.getElementById('btn-open-complaints');
const rfqWrapper = document.getElementById('contractor-rfq-dropdown-wrapper');
const projectChatBtn = document.getElementById('btn-open-project-chat');
if (extraBtn) extraBtn.style.display = 'inline-flex';
if (deductBtn) deductBtn.style.display = 'inline-flex';
if (billingBtn) billingBtn.style.display = 'inline-flex';
if (complaintsBtn) complaintsBtn.style.display = 'inline-flex';
if (rfqWrapper) rfqWrapper.style.display = 'inline-block';
if (projectChatBtn) projectChatBtn.style.display = 'inline-flex';
// Hide other toolbar buttons
document.getElementById('btn-open-project-docs').style.display = 'none';
document.getElementById('btn-open-project-progress').style.display = 'none';
document.getElementById('btn-open-project-fat').style.display = 'inline-flex';
document.getElementById('btn-open-project-test-script').style.display = 'none';
document.getElementById('btn-open-revisions').style.display = 'none';
document.getElementById('btn-open-attachments').style.display = 'none';
// Hide RFQ creation trigger for contractor
const createRfqBtn = document.getElementById('btn-create-rfq-trigger');
if (createRfqBtn) createRfqBtn.style.display = 'none';
// Hide chat button for contractor
const chatBtn = document.getElementById('btn-open-chat');
if (chatBtn) chatBtn.style.display = 'none';
// Align FAT and RFQ buttons to the left next to project details
const metaHeader = document.querySelector('.project-meta-header');
if (metaHeader) {
metaHeader.style.justifyContent = 'flex-start';
metaHeader.style.gap = '24px';
}
if (summaryCards) summaryCards.style.display = 'none';
// Populate dynamic RFQ dropdown items
const rfqContainer = document.getElementById('contractor-rfq-dropdown-menu');
const rfqGlobalBadge = document.getElementById('contractor-rfq-global-badge');
if (state.activeProjectId && rfqContainer) {
authFetch(`${API_BASE}/projects/${state.activeProjectId}/contractor-rfqs`)
.then(res => {
if (res.ok) return res.json();
return [];
})
.then(rfqs => {
rfqContainer.innerHTML = '';
let hasAnyNew = false;
if (rfqs.length === 0) {
rfqContainer.innerHTML = `
<div style="padding: 10px 16px; font-size: 0.85rem; color: var(--text-muted); text-align: center;">
${state.currentLanguage === 'en' ? 'No RFQs' : 'ไม่มีใบขอราคา'}
</div>
`;
} else {
rfqs.forEach(rfq => {
const isNew = rfq.status === 'sent';
if (isNew) hasAnyNew = true;
const item = document.createElement('button');
item.className = 'dropdown-item';
item.innerHTML = `
<i class="fa-solid fa-file-invoice-dollar text-success"></i>
<span>${state.currentLanguage === 'en' ? 'Quotation Request' : 'ขอใบราคา'} ${escapeHtml(rfq.rfq_number)}</span>
${isNew ? '<span class="badge bg-danger" style="background-color:#ef4444; color:white; font-size:0.65rem; font-weight:700; padding:1px 4px; border-radius:10px; margin-left:6px; animation: pulse 2s infinite;">New</span>' : ''}
`;
item.addEventListener('click', async () => {
rfqContainer.classList.remove('active');
state.selectedRfqId = rfq.id;
await fetchActiveProjectDetails();
renderWorkspace();
openRfqDetailModal(rfq.id);
});
rfqContainer.appendChild(item);
});
}
if (rfqGlobalBadge) {
rfqGlobalBadge.style.display = hasAnyNew ? 'inline-block' : 'none';
}
})
.catch(err => console.error('Failed to fetch contractor RFQs:', err));
}
} else {
if (rfqDirectBtn) rfqDirectBtn.style.display = 'none';
if (contractorDropdown) contractorDropdown.style.display = 'inline-block';
if (exportDropdown) {
exportDropdown.style.display = 'inline-block';
const bomTrigger = document.getElementById('btn-print-bom-trigger');
const freeIssueTrigger = document.getElementById('btn-print-free-issue-trigger');
if (bomTrigger) bomTrigger.style.display = 'block';
if (freeIssueTrigger) freeIssueTrigger.style.display = 'block';
}
// Hide contractor-specific buttons for others
const extraBtn = document.getElementById('btn-open-extra-work');
const deductBtn = document.getElementById('btn-open-deduct-work');
const billingBtn = document.getElementById('btn-open-billing');
const complaintsBtn = document.getElementById('btn-open-complaints');
const rfqWrapper = document.getElementById('contractor-rfq-dropdown-wrapper');
const projectChatBtn = document.getElementById('btn-open-project-chat');
if (extraBtn) extraBtn.style.display = 'none';
if (deductBtn) deductBtn.style.display = 'none';
if (billingBtn) billingBtn.style.display = 'none';
if (complaintsBtn) complaintsBtn.style.display = 'none';
if (projectChatBtn) projectChatBtn.style.display = 'none';
if (rfqWrapper) {
rfqWrapper.style.display = 'none';
const rfqContainer = document.getElementById('contractor-rfq-dropdown-menu');
if (rfqContainer) rfqContainer.innerHTML = '';
}
// Restore defaults for non-contractor roles
document.getElementById('btn-open-project-docs').style.display = 'inline-flex';
document.getElementById('btn-open-project-progress').style.display = 'inline-flex';
document.getElementById('btn-open-project-fat').style.display = 'inline-flex';
document.getElementById('btn-open-project-test-script').style.display = 'inline-flex';
document.getElementById('btn-open-revisions').style.display = 'inline-flex';
document.getElementById('btn-open-attachments').style.display = 'inline-flex';
const chatBtn = document.getElementById('btn-open-chat');
if (chatBtn) chatBtn.style.display = 'inline-flex';
// Show RFQ creation trigger for others
const createRfqBtn = document.getElementById('btn-create-rfq-trigger');
if (createRfqBtn) createRfqBtn.style.display = 'inline-flex';
const alarmBadge = document.getElementById('rfq-alarm-badge');
if (alarmBadge) alarmBadge.style.display = 'none';
// Restore default layout
const metaHeader = document.querySelector('.project-meta-header');
if (metaHeader) {
metaHeader.style.justifyContent = 'space-between';
metaHeader.style.gap = '';
}
if (summaryCards) summaryCards.style.display = '';
}
}
function setupContractorFeatures() {
// Helper to get active project ID
const getActiveId = () => state.activeProjectId;
// 1. Extra Works Handlers
const openExtraBtn = document.getElementById('btn-open-extra-work');
const modalExtra = document.getElementById('modal-extra-works');
const formExtra = document.getElementById('form-create-extra-work');
const btnCreateExtra = document.getElementById('btn-create-extra-work-trigger');
const btnCancelExtra = document.getElementById('btn-cancel-extra-work');
const extraTbody = document.getElementById('extra-works-tbody');
const btnOrderExtra = document.getElementById('btn-order-extra-work-trigger');
const formOrderExtra = document.getElementById('form-order-extra-work');
const btnCancelOrderExtra = document.getElementById('btn-cancel-order-extra-work');
const orderExtraContractorSelect = document.getElementById('order-extra-work-contractor');
async function populateContractorSelect(selectEl, pId) {
if (!selectEl) return;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/contractor-rfqs`);
if (!res.ok) return;
const rfqs = await res.json();
const seen = new Map();
rfqs.forEach(r => {
if (!seen.has(r.contractor_id)) seen.set(r.contractor_id, r.contractor_name);
});
selectEl.innerHTML = Array.from(seen.entries()).map(([id, name]) =>
`<option value="${id}">${escapeHtml(name)}</option>`
).join('');
if (seen.size === 0) {
selectEl.innerHTML = `<option value="">ไม่มีผู้รับเหมาสำหรับโครงการนี้</option>`;
}
} catch (err) {
console.error(err);
}
}
const openExtraHandler = () => {
const pId = getActiveId();
if (!pId) {
alert('Please select a project first');
return;
}
modalExtra.classList.add('active');
if (formExtra) formExtra.style.display = 'none';
if (formOrderExtra) formOrderExtra.style.display = 'none';
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
const isCreator = state.activeProjectSummary && state.activeProjectSummary.created_by === state.currentUser?.id;
const isAdmin = state.currentUser?.role === 'admin' || state.currentUser?.role === 'superadmin';
if (btnCreateExtra) {
btnCreateExtra.style.display = isContractor ? 'inline-flex' : 'none';
}
if (btnOrderExtra) {
btnOrderExtra.style.display = (isCreator || isAdmin) ? 'inline-flex' : 'none';
}
if (isCreator || isAdmin) {
populateContractorSelect(orderExtraContractorSelect, pId);
}
loadExtraWorks();
};
if (openExtraBtn && modalExtra) {
openExtraBtn.addEventListener('click', openExtraHandler);
}
const adminOpenExtraBtn = document.getElementById('btn-admin-open-extra-work');
if (adminOpenExtraBtn && modalExtra) {
adminOpenExtraBtn.addEventListener('click', () => {
document.getElementById('contractor-dropdown-menu')?.classList.remove('active');
openExtraHandler();
});
}
if (btnCreateExtra && formExtra) {
btnCreateExtra.addEventListener('click', () => {
formExtra.style.display = formExtra.style.display === 'none' ? 'block' : 'none';
});
}
if (btnCancelExtra && formExtra) {
btnCancelExtra.addEventListener('click', () => {
formExtra.style.display = 'none';
formExtra.reset();
});
}
if (formExtra) {
formExtra.addEventListener('submit', async (e) => {
e.preventDefault();
const pId = getActiveId();
if (!pId) return;
const work_name = document.getElementById('extra-work-name').value;
const quantity = document.getElementById('extra-work-qty').value;
const estimated_cost = document.getElementById('extra-work-cost').value;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/extra-works`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ work_name, quantity, estimated_cost })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Extra work request submitted successfully' : 'ส่งคำขอเพิ่มงานเรียบร้อยแล้ว');
formExtra.reset();
formExtra.style.display = 'none';
loadExtraWorks();
} else {
const err = await res.json();
alert(err.error || 'Failed to submit extra work request');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
}
if (btnOrderExtra && formOrderExtra) {
btnOrderExtra.addEventListener('click', () => {
formOrderExtra.style.display = formOrderExtra.style.display === 'none' ? 'block' : 'none';
});
}
if (btnCancelOrderExtra && formOrderExtra) {
btnCancelOrderExtra.addEventListener('click', () => {
formOrderExtra.style.display = 'none';
formOrderExtra.reset();
});
}
const orderExtraQty = document.getElementById('order-extra-work-qty');
const orderExtraMaterialRate = document.getElementById('order-extra-work-material-rate');
const orderExtraLabourRate = document.getElementById('order-extra-work-labour-rate');
const orderExtraTotal = document.getElementById('order-extra-work-total');
function recomputeOrderExtraTotal() {
if (!orderExtraTotal) return;
const qty = parseFloat(orderExtraQty?.value) || 0;
const matRate = parseFloat(orderExtraMaterialRate?.value) || 0;
const labRate = parseFloat(orderExtraLabourRate?.value) || 0;
const total = qty * (matRate + labRate);
orderExtraTotal.textContent = `฿${total.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})}`;
}
[orderExtraQty, orderExtraMaterialRate, orderExtraLabourRate].forEach(el => {
if (el) el.addEventListener('input', recomputeOrderExtraTotal);
});
if (formOrderExtra) {
formOrderExtra.addEventListener('submit', async (e) => {
e.preventDefault();
const pId = getActiveId();
if (!pId) return;
const contractor_id = orderExtraContractorSelect?.value;
const work_name = document.getElementById('order-extra-work-name').value;
const section_name = document.getElementById('order-extra-work-section').value;
const brand = document.getElementById('order-extra-work-brand').value;
const quantity = orderExtraQty.value;
const unit = document.getElementById('order-extra-work-unit').value;
const material_rate = orderExtraMaterialRate.value;
const labour_rate = orderExtraLabourRate.value;
if (!contractor_id) {
alert(state.currentLanguage === 'en' ? 'Please select a contractor' : 'กรุณาเลือกผู้รับเหมา');
return;
}
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/extra-works`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contractor_id, work_name, section_name, brand, quantity, unit, material_rate, labour_rate })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Extra work order saved successfully' : 'บันทึกงานสั่งเพิ่มเรียบร้อยแล้ว');
formOrderExtra.reset();
formOrderExtra.style.display = 'none';
recomputeOrderExtraTotal();
loadExtraWorks();
} else {
const err = await res.json();
alert(err.error || 'Failed to order extra work');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
}
async function loadExtraWorks() {
const pId = getActiveId();
if (!pId || !extraTbody) return;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/extra-works`);
if (res.ok) {
const data = await res.json();
extraTbody.innerHTML = data.map(item => {
const detailParts = [item.section_name, item.brand].filter(Boolean).map(escapeHtml);
const detailLine = detailParts.length ? `<br><small style="color:var(--text-secondary);">${detailParts.join(' / ')}</small>` : '';
const statusText = item.status === 'approved' ? (state.currentLanguage === 'en' ? 'Approved' : 'อนุมัติแล้ว') :
item.status === 'rejected' ? (state.currentLanguage === 'en' ? 'Rejected' : 'ปฏิเสธแล้ว') :
(state.currentLanguage === 'en' ? 'Pending' : 'รอดำเนินการ');
return `
<tr>
<td>${parseDbDate(item.created_at).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH')}</td>
<td>${escapeHtml(item.contractor_name || '')}</td>
<td><strong>${escapeHtml(item.work_name)}</strong>${detailLine}</td>
<td>${parseFloat(item.quantity).toLocaleString()}${item.unit ? ' ' + escapeHtml(item.unit) : ''}</td>
<td>฿${parseFloat(item.estimated_cost).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})}</td>
<td><span class="badge badge-${item.status === 'approved' ? 'success' : item.status === 'rejected' ? 'danger' : 'warning'}">${statusText}</span></td>
</tr>
`;
}).join('');
if (data.length === 0) {
extraTbody.innerHTML = `<tr><td colspan="6" style="text-align:center;">${state.currentLanguage === 'en' ? 'No extra work records found' : 'ไม่มีข้อมูลรายการงานเพิ่ม'}</td></tr>`;
}
}
} catch (err) {
console.error(err);
}
}
// 2. Deduct Works Handlers
const openDeductBtn = document.getElementById('btn-open-deduct-work');
const modalDeduct = document.getElementById('modal-deduct-works');
const formDeduct = document.getElementById('form-create-deduct-work');
const btnCreateDeduct = document.getElementById('btn-create-deduct-work-trigger');
const btnCancelDeduct = document.getElementById('btn-cancel-deduct-work');
const deductTbody = document.getElementById('deduct-works-tbody');
const btnRecordDeduct = document.getElementById('btn-record-deduct-work-trigger');
const formRecordDeduct = document.getElementById('form-record-deduct-work');
const btnCancelRecordDeduct = document.getElementById('btn-cancel-record-deduct-work');
const recordDeductContractorSelect = document.getElementById('record-deduct-work-contractor');
const recordDeductItemSelect = document.getElementById('record-deduct-work-item');
const recordDeductQuotedQty = document.getElementById('record-deduct-work-quoted-qty');
const recordDeductActualQty = document.getElementById('record-deduct-work-actual-qty');
const recordDeductRate = document.getElementById('record-deduct-work-rate');
const recordDeductVariance = document.getElementById('record-deduct-work-variance');
const openDeductHandler = () => {
const pId = getActiveId();
if (!pId) {
alert('Please select a project first');
return;
}
modalDeduct.classList.add('active');
if (formDeduct) formDeduct.style.display = 'none';
if (formRecordDeduct) formRecordDeduct.style.display = 'none';
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
const isCreator = state.activeProjectSummary && state.activeProjectSummary.created_by === state.currentUser?.id;
const isAdmin = state.currentUser?.role === 'admin' || state.currentUser?.role === 'superadmin';
if (btnCreateDeduct) {
btnCreateDeduct.style.display = isContractor ? 'inline-flex' : 'none';
}
if (btnRecordDeduct) {
btnRecordDeduct.style.display = (isCreator || isAdmin) ? 'inline-flex' : 'none';
}
if (isCreator || isAdmin) {
populateContractorSelect(recordDeductContractorSelect, pId).then(() => {
if (recordDeductContractorSelect?.value) loadDeductContractItems();
});
}
loadDeductWorks();
};
if (openDeductBtn && modalDeduct) {
openDeductBtn.addEventListener('click', openDeductHandler);
}
const adminOpenDeductBtn = document.getElementById('btn-admin-open-deduct-work');
if (adminOpenDeductBtn && modalDeduct) {
adminOpenDeductBtn.addEventListener('click', () => {
document.getElementById('contractor-dropdown-menu')?.classList.remove('active');
openDeductHandler();
});
}
if (btnCreateDeduct && formDeduct) {
btnCreateDeduct.addEventListener('click', () => {
formDeduct.style.display = formDeduct.style.display === 'none' ? 'block' : 'none';
});
}
if (btnCancelDeduct && formDeduct) {
btnCancelDeduct.addEventListener('click', () => {
formDeduct.style.display = 'none';
formDeduct.reset();
});
}
if (formDeduct) {
formDeduct.addEventListener('submit', async (e) => {
e.preventDefault();
const pId = getActiveId();
if (!pId) return;
const item_name = document.getElementById('deduct-work-name').value;
const quantity = document.getElementById('deduct-work-qty').value;
const deduct_amount = document.getElementById('deduct-work-amount').value;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/deduct-works`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_name, quantity, deduct_amount })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Deducted work request submitted successfully' : 'เสนอลดงานเรียบร้อยแล้ว');
formDeduct.reset();
formDeduct.style.display = 'none';
loadDeductWorks();
} else {
const err = await res.json();
alert(err.error || 'Failed to submit deduct work request');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
}
if (btnRecordDeduct && formRecordDeduct) {
btnRecordDeduct.addEventListener('click', () => {
formRecordDeduct.style.display = formRecordDeduct.style.display === 'none' ? 'block' : 'none';
});
}
if (btnCancelRecordDeduct && formRecordDeduct) {
btnCancelRecordDeduct.addEventListener('click', () => {
formRecordDeduct.style.display = 'none';
formRecordDeduct.reset();
resetDeductItemSelect();
});
}
function resetDeductItemSelect() {
if (recordDeductItemSelect) {
recordDeductItemSelect.innerHTML = `<option value="">-- เลือกผู้รับเหมาก่อน --</option>`;
recordDeductItemSelect.disabled = true;
}
if (recordDeductQuotedQty) recordDeductQuotedQty.value = '-';
if (recordDeductRate) recordDeductRate.value = '-';
if (recordDeductVariance) recordDeductVariance.textContent = '-';
}
async function loadDeductContractItems() {
const pId = getActiveId();
const contractorId = recordDeductContractorSelect?.value;
if (!pId || !contractorId || !recordDeductItemSelect) return;
recordDeductItemSelect.innerHTML = `<option value="">กำลังโหลด...</option>`;
recordDeductItemSelect.disabled = true;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/contract-quotation?contractor_id=${contractorId}`);
if (res.ok) {
const data = await res.json();
if (!data.items || data.items.length === 0) {
recordDeductItemSelect.innerHTML = `<option value="">ไม่พบใบเสนอราคาที่อนุมัติของผู้รับเหมานี้</option>`;
return;
}
recordDeductItemSelect.innerHTML = data.items.map(it => {
const rate = (parseFloat(it.material_rate) || 0) + (parseFloat(it.labour_rate) || 0);
const label = [it.section_name, it.description].filter(Boolean).join(' - ');
return `<option value="${it.id}" data-qty="${it.qty}" data-unit="${escapeHtml(it.unit || '')}" data-rate="${rate}">${escapeHtml(label)}</option>`;
}).join('');
recordDeductItemSelect.disabled = false;
recordDeductItemSelect.dispatchEvent(new Event('change'));
}
} catch (err) {
console.error(err);
}
}
if (recordDeductContractorSelect) {
recordDeductContractorSelect.addEventListener('change', () => {
resetDeductItemSelect();
loadDeductContractItems();
});
}
function updateDeductVariancePreview() {
if (!recordDeductVariance) return;
const selectedOption = recordDeductItemSelect?.options[recordDeductItemSelect.selectedIndex];
const quotedQty = parseFloat(selectedOption?.dataset.qty) || 0;
const rate = parseFloat(selectedOption?.dataset.rate) || 0;
const actualQty = parseFloat(recordDeductActualQty?.value) || 0;
const varianceQty = quotedQty - actualQty;
const amount = varianceQty * rate;
const label = amount >= 0 ? 'ลด' : 'เพิ่ม';
const color = amount >= 0 ? 'var(--success-color, #16a34a)' : 'var(--danger-color, #dc2626)';
recordDeductVariance.innerHTML = `<span style="color:${color};">฿${Math.abs(amount).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})} (${label})</span>`;
}
if (recordDeductItemSelect) {
recordDeductItemSelect.addEventListener('change', () => {
const selectedOption = recordDeductItemSelect.options[recordDeductItemSelect.selectedIndex];
const qty = selectedOption?.dataset.qty;
const unit = selectedOption?.dataset.unit;
const rate = selectedOption?.dataset.rate;
if (recordDeductQuotedQty) recordDeductQuotedQty.value = qty !== undefined ? `${parseFloat(qty).toLocaleString()} ${unit || ''}`.trim() : '-';
if (recordDeductRate) recordDeductRate.value = rate !== undefined ? `฿${parseFloat(rate).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})}` : '-';
updateDeductVariancePreview();
});
}
if (recordDeductActualQty) {
recordDeductActualQty.addEventListener('input', updateDeductVariancePreview);
}
if (formRecordDeduct) {
formRecordDeduct.addEventListener('submit', async (e) => {
e.preventDefault();
const pId = getActiveId();
if (!pId) return;
const contractor_id = recordDeductContractorSelect?.value;
const rfq_item_id = recordDeductItemSelect?.value;
const actual_qty = recordDeductActualQty?.value;
if (!contractor_id || !rfq_item_id) {
alert(state.currentLanguage === 'en' ? 'Please select a contractor and an item from the quotation' : 'กรุณาเลือกผู้รับเหมาและรายการในใบเสนอราคา');
return;
}
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/deduct-works`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contractor_id, rfq_item_id, actual_qty })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Deduct/extra work recorded successfully' : 'บันทึกงานลด/เพิ่มเรียบร้อยแล้ว');
formRecordDeduct.reset();
formRecordDeduct.style.display = 'none';
resetDeductItemSelect();
loadDeductWorks();
} else {
const err = await res.json();
alert(err.error || 'Failed to record deduct work');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
}
async function loadDeductWorks() {
const pId = getActiveId();
if (!pId || !deductTbody) return;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/deduct-works`);
if (res.ok) {
const data = await res.json();
deductTbody.innerHTML = data.map(item => {
const amount = parseFloat(item.deduct_amount) || 0;
const isAdd = amount < 0;
const amountLabel = `฿${Math.abs(amount).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})}${isAdd ? (state.currentLanguage === 'en' ? ' (Add)' : ' (เพิ่ม)') : ''}`;
const quotedText = state.currentLanguage === 'en' ? 'Quoted: ' : 'ตามสัญญา: ';
const actualText = state.currentLanguage === 'en' ? ' / Actual: ' : ' / ติดตั้งจริง: ';
const detailLine = item.quoted_qty ? `<br><small style="color:var(--text-secondary);">${quotedText}${parseFloat(item.quoted_qty).toLocaleString()}${actualText}${parseFloat(item.actual_qty).toLocaleString()}</small>` : '';
const statusText = item.status === 'approved' ? (state.currentLanguage === 'en' ? 'Approved' : 'อนุมัติแล้ว') :
item.status === 'rejected' ? (state.currentLanguage === 'en' ? 'Rejected' : 'ปฏิเสธแล้ว') :
(state.currentLanguage === 'en' ? 'Pending' : 'รอดำเนินการ');
return `
<tr>
<td>${parseDbDate(item.created_at).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH')}</td>
<td>${escapeHtml(item.contractor_name || '')}</td>
<td><strong>${escapeHtml(item.item_name)}</strong>${detailLine}</td>
<td>${parseFloat(item.quantity).toLocaleString()}</td>
<td style="${isAdd ? 'color:var(--danger-color, #dc2626);' : ''}">${amountLabel}</td>
<td><span class="badge badge-${item.status === 'approved' ? 'success' : item.status === 'rejected' ? 'danger' : 'warning'}">${statusText}</span></td>
</tr>
`;
}).join('');
if (data.length === 0) {
deductTbody.innerHTML = `<tr><td colspan="6" style="text-align:center;">${state.currentLanguage === 'en' ? 'No deducted work records found' : 'ไม่มีข้อมูลรายการงานลด'}</td></tr>`;
}
}
} catch (err) {
console.error(err);
}
}
// 2b. Project Chat Handlers
const btnOpenProjectChat = document.getElementById('btn-open-project-chat');
if (btnOpenProjectChat) {
btnOpenProjectChat.addEventListener('click', async () => {
const pId = getActiveId();
if (!pId) {
alert('Please select a project first');
return;
}
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/chat/open`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
await openChatModal();
await openConversationThread(data.conversation_id, data.name);
} else {
const err = await res.json();
alert(err.error || 'Failed to open chat');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
}
const btnAdminOpenProjectChat = document.getElementById('btn-admin-open-project-chat');
const modalProjectChatPicker = document.getElementById('modal-project-chat-picker');
const projectChatPickerContractor = document.getElementById('project-chat-picker-contractor');
const btnProjectChatPickerOpen = document.getElementById('btn-project-chat-picker-open');
if (btnAdminOpenProjectChat && modalProjectChatPicker) {
btnAdminOpenProjectChat.addEventListener('click', () => {
document.getElementById('contractor-dropdown-menu')?.classList.remove('active');
const pId = getActiveId();
if (!pId) {
alert('Please select a project first');
return;
}
modalProjectChatPicker.classList.add('active');
populateContractorSelect(projectChatPickerContractor, pId);
});
}
if (btnProjectChatPickerOpen) {
btnProjectChatPickerOpen.addEventListener('click', async () => {
const pId = getActiveId();
const contractor_id = projectChatPickerContractor?.value;
if (!pId || !contractor_id) {
alert('กรุณาเลือกผู้รับเหมา');
return;
}
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/chat/open`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contractor_id })
});
if (res.ok) {
const data = await res.json();
modalProjectChatPicker.classList.remove('active');
await openChatModal();
await openConversationThread(data.conversation_id, data.name);
} else {
const err = await res.json();
alert(err.error || 'Failed to open chat');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
}
// 3. Billing Invoices Handlers
const openBillingBtn = document.getElementById('btn-open-billing');
const modalBilling = document.getElementById('modal-billing');
const formInvoice = document.getElementById('form-create-invoice');
const btnCreateInvoice = document.getElementById('btn-create-invoice-trigger');
const btnCancelInvoice = document.getElementById('btn-cancel-invoice');
const invoicesTbody = document.getElementById('invoices-tbody');
if (openBillingBtn && modalBilling) {
openBillingBtn.addEventListener('click', () => {
const pId = getActiveId();
if (!pId) {
alert('Please select a project first');
return;
}
modalBilling.classList.add('active');
if (formInvoice) formInvoice.style.display = 'none';
loadInvoices();
});
}
if (btnCreateInvoice && formInvoice) {
btnCreateInvoice.addEventListener('click', () => {
formInvoice.style.display = formInvoice.style.display === 'none' ? 'block' : 'none';
});
}
if (btnCancelInvoice && formInvoice) {
btnCancelInvoice.addEventListener('click', () => {
formInvoice.style.display = 'none';
formInvoice.reset();
});
}
if (formInvoice) {
formInvoice.addEventListener('submit', async (e) => {
e.preventDefault();
const pId = getActiveId();
if (!pId) return;
const invoice_no = document.getElementById('invoice-no').value;
const description = document.getElementById('invoice-desc').value;
const amount = document.getElementById('invoice-amount').value;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/invoices`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_no, description, amount })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Invoice submitted successfully' : 'ส่งเอกสารวางบิลเรียบร้อยแล้ว');
formInvoice.reset();
formInvoice.style.display = 'none';
loadInvoices();
} else {
const err = await res.json();
alert(err.error || 'Failed to submit invoice');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
}
async function loadInvoices() {
const pId = getActiveId();
if (!pId || !invoicesTbody) return;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/invoices`);
if (res.ok) {
const data = await res.json();
invoicesTbody.innerHTML = data.map(item => {
const statusText = item.status === 'paid' ? (state.currentLanguage === 'en' ? 'Paid' : 'ชำระแล้ว') :
item.status === 'overdue' ? (state.currentLanguage === 'en' ? 'Overdue' : 'เกินกำหนด') :
(state.currentLanguage === 'en' ? 'Pending' : 'รอชำระ');
return `
<tr>
<td>${parseDbDate(item.created_at).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH')}</td>
<td>${escapeHtml(item.contractor_name || '')}</td>
<td><code>${escapeHtml(item.invoice_no)}</code></td>
<td>${escapeHtml(item.description)}</td>
<td>฿${parseFloat(item.amount).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})}</td>
<td><span class="badge badge-${item.status === 'paid' ? 'success' : item.status === 'overdue' ? 'danger' : 'warning'}">${statusText}</span></td>
</tr>
`;
}).join('');
if (data.length === 0) {
invoicesTbody.innerHTML = `<tr><td colspan="6" style="text-align:center;">${state.currentLanguage === 'en' ? 'No billing records found' : 'ไม่มีข้อมูลเอกสารวางบิล'}</td></tr>`;
}
}
} catch (err) {
console.error(err);
}
}
// 4. Complaints Handlers
const openComplaintsBtn = document.getElementById('btn-open-complaints');
const modalComplaints = document.getElementById('modal-complaints');
const formComplaint = document.getElementById('form-create-complaint');
const btnCreateComplaint = document.getElementById('btn-create-complaint-trigger');
const btnCancelComplaint = document.getElementById('btn-cancel-complaint');
const complaintsTbody = document.getElementById('complaints-tbody');
if (openComplaintsBtn && modalComplaints) {
openComplaintsBtn.addEventListener('click', () => {
const pId = getActiveId();
if (!pId) {
alert('Please select a project first');
return;
}
modalComplaints.classList.add('active');
if (formComplaint) formComplaint.style.display = 'none';
loadComplaints();
});
}
if (btnCreateComplaint && formComplaint) {
btnCreateComplaint.addEventListener('click', () => {
formComplaint.style.display = formComplaint.style.display === 'none' ? 'block' : 'none';
});
}
if (btnCancelComplaint && formComplaint) {
btnCancelComplaint.addEventListener('click', () => {
formComplaint.style.display = 'none';
formComplaint.reset();
});
}
if (formComplaint) {
formComplaint.addEventListener('submit', async (e) => {
e.preventDefault();
const pId = getActiveId();
if (!pId) return;
const title = document.getElementById('complaint-title').value;
const detail = document.getElementById('complaint-detail').value;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/complaints`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, detail })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Complaint submitted successfully' : 'ส่งเรื่องร้องเรียนเรียบร้อยแล้ว');
formComplaint.reset();
formComplaint.style.display = 'none';
loadComplaints();
} else {
const err = await res.json();
alert(err.error || 'Failed to submit complaint');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
}
async function loadComplaints() {
const pId = getActiveId();
if (!pId || !complaintsTbody) return;
try {
const res = await authFetch(`${API_BASE}/projects/${pId}/complaints`);
if (res.ok) {
const data = await res.json();
complaintsTbody.innerHTML = data.map(item => {
const statusText = item.status === 'resolved' ? (state.currentLanguage === 'en' ? 'Resolved' : 'แก้ไขแล้ว') :
item.status === 'in_progress' ? (state.currentLanguage === 'en' ? 'In Progress' : 'กำลังดำเนินการ') :
(state.currentLanguage === 'en' ? 'Pending' : 'รอดำเนินการ');
return `
<tr>
<td>${parseDbDate(item.created_at).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH')}</td>
<td>${escapeHtml(item.contractor_name || '')}</td>
<td><strong>${escapeHtml(item.title)}</strong></td>
<td>${escapeHtml(item.detail)}</td>
<td><span class="badge badge-${item.status === 'resolved' ? 'success' : item.status === 'in_progress' ? 'info' : 'warning'}">${statusText}</span></td>
</tr>
`;
}).join('');
if (data.length === 0) {
complaintsTbody.innerHTML = `<tr><td colspan="5" style="text-align:center;">${state.currentLanguage === 'en' ? 'No complaints found' : 'ไม่มีข้อมูลเรื่องร้องเรียน'}</td></tr>`;
}
}
} catch (err) {
console.error(err);
}
}
}
function setupEventListeners() {
setupContractorFeatures();
// Global event delegation for PDF spec sheets
document.addEventListener('click', (e) => {
const pdfLink = e.target.closest('.pdf-spec-link');
if (pdfLink) {
e.preventDefault();
e.stopPropagation();
const productId = pdfLink.getAttribute('data-product-id');
const description = pdfLink.getAttribute('data-description');
if (productId) {
openPdfViewer(productId, description);
}
}
});
// Workspace tabs handler
document.querySelectorAll('.workspace-mode-tabs .btn-mode-tab').forEach(btn => {
btn.addEventListener('click', (e) => {
const mode = e.currentTarget.id.replace('btn-mode-', '');
state.workspaceMode = mode;
renderWorkspace();
});
});
// Main Menu Dropdown Toggle
const mainMenuBtn = document.getElementById('btn-main-menu-trigger');
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
if (mainMenuBtn && mainMenuDropdown) {
mainMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
mainMenuDropdown.classList.toggle('active');
// Close other dropdowns if open
document.getElementById('project-selector-dropdown')?.classList.remove('active');
document.getElementById('export-dropdown-menu')?.classList.remove('active');
document.getElementById('contractor-rfq-dropdown-menu')?.classList.remove('active');
document.getElementById('announcement-bell-dropdown')?.classList.remove('active');
});
}
// Auto-close main menu dropdown when clicking any menu-dropdown-item except message bell
document.querySelectorAll('.menu-dropdown-item').forEach(item => {
item.addEventListener('click', (e) => {
if (item.id !== 'btn-open-announcements-bell') {
mainMenuDropdown?.classList.remove('active');
}
});
});
// Close main menu dropdown when clicking outside
document.addEventListener('click', (e) => {
if (mainMenuDropdown && !mainMenuDropdown.contains(e.target) && e.target !== mainMenuBtn && !mainMenuBtn.contains(e.target)) {
mainMenuDropdown.classList.remove('active');
}
});
// Project Selector Dropdown Toggle
const projSelectorBtn = document.getElementById('btn-project-selector');
const projSelectorDropdown = document.getElementById('project-selector-dropdown');
if (projSelectorBtn && projSelectorDropdown) {
projSelectorBtn.addEventListener('click', (e) => {
e.stopPropagation();
projSelectorDropdown.classList.toggle('active');
// Close other dropdowns if open
document.getElementById('export-dropdown-menu')?.classList.remove('active');
document.getElementById('contractor-rfq-dropdown-menu')?.classList.remove('active');
});
}
// Export/Print Dropdown Toggle
const exportBtn = document.getElementById('btn-export-dropdown-trigger');
const exportMenu = document.getElementById('export-dropdown-menu');
if (exportBtn && exportMenu) {
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.classList.toggle('active');
// Close other dropdowns if open
document.getElementById('project-selector-dropdown')?.classList.remove('active');
document.getElementById('contractor-rfq-dropdown-menu')?.classList.remove('active');
});
}
// Contractor RFQ Dropdown Toggle
const contractorRfqBtn = document.getElementById('btn-contractor-rfq-dropdown');
const contractorRfqMenu = document.getElementById('contractor-rfq-dropdown-menu');
if (contractorRfqBtn && contractorRfqMenu) {
contractorRfqBtn.addEventListener('click', (e) => {
e.stopPropagation();
contractorRfqMenu.classList.toggle('active');
// Close other dropdowns if open
document.getElementById('project-selector-dropdown')?.classList.remove('active');
document.getElementById('export-dropdown-menu')?.classList.remove('active');
});
}
// Close dropdowns on outside click
document.addEventListener('click', (e) => {
if (projSelectorDropdown && !projSelectorDropdown.contains(e.target) && e.target !== projSelectorBtn) {
projSelectorDropdown.classList.remove('active');
}
if (exportMenu && !exportMenu.contains(e.target) && e.target !== exportBtn) {
exportMenu.classList.remove('active');
}
if (contractorRfqMenu && !contractorRfqMenu.contains(e.target) && e.target !== contractorRfqBtn && !contractorRfqBtn.contains(e.target)) {
contractorRfqMenu.classList.remove('active');
}
});
// Project Dropdown Search Input
const dropdownSearch = document.getElementById('project-dropdown-search');
if (dropdownSearch) {
dropdownSearch.addEventListener('input', () => {
renderSidebar();
});
dropdownSearch.addEventListener('click', (e) => {
e.stopPropagation();
});
}
// Show Deleted Projects Checkbox
const chkShowDeleted = document.getElementById('chk-show-deleted-projects');
if (chkShowDeleted) {
chkShowDeleted.addEventListener('change', () => {
fetchProjects();
});
chkShowDeleted.addEventListener('click', (e) => {
e.stopPropagation();
});
}
// Login Form Submit
document.getElementById('form-login').addEventListener('submit', handleLogin);
// Logout Button
document.getElementById('btn-logout').addEventListener('click', handleLogout);
// Project creation button (dropdown header & empty state)
const createProjectHeader = document.getElementById('btn-create-project-header');
if (createProjectHeader) {
createProjectHeader.addEventListener('click', () => {
if (projSelectorDropdown) projSelectorDropdown.classList.remove('active');
openProjectModal();
});
}
document.getElementById('btn-create-project-empty').addEventListener('click', () => openProjectModal());
document.getElementById('btn-close-project-modal').addEventListener('click', closeProjectModal);
document.getElementById('btn-cancel-project-modal').addEventListener('click', closeProjectModal);
document.getElementById('form-project').addEventListener('submit', handleProjectSubmit);
// Active project actions
document.getElementById('btn-edit-project-active').addEventListener('click', () => {
if (state.activeProjectSummary) openProjectModal(state.activeProjectSummary);
});
// Project status direct dropdown listener
const statusSelect = document.getElementById('active-project-status-select');
if (statusSelect) {
statusSelect.addEventListener('change', async (e) => {
if (!state.activeProjectId) return;
const newStatus = e.target.value;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (!res.ok) {
throw new Error('Failed to update project status');
}
showToast(state.currentLanguage === 'en' ? 'Project status updated successfully' : 'อัปเดตสถานะโครงการเรียบร้อยแล้ว');
await fetchActiveProjectDetails();
renderWorkspace();
await fetchProjects();
} catch (err) {
showToast(err.message, 'error');
if (state.activeProjectSummary) {
statusSelect.value = state.activeProjectSummary.status;
}
}
});
}
document.getElementById('btn-clone-project-active').addEventListener('click', handleCloneProject);
document.getElementById('btn-delete-project-active').addEventListener('click', handleDeleteProject);
document.getElementById('btn-restore-project').addEventListener('click', handleRestoreProject);
// Refresh project details
document.getElementById('btn-refresh-project-details').addEventListener('click', async () => {
const btn = document.getElementById('btn-refresh-project-details');
const icon = btn.querySelector('i');
if (icon) icon.classList.add('fa-spin');
try {
await fetchProjects();
if (state.activeProjectId) {
await selectProject(state.activeProjectId);
}
showToast(state.currentLanguage === 'en' ? 'Data refreshed' : 'รีเฟรชข้อมูลสำเร็จ');
} catch (e) {
console.error(e);
showToast(state.currentLanguage === 'en' ? 'Failed to refresh data' : 'ไม่สามารถรีเฟรชข้อมูลได้', 'error');
} finally {
if (icon) icon.classList.remove('fa-spin');
}
});
// Revision History Actions
document.getElementById('btn-open-revisions').addEventListener('click', openRevisionsModal);
document.getElementById('btn-close-revisions-modal').addEventListener('click', closeRevisionsModal);
document.getElementById('btn-close-revisions-footer').addEventListener('click', closeRevisionsModal);
document.getElementById('btn-compare-revisions').addEventListener('click', compareRevisions);
// Subsection creation
document.getElementById('btn-add-section').addEventListener('click', openSubsectionModal);
document.getElementById('btn-close-subsection-modal').addEventListener('click', closeSubsectionModal);
document.getElementById('btn-cancel-subsection-modal').addEventListener('click', closeSubsectionModal);
document.getElementById('form-subsection').addEventListener('submit', handleSubsectionSubmit);
document.getElementById('subsection-name').addEventListener('change', (e) => {
const customNameGroup = document.getElementById('subsection-custom-name-group');
const customInput = document.getElementById('subsection-custom-name');
if (e.target.value === '__custom__') {
customNameGroup.style.display = 'block';
customInput.required = true;
customInput.focus();
} else {
customNameGroup.style.display = 'none';
customInput.required = false;
}
});
// Template Manager
document.getElementById('btn-open-template-manager').addEventListener('click', openTemplateManagerModal);
document.getElementById('btn-close-template-manager').addEventListener('click', closeTemplateManagerModal);
document.getElementById('btn-cancel-template-manager').addEventListener('click', closeTemplateManagerModal);
document.getElementById('template-select-system').addEventListener('change', (e) => {
loadTemplateItemsForSelectedSystem();
});
document.getElementById('btn-template-select-catalog').addEventListener('click', openExplorerModalForTemplate);
document.getElementById('btn-save-system-template').addEventListener('click', saveSystemTemplate);
document.getElementById('btn-template-clone').addEventListener('click', async () => {
const select = document.getElementById('template-select-system');
const systemName = select.value;
if (systemName === '__custom__') {
showToast(state.currentLanguage === 'en' ? 'Please select a system template to clone' : 'กรุณาเลือกระบบงานที่ต้องการโคลน', 'error');
return;
}
const templateObj = allSystemTemplates.find(t => t.system_name === systemName);
const items = templateObj ? templateObj.items : [];
const defaultNewName = systemName + ' - Copy';
const newNamePrompt = state.currentLanguage === 'en' ? 'Enter a name for the cloned system template:' : 'กรุณาระบุชื่อระบบงานใหม่ที่ต้องการโคลน:';
const newName = prompt(newNamePrompt, defaultNewName);
if (newName === null) return; // Cancelled
const trimmedNewName = newName.trim();
if (!trimmedNewName) {
showToast(state.currentLanguage === 'en' ? 'System name cannot be empty' : 'ชื่อระบบงานต้องไม่ว่างเปล่า', 'error');
return;
}
try {
const res = await authFetch(`${API_BASE}/system-templates/${encodeURIComponent(trimmedNewName)}`, {
method: 'PUT',
body: JSON.stringify({ items })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Template cloned successfully' : 'โคลนเทมเพลตระบบงานสำเร็จแล้ว');
await populateSystemDropdowns();
select.value = trimmedNewName;
loadTemplateItemsForSelectedSystem();
} else {
const err = await res.json();
showToast(err.error || (state.currentLanguage === 'en' ? 'Failed to clone template' : 'ไม่สามารถโคลนเทมเพลตได้'), 'error');
}
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to clone template' : 'ไม่สามารถโคลนเทมเพลตได้', 'error');
}
});
// Catalog Explorer
document.getElementById('btn-close-explorer-modal').addEventListener('click', closeExplorerModal);
document.getElementById('btn-close-explorer-footer').addEventListener('click', closeExplorerModal);
document.getElementById('explorer-search').addEventListener('input', debounce(filterExplorerProducts, 300));
document.getElementById('explorer-category-select').addEventListener('change', filterExplorerProducts);
document.getElementById('explorer-group-select').addEventListener('change', filterExplorerProducts);
document.getElementById('explorer-system-select').addEventListener('change', filterExplorerProducts);
// Catalog Manager Database Modal
document.getElementById('btn-open-catalog-manager').addEventListener('click', openCatalogManagerModal);
document.getElementById('btn-close-catalog-modal').addEventListener('click', closeCatalogManagerModal);
document.getElementById('btn-cancel-catalog-footer').addEventListener('click', closeCatalogManagerModal);
document.getElementById('btn-save-catalog-footer').addEventListener('click', () => {
showToast('บันทึกข้อมูลแคตตาล็อกสินค้าลงฐานข้อมูลเรียบร้อยแล้ว');
closeCatalogManagerModal();
});
document.getElementById('catalog-manager-search').addEventListener('input', debounce(filterManagerProducts, 300));
document.getElementById('catalog-manager-category').addEventListener('change', filterManagerProducts);
document.getElementById('catalog-manager-group').addEventListener('change', filterManagerProducts);
document.getElementById('catalog-manager-system').addEventListener('change', filterManagerProducts);
document.getElementById('btn-add-catalog-product').addEventListener('click', () => openProductDetailModal());
// Catalog Product Add/Edit Form Detail
document.getElementById('btn-close-product-detail-modal').addEventListener('click', closeProductDetailModal);
document.getElementById('btn-close-product-detail-footer').addEventListener('click', closeProductDetailModal);
document.getElementById('form-product-detail').addEventListener('submit', handleProductDetailSubmit);
// Tab active state switcher for product catalog detail modal
document.querySelectorAll('.product-detail-tabs .tab-item').forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
const target = tab.getAttribute('href');
switchProductDetailTab(target);
});
});
// Auto-switch to correct tab if a hidden input fails validation
const prodDetailForm = document.getElementById('form-product-detail');
if (prodDetailForm) {
prodDetailForm.addEventListener('invalid', (e) => {
const invalidField = e.target;
const parentCard = invalidField.closest('.form-card');
if (parentCard) {
switchProductDetailTab('#' + parentCard.id);
}
}, true);
}
// Image file selection trigger
const imageInput = document.getElementById('product-image-file');
document.getElementById('btn-trigger-file-input').addEventListener('click', () => imageInput.click());
imageInput.addEventListener('change', handleImageSelection);
// Mobile camera capture trigger (opens native camera app on mobile devices)
const imageCameraInput = document.getElementById('product-image-camera');
document.getElementById('btn-trigger-camera-input').addEventListener('click', () => imageCameraInput.click());
imageCameraInput.addEventListener('change', handleImageSelection);
// PDF file selection trigger
const pdfInput = document.getElementById('product-pdf-file');
document.getElementById('btn-trigger-pdf-input').addEventListener('click', () => pdfInput.click());
pdfInput.addEventListener('change', handlePdfSelection);
// Report printing triggers
document.getElementById('btn-print-quotation-trigger')?.addEventListener('click', () => triggerPrintReport('quotation'));
document.getElementById('btn-print-bom-trigger')?.addEventListener('click', () => triggerPrintReport('bom'));
document.getElementById('btn-print-free-issue-trigger')?.addEventListener('click', () => triggerPrintReport('free-issue'));
// Procurement report bindings
document.getElementById('btn-rep-proc-quote')?.addEventListener('click', () => triggerPrintReport('quotation'));
document.getElementById('btn-rep-proc-bom')?.addEventListener('click', () => triggerPrintReport('bom'));
// Wire up all new report items to show a simulated print preview or trigger actual modals
const reportItemIds = [
'btn-rep-est-scope', 'btn-rep-est-catalog', 'btn-rep-est-rfq-contractor',
'btn-rep-est-rfq-material', 'btn-rep-est-revisions', 'btn-rep-proc-profit',
'btn-rep-prod-req-mat', 'btn-rep-prod-missing-mat', 'btn-rep-prod-progress',
'btn-rep-prod-plan', 'btn-rep-prod-issues', 'btn-rep-proj-issues',
'btn-rep-proj-service', 'btn-rep-proj-extra', 'btn-rep-proj-deduct',
'btn-rep-proj-app-docs', 'btn-rep-proj-app-extra', 'btn-rep-proj-del-docs',
'btn-rep-proj-del-asbuilt', 'btn-rep-sale-quote', 'btn-rep-sale-delivery',
'btn-rep-sale-ack', 'btn-rep-sale-pnl', 'btn-rep-sale-issues',
'btn-rep-sale-comments', 'btn-rep-sale-contact', 'btn-rep-excl-daily-est',
'btn-rep-excl-daily-sale', 'btn-rep-excl-daily-design', 'btn-rep-excl-daily-prod',
'btn-rep-excl-daily-qc', 'btn-rep-excl-daily-proc', 'btn-rep-excl-daily-stock',
'btn-rep-excl-progress', 'btn-rep-excl-issues', 'btn-rep-excl-complaints'
];
reportItemIds.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
// If it is a progress report trigger, let's open progress report
if (id === 'btn-rep-excl-progress') {
triggerPrintProgressReport();
return;
}
// If extra work report, we can open the extra works modal
if (id === 'btn-rep-proj-extra') {
document.getElementById('btn-open-extra-work')?.click();
return;
}
// If deduct work report, we can open deduct works modal
if (id === 'btn-rep-proj-deduct') {
document.getElementById('btn-open-deduct-work')?.click();
return;
}
const repTitle = btn.textContent.trim().replace(/^[a-z0-9.]+\s*/i, '');
showToast(
state.currentLanguage === 'en'
? `Generating ${repTitle}...`
: `กำลังสร้าง ${repTitle}...`,
'info'
);
// Show printable simulation window or simulated PDF
setTimeout(() => {
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>${repTitle}</title>
<link href="https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Sarabun', sans-serif; padding: 40px; color: #1e293b; background: #f8fafc; }
.report-card { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); max-width: 800px; margin: 0 auto; border-top: 5px solid #0f172a; }
h1 { font-size: 1.8rem; font-weight: 700; margin-bottom: 20px; border-bottom: 2px solid #e2e8f0; padding-bottom: 10px; }
.meta-row { display: flex; justify-content: space-between; margin-bottom: 30px; font-size: 0.9rem; color: #64748b; }
.content-block { line-height: 1.6; margin-bottom: 30px; }
.stamp { font-size: 0.8rem; color: #94a3b8; text-align: center; margin-top: 50px; border-top: 1px dashed #cbd5e1; padding-top: 20px; }
</style>
</head>
<body>
<div class="report-card">
<h1>${repTitle}</h1>
<div class="meta-row">
<div><strong>โครงการ / Project ID:</strong> ${state.activeProjectId || '-'}</div>
<div><strong>วันที่ / Date:</strong> ${new Date().toLocaleDateString('th-TH')}</div>
</div>
<div class="content-block">
<p>ระบบกำลังดึงข้อมูลสำหรับรายงาน <strong>"${repTitle}"</strong>...</p>
<p>รายงานนี้ประกอบด้วยสถิติ, ข้อมูลสรุปความก้าวหน้าการดำเนินงาน, และรายละเอียดเชิงลึกของโครงการแบบ Real-time</p>
</div>
<div class="stamp">
เอกสารรายงานระบบประเมินราคา (Smart Estimation Dashboard Report)
</div>
</div>
</body>
</html>
`);
printWindow.document.close();
}
}, 800);
});
}
});
// Excel Import/Export triggers
const btnExportExcel = document.getElementById('btn-export-excel');
if (btnExportExcel) {
btnExportExcel.addEventListener('click', async () => {
if (!state.activeProjectId) return;
try {
showGlobalSpinner(state.currentLanguage === 'en' ? 'Exporting Excel...' : 'กำลังส่งออก Excel...');
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/export-excel`);
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Project_${state.activeProjectId}_Estimates.xlsx`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
showToast(state.currentLanguage === 'en' ? 'Excel exported successfully' : 'ส่งออกไฟล์ Excel เรียบร้อยแล้ว', 'success');
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to export Excel' : 'เกิดข้อผิดพลาดในการส่งออก Excel', 'error');
} finally {
hideGlobalSpinner();
}
});
}
const btnImportExcel = document.getElementById('btn-import-excel');
const inputImportExcel = document.getElementById('input-import-excel');
if (btnImportExcel && inputImportExcel) {
btnImportExcel.addEventListener('click', () => {
inputImportExcel.click();
});
inputImportExcel.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('excel', file);
try {
showGlobalSpinner(state.currentLanguage === 'en' ? 'Importing Excel data...' : 'กำลังนำเข้าและซิงค์ข้อมูลจาก Excel...');
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/import-excel`, {
method: 'POST',
body: formData
});
const data = await res.json();
showToast(state.currentLanguage === 'en' ? 'Excel imported successfully' : 'นำเข้าและซิงค์ข้อมูลจาก Excel เรียบร้อยแล้ว', 'success');
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
console.error(err);
showToast(err.message || (state.currentLanguage === 'en' ? 'Failed to import Excel' : 'เกิดข้อผิดพลาดในการนำเข้า Excel'), 'error');
} finally {
inputImportExcel.value = '';
hideGlobalSpinner();
}
});
}
// Supplier Manager Database Modal
document.getElementById('btn-open-supplier-manager').addEventListener('click', openSupplierManagerModal);
document.getElementById('btn-close-supplier-manager-modal').addEventListener('click', closeSupplierManagerModal);
document.getElementById('supplier-manager-search').addEventListener('input', debounce(filterSuppliers, 300));
document.getElementById('btn-add-supplier').addEventListener('click', () => openSupplierDetailModal());
// Supplier Detail Form Actions
document.getElementById('btn-close-supplier-detail-modal').addEventListener('click', closeSupplierDetailModal);
document.getElementById('btn-close-supplier-detail-footer').addEventListener('click', closeSupplierDetailModal);
document.getElementById('form-supplier-detail').addEventListener('submit', handleSupplierDetailSubmit);
// Contractor Directory Manager Modal
document.getElementById('btn-open-contractor-manager').addEventListener('click', () => {
document.getElementById('contractor-dropdown-menu')?.classList.remove('active');
openContractorManagerModal();
});
document.getElementById('btn-close-contractor-manager-modal').addEventListener('click', closeContractorManagerModal);
document.getElementById('contractor-manager-search').addEventListener('input', debounce(filterContractors, 300));
document.getElementById('btn-add-contractor').addEventListener('click', () => openContractorDetailModal());
// Contractor Detail Form Actions
document.getElementById('btn-close-contractor-detail-modal').addEventListener('click', closeContractorDetailModal);
document.getElementById('btn-close-contractor-detail-footer').addEventListener('click', closeContractorDetailModal);
document.getElementById('form-contractor-detail').addEventListener('submit', handleContractorDetailSubmit);
// Contractors Dropdown Toggle
const contractorDropdownBtn = document.getElementById('btn-contractor-dropdown-trigger');
const contractorDropdownMenu = document.getElementById('contractor-dropdown-menu');
if (contractorDropdownBtn && contractorDropdownMenu) {
contractorDropdownBtn.addEventListener('click', (e) => {
e.stopPropagation();
contractorDropdownMenu.classList.toggle('active');
document.getElementById('export-dropdown-menu')?.classList.remove('active');
document.getElementById('project-selector-dropdown')?.classList.remove('active');
});
document.addEventListener('click', (e) => {
if (!contractorDropdownMenu.contains(e.target) && e.target !== contractorDropdownBtn) {
contractorDropdownMenu.classList.remove('active');
}
});
}
// Contractor RFQ List Modal
document.getElementById('btn-open-project-rfq').addEventListener('click', () => {
document.getElementById('contractor-dropdown-menu')?.classList.remove('active');
openProjectRfqModal();
});
const rfqDirectBtn = document.getElementById('btn-open-project-rfq-direct');
if (rfqDirectBtn) {
rfqDirectBtn.addEventListener('click', () => {
openProjectRfqModal();
});
}
document.getElementById('btn-close-project-rfq-modal').addEventListener('click', closeProjectRfqModal);
document.getElementById('rfq-list-search').addEventListener('input', debounce(filterRfqList, 300));
document.getElementById('btn-create-rfq-trigger').addEventListener('click', openCreateRfqModal);
// Create RFQ Dialog
document.getElementById('btn-close-create-rfq-modal').addEventListener('click', closeCreateRfqModal);
document.getElementById('btn-cancel-create-rfq').addEventListener('click', closeCreateRfqModal);
document.getElementById('form-create-rfq').addEventListener('submit', handleCreateRfqSubmit);
// Custom RFQ Confirmation Dialog Listeners
document.getElementById('btn-close-rfq-confirm-modal').addEventListener('click', () => {
document.getElementById('modal-rfq-confirm').classList.remove('active');
});
document.getElementById('btn-cancel-rfq-confirm').addEventListener('click', () => {
document.getElementById('modal-rfq-confirm').classList.remove('active');
});
document.getElementById('btn-submit-rfq-confirm').addEventListener('click', submitRfqQuotationConfirmed);
// Activity Log Modal
document.getElementById('btn-open-activity-log').addEventListener('click', openActivityLogModal);
document.getElementById('btn-close-activity-log-modal').addEventListener('click', closeActivityLogModal);
document.getElementById('activity-log-search').addEventListener('input', debounce(filterActivityLog, 300));
document.getElementById('activity-log-date-from').addEventListener('change', filterActivityLog);
document.getElementById('activity-log-date-to').addEventListener('change', filterActivityLog);
document.getElementById('btn-activity-log-clear-dates').addEventListener('click', () => {
document.getElementById('activity-log-date-from').value = '';
document.getElementById('activity-log-date-to').value = '';
filterActivityLog();
});
document.getElementById('btn-activity-log-prev-page').addEventListener('click', () => {
if (state.activityLogPage > 1) {
state.activityLogPage--;
fetchAndRenderActivityLog();
}
});
document.getElementById('btn-activity-log-next-page').addEventListener('click', () => {
const totalPages = Math.max(Math.ceil(state.activityLogTotal / state.activityLogLimit), 1);
if (state.activityLogPage < totalPages) {
state.activityLogPage++;
fetchAndRenderActivityLog();
}
});
// Department Reports Modal
document.getElementById('btn-open-reports').addEventListener('click', openReportsModal);
document.getElementById('btn-close-reports-modal').addEventListener('click', closeReportsModal);
document.getElementById('btn-close-reports-footer').addEventListener('click', closeReportsModal);
// Internal Chat Modal
document.getElementById('btn-open-chat').addEventListener('click', openChatModal);
document.getElementById('btn-close-chat-modal').addEventListener('click', closeChatModal);
document.getElementById('btn-new-conversation').addEventListener('click', openNewConversationModal);
document.getElementById('btn-close-new-conversation-modal').addEventListener('click', closeNewConversationModal);
document.getElementById('new-conversation-search').addEventListener('input', debounce(filterNewConversationUserList, 200));
document.getElementById('btn-toggle-group-mode').addEventListener('click', toggleGroupChatMode);
document.getElementById('btn-submit-group-chat').addEventListener('click', handleSubmitGroupChat);
document.getElementById('form-chat-send').addEventListener('submit', handleChatSendSubmit);
// Group members management listeners
document.getElementById('btn-manage-group').addEventListener('click', openGroupMembersModal);
document.getElementById('btn-close-group-members-modal').addEventListener('click', closeGroupMembersModal);
document.getElementById('btn-submit-invite').addEventListener('click', handleInviteMember);
// Audio recording listeners
document.getElementById('btn-chat-mic').addEventListener('click', () => {
const isRecording = state.mediaRecorder && state.mediaRecorder.state === 'recording';
if (isRecording) {
stopVoiceRecording(true); // Send
} else {
startVoiceRecording();
}
});
document.getElementById('btn-chat-mic-cancel').addEventListener('click', () => stopVoiceRecording(false));
document.getElementById('btn-chat-mic-send').addEventListener('click', () => stopVoiceRecording(true));
// Chat search listeners
document.getElementById('chat-search-input').addEventListener('input', debounce(async () => {
await fetchAndRenderConversations();
}, 200));
// Message search listeners
document.getElementById('btn-chat-message-search-toggle').addEventListener('click', () => {
const container = document.getElementById('chat-message-search-container');
const isSearchVisible = container.style.display === 'flex';
if (isSearchVisible) {
container.style.display = 'none';
document.getElementById('chat-message-search-input').value = '';
document.getElementById('btn-chat-message-search-clear').style.display = 'none';
fetchAndRenderChatMessages();
} else {
container.style.display = 'flex';
document.getElementById('chat-message-search-input').focus();
}
});
document.getElementById('chat-message-search-input').addEventListener('input', debounce(() => {
const val = document.getElementById('chat-message-search-input').value;
document.getElementById('btn-chat-message-search-clear').style.display = val ? 'inline-block' : 'none';
fetchAndRenderChatMessages();
}, 200));
document.getElementById('btn-chat-message-search-clear').addEventListener('click', () => {
document.getElementById('chat-message-search-input').value = '';
document.getElementById('btn-chat-message-search-clear').style.display = 'none';
fetchAndRenderChatMessages();
});
// Chat Emoji Popover trigger
document.getElementById('btn-chat-emoji').addEventListener('click', () => {
const popover = document.getElementById('chat-emoji-popover');
popover.style.display = popover.style.display === 'none' ? 'flex' : 'none';
});
// Close Emoji popover on click outside
document.addEventListener('click', (e) => {
const popover = document.getElementById('chat-emoji-popover');
const emojiBtn = document.getElementById('btn-chat-emoji');
if (popover && popover.style.display === 'flex' && !popover.contains(e.target) && e.target !== emojiBtn && !emojiBtn.contains(e.target)) {
popover.style.display = 'none';
}
});
// Chat Image Upload trigger & upload event
document.getElementById('btn-chat-image').addEventListener('click', () => {
document.getElementById('chat-image-input').click();
});
document.getElementById('chat-image-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (file) {
await handleChatImageUpload(file);
}
});
// Announcement Acknowledgment Modal
const ackAnnBtn = document.getElementById('btn-acknowledge-announcement');
if (ackAnnBtn) {
ackAnnBtn.addEventListener('click', handleAcknowledgeAnnouncement);
}
// My Profile / Change Password Modal
document.getElementById('btn-open-my-profile').addEventListener('click', openMyProfileModal);
document.getElementById('btn-close-my-profile-modal').addEventListener('click', closeMyProfileModal);
document.getElementById('btn-close-my-profile-footer').addEventListener('click', closeMyProfileModal);
document.getElementById('form-my-profile').addEventListener('submit', handleMyProfileSubmit);
// RFQ Detail / Quotation Form Modal
document.getElementById('btn-close-rfq-detail-modal').addEventListener('click', closeRfqDetailModal);
document.getElementById('btn-close-rfq-detail-footer').addEventListener('click', closeRfqDetailModal);
document.getElementById('btn-rfq-save-draft').addEventListener('click', handleSaveRfqDraft);
document.getElementById('btn-rfq-submit').addEventListener('click', handleRfqSubmit);
document.getElementById('btn-rfq-select-quote-file').addEventListener('click', () => document.getElementById('rfq-quote-file-input').click());
document.getElementById('rfq-quote-file-input').addEventListener('change', handleRfqQuoteFileSelected);
document.getElementById('btn-rfq-remove-quote-file').addEventListener('click', handleRfqRemoveQuoteFile);
document.getElementById('btn-rfq-print-preview').addEventListener('click', printRfqQuotation);
// Update supplier website link on dropdown change
document.getElementById('detail-supplier-id').addEventListener('change', (e) => {
updateSupplierWebsiteLink(e.target.value);
});
// Supplier quote file selection trigger
const supplierQuoteInput = document.getElementById('product-supplier-quote-file');
document.getElementById('btn-trigger-supplier-quote-input').addEventListener('click', () => supplierQuoteInput.click());
supplierQuoteInput.addEventListener('change', handleSupplierQuoteSelection);
// Project Approval toggle
document.getElementById('btn-approve-project').addEventListener('click', handleProjectApprovalToggle);
// Proposal Letter Editor triggers
document.getElementById('btn-edit-proposal-letter').addEventListener('click', openProposalEditor);
document.getElementById('btn-close-proposal-editor-modal').addEventListener('click', closeProposalEditor);
document.getElementById('btn-print-proposal-letter').addEventListener('click', () => {
document.body.classList.add('print-mode-proposal');
window.print();
});
// Project Approval attachments list trigger
document.getElementById('btn-open-attachments').addEventListener('click', openApprovalAttachmentsModal);
document.getElementById('btn-close-attachments-modal').addEventListener('click', closeApprovalAttachmentsModal);
document.getElementById('btn-close-attachments-footer').addEventListener('click', closeApprovalAttachmentsModal);
// Project Documents Vault trigger
document.getElementById('btn-open-project-docs').addEventListener('click', openProjectDocsModal);
document.getElementById('btn-close-project-docs-modal').addEventListener('click', closeProjectDocsModal);
document.getElementById('btn-close-project-docs-footer').addEventListener('click', closeProjectDocsModal);
// PDF Embedded Viewer close trigger
document.getElementById('btn-close-pdf-viewer-modal').addEventListener('click', closePdfViewer);
// Image lightbox Modal close
document.getElementById('btn-close-lightbox').addEventListener('click', () => {
document.getElementById('modal-lightbox').classList.remove('active');
});
// Pagination Controls Listeners
document.getElementById('btn-explorer-prev-page').addEventListener('click', () => {
if (state.explorerCurrentPage > 1) {
state.explorerCurrentPage--;
renderExplorerProductsPagination();
}
});
document.getElementById('btn-explorer-next-page').addEventListener('click', () => {
const totalPages = Math.ceil(state.explorerProducts.length / state.explorerPageSize) || 1;
if (state.explorerCurrentPage < totalPages) {
state.explorerCurrentPage++;
renderExplorerProductsPagination();
}
});
document.getElementById('btn-manager-prev-page').addEventListener('click', () => {
if (state.managerCurrentPage > 1) {
state.managerCurrentPage--;
renderManagerProductsPagination();
}
});
document.getElementById('btn-manager-next-page').addEventListener('click', () => {
const totalPages = Math.ceil(state.managerProducts.length / state.managerPageSize) || 1;
if (state.managerCurrentPage < totalPages) {
state.managerCurrentPage++;
renderManagerProductsPagination();
}
});
// Cleanup print classes after document prints
window.onafterprint = () => {
document.body.classList.remove('print-mode-quotation');
document.body.classList.remove('print-mode-bom');
document.body.classList.remove('print-mode-proposal');
document.body.classList.remove('print-mode-free-issue');
};
// Image URL input changes to update preview
document.getElementById('product-image-url').addEventListener('input', (e) => {
const url = e.target.value.trim();
const preview = document.getElementById('product-image-preview');
if (url) {
// Reset selected file if URL is pasted/entered
state.imageFileToUpload = null;
document.getElementById('product-image-file').value = '';
preview.innerHTML = `<img src="${url}" alt="URL Preview" style="max-width:100%; max-height:100%; object-fit:contain;" onerror="this.src=''; this.parentNode.innerHTML='<i class=\'fa-regular fa-image placeholder-icon\'></i>';">`;
} else {
preview.innerHTML = '<i class="fa-regular fa-image placeholder-icon"></i>';
}
});
// Capture clipboard paste events for product image
document.addEventListener('paste', (e) => {
const modal = document.getElementById('modal-product-detail');
if (!modal || !modal.classList.contains('active')) return;
const clipboardData = e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData);
if (!clipboardData) return;
let imageFile = null;
// 1. Try files list first (covers file explorer copies)
if (clipboardData.files && clipboardData.files.length > 0) {
for (let i = 0; i < clipboardData.files.length; i++) {
const file = clipboardData.files[i];
if (file.type && file.type.startsWith('image/')) {
imageFile = file;
break;
}
}
}
// 2. Try items list (covers direct screenshot/browser copies)
if (!imageFile && clipboardData.items && clipboardData.items.length > 0) {
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item.type && item.type.startsWith('image/')) {
imageFile = item.getAsFile();
if (imageFile) {
break;
}
}
}
}
if (imageFile) {
// Prevent default paste to prevent inserting filename or image text into focused inputs if any
e.preventDefault();
state.imageFileToUpload = imageFile;
document.getElementById('product-image-url').value = ''; // clear url input if pasting file
const reader = new FileReader();
reader.onload = (event) => {
const preview = document.getElementById('product-image-preview');
if (preview) {
preview.innerHTML = `<img src="${event.target.result}" alt="Pasted Preview" style="max-width:100%; max-height:100%; object-fit:contain;">`;
}
};
reader.readAsDataURL(imageFile);
showToast(state.currentLanguage === 'en' ? 'Image pasted from clipboard' : 'วางรูปภาพจากคลิปบอร์ดแล้ว');
}
}, true);
// Click on preview box to paste image from clipboard directly
document.getElementById('product-image-preview').addEventListener('click', async () => {
if (navigator.clipboard && navigator.clipboard.read) {
try {
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
for (const type of item.types) {
if (type.startsWith('image/')) {
const blob = await item.getType(type);
const file = new File([blob], 'clipboard-image.png', { type });
state.imageFileToUpload = file;
document.getElementById('product-image-url').value = '';
const reader = new FileReader();
reader.onload = (event) => {
document.getElementById('product-image-preview').innerHTML = `<img src="${event.target.result}" alt="Pasted Image" style="max-width:100%; max-height:100%; object-fit:contain;">`;
};
reader.readAsDataURL(file);
showToast(state.currentLanguage === 'en' ? 'Image pasted from clipboard' : 'วางรูปภาพจากคลิปบอร์ดแล้ว');
return;
}
}
}
} catch (err) {
console.warn('Direct clipboard read failed, focusing for Ctrl+V fallback:', err);
document.getElementById('product-image-preview').focus();
}
} else {
document.getElementById('product-image-preview').focus();
}
});
// Admin Panel Modal Listeners
const openAdminBtn = document.getElementById('btn-open-admin-panel');
if (openAdminBtn) openAdminBtn.addEventListener('click', openAdminPanelModal);
document.getElementById('btn-close-admin-modal').addEventListener('click', closeAdminPanelModal);
document.getElementById('btn-close-admin-footer').addEventListener('click', closeAdminPanelModal);
document.getElementById('btn-add-user').addEventListener('click', openAddUserModal);
document.getElementById('btn-refresh-admin-users').addEventListener('click', fetchAdminUsers);
document.getElementById('btn-close-add-user-modal').addEventListener('click', closeAddUserModal);
document.getElementById('btn-cancel-add-user').addEventListener('click', closeAddUserModal);
document.getElementById('form-add-user').addEventListener('submit', handleNewUserSubmit);
// Super Admin System Panel Modal Listeners
const openSystemBtn = document.getElementById('btn-open-system-panel');
if (openSystemBtn) openSystemBtn.addEventListener('click', openSystemPanel);
document.getElementById('btn-close-system-modal').addEventListener('click', closeSystemPanel);
document.getElementById('btn-close-system-footer').addEventListener('click', closeSystemPanel);
document.getElementById('btn-save-schedule-config').addEventListener('click', saveBackupScheduleConfig);
document.getElementById('btn-trigger-manual-backup').addEventListener('click', triggerManualBackup);
document.getElementById('btn-trigger-db-repair').addEventListener('click', triggerDbRepair);
document.getElementById('form-restore-upload').addEventListener('submit', handleRestoreUpload);
document.getElementById('tab-users-btn').addEventListener('click', () => switchAdminTab('users'));
document.getElementById('tab-permissions-btn').addEventListener('click', () => switchAdminTab('permissions'));
document.getElementById('btn-admin-refresh').addEventListener('click', handleAdminPanelRefresh);
// Legacy announcements listeners commented out to let announcements.js manage it
// const openAnnouncementsBtn = document.getElementById('btn-open-announcements');
// if (openAnnouncementsBtn) openAnnouncementsBtn.addEventListener('click', openAnnouncementsManagerModal);
// document.getElementById('btn-close-announcements-manager-modal').addEventListener('click', closeAnnouncementsManagerModal);
// document.getElementById('btn-close-announcements-manager-footer').addEventListener('click', closeAnnouncementsManagerModal);
// document.getElementById('btn-add-announcement').addEventListener('click', () => openAnnouncementDetailModal());
// document.getElementById('btn-close-announcement-detail-modal').addEventListener('click', closeAnnouncementDetailModal);
// document.getElementById('btn-cancel-announcement-detail').addEventListener('click', closeAnnouncementDetailModal);
// document.getElementById('form-announcement-detail').addEventListener('submit', handleAnnouncementDetailSubmit);
document.getElementById('admin-select-project').addEventListener('change', (e) => {
fetchProjectPermissions(e.target.value);
});
// Initialize project documents vault drag & drop dropzone
setupDocsDropzone();
// Initialize project progress update event listeners and dropzone
document.getElementById('btn-open-project-progress').addEventListener('click', openProjectProgressModal);
document.getElementById('btn-close-project-progress-modal').addEventListener('click', closeProjectProgressModal);
document.getElementById('btn-close-project-progress-footer').addEventListener('click', closeProjectProgressModal);
document.getElementById('btn-new-progress-trigger').addEventListener('click', () => {
clearProgressForm();
document.getElementById('progress-form-panel').style.display = 'flex';
document.getElementById('modal-project-progress').classList.add('form-open');
setCurrentProgressTimestamp();
buildProgressSystemDropdown(); // Rebuild systems dropdown
});
document.getElementById('btn-cancel-progress-form').addEventListener('click', () => {
document.getElementById('progress-form-panel').style.display = 'none';
document.getElementById('modal-project-progress').classList.remove('form-open');
clearProgressForm();
});
document.getElementById('btn-progress-toggle-camera').addEventListener('click', toggleProgressCamera);
document.getElementById('btn-progress-camera-snap').addEventListener('click', snapProgressPhoto);
document.getElementById('form-project-progress').addEventListener('submit', handleProgressFormSubmit);
setupProgressImageDropzone();
// Initialize FAT event listeners
document.getElementById('btn-open-project-fat').addEventListener('click', openProjectFatModal);
document.getElementById('btn-close-project-fat-modal').addEventListener('click', closeProjectFatModal);
document.getElementById('btn-close-project-fat-footer').addEventListener('click', closeProjectFatModal);
document.getElementById('btn-new-fat-trigger').addEventListener('click', openCreateFatModal);
document.getElementById('btn-print-all-fat').addEventListener('click', printAllFatReports);
document.getElementById('btn-close-create-fat-modal').addEventListener('click', closeCreateFatModal);
document.getElementById('btn-cancel-create-fat').addEventListener('click', closeCreateFatModal);
document.getElementById('form-create-fat-report').addEventListener('submit', handleCreateFatSubmit);
// Mobile drawer toggles for FAT and Test Script
document.getElementById('btn-toggle-fat-sidebar').addEventListener('click', () => {
const sidebar = document.querySelector('#modal-project-fat .fat-sidebar');
if (sidebar) sidebar.classList.toggle('drawer-active');
});
document.getElementById('btn-toggle-test-script-sidebar').addEventListener('click', () => {
const sidebar = document.querySelector('#modal-project-test-script .test-script-sidebar');
if (sidebar) sidebar.classList.toggle('drawer-active');
});
// Container management and filter listeners
document.getElementById('fat-filter-container').addEventListener('change', fetchAndRenderFatReportsList);
document.getElementById('btn-add-container-no').addEventListener('click', handleAddContainerNo);
document.getElementById('fat-select-container').addEventListener('change', (e) => {
const inputNew = document.getElementById('fat-input-container-new');
if (e.target.value === '__NEW__') {
inputNew.style.display = 'block';
inputNew.value = '';
inputNew.focus();
} else {
inputNew.style.display = 'none';
}
});
// Initialize Test Script event listeners
document.getElementById('btn-open-project-test-script').addEventListener('click', openProjectTestScriptModal);
document.getElementById('btn-close-project-test-script-modal').addEventListener('click', closeProjectTestScriptModal);
document.getElementById('btn-close-project-test-script-footer').addEventListener('click', closeProjectTestScriptModal);
// Listen for custom system option selection
document.getElementById('progress-system-select').addEventListener('change', (e) => {
const customInput = document.getElementById('progress-system-custom');
if (e.target.value === 'CUSTOM') {
customInput.style.display = 'block';
customInput.required = true;
customInput.focus();
} else {
customInput.style.display = 'none';
customInput.required = false;
customInput.value = '';
}
});
// Listen for print progress report click
document.getElementById('btn-print-progress-report').addEventListener('click', triggerPrintProgressReport);
document.getElementById('btn-refresh-progress-list').addEventListener('click', fetchAndRenderProgressList);
// Initialize Web Speech API
initSpeechRecognition();
}
// ==========================================
// Authentication handlers
// ==========================================
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
const err = await res.json();
const errMsg = err.error === 'Username หรือ Password ไม่ถูกต้อง' ? t('alert_login_failed') : (err.error || t('alert_login_failed'));
throw new Error(errMsg);
}
const user = await res.json();
// Store JWT token separately
if (user.token) {
localStorage.setItem('authToken', user.token);
}
localStorage.setItem('currentUser', JSON.stringify({ id: user.id, username: user.username, role: user.role }));
showToast(t('toast_login_success'));
checkSession();
resetIdleTimer();
} catch (err) {
showToast(err.message, 'error');
}
}
async function handleLogout() {
if (state.currentUser) {
const userId = state.currentUser.id;
const key = `modifiedProjects_${userId}`;
let modifiedProjects = [];
try {
modifiedProjects = JSON.parse(localStorage.getItem(key)) || [];
} catch (e) {
modifiedProjects = [];
}
try {
await fetch(`${API_BASE}/logout`, {
method: 'POST',
headers: {
...getHeaders()
},
body: JSON.stringify({ userId, modifiedProjects })
});
localStorage.removeItem(key);
} catch (err) {
console.error('Failed to report logout revisions:', err);
}
}
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
state.currentUser = null;
state.activeProjectId = null;
state.activeProject = null;
state.activeProjectSummary = null;
if (state.chatUnreadPollInterval) {
clearInterval(state.chatUnreadPollInterval);
state.chatUnreadPollInterval = null;
}
if (state.announcementPollInterval) {
clearInterval(state.announcementPollInterval);
state.announcementPollInterval = null;
}
if (state.contractorProjectsPollInterval) {
clearInterval(state.contractorProjectsPollInterval);
state.contractorProjectsPollInterval = null;
}
stopChatThreadPolling();
checkSession();
showToast(t('toast_logout_success'));
}
function openMyProfileModal() {
if (!state.currentUser) return;
document.getElementById('form-my-profile').reset();
document.getElementById('my-profile-username').value = state.currentUser.username;
document.getElementById('my-profile-role').value = translateRole(state.currentUser.role);
document.getElementById('modal-my-profile').classList.add('active');
}
function closeMyProfileModal() {
document.getElementById('modal-my-profile').classList.remove('active');
}
async function handleMyProfileSubmit(e) {
e.preventDefault();
const submitBtn = e.target.querySelector('button[type="submit"]');
const originalText = submitBtn ? submitBtn.innerHTML : '';
const currentPassword = document.getElementById('my-profile-current-password').value;
const newPassword = document.getElementById('my-profile-new-password').value;
const confirmPassword = document.getElementById('my-profile-confirm-password').value;
if (newPassword !== confirmPassword) {
showToast(state.currentLanguage === 'en' ? 'New password and confirmation do not match' : 'รหัสผ่านใหม่และการยืนยันไม่ตรงกัน', 'error');
return;
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = state.currentLanguage === 'en' ? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...' : '<i class="fa-solid fa-spinner fa-spin"></i> กำลังบันทึก...';
}
try {
const res = await authFetch(`${API_BASE}/users/me/password`, {
method: 'PUT',
body: JSON.stringify({ currentPassword, newPassword })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || (state.currentLanguage === 'en' ? 'Failed to update password' : 'ไม่สามารถบันทึกรหัสผ่านใหม่ได้'));
}
showToast(state.currentLanguage === 'en' ? 'Password updated successfully' : 'บันทึกรหัสผ่านใหม่เรียบร้อยแล้ว');
closeMyProfileModal();
} catch (err) {
showToast(err.message, 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
}
}
// ==========================================
// Project CRUD Operations
// ==========================================
async function fetchProjects() {
try {
const showDeleted = document.getElementById('chk-show-deleted-projects')?.checked || false;
const res = await authFetch(`${API_BASE}/projects?show_deleted=${showDeleted}`);
state.projects = await res.json();
renderSidebar();
} catch (err) {
showToast(t('toast_error_permission'), 'error');
}
}
function renderSidebar() {
const container = document.getElementById('project-dropdown-list');
if (!container) return;
container.innerHTML = '';
if (state.projects.length === 0) {
container.innerHTML = `<p style="padding: 20px; font-size: 0.85rem; color: #cbd5e1; text-align: center;">${state.currentLanguage === 'en' ? 'No projects in system' : 'ไม่มีโครงการในระบบ'}</p>`;
return;
}
// Read current search query
const searchQuery = (document.getElementById('project-dropdown-search')?.value || '').toLowerCase().trim();
const filteredProjects = state.projects.filter(project => {
const name = (project.name || '').toLowerCase();
const client = (project.client_name || '').toLowerCase();
return name.includes(searchQuery) || client.includes(searchQuery);
});
filteredProjects.sort((a, b) => {
const nameA = ((state.currentUser && state.currentUser.role === 'contractor' && a.external_name) ? a.external_name : a.name) || '';
const nameB = ((state.currentUser && state.currentUser.role === 'contractor' && b.external_name) ? b.external_name : b.name) || '';
return nameA.localeCompare(nameB, undefined, { sensitivity: 'base' });
});
if (filteredProjects.length === 0) {
container.innerHTML = `<p style="padding: 20px; font-size: 0.85rem; color: #94a3b8; text-align: center;">${state.currentLanguage === 'en' ? 'No matching projects' : 'ไม่พบโครงการที่ค้นหา'}</p>`;
return;
}
filteredProjects.forEach(project => {
const isActive = project.id === state.activeProjectId;
const item = document.createElement('button');
item.type = 'button';
item.className = `dropdown-item-project ${isActive ? 'active' : ''}`;
const hasPendingBadge = project.has_pending_rfq ? `<span class="badge bg-danger" style="background-color:#ef4444; color:white; font-size:0.65rem; font-weight:700; padding:1px 4px; border-radius:10px; margin-left:6px; animation: pulse 2s infinite;">New</span>` : '';
item.innerHTML = `
<div class="dropdown-item-project-left">
<div class="dropdown-item-project-name">${escapeHtml((state.currentUser && state.currentUser.role === 'contractor' && project.external_name) ? project.external_name : project.name)} ${hasPendingBadge}</div>
<div class="dropdown-item-project-client"><i class="fa-regular fa-building" style="font-size:0.7rem;"></i> ${escapeHtml(project.client_name || (state.currentLanguage === 'en' ? 'Not Specified' : 'ไม่ระบุลูกค้า'))}</div>
</div>
<div class="dropdown-item-project-right">
<span class="dropdown-item-project-price">${formatCurrency(project.total_quote)}</span>
<span class="badge-rev">REV ${project.current_revision || 0}</span>
</div>
`;
item.addEventListener('click', () => {
selectProject(project.id);
const projSelectorDropdown = document.getElementById('project-selector-dropdown');
if (projSelectorDropdown) projSelectorDropdown.classList.remove('active');
});
container.appendChild(item);
});
// Update global badge
const hasAnyPending = state.projects.some(p => p.has_pending_rfq);
const globalBadge = document.getElementById('project-selector-global-badge');
if (globalBadge) {
globalBadge.style.display = hasAnyPending ? 'inline-block' : 'none';
}
}
async function selectProject(id) {
state.activeProjectId = id;
state.selectedRfqId = null; // Reset RFQ selection on project switch
renderSidebar();
showGlobalSpinner(state.currentLanguage === 'en' ? 'Loading project details...' : 'กำลังโหลดข้อมูลโครงการ...');
try {
await Promise.all([
fetchActiveProjectDetails(),
fetchActiveProjectDocs()
]);
} catch (err) {
console.error(err);
} finally {
hideGlobalSpinner();
}
renderWorkspace();
}
async function fetchActiveProjectDetails() {
if (!state.activeProjectId) return;
try {
let url = `${API_BASE}/projects/${state.activeProjectId}/summary?mode=${state.workspaceMode}`;
if (state.selectedRfqId) {
url += `&rfq_id=${state.selectedRfqId}`;
}
const res = await authFetch(url);
state.activeProjectSummary = await res.json();
// Auto-initialize selectedRfqId on first project load
if (!state.selectedRfqId && state.activeProjectSummary.latest_rfq_id) {
state.selectedRfqId = state.activeProjectSummary.latest_rfq_id;
}
state.activeProject = {
id: state.activeProjectSummary.id,
name: state.activeProjectSummary.name,
client_name: state.activeProjectSummary.client_name,
description: state.activeProjectSummary.description
};
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load project summary' : 'ไม่สามารถโหลดข้อมูลสรุปโครงการได้', 'error');
}
}
function renderWorkspace() {
const emptyState = document.getElementById('empty-workspace-state');
const activeState = document.getElementById('project-workspace-active');
const selectedProjNameSpan = document.getElementById('current-project-selector-name');
if (selectedProjNameSpan) {
if (state.activeProjectId && state.activeProjectSummary) {
selectedProjNameSpan.textContent = `${state.activeProjectSummary.name} [REV ${state.activeProjectSummary.current_revision || 0}]`;
} else {
selectedProjNameSpan.textContent = t('select_project_placeholder');
}
}
if (!state.activeProjectId || !state.activeProjectSummary) {
emptyState.style.display = 'flex';
activeState.style.display = 'none';
applyRoleBasedUI();
return;
}
emptyState.style.display = 'none';
activeState.style.display = 'flex';
// Show/hide soft-deleted warning banner
const isDeleted = state.activeProjectSummary.is_deleted === 1;
const deletedBanner = document.getElementById('deleted-project-banner');
const restoreBtn = document.getElementById('btn-restore-project');
if (deletedBanner) {
if (isDeleted) {
deletedBanner.style.display = 'flex';
// Only creator, admin, or superadmin can restore
const isCreator = state.activeProjectSummary.created_by === state.currentUser?.id;
const isAdmin = state.currentUser?.role === 'admin' || state.currentUser?.role === 'superadmin';
if (isCreator || isAdmin) {
restoreBtn.style.display = 'inline-flex';
} else {
restoreBtn.style.display = 'none';
}
} else {
deletedBanner.style.display = 'none';
}
}
// Set text headers and status badge
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
const displayProjectName = (isContractor && state.activeProjectSummary.external_name)
? state.activeProjectSummary.external_name
: state.activeProjectSummary.name;
document.getElementById('active-project-name').textContent = displayProjectName;
document.getElementById('active-project-rev').textContent = `REV ${state.activeProjectSummary.current_revision || 0}`;
const typeBadge = document.getElementById('active-project-type');
if (typeBadge) {
if (state.activeProjectSummary.project_type) {
typeBadge.textContent = state.activeProjectSummary.project_type;
typeBadge.style.display = 'inline-block';
} else {
typeBadge.style.display = 'none';
}
}
document.getElementById('active-project-client').innerHTML = `<i class="fa-regular fa-building"></i> ${t('project_client_label')} ${escapeHtml(state.activeProjectSummary.client_name || (state.currentLanguage === 'en' ? 'Not Specified' : 'ไม่ระบุ'))}`;
document.getElementById('active-project-desc').textContent = state.activeProjectSummary.description || (state.currentLanguage === 'en' ? 'No project details provided' : 'ไม่มีรายละเอียดโครงการ');
const rfqBadge = document.getElementById('contractor-rfq-ref-badge');
if (rfqBadge) {
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
if (isContractor && state.activeProjectSummary.latest_rfq_number) {
rfqBadge.textContent = state.currentLanguage === 'en'
? `RFQ Ref: ${state.activeProjectSummary.latest_rfq_number}`
: `อ้างอิง RFQ: ${state.activeProjectSummary.latest_rfq_number}`;
rfqBadge.style.display = 'inline-block';
} else {
rfqBadge.style.display = 'none';
}
}
// Project Approval status UI
const isApproved = state.activeProjectSummary.status === 'อนุมัติแล้ว';
const statusBadge = document.getElementById('active-project-status');
const statusSelect = document.getElementById('active-project-status-select');
const approveBtn = document.getElementById('btn-approve-project');
const userRole = state.currentUser ? state.currentUser.role : 'user';
const allowedStatusRoles = ['admin', 'superadmin', 'supervisor', 'estimate', 'production', 'procurment', 'sale', 'excusive', 'stock'];
const canChangeStatus = state.currentUser && allowedStatusRoles.includes(userRole);
const statusStyles = {
'รอดำเนินการ': { bg: '#fef3c7', text: '#92400e', transKey: 'status_pending' },
'อนุมัติแล้ว': { bg: '#d1fae5', text: '#065f46', transKey: 'status_approved' },
'กำลังดำเนินงาน': { bg: '#e0f2fe', text: '#0369a1', transKey: 'status_in_progress' },
'เสร็จสิ้น': { bg: '#d1fae5', text: '#065f46', transKey: 'status_completed' },
'ยกเลิก': { bg: '#fee2e2', text: '#b91c1c', transKey: 'status_cancelled' }
};
const curStatus = state.activeProjectSummary.status || 'รอดำเนินการ';
const styleObj = statusStyles[curStatus] || { bg: '#fef3c7', text: '#92400e', transKey: 'status_pending' };
statusBadge.textContent = t(styleObj.transKey);
statusBadge.style.backgroundColor = styleObj.bg;
statusBadge.style.color = styleObj.text;
if (canChangeStatus) {
statusBadge.style.display = 'none';
if (statusSelect) {
statusSelect.style.display = 'inline-block';
statusSelect.value = curStatus;
statusSelect.style.backgroundColor = styleObj.bg;
statusSelect.style.color = styleObj.text;
statusSelect.style.borderColor = styleObj.text;
}
} else {
statusBadge.style.display = 'inline-block';
if (statusSelect) statusSelect.style.display = 'none';
}
if (isApproved) {
approveBtn.innerHTML = `<i class="fa-solid fa-rotate-left"></i> ${state.currentLanguage === 'en' ? 'Cancel Approval' : 'ยกเลิกการอนุมัติ'}`;
approveBtn.className = 'btn btn-danger-outline btn-sm';
} else {
approveBtn.innerHTML = `<i class="fa-solid fa-check-double"></i> ${t('btn_approve_project')}`;
approveBtn.className = 'btn btn-success btn-sm';
}
if (isReadOnlyRole(userRole)) {
approveBtn.style.display = 'none';
} else {
approveBtn.style.display = 'inline-flex';
}
// Render Stats
const isProdMode = state.workspaceMode === 'production' || state.workspaceMode === 'product-supply';
const totalCost = state.activeProjectSummary.production_cost_total;
const totalQuote = state.activeProjectSummary.price_total;
const totalProfit = totalQuote - totalCost;
const margin = totalQuote > 0 ? (totalProfit / totalQuote) * 100 : 0;
document.getElementById('summary-total-cost').textContent = formatCurrency(totalCost);
document.getElementById('summary-cost-material').textContent = formatCurrency(state.activeProjectSummary.production_cost_material);
document.getElementById('summary-cost-labour').textContent = formatCurrency(state.activeProjectSummary.production_cost_labour);
document.getElementById('summary-total-quote').textContent = formatCurrency(totalQuote);
document.getElementById('summary-quote-material').textContent = formatCurrency(state.activeProjectSummary.price_material);
document.getElementById('summary-quote-labour').textContent = formatCurrency(state.activeProjectSummary.price_labour);
const profitEl = document.getElementById('summary-total-profit');
const marginEl = document.getElementById('summary-profit-margin');
const profitCard = document.getElementById('stat-profit-card');
const labelEl = document.getElementById('profit-card-label');
profitEl.textContent = formatCurrency(totalProfit);
marginEl.textContent = `${margin.toFixed(2)}%`;
if (totalProfit >= 0) {
profitCard.className = 'stat-card profit profit-state';
labelEl.textContent = state.currentLanguage === 'en' ? 'Project Profit (Profit)' : 'กำไรโครงการ (Profit)';
} else {
profitCard.className = 'stat-card profit loss-state';
labelEl.textContent = state.currentLanguage === 'en' ? 'Project Loss (Loss)' : 'ขาดทุนโครงการ (Loss)';
}
// Toggle visibility of Cost and Profit cards in Estimate mode
const costCard = document.querySelector('.stat-card.cost');
const summaryGrid = document.querySelector('.summary-cards-grid');
if (costCard) {
costCard.style.display = state.workspaceMode === 'estimate' ? 'none' : 'flex';
}
if (profitCard) {
profitCard.style.display = state.workspaceMode === 'estimate' ? 'none' : 'flex';
}
if (summaryGrid) {
if (state.workspaceMode === 'estimate') {
summaryGrid.classList.add('estimate-mode');
} else {
summaryGrid.classList.remove('estimate-mode');
}
}
// Sync tab button active classes
document.querySelectorAll('.workspace-mode-tabs .btn-mode-tab').forEach(btn => {
if (btn.id === `btn-mode-${state.workspaceMode}`) {
btn.className = 'btn btn-sm btn-primary btn-mode-tab active';
} else {
btn.className = 'btn btn-sm btn-outline btn-mode-tab';
}
});
const sectionList = document.getElementById('sections-list-container');
const sectionsHeader = document.querySelector('.sections-container-header');
const compareContainer = document.getElementById('compare-mode-container');
const supplierCompareContainer = document.getElementById('supplier-compare-mode-container');
if (state.workspaceMode === 'compare') {
if (sectionList) sectionList.style.display = 'none';
if (sectionsHeader) sectionsHeader.style.display = 'none';
if (supplierCompareContainer) supplierCompareContainer.style.display = 'none';
if (compareContainer) {
compareContainer.style.display = 'block';
renderComparisonMode();
}
} else if (state.workspaceMode === 'supplier-compare') {
if (sectionList) sectionList.style.display = 'none';
if (sectionsHeader) sectionsHeader.style.display = 'none';
if (compareContainer) compareContainer.style.display = 'none';
if (supplierCompareContainer) {
supplierCompareContainer.style.display = 'block';
renderSupplierComparisonMode();
}
} else {
if (compareContainer) compareContainer.style.display = 'none';
if (supplierCompareContainer) supplierCompareContainer.style.display = 'none';
if (sectionList) sectionList.style.display = 'block';
if (sectionsHeader) sectionsHeader.style.display = 'flex';
renderSections();
}
applyRoleBasedUI();
}
function openProjectModal(project = null) {
const modal = document.getElementById('modal-project');
const title = document.getElementById('project-modal-title');
const form = document.getElementById('form-project');
const typeSelect = document.getElementById('project-type');
form.reset();
if (project) {
title.textContent = t('modal_project_edit_title');
document.getElementById('project-modal-id').value = project.id;
document.getElementById('project-name').value = project.name;
document.getElementById('project-client').value = project.client_name || '';
document.getElementById('project-external-name').value = project.external_name || '';
document.getElementById('project-description').value = project.description || '';
if (typeSelect) {
typeSelect.value = project.project_type || '';
typeSelect.disabled = true;
}
} else {
title.textContent = t('modal_project_create_title');
document.getElementById('project-modal-id').value = '';
document.getElementById('project-external-name').value = '';
if (typeSelect) {
typeSelect.value = '';
typeSelect.disabled = false;
}
}
modal.classList.add('active');
}
function closeProjectModal() {
document.getElementById('modal-project').classList.remove('active');
}
async function handleProjectSubmit(e) {
e.preventDefault();
const id = document.getElementById('project-modal-id').value;
const formData = new FormData();
formData.append('name', document.getElementById('project-name').value);
formData.append('client_name', document.getElementById('project-client').value);
formData.append('external_name', document.getElementById('project-external-name').value);
formData.append('description', document.getElementById('project-description').value);
const typeSelect = document.getElementById('project-type');
if (typeSelect) {
formData.append('project_type', typeSelect.value);
}
try {
let res;
if (id) {
res = await authFetch(`${API_BASE}/projects/${id}`, {
method: 'PUT',
body: formData
});
showToast(t('toast_project_updated'));
} else {
res = await authFetch(`${API_BASE}/projects`, {
method: 'POST',
body: formData
});
showToast(t('toast_project_created'));
}
const project = await res.json();
closeProjectModal();
await fetchProjects();
selectProject(project.id);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to save project' : 'ไม่สามารถบันทึกข้อมูลโครงการได้', 'error');
}
}
async function handleDeleteProject() {
if (!state.activeProjectId) return;
// First ask for password confirmation
const password = prompt(t('enter_password_delete'));
if (password === null) return; // User cancelled
if (password.trim() === '') {
showToast(t('toast_error_required'), 'error');
return;
}
if (!confirm(t('confirm_delete_project'))) return;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}`, {
method: 'DELETE',
headers: {
'x-confirm-password': password
}
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || t('toast_error_permission'));
}
showToast(t('toast_project_deleted'));
state.activeProjectId = null;
state.activeProject = null;
state.activeProjectSummary = null;
await fetchProjects();
if (state.projects.length > 0) {
selectProject(state.projects[0].id);
} else {
renderWorkspace();
}
} catch (err) {
showToast(err.message === 'Incorrect password' ? t('alert_wrong_password') : err.message, 'error');
}
}
async function handleRestoreProject() {
if (!state.activeProjectId) return;
if (!confirm(t('confirm_restore_project'))) return;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/restore`, {
method: 'POST'
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || t('toast_error_permission'));
}
showToast(t('toast_project_restored'));
// Refresh project list and active details
await fetchProjects();
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
showToast(err.message, 'error');
}
}
async function handleCloneProject() {
if (!state.activeProjectId) return;
if (!confirm(t('confirm_clone_project'))) return;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/clone`, {
method: 'POST'
});
if (!res.ok) {
throw new Error('Failed to clone project');
}
const clonedProject = await res.json();
showToast(state.currentLanguage === 'en' ? 'Project cloned successfully' : 'โคลนโครงการสำเร็จแล้ว');
await fetchProjects();
selectProject(clonedProject.id);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to clone project' : 'ไม่สามารถโคลนโครงการได้', 'error');
}
}
// ==========================================
// Subsection & Takeoff Operations
// ==========================================
function renderSections() {
const container = document.getElementById('sections-list-container');
container.innerHTML = '';
const sections = state.activeProjectSummary.sections || [];
if (sections.length === 0) {
container.innerHTML = `
<div class="empty-section-state" style="border: 1px dashed var(--border-color); border-radius: var(--radius-md); padding: 40px;">
<i class="fa-solid fa-sitemap"></i>
<p>${state.currentLanguage === 'en' ? 'No systems in this project' : 'ยังไม่มีระบบงานในโครงการนี้'}</p>
<button class="btn btn-outline btn-sm" id="btn-add-section-empty" style="margin-top: 10px;">
<i class="fa-solid fa-plus"></i> ${state.currentLanguage === 'en' ? 'Add First System' : 'เพิ่มระบบงานแรก'}
</button>
</div>
`;
if (state.currentUser && isReadOnlyRole(state.currentUser.role)) {
document.getElementById('btn-add-section-empty').style.display = 'none';
} else {
document.getElementById('btn-add-section-empty').addEventListener('click', openSubsectionModal);
}
return;
}
const role = state.currentUser ? state.currentUser.role : 'user';
sections.forEach(sec => {
const panel = document.createElement('div');
panel.className = 'subsection-panel';
panel.dataset.sectionId = sec.id;
const isProdMode = state.workspaceMode === 'production' || state.workspaceMode === 'product-supply';
const costTotal = sec.production_cost_total;
const priceTotal = sec.price_total;
const profitTotal = priceTotal - costTotal;
const marginPercent = priceTotal > 0 ? (profitTotal / priceTotal) * 100 : 0;
const isProfit = profitTotal >= 0;
const costMat = sec.production_cost_material || 0;
const costLab = sec.production_cost_labour || 0;
const priceMat = sec.price_material || 0;
const priceLab = sec.price_labour || 0;
const isCollapsed = state.collapsedSections.has(Number(sec.id));
const iconMarkup = isCollapsed ? '<i class="fa-solid fa-chevron-right"></i>' : '<i class="fa-solid fa-chevron-down"></i>';
const displayStyle = isCollapsed ? 'none' : 'block';
const hasSpec = sec.has_spec === 1 || !!sec.spec_name;
let specMarkup = '';
if (hasSpec) {
specMarkup = `
<button class="btn btn-outline btn-sm btn-view-sec-spec" style="display:inline-flex; align-items:center; gap:6px;" onclick="openSectionPdfViewer(${sec.id}, '${escapeJsString(sec.name)}')">
<i class="fa-solid fa-file-pdf text-danger"></i> <span style="max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: middle;">${escapeHtml(sec.spec_name)}</span>
</button>
`;
} else {
if (role === 'admin' || role === 'superadmin' || role === 'supervisor' || role === 'estimate') {
specMarkup = `
<button class="btn btn-outline btn-sm btn-upload-sec-spec" onclick="document.getElementById('input-sec-spec-${sec.id}').click()">
<i class="fa-solid fa-file-upload"></i> ${state.currentLanguage === 'en' ? 'Upload Spec' : 'อัปโหลดสเปก PDF'}
</button>
<input type="file" id="input-sec-spec-${sec.id}" style="display:none;" accept=".pdf" onchange="handleSectionSpecUpload(event, ${sec.id})">
`;
}
}
const disabledAttr = (isReadOnlyRole(role) || (state.activeProjectSummary && state.activeProjectSummary.is_deleted === 1)) ? 'disabled' : '';
panel.innerHTML = `
<div class="subsection-header">
<div class="subsection-title-area" style="cursor: pointer; display: flex; align-items: center; gap: 8px;">
<span class="collapse-icon" id="collapse-icon-${sec.id}" style="color: var(--text-muted); font-size: 0.9rem; width: 16px; display: inline-block; text-align: center;">
${iconMarkup}
</span>
<h3 style="margin: 0; display: flex; align-items: center; gap: 6px;">
<span>${escapeHtml(sec.name)}</span>
${isReadOnlyRole(role) ? '' : `
<span class="edit-sec-name-btn" title="${state.currentLanguage === 'en' ? 'Rename' : 'แก้ไขชื่อ'}" data-section-id="${sec.id}" data-section-name="${escapeHtml(sec.name)}" onmouseover="this.style.color='var(--primary-color)'" onmouseout="this.style.color='var(--text-muted)'" style="color: var(--text-muted); cursor: pointer; padding: 2px 6px; font-size: 0.85rem; display: inline-flex; align-items: center; justify-content: center; transition: color 0.2s;">
<i class="fa-solid fa-pen-to-square"></i>
</span>
`}
</h3>
<span class="subsection-badge-count" id="badge-count-${sec.id}">${sec.items_count} ${t('badge_items_count')}</span>
</div>
<div class="subsection-summary-info">
<span class="sub-sum-cost">
${state.currentLanguage === 'en' ? 'Cost' : 'ทุน'}: <b>${formatCurrency(costTotal)}</b>
${(role === 'admin' || role === 'superadmin' || role === 'supervisor') ? `
<span style="font-size:0.725rem; font-weight:normal; color:var(--text-muted); margin-left:4px;">
(Mat: ${formatCurrency(costMat)} / Lab: ${formatCurrency(costLab)})
</span>` : ''}
</span>
<span class="sub-sum-quote">
${state.currentLanguage === 'en' ? 'Quote' : 'เสนอราคา'}: <b>${formatCurrency(priceTotal)}</b>
<span style="font-size:0.725rem; font-weight:normal; color:var(--text-muted); margin-left:4px;">
(Mat: ${formatCurrency(priceMat)} / Lab: ${formatCurrency(priceLab)})
</span>
</span>
<span class="sub-sum-profit ${isProfit ? 'profit' : 'loss'}">
${isProfit ? (state.currentLanguage === 'en' ? 'Profit' : 'กำไร') : (state.currentLanguage === 'en' ? 'Loss' : 'ขาดทุน')}: <b>${formatCurrency(profitTotal)} (${marginPercent.toFixed(1)}%)</b>
</span>
<div class="profit-adjuster-wrapper" style="display: ${isProdMode ? 'none' : 'inline-flex'}; align-items: center; gap: 4px; background: var(--bg-panel); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-color); margin-left: 8px;">
<span style="font-size: 0.8rem; font-weight: 600; color: var(--text-muted);">${state.currentLanguage === 'en' ? 'Adjust Profit' : 'ปรับกำไร'}:</span>
<input type="number" step="0.5" class="profit-adjust-input" data-section-id="${sec.id}" value="${(typeof marginPercent === 'number' && !isNaN(marginPercent)) ? marginPercent.toFixed(1) : (sec.profit_percent || 0)}" ${disabledAttr} style="width: 50px; border: none; background: transparent; text-align: center; font-weight: 700; color: var(--text-main); font-size: 0.85rem; padding: 0;" />
<span style="font-size: 0.8rem; font-weight: 700; color: var(--text-muted);">%</span>
</div>
<div class="subsection-actions" style="display:flex; gap:8px; align-items:center;">
${specMarkup}
${isReadOnlyRole(role) ? '' : `
<button class="btn btn-outline btn-sm btn-move-section-up" data-section-id="${sec.id}" style="color: var(--text-muted); padding: 6px 10px;" title="${state.currentLanguage === 'en' ? 'Move Up' : 'เลื่อนขึ้น'}">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="btn btn-outline btn-sm btn-move-section-down" data-section-id="${sec.id}" style="color: var(--text-muted); padding: 6px 10px;" title="${state.currentLanguage === 'en' ? 'Move Down' : 'เลื่อนลง'}">
<i class="fa-solid fa-arrow-down"></i>
</button>
${(role === 'admin' || role === 'superadmin' || role === 'estimate') ? `
<button class="btn btn-outline btn-sm btn-revert-section-prices" data-section-id="${sec.id}" style="color: #6366f1; border-color: #c7d2fe; padding: 6px 10px;" title="${state.currentLanguage === 'en' ? 'Reset all items in this system to catalog prices' : 'คืนค่าราคาทุกสินค้าในระบบงานนี้จากแคตตาล็อก'}">
<i class="fa-solid fa-rotate-left"></i> ${state.currentLanguage === 'en' ? 'Undo Price' : 'คืนค่าราคา'}
</button>
` : ''}
<button class="btn btn-primary btn-sm btn-add-item-to-sec" data-section-id="${sec.id}" data-section-name="${escapeHtml(sec.name)}">
<i class="fa-solid fa-cart-plus"></i> ${state.currentLanguage === 'en' ? 'Select Product' : 'เลือกสินค้า'}
</button>
<button class="btn btn-danger-outline btn-sm btn-delete-section" data-section-id="${sec.id}">
<i class="fa-solid fa-trash-can"></i> ${state.currentLanguage === 'en' ? 'Delete System' : 'ลบระบบงาน'}
</button>
`}
</div>
</div>
</div>
<div class="takeoff-table-wrapper" style="display: ${displayStyle};">
<table class="takeoff-table mode-${state.workspaceMode}">
<thead>
<tr>
<th width="60">${t('th_image')}</th>
<th>${state.currentLanguage === 'en' ? 'Brand / Product Description / Model / Spec' : 'แบรนด์ / รายละเอียดสินค้า / รุ่น / เอกสาร'}</th>
<th width="75" class="text-center">${t('label_qty')}</th>
<th width="50" class="text-center">${t('th_unit')}</th>
<th width="70" class="text-center">${t('label_free_issue')}</th>
<th width="95" class="text-right">${t('th_cost_mt')}</th>
<th width="95" class="text-right">${t('th_cost_lb')}</th>
<th width="95" class="text-right">
${(state.workspaceMode === 'production' || state.workspaceMode === 'product-supply')
? (state.currentLanguage === 'en' ? 'Material Sale Price (฿)' : 'ราคาขายวัสดุ (฿)')
: (t('letter_table_mt') + ' (฿)')}
</th>
<th width="95" class="text-right">
${(state.workspaceMode === 'production' || state.workspaceMode === 'product-supply')
? (state.currentLanguage === 'en' ? 'Labour Sale Price (฿)' : 'ราคาขายค่าแรง (฿)')
: (t('letter_table_lb') + ' (฿)')}
</th>
<th width="70" class="text-center">${t('label_prod_discount')}</th>
<th width="105" class="text-right">${state.currentLanguage === 'en' ? 'Total Cost (฿)' : 'ทุนรวม (฿)'}</th>
<th width="105" class="text-right">${state.currentLanguage === 'en' ? 'Total Sale (฿)' : 'ขายรวม (฿)'}</th>
<th width="105" class="text-right">${state.currentLanguage === 'en' ? 'Profit (฿)' : 'กำไร (฿)'}</th>
<th width="45" class="text-center">${t('table_action_delete')}</th>
</tr>
</thead>
<tbody id="tbody-section-items-${sec.id}">
<!-- Takeoff items populated dynamically -->
</tbody>
</table>
</div>
`;
container.appendChild(panel);
// Hide panel buttons for read-only user role or deleted project
if (isReadOnlyRole(role) || (state.activeProjectSummary && state.activeProjectSummary.is_deleted === 1)) {
panel.querySelector('.subsection-actions').style.display = 'none';
}
loadSectionItems(sec.id);
});
// Bind click events to section title areas for collapse/expand
container.querySelectorAll('.subsection-title-area').forEach(titleArea => {
titleArea.addEventListener('click', (e) => {
if (e.target.closest('.edit-sec-name-btn')) {
return;
}
const panel = titleArea.closest('.subsection-panel');
const sectionId = panel.dataset.sectionId;
toggleSectionCollapse(titleArea, sectionId);
});
});
// Bind click events to edit section name buttons
container.querySelectorAll('.edit-sec-name-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const sectionId = Number(btn.dataset.sectionId);
const sectionName = btn.dataset.sectionName;
openSubsectionModalForEdit(e, sectionId, sectionName);
});
});
// Add event listeners for section level buttons
document.querySelectorAll('.btn-add-item-to-sec').forEach(btn => {
btn.addEventListener('click', (e) => {
const sectionId = e.currentTarget.dataset.sectionId;
const sectionName = e.currentTarget.dataset.sectionName;
openExplorerModal(sectionId, sectionName);
});
});
document.querySelectorAll('.btn-delete-section').forEach(btn => {
btn.addEventListener('click', handleDeleteSection);
});
document.querySelectorAll('.btn-move-section-up').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const panel = e.currentTarget.closest('.subsection-panel');
const parent = panel.parentNode;
const prev = panel.previousElementSibling;
if (prev && prev.classList.contains('subsection-panel')) {
parent.insertBefore(panel, prev);
await saveNewSectionsOrder(parent);
}
});
});
document.querySelectorAll('.btn-move-section-down').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const panel = e.currentTarget.closest('.subsection-panel');
const parent = panel.parentNode;
const next = panel.nextElementSibling;
if (next && next.classList.contains('subsection-panel')) {
parent.insertBefore(next, panel);
await saveNewSectionsOrder(parent);
}
});
});
document.querySelectorAll('.btn-revert-section-prices').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const sectionId = e.currentTarget.dataset.sectionId;
if (!confirm(state.currentLanguage === 'en'
? 'Are you sure you want to reset all items in this system back to their catalog price defaults?'
: 'คุณแน่ใจหรือไม่ว่าต้องการคืนค่าราคาและต้นทุนของทุกสินค้าในระบบงานนี้กลับไปเป็นค่าเริ่มต้นจากแคตตาล็อก?')) {
return;
}
showToast(state.currentLanguage === 'en' ? 'Resetting system pricing...' : 'กำลังคืนค่าราคาของระบบงาน...');
try {
const res = await authFetch(`${API_BASE}/sections/${sectionId}/reset-prices`, {
method: 'PUT'
});
if (!res.ok) {
throw new Error('Reset failed');
}
showToast(state.currentLanguage === 'en' ? 'System pricing reset from catalog!' : 'คืนค่าราคาของระบบงานสำเร็จ!');
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to reset system pricing' : 'ไม่สามารถคืนค่าราคาของระบบงานได้', 'error');
}
});
});
document.querySelectorAll('.profit-adjust-input').forEach(input => {
input.addEventListener('change', async (e) => {
const sectionId = e.target.dataset.sectionId;
const profitVal = parseFloat(e.target.value) || 0;
showToast(state.currentLanguage === 'en' ? 'Updating profit margin...' : 'กำลังปรับสัดส่วนกำไร...');
try {
const res = await authFetch(`${API_BASE}/sections/${sectionId}/profit`, {
method: 'PUT',
body: JSON.stringify({ profit_percent: profitVal })
});
if (!res.ok) {
throw new Error('Update failed');
}
showToast(state.currentLanguage === 'en' ? 'Profit margin updated!' : 'ปรับสัดส่วนกำไรสำเร็จ!');
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to update profit margin' : 'ไม่สามารถปรับสัดส่วนกำไรได้', 'error');
}
});
input.addEventListener('click', (e) => {
e.stopPropagation();
});
});
}
async function saveNewSectionsOrder(container) {
const panels = Array.from(container.querySelectorAll('.subsection-panel'));
const sectionIds = panels.map(p => p.dataset.sectionId).filter(id => id);
try {
await authFetch(`${API_BASE}/projects/${state.activeProjectId}/reorder-sections`, {
method: 'PUT',
body: JSON.stringify({ sectionIds })
});
} catch (err) {
console.error('Failed to save new sections order:', err);
showToast(state.currentLanguage === 'en' ? 'Failed to save systems order' : 'ไม่สามารถจัดเก็บตำแหน่งของระบบงานได้', 'error');
}
}
async function saveNewItemsOrder(sectionId, tbodyEl) {
const rows = Array.from(tbodyEl.querySelectorAll('tr'));
const itemIds = rows.map(r => r.dataset.itemId).filter(id => id);
try {
await authFetch(`${API_BASE}/sections/${sectionId}/reorder-items`, {
method: 'PUT',
body: JSON.stringify({ itemIds })
});
} catch (err) {
console.error('Failed to save new items order:', err);
showToast(state.currentLanguage === 'en' ? 'Failed to save items order' : 'ไม่สามารถจัดเก็บตำแหน่งของรายการสินค้าได้', 'error');
}
}
function toggleSectionCollapse(elementOrId, sectionId) {
let id;
let panel;
if (elementOrId && (elementOrId instanceof HTMLElement || elementOrId.tagName)) {
panel = elementOrId.closest('.subsection-panel');
id = Number(sectionId || (panel ? panel.dataset.sectionId : 0));
} else if (elementOrId && elementOrId.target) {
panel = elementOrId.currentTarget.closest('.subsection-panel');
id = Number(sectionId || (panel ? panel.dataset.sectionId : 0));
} else {
id = Number(elementOrId);
panel = document.querySelector(`.subsection-panel[data-section-id="${id}"]`);
}
if (!panel && id) {
panel = document.querySelector(`.subsection-panel[data-section-id="${id}"]`);
}
if (!panel) {
console.error('toggleSectionCollapse: subsection-panel not found for', elementOrId, sectionId);
return;
}
if (!id && panel) {
id = Number(panel.dataset.sectionId);
}
const tableWrapper = panel.querySelector('.takeoff-table-wrapper');
const icon = panel.querySelector('.collapse-icon');
console.log('toggleSectionCollapse - id:', id, 'panel:', panel, 'tableWrapper:', tableWrapper, 'icon:', icon);
if (state.collapsedSections.has(id)) {
state.collapsedSections.delete(id);
if (tableWrapper) tableWrapper.style.display = 'block';
if (icon) icon.innerHTML = '<i class="fa-solid fa-chevron-down"></i>';
} else {
state.collapsedSections.add(id);
if (tableWrapper) tableWrapper.style.display = 'none';
if (icon) icon.innerHTML = '<i class="fa-solid fa-chevron-right"></i>';
}
}
window.toggleSectionCollapse = toggleSectionCollapse;
window.openSubsectionModalForEdit = openSubsectionModalForEdit;
window.openSectionPdfViewer = openSectionPdfViewer;
window.handleSectionSpecUpload = handleSectionSpecUpload;
window.handleDeletePdfDirect = handleDeletePdfDirect;
window.openPdfViewer = openPdfViewer;
window.handleFatCheckboxChange = handleFatCheckboxChange;
window.openLightbox = openLightbox;
window.openSupplierQuoteViewer = openSupplierQuoteViewer;
window.handleDeleteSupplierQuoteDirect = handleDeleteSupplierQuoteDirect;
async function loadSectionItems(sectionId) {
try {
let url = `${API_BASE}/sections/${sectionId}/items`;
if (state.selectedRfqId) {
url += `?rfq_id=${state.selectedRfqId}`;
}
const res = await authFetch(url);
const items = await res.json();
const tbody = document.getElementById(`tbody-section-items-${sectionId}`);
tbody.innerHTML = '';
if (items.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="14" class="empty-section-state">
<i class="fa-solid fa-list-check"></i>
<p>${state.currentLanguage === 'en' ? 'No items takeoff in this system. Click "Select Product" above to add items.' : 'ไม่มีสินค้าที่ถูกถอดจำนวนในระบบงานนี้ คลิกที่ปุ่ม "เลือกสินค้า" ด้านบนเพื่อเพิ่มรายการ'}</p>
</td>
</tr>
`;
return;
}
const role = state.currentUser ? state.currentUser.role : 'user';
items.forEach(item => {
const tr = document.createElement('tr');
tr.dataset.itemId = item.id;
tr.dataset.brand = item.brand || '';
tr.dataset.description = item.description || '';
tr.dataset.type = item.type || '';
tr.dataset.unit = item.unit || '';
tr.dataset.prodGroup = item.prod_group || '';
tr.dataset.smNo = item.sm_no || '';
if (item.is_free_issue) {
tr.classList.add('free-issue-row');
}
let qty = parseFloat(item.qty) || 0;
let costMat = item.is_free_issue ? 0 : (parseFloat(item.cost_material) || 0);
let costLab = parseFloat(item.cost_labour) || 0;
let priceMat = item.is_free_issue ? 0 : (parseFloat(item.price_material) || 0);
let priceLab = parseFloat(item.price_labour) || 0;
const discount = parseFloat(item.discount) || 0;
if (state.workspaceMode === 'production' || state.workspaceMode === 'product-supply') {
qty = item.production_qty !== null ? (parseFloat(item.production_qty) || 0) : qty;
if (state.workspaceMode === 'product-supply') {
costMat = item.is_free_issue ? 0 : (item.production_cost_material !== null ? (parseFloat(item.production_cost_material) || 0) : (parseFloat(item.supplier_price) || 0));
} else {
costMat = item.is_free_issue ? 0 : (item.production_cost_material !== null ? (parseFloat(item.production_cost_material) || 0) : costMat);
}
costLab = item.production_cost_labour !== null ? (parseFloat(item.production_cost_labour) || 0) : costLab;
priceMat = item.is_free_issue ? 0 : (parseFloat(item.price_material) || 0);
priceLab = parseFloat(item.price_labour) || 0;
}
const totalCost = (costMat + costLab) * qty;
const totalQuote = (priceMat + priceLab) * qty * (1 - discount / 100);
const profit = totalQuote - totalCost;
const isProfit = profit >= 0;
// Render Product image from LONGBLOB router
let imgHtml = `<div class="thumbnail-placeholder"><i class="fa-regular fa-image"></i></div>`;
if (item.has_image && item.product_id) {
const imgUrl = withAuthToken(`${API_BASE}/products/${item.product_id}/image?t=${Date.now()}`);
imgHtml = `<img src="${imgUrl}" class="product-thumbnail" alt="Product" onclick="openLightbox('${imgUrl}')">`;
}
// PDF spec sheet icon trigger
let pdfLinkHtml = '';
if (item.has_pdf && item.product_id) {
pdfLinkHtml = `
<div class="pdf-spec-link" title="${state.currentLanguage === 'en' ? 'Open Product Datasheet (PDF)' : 'เปิดดูคู่มือเทคนิคสินค้า (PDF Datasheet)'}"
data-product-id="${item.product_id}" data-description="${escapeHtml(item.description)}">
<i class="fa-solid fa-file-pdf text-danger" style="pointer-events: none;"></i>
</div>
`;
}
const freeIssueBadge = item.is_free_issue ? `
<span class="badge" style="font-size:0.65rem; padding: 2px 6px; border-radius: 4px; background-color:#fee2e2; color:#ef4444; border: 1px solid #fca5a5; font-weight:600; margin-left: 6px;">Free Issue</span>
` : '';
const isProdMode = state.workspaceMode === 'production' || state.workspaceMode === 'product-supply';
const inputStyle = isProdMode ? 'background-color: #eff6ff; border-color: #3b82f6; color: #1e3a8a;' : '';
tr.innerHTML = `
<td>
<div class="product-thumb-cell">
${imgHtml}
</div>
</td>
<td>
<div class="product-details-cell">
<div class="prod-title" style="display:flex; align-items:center; gap:8px; flex-wrap: wrap;">
${escapeHtml(item.description)}
${pdfLinkHtml}
${freeIssueBadge}
</div>
<div class="prod-meta">
<span><b>SM No.:</b> ${escapeHtml(item.sm_no || '-')}</span> |
<span><b>${t('th_brand')}:</b> ${escapeHtml(item.brand || '-')}</span> |
<span><b>${state.currentLanguage === 'en' ? 'Model' : 'รุ่น'}:</b> ${escapeHtml(item.model || '-')}</span> |
<span><b>${state.currentLanguage === 'en' ? 'Type' : 'ประเภท'}:</b> ${escapeHtml(item.type || '-')}</span> |
<span><b>${state.currentLanguage === 'en' ? 'Group' : 'กลุ่ม'}:</b> ${escapeHtml(item.prod_group || '-')}</span>
${['superadmin', 'excusive', 'admin', 'estimate', 'procurment', 'procurement_manager'].includes(role) ? ` | <span style="color: #4f46e5; font-weight: 600;"><b>Supplier Price:</b> ${formatCurrency(item.supplier_price || 0)}</span>` : ''}
</div>
</div>
</td>
<td class="text-center">
<input type="number" step="any" class="qty-input table-number-input" data-field="qty" value="${qty}" style="${inputStyle}">
</td>
<td class="text-center">${escapeHtml(item.unit || '-')}</td>
<td class="text-center">
<input type="checkbox" class="free-issue-checkbox" data-field="is_free_issue" ${item.is_free_issue ? 'checked' : ''} ${isProdMode ? 'disabled' : ''}>
</td>
<td class="text-right">
<input type="number" step="0.01" class="table-number-input" data-field="cost_material" value="${costMat}" data-org-value="${item.cost_material || 0}" ${item.is_free_issue ? 'disabled' : ''} style="${inputStyle}">
</td>
<td class="text-right">
<input type="number" step="0.01" class="table-number-input" data-field="cost_labour" value="${costLab}" style="${inputStyle}">
</td>
<td class="text-right">
<input type="number" step="0.01" class="table-number-input" data-field="price_material" value="${priceMat}" data-org-value="${item.price_material || 0}" ${item.is_free_issue || isProdMode ? 'disabled' : ''}>
</td>
<td class="text-right">
<input type="number" step="0.01" class="table-number-input" data-field="price_labour" value="${priceLab}" ${isProdMode ? 'disabled' : ''}>
</td>
<td class="text-center">
<input type="number" step="0.01" class="discount-input table-number-input" data-field="discount" value="${discount}" ${isProdMode ? 'disabled' : ''}>
</td>
<td class="text-right font-bold">${formatNumber(totalCost)}</td>
<td class="text-right font-bold text-primary-color">${formatNumber(totalQuote)}</td>
<td class="text-right font-bold" style="color: ${isProfit ? 'var(--success)' : 'var(--danger)'}">
${formatNumber(profit)}
</td>
<td class="text-center">
<div style="display: inline-flex; gap: 6px; align-items: center; justify-content: center;">
${(item.product_id && (role === 'admin' || role === 'superadmin' || role === 'estimate')) ? `
<button class="btn-icon btn-sm btn-revert-item-price" data-item-id="${item.id}" style="color: #6366f1; cursor: pointer;" title="${state.currentLanguage === 'en' ? 'Reset to Catalog Price' : 'คืนค่าราคาจากแคตตาล็อก'}">
<i class="fa-solid fa-rotate-left"></i>
</button>
` : ''}
<button class="btn-icon btn-sm btn-sort-item-up" data-item-id="${item.id}" style="color: var(--text-muted); cursor: pointer;" title="${state.currentLanguage === 'en' ? 'Move Up' : 'เลื่อนขึ้น'}">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="btn-icon btn-sm btn-sort-item-down" data-item-id="${item.id}" style="color: var(--text-muted); cursor: pointer;" title="${state.currentLanguage === 'en' ? 'Move Down' : 'เลื่อนลง'}">
<i class="fa-solid fa-arrow-down"></i>
</button>
<button class="btn-icon btn-danger-icon btn-sm btn-delete-item" data-item-id="${item.id}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
`;
// Disable buttons and inputs if viewer user or project is deleted
if (role === 'contractor' && !(state.activeProjectSummary && state.activeProjectSummary.is_deleted === 1)) {
tr.querySelectorAll('.table-number-input').forEach(inp => {
const field = inp.dataset.field;
if (field === 'price_material' || field === 'price_labour') {
inp.disabled = false;
inp.addEventListener('change', handleInlineItemEdit);
} else {
inp.disabled = true;
}
});
tr.querySelector('.free-issue-checkbox').disabled = true;
tr.querySelector('.btn-delete-item').style.display = 'none';
const upBtn = tr.querySelector('.btn-sort-item-up');
const downBtn = tr.querySelector('.btn-sort-item-down');
if (upBtn) upBtn.style.display = 'none';
if (downBtn) downBtn.style.display = 'none';
} else if (isReadOnlyRole(role) || (state.activeProjectSummary && state.activeProjectSummary.is_deleted === 1)) {
tr.querySelectorAll('.table-number-input').forEach(inp => inp.disabled = true);
tr.querySelector('.free-issue-checkbox').disabled = true;
tr.querySelector('.btn-delete-item').style.display = 'none';
const upBtn = tr.querySelector('.btn-sort-item-up');
const downBtn = tr.querySelector('.btn-sort-item-down');
if (upBtn) upBtn.style.display = 'none';
if (downBtn) downBtn.style.display = 'none';
} else {
tr.querySelectorAll('.table-number-input').forEach(input => {
input.addEventListener('change', handleInlineItemEdit);
});
tr.querySelector('.btn-delete-item').addEventListener('click', handleDeleteItem);
const revertBtn = tr.querySelector('.btn-revert-item-price');
if (revertBtn) {
revertBtn.addEventListener('click', async () => {
if (!confirm(state.currentLanguage === 'en'
? 'Are you sure you want to reset this item\'s pricing back to the catalog defaults?'
: 'คุณแน่ใจหรือไม่ว่าต้องการคืนค่าราคาและต้นทุนของรายการนี้กลับไปเป็นค่าเริ่มต้นจากแคตตาล็อก?')) {
return;
}
try {
const res = await authFetch(`${API_BASE}/items/${item.id}/reset-price`, {
method: 'PUT'
});
if (!res.ok) {
throw new Error('Reset failed');
}
showToast(state.currentLanguage === 'en' ? 'Item pricing reset from catalog!' : 'คืนค่าราคาจากแคตตาล็อกสำเร็จ!');
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to reset item pricing' : 'ไม่สามารถคืนค่าราคาจากแคตตาล็อกได้', 'error');
}
});
}
const upBtn = tr.querySelector('.btn-sort-item-up');
if (upBtn) {
upBtn.addEventListener('click', async (e) => {
const btn = e.currentTarget;
const trRow = btn.closest('tr');
const tbodyEl = trRow.parentNode;
const prevRow = trRow.previousElementSibling;
if (prevRow) {
tbodyEl.insertBefore(trRow, prevRow);
await saveNewItemsOrder(sectionId, tbodyEl);
}
});
}
const downBtn = tr.querySelector('.btn-sort-item-down');
if (downBtn) {
downBtn.addEventListener('click', async (e) => {
const btn = e.currentTarget;
const trRow = btn.closest('tr');
const tbodyEl = trRow.parentNode;
const nextRow = trRow.nextElementSibling;
if (nextRow) {
tbodyEl.insertBefore(nextRow, trRow);
await saveNewItemsOrder(sectionId, tbodyEl);
}
});
}
// Supervisor cannot edit product prices or properties
if (role === 'supervisor') {
tr.querySelector('[data-field="cost_material"]').disabled = true;
tr.querySelector('[data-field="cost_labour"]').disabled = true;
tr.querySelector('[data-field="price_material"]').disabled = true;
tr.querySelector('[data-field="price_labour"]').disabled = true;
}
// Bind Free Issue Checkbox Change Listener
tr.querySelector('.free-issue-checkbox').addEventListener('change', async (e) => {
const checkbox = e.target;
const tr = checkbox.closest('tr');
const costInput = tr.querySelector('[data-field="cost_material"]');
const priceInput = tr.querySelector('[data-field="price_material"]');
const role = state.currentUser ? state.currentUser.role : 'user';
if (checkbox.checked) {
tr.classList.add('free-issue-row');
costInput.dataset.orgValue = costInput.value;
priceInput.dataset.orgValue = priceInput.value;
costInput.value = 0;
priceInput.value = 0;
costInput.disabled = true;
priceInput.disabled = true;
} else {
tr.classList.remove('free-issue-row');
costInput.value = costInput.dataset.orgValue || 0;
priceInput.value = priceInput.dataset.orgValue || 0;
if (role !== 'supervisor' && role !== 'user') {
costInput.disabled = false;
priceInput.disabled = false;
}
}
await handleInlineItemEdit({ target: checkbox });
});
}
tbody.appendChild(tr);
});
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load subsection items' : 'ไม่สามารถโหลดรายการสินค้าในส่วนย่อยได้', 'error');
}
}
function openSubsectionModal() {
document.getElementById('form-subsection').reset();
document.getElementById('subsection-modal-id').value = '';
const select = document.getElementById('subsection-name');
const existingTemp = select.querySelector('.temp-option');
if (existingTemp) existingTemp.remove();
select.value = '';
document.getElementById('subsection-custom-name-group').style.display = 'none';
document.getElementById('subsection-custom-name').value = '';
document.getElementById('subsection-custom-name').required = false;
document.getElementById('subsection-auto-populate-group').style.display = 'flex';
document.getElementById('subsection-auto-populate').checked = false;
const title = document.getElementById('subsection-modal-title');
const submitBtn = document.getElementById('btn-submit-subsection');
title.textContent = t('modal_sub_title');
title.setAttribute('data-i18n', 'modal_sub_title');
submitBtn.textContent = t('btn_add_sub');
submitBtn.setAttribute('data-i18n', 'btn_add_sub');
document.getElementById('modal-subsection').classList.add('active');
}
function openSubsectionModalForEdit(e, sectionId, sectionName) {
if (e) {
e.stopPropagation();
}
document.getElementById('form-subsection').reset();
document.getElementById('subsection-modal-id').value = sectionId;
document.getElementById('subsection-custom-name-group').style.display = 'none';
document.getElementById('subsection-custom-name').value = '';
document.getElementById('subsection-custom-name').required = false;
document.getElementById('subsection-auto-populate-group').style.display = 'none';
document.getElementById('subsection-auto-populate').checked = false;
const select = document.getElementById('subsection-name');
// Check if the sectionName is already in the select options
let found = false;
for (let i = 0; i < select.options.length; i++) {
if (select.options[i].value === sectionName) {
found = true;
break;
}
}
// If not found, add a temporary option
if (!found) {
const existingTemp = select.querySelector('.temp-option');
if (existingTemp) existingTemp.remove();
const tempOpt = document.createElement('option');
tempOpt.value = sectionName;
tempOpt.textContent = sectionName;
tempOpt.className = 'temp-option';
select.appendChild(tempOpt);
}
select.value = sectionName;
const title = document.getElementById('subsection-modal-title');
const submitBtn = document.getElementById('btn-submit-subsection');
title.textContent = t('modal_sub_title_edit');
title.setAttribute('data-i18n', 'modal_sub_title_edit');
submitBtn.textContent = t('btn_save_changes');
submitBtn.setAttribute('data-i18n', 'btn_save_changes');
document.getElementById('modal-subsection').classList.add('active');
}
function closeSubsectionModal() {
document.getElementById('modal-subsection').classList.remove('active');
}
async function handleSubsectionSubmit(e) {
e.preventDefault();
if (!state.activeProjectId) return;
const id = document.getElementById('subsection-modal-id').value;
let name = document.getElementById('subsection-name').value;
if (name === '__custom__') {
name = document.getElementById('subsection-custom-name').value.trim();
}
try {
if (id) {
// Edit mode
await authFetch(`${API_BASE}/sections/${id}`, {
method: 'PUT',
body: JSON.stringify({ name })
});
markProjectModified(state.activeProjectId);
showToast(state.currentLanguage === 'en' ? 'System renamed successfully' : 'แก้ไขชื่อระบบงานสำเร็จแล้ว');
} else {
// Create mode
const auto_populate = document.getElementById('subsection-auto-populate').checked;
await authFetch(`${API_BASE}/projects/${state.activeProjectId}/sections`, {
method: 'POST',
body: JSON.stringify({ name, auto_populate })
});
markProjectModified(state.activeProjectId);
showToast(t('toast_section_created'));
}
closeSubsectionModal();
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Operation failed' : 'ดำเนินการไม่สำเร็จ', 'error');
}
}
let allSystemTemplates = [];
const DEFAULT_SYSTEM_TEMPLATES = {};
async function populateSystemDropdowns() {
try {
const res = await authFetch(`${API_BASE}/system-templates`);
allSystemTemplates = await res.json();
const allNames = new Set();
allSystemTemplates.forEach(t => {
if (t.system_name) {
allNames.add(t.system_name);
}
});
const defaultNames = [];
defaultNames.forEach(name => {
allNames.add(name);
});
// 1. Populate template-select-system
const templateSelect = document.getElementById('template-select-system');
if (templateSelect) {
const currentVal = templateSelect.value;
templateSelect.innerHTML = '';
allNames.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
templateSelect.appendChild(opt);
});
const optCustom = document.createElement('option');
optCustom.value = '__custom__';
optCustom.dataset.i18n = 'opt_custom_system';
optCustom.textContent = state.currentLanguage === 'en' ? '+ Add New System Template...' : '+ เพิ่มระบบงานใหม่...';
templateSelect.appendChild(optCustom);
if (currentVal && allNames.has(currentVal)) {
templateSelect.value = currentVal;
} else {
templateSelect.selectedIndex = 0;
}
}
// 2. Populate subsection-name
const subSelect = document.getElementById('subsection-name');
if (subSelect) {
const currentVal = subSelect.value;
subSelect.innerHTML = '';
// Add placeholder option
const optPlaceholder = document.createElement('option');
optPlaceholder.value = '';
optPlaceholder.dataset.i18n = 'placeholder_sub_name';
optPlaceholder.textContent = state.currentLanguage === 'en' ? '-- Select System Name --' : '-- เลือกชื่อระบบงาน --';
subSelect.appendChild(optPlaceholder);
allNames.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
subSelect.appendChild(opt);
});
const optCustom = document.createElement('option');
optCustom.value = '__custom__';
optCustom.dataset.i18n = 'option_custom_system';
optCustom.style.fontWeight = 'bold';
optCustom.style.color = 'var(--primary-color)';
optCustom.textContent = state.currentLanguage === 'en' ? '+ Custom Name...' : '+ ระบุเอง (Custom)...';
subSelect.appendChild(optCustom);
if (currentVal && allNames.has(currentVal)) {
subSelect.value = currentVal;
} else {
subSelect.selectedIndex = 0;
}
}
applyLanguage();
} catch (err) {
console.error('Failed to populate system dropdowns:', err);
}
}
async function openTemplateManagerModal() {
const templateSelect = document.getElementById('template-select-system');
const saveBtn = document.getElementById('btn-save-system-template');
const catalogBtn = document.getElementById('btn-template-select-catalog');
const cloneBtn = document.getElementById('btn-template-clone');
const renameGroup = document.getElementById('template-rename-group');
if (state.currentUser && isReadOnlyRole(state.currentUser.role)) {
saveBtn.style.display = 'none';
if (catalogBtn) catalogBtn.style.display = 'none';
if (cloneBtn) cloneBtn.style.display = 'none';
if (renameGroup) renameGroup.style.display = 'none';
} else {
saveBtn.style.display = 'inline-block';
if (catalogBtn) catalogBtn.style.display = 'inline-block';
if (cloneBtn) cloneBtn.style.display = 'inline-block';
if (renameGroup) renameGroup.style.display = 'block';
}
try {
await populateSystemDropdowns();
document.getElementById('template-custom-system-group').style.display = 'none';
document.getElementById('template-custom-system-name').value = '';
if (templateSelect.options.length > 0) {
templateSelect.selectedIndex = 0;
loadTemplateItemsForSelectedSystem();
}
document.getElementById('modal-template-manager').classList.add('active');
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load templates' : 'ไม่สามารถโหลดเทมเพลตระบบงานได้', 'error');
}
}
function closeTemplateManagerModal() {
document.getElementById('modal-template-manager').classList.remove('active');
}
function loadTemplateItemsForSelectedSystem() {
const systemName = document.getElementById('template-select-system').value;
const container = document.getElementById('template-items-list-container');
container.innerHTML = '';
let items = [];
const templateObj = allSystemTemplates.find(t => t.system_name === systemName);
if (templateObj) {
items = templateObj.items;
} else if (DEFAULT_SYSTEM_TEMPLATES[systemName]) {
items = DEFAULT_SYSTEM_TEMPLATES[systemName];
}
const renameInput = document.getElementById('template-system-name-input');
const renameGroup = document.getElementById('template-rename-group');
const customGroup = document.getElementById('template-custom-system-group');
const customInput = document.getElementById('template-custom-system-name');
if (systemName === '__custom__') {
if (renameGroup) renameGroup.style.display = 'none';
if (renameInput) renameInput.value = '';
if (customGroup) customGroup.style.display = 'block';
if (customInput) {
customInput.required = true;
setTimeout(() => {
try { customInput.focus(); } catch (e) {}
}, 50);
}
} else {
if (renameGroup) renameGroup.style.display = 'block';
if (renameInput) renameInput.value = systemName;
if (customGroup) customGroup.style.display = 'none';
if (customInput) {
customInput.value = '';
customInput.required = false;
}
}
items.forEach(item => {
addTemplateItemRowDOM(item);
});
if (items.length === 0) {
container.innerHTML = `<div class="text-muted text-center" style="padding: 20px;" data-i18n="no_template_items">ไม่มีสินค้าในเทมเพลตนี้</div>`;
applyLanguage();
}
}
function addTemplateItemRowDOM(value = '') {
const container = document.getElementById('template-items-list-container');
const placeholder = container.querySelector('[data-i18n="no_template_items"]');
if (placeholder) placeholder.remove();
let desc = '';
let brand = '';
let model = '';
let type = '';
let unit = 'Set';
let prodGroup = '';
let qty = 1;
if (typeof value === 'string') {
desc = value;
} else if (value && typeof value === 'object') {
desc = value.description || '';
brand = value.brand || '';
model = value.model || '';
type = value.type || '';
unit = value.unit || 'Set';
prodGroup = value.prod_group || '';
qty = parseFloat(value.qty) || 1;
}
const row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.justifyContent = 'space-between';
row.style.gap = '12px';
row.style.padding = '12px';
row.style.border = '1px solid var(--border-color)';
row.style.borderRadius = 'var(--radius-md)';
row.style.backgroundColor = 'var(--card-bg)';
row.className = 'template-item-row';
row.dataset.brand = brand;
row.dataset.model = model;
row.dataset.type = type;
row.dataset.unit = unit;
row.dataset.prodGroup = prodGroup;
const infoCol = document.createElement('div');
infoCol.style.flex = '1';
infoCol.style.display = 'flex';
infoCol.style.flexDirection = 'column';
infoCol.style.gap = '4px';
const descInput = document.createElement('input');
descInput.type = 'text';
descInput.className = 'form-control template-item-input';
descInput.value = desc;
descInput.placeholder = state.currentLanguage === 'en' ? 'Item description' : 'ชื่อสินค้า/รายละเอียด';
descInput.style.fontWeight = '600';
descInput.style.fontSize = '0.95rem';
descInput.style.border = 'none';
descInput.style.borderBottom = '1px dashed var(--border-color)';
descInput.style.borderRadius = '0';
descInput.style.padding = '2px 0';
descInput.style.backgroundColor = 'transparent';
descInput.style.color = 'var(--text-main)';
if (state.currentUser && isReadOnlyRole(state.currentUser.role)) {
descInput.readOnly = true;
}
const metaText = document.createElement('div');
metaText.style.fontSize = '0.75rem';
metaText.style.color = 'var(--text-muted)';
const brandLabel = state.currentLanguage === 'en' ? 'Brand' : 'แบรนด์';
const modelLabel = state.currentLanguage === 'en' ? 'Model' : 'รุ่น';
const typeLabel = state.currentLanguage === 'en' ? 'Type' : 'ประเภท';
const groupLabel = state.currentLanguage === 'en' ? 'Group' : 'กลุ่ม';
metaText.innerHTML = `
${brandLabel}: ${escapeHtml(brand || '-')} |
${modelLabel}: ${escapeHtml(model || '-')} |
${typeLabel}: ${escapeHtml(type || '-')} |
${groupLabel}: ${escapeHtml(prodGroup || '-')}
`;
infoCol.appendChild(descInput);
infoCol.appendChild(metaText);
const actionCol = document.createElement('div');
actionCol.style.display = 'flex';
actionCol.style.alignItems = 'center';
actionCol.style.gap = '10px';
const qtyInput = document.createElement('input');
qtyInput.type = 'number';
qtyInput.className = 'form-control template-item-qty';
qtyInput.value = qty;
qtyInput.min = '0.01';
qtyInput.step = 'any';
qtyInput.style.width = '65px';
qtyInput.style.padding = '6px';
qtyInput.style.textAlign = 'center';
qtyInput.style.border = '1px solid var(--border-color)';
qtyInput.style.borderRadius = 'var(--radius-sm)';
qtyInput.style.backgroundColor = 'var(--bg-main)';
qtyInput.style.color = 'var(--text-main)';
if (state.currentUser && isReadOnlyRole(state.currentUser.role)) {
qtyInput.readOnly = true;
}
const unitLabel = document.createElement('span');
unitLabel.style.fontSize = '0.85rem';
unitLabel.style.color = 'var(--text-muted)';
unitLabel.style.minWidth = '30px';
unitLabel.textContent = unit;
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn btn-danger-outline btn-sm';
deleteBtn.style.padding = '8px 12px';
deleteBtn.innerHTML = '<i class="fa-solid fa-trash"></i>';
deleteBtn.onclick = () => {
row.remove();
if (container.querySelectorAll('.template-item-row').length === 0) {
container.innerHTML = `<div class="text-muted text-center" style="padding: 20px;" data-i18n="no_template_items">ไม่มีสินค้าในเทมเพลตนี้</div>`;
applyLanguage();
}
};
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'btn btn-outline btn-sm';
upBtn.style.padding = '8px 12px';
upBtn.style.borderColor = 'var(--border-color)';
upBtn.style.color = 'var(--text-muted)';
upBtn.innerHTML = '<i class="fa-solid fa-arrow-up"></i>';
upBtn.onclick = () => {
const prev = row.previousElementSibling;
if (prev && prev.classList.contains('template-item-row')) {
container.insertBefore(row, prev);
}
};
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'btn btn-outline btn-sm';
downBtn.style.padding = '8px 12px';
downBtn.style.borderColor = 'var(--border-color)';
downBtn.style.color = 'var(--text-muted)';
downBtn.innerHTML = '<i class="fa-solid fa-arrow-down"></i>';
downBtn.onclick = () => {
const next = row.nextElementSibling;
if (next && next.classList.contains('template-item-row')) {
container.insertBefore(next, row);
}
};
if (state.currentUser && isReadOnlyRole(state.currentUser.role)) {
deleteBtn.style.display = 'none';
upBtn.style.display = 'none';
downBtn.style.display = 'none';
}
actionCol.appendChild(qtyInput);
actionCol.appendChild(unitLabel);
actionCol.appendChild(upBtn);
actionCol.appendChild(downBtn);
actionCol.appendChild(deleteBtn);
row.appendChild(infoCol);
row.appendChild(actionCol);
container.appendChild(row);
}
async function saveSystemTemplate() {
const select = document.getElementById('template-select-system');
const systemName = select.value;
let finalSystemName = systemName;
let isRename = false;
let templateId = null;
if (systemName === '__custom__') {
const customName = document.getElementById('template-custom-system-name').value.trim();
if (!customName) {
showToast(state.currentLanguage === 'en' ? 'Please enter a system name' : 'กรุณาระบุชื่อระบบงานใหม่', 'error');
return;
}
finalSystemName = customName;
} else {
const renameInput = document.getElementById('template-system-name-input');
if (renameInput) {
const newName = renameInput.value.trim();
if (!newName) {
showToast(state.currentLanguage === 'en' ? 'System name cannot be empty' : 'ชื่อระบบงานต้องไม่ว่างเปล่า', 'error');
return;
}
if (newName !== systemName) {
finalSystemName = newName;
isRename = true;
const templateObj = allSystemTemplates.find(t => t.system_name === systemName);
if (templateObj) {
templateId = templateObj.id;
}
}
}
}
const rows = document.querySelectorAll('.template-item-row');
const items = [];
rows.forEach(row => {
const descInput = row.querySelector('.template-item-input');
const qtyInput = row.querySelector('.template-item-qty');
if (!descInput || !qtyInput) return;
const description = descInput.value.trim();
const qty = parseFloat(qtyInput.value) || 1;
if (description) {
items.push({
description,
qty,
brand: row.dataset.brand || '',
model: row.dataset.model || '',
type: row.dataset.type || '',
unit: row.dataset.unit || 'Set',
prod_group: row.dataset.prodGroup || ''
});
}
});
try {
let res;
if (isRename && templateId) {
res = await authFetch(`${API_BASE}/system-templates/id/${templateId}`, {
method: 'PUT',
body: JSON.stringify({ system_name: finalSystemName, items })
});
} else {
res = await authFetch(`${API_BASE}/system-templates/${encodeURIComponent(finalSystemName)}`, {
method: 'PUT',
body: JSON.stringify({ items })
});
}
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Template saved successfully' : 'บันทึกเทมเพลตระบบงานสำเร็จแล้ว');
// Re-populate dropdowns
await populateSystemDropdowns();
// Set active selection
select.value = finalSystemName;
document.getElementById('template-custom-system-group').style.display = 'none';
document.getElementById('template-custom-system-name').value = '';
closeTemplateManagerModal();
} else {
throw new Error('Save failed');
}
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to save template' : 'ไม่สามารถบันทึกเทมเพลตได้', 'error');
}
}
async function handleDeleteSection(e) {
const btn = e.target.closest('.btn-delete-section');
if (!btn) return;
const id = btn.dataset.sectionId;
console.log("handleDeleteSection triggered for ID:", id);
if (!confirm(t('confirm_delete_section'))) return;
try {
await authFetch(`${API_BASE}/sections/${id}`, { method: 'DELETE' });
markProjectModified(state.activeProjectId);
showToast(t('toast_section_deleted'));
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Delete failed' : 'ลบไม่สำเร็จ', 'error');
}
}
async function handleInlineItemEdit(e) {
const input = e.target;
const tr = input.closest('tr');
const itemId = tr.dataset.itemId;
const role = state.currentUser ? state.currentUser.role : 'user';
const qty = parseFloat(tr.querySelector('[data-field="qty"]').value) || 0;
const cost_material = parseFloat(tr.querySelector('[data-field="cost_material"]').value) || 0;
const cost_labour = parseFloat(tr.querySelector('[data-field="cost_labour"]').value) || 0;
const price_material = parseFloat(tr.querySelector('[data-field="price_material"]').value) || 0;
const price_labour = parseFloat(tr.querySelector('[data-field="price_labour"]').value) || 0;
const discount = parseFloat(tr.querySelector('[data-field="discount"]').value) || 0;
const is_free_issue = tr.querySelector('.free-issue-checkbox').checked ? 1 : 0;
const brand = tr.dataset.brand || '';
const type = tr.dataset.type || '';
const description = tr.dataset.description || '';
const unit = tr.dataset.unit || '';
const prod_group = tr.dataset.prodGroup || '';
const sm_no = tr.dataset.smNo || '';
let payload = {};
if (state.workspaceMode === 'production' || state.workspaceMode === 'product-supply') {
payload = {
production_qty: qty,
production_cost_material: cost_material,
production_cost_labour: cost_labour,
production_price_material: price_material,
production_price_labour: price_labour
};
} else {
payload = {
qty,
cost_material,
cost_labour,
price_material,
price_labour,
discount,
brand,
description,
type,
unit,
is_free_issue,
prod_group,
sm_no
};
}
try {
const res = await authFetch(`${API_BASE}/items/${itemId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Update failed');
await fetchActiveProjectDetails();
const isProdMode = state.workspaceMode === 'production' || state.workspaceMode === 'product-supply';
const totalCost = state.activeProjectSummary.production_cost_total;
const totalQuote = state.activeProjectSummary.price_total;
const totalProfit = totalQuote - totalCost;
const margin = totalQuote > 0 ? (totalProfit / totalQuote) * 100 : 0;
document.getElementById('summary-total-cost').textContent = formatCurrency(totalCost);
document.getElementById('summary-cost-material').textContent = formatCurrency(state.activeProjectSummary.production_cost_material);
document.getElementById('summary-cost-labour').textContent = formatCurrency(state.activeProjectSummary.production_cost_labour);
document.getElementById('summary-total-quote').textContent = formatCurrency(totalQuote);
document.getElementById('summary-quote-material').textContent = formatCurrency(state.activeProjectSummary.price_material);
document.getElementById('summary-quote-labour').textContent = formatCurrency(state.activeProjectSummary.price_labour);
const profitEl = document.getElementById('summary-total-profit');
const marginEl = document.getElementById('summary-profit-margin');
const profitCard = document.getElementById('stat-profit-card');
const labelEl = document.getElementById('profit-card-label');
profitEl.textContent = formatCurrency(totalProfit);
marginEl.textContent = `${margin.toFixed(2)}%`;
if (totalProfit >= 0) {
profitCard.className = 'stat-card profit profit-state';
labelEl.textContent = state.currentLanguage === 'en' ? 'Project Profit (Profit)' : 'กำไรโครงการ (Profit)';
} else {
profitCard.className = 'stat-card profit loss-state';
labelEl.textContent = state.currentLanguage === 'en' ? 'Project Loss (Loss)' : 'ขาดทุนโครงการ (Loss)';
}
await fetchProjects();
const totalCostRow = (cost_material + cost_labour) * qty;
const totalQuoteRow = (price_material + price_labour) * qty * (1 - discount / 100);
const profitRow = totalQuoteRow - totalCostRow;
tr.cells[10].innerText = formatNumber(totalCostRow);
tr.cells[11].innerText = formatNumber(totalQuoteRow);
tr.cells[12].innerText = formatNumber(profitRow);
tr.cells[12].style.color = profitRow >= 0 ? 'var(--success)' : 'var(--danger)';
// Update section summary headers directly
const secSummary = state.activeProjectSummary.sections.find(s => s.id === parseInt(tr.closest('.subsection-panel').dataset.sectionId));
if (secSummary) {
const panel = tr.closest('.subsection-panel');
const role = state.currentUser ? state.currentUser.role : 'user';
const secCostTotal = secSummary.production_cost_total;
const secPriceTotal = secSummary.price_total;
const secProfitVal = secPriceTotal - secCostTotal;
const isSecProfit = secProfitVal >= 0;
const secMarginVal = secPriceTotal > 0 ? (secProfitVal / secPriceTotal) * 100 : 0;
// Update Cost span directly to preserve Mat/Lab details
const costSpan = panel.querySelector('.sub-sum-cost');
if (costSpan) {
const costMatVal = secSummary.production_cost_material || 0;
const costLabVal = secSummary.production_cost_labour || 0;
costSpan.innerHTML = (state.currentLanguage === 'en' ? 'Cost' : 'ทุน') + ': <b>' + formatCurrency(secCostTotal) + '</b>' +
((role === 'admin' || role === 'superadmin' || role === 'supervisor') ?
' <span style="font-size:0.725rem; font-weight:normal; color:var(--text-muted); margin-left:4px;">(Mat: ' + formatCurrency(costMatVal) + ' / Lab: ' + formatCurrency(costLabVal) + ')</span>' : '');
}
// Update Quote span directly to preserve Mat/Lab details
const quoteSpan = panel.querySelector('.sub-sum-quote');
if (quoteSpan) {
const priceMatVal = secSummary.price_material || 0;
const priceLabVal = secSummary.price_labour || 0;
quoteSpan.innerHTML = (state.currentLanguage === 'en' ? 'Quote' : 'เสนอราคา') + ': <b>' + formatCurrency(secPriceTotal) + '</b>' +
' <span style="font-size:0.725rem; font-weight:normal; color:var(--text-muted); margin-left:4px;">(Mat: ' + formatCurrency(priceMatVal) + ' / Lab: ' + formatCurrency(priceLabVal) + ')</span>';
}
// Update Profit span directly
const profitSpan = panel.querySelector('.sub-sum-profit');
if (profitSpan) {
profitSpan.className = 'sub-sum-profit ' + (isSecProfit ? 'profit' : 'loss');
profitSpan.innerHTML = (isSecProfit ? (state.currentLanguage === 'en' ? 'Profit' : 'กำไร') : (state.currentLanguage === 'en' ? 'Loss' : 'ขาดทุน')) + ': <b>' + formatCurrency(secProfitVal) + ' (' + secMarginVal.toFixed(1) + '%)</b>';
}
// Update Profit Adjuster input value
const adjustInput = panel.querySelector('.profit-adjust-input');
if (adjustInput) {
adjustInput.value = (typeof secMarginVal === 'number' && !isNaN(secMarginVal)) ? secMarginVal.toFixed(1) : (secSummary.profit_percent || 0);
}
}
markProjectModified(state.activeProjectId);
showToast(t('toast_item_updated'));
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to update' : 'ไม่สามารถอัปเดตข้อมูลได้', 'error');
}
}
async function handleDeleteItem(e) {
const btn = e.target.closest('.btn-delete-item');
if (!btn) return;
const id = btn.dataset.itemId;
console.log("handleDeleteItem triggered for ID:", id);
if (!confirm(t('confirm_delete_item'))) return;
try {
await authFetch(`${API_BASE}/items/${id}`, { method: 'DELETE' });
markProjectModified(state.activeProjectId);
showToast(t('toast_item_deleted'));
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Delete failed' : 'ลบไม่สำเร็จ', 'error');
}
}
// ==========================================
// Catalog Explorer Modal (Add items to takeoff)
// ==========================================
async function openExplorerModal(sectionId, sectionName) {
state.isSelectingForTemplate = false;
state.selectedSectionId = sectionId;
document.getElementById('target-section-name').textContent = sectionName;
document.getElementById('explorer-search').value = '';
buildCategoryDropdown('explorer-category-select');
buildGroupDropdown('explorer-group-select');
buildSystemDropdown('explorer-system-select');
await filterExplorerProducts();
document.getElementById('modal-catalog-explorer').classList.add('active');
}
async function openExplorerModalForTemplate() {
state.selectedSectionId = null;
state.isSelectingForTemplate = true;
const systemName = document.getElementById('template-select-system').value;
document.getElementById('target-section-name').textContent = systemName;
document.getElementById('explorer-search').value = '';
buildCategoryDropdown('explorer-category-select');
buildGroupDropdown('explorer-group-select');
buildSystemDropdown('explorer-system-select');
await filterExplorerProducts();
document.getElementById('modal-catalog-explorer').classList.add('active');
}
function closeExplorerModal() {
state.isSelectingForTemplate = false;
document.getElementById('modal-catalog-explorer').classList.remove('active');
}
async function filterExplorerProducts() {
const searchVal = document.getElementById('explorer-search').value;
const catVal = document.getElementById('explorer-category-select').value;
const groupVal = document.getElementById('explorer-group-select').value;
const systemVal = document.getElementById('explorer-system-select').value;
let url = `${API_BASE}/products?`;
if (searchVal) url += `q=${encodeURIComponent(searchVal)}&`;
if (catVal) url += `category=${encodeURIComponent(catVal)}&`;
if (groupVal) url += `group=${encodeURIComponent(groupVal)}&`;
if (systemVal) url += `system=${encodeURIComponent(systemVal)}&`;
try {
const res = await authFetch(url);
const products = await res.json();
state.explorerProducts = products;
state.explorerCurrentPage = 1;
renderExplorerProductsPagination();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to search catalog products' : 'ไม่สามารถค้นหาข้อมูลสินค้าได้', 'error');
}
}
function renderExplorerProductsPagination() {
const products = state.explorerProducts || [];
const page = state.explorerCurrentPage;
const pageSize = state.explorerPageSize;
const totalItems = products.length;
const totalPages = Math.ceil(totalItems / pageSize) || 1;
if (state.explorerCurrentPage > totalPages) state.explorerCurrentPage = totalPages;
if (state.explorerCurrentPage < 1) state.explorerCurrentPage = 1;
const startIndex = (state.explorerCurrentPage - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, totalItems);
const paginatedProducts = products.slice(startIndex, endIndex);
// Update pagination labels
document.getElementById('explorer-page-start').textContent = totalItems === 0 ? 0 : startIndex + 1;
document.getElementById('explorer-page-end').textContent = endIndex;
document.getElementById('explorer-total-items').textContent = totalItems;
// Enable/disable buttons
document.getElementById('btn-explorer-prev-page').disabled = state.explorerCurrentPage <= 1;
document.getElementById('btn-explorer-next-page').disabled = state.explorerCurrentPage >= totalPages;
renderExplorerProducts(paginatedProducts);
}
function renderExplorerProducts(products) {
const tbody = document.getElementById('explorer-products-tbody');
tbody.innerHTML = '';
const role = state.currentUser ? state.currentUser.role : 'user';
if (products.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" style="padding: 30px; text-align: center; color: var(--text-muted);">${state.currentLanguage === 'en' ? 'No products found in catalog' : 'ไม่พบสินค้าในแคตตาล็อก'}</td></tr>`;
return;
}
products.forEach(p => {
const tr = document.createElement('tr');
let imgHtml = `<div class="thumbnail-placeholder"><i class="fa-regular fa-image"></i></div>`;
if (p.has_image) {
const imgUrl = withAuthToken(`${API_BASE}/products/${p.id}/image?t=${Date.now()}`);
imgHtml = `<img src="${imgUrl}" class="product-thumbnail" alt="Product" onclick="openLightbox('${imgUrl}')">`;
}
let pdfLinkHtml = '';
if (p.has_pdf) {
pdfLinkHtml = `
<div class="pdf-spec-link" title="${state.currentLanguage === 'en' ? 'Open Product Datasheet (PDF)' : 'เปิดดูคู่มือเทคนิคสินค้า (PDF Datasheet)'}"
data-product-id="${p.id}" data-description="${escapeHtml(p.description)}">
<i class="fa-solid fa-file-pdf text-danger" style="pointer-events: none;"></i>
</div>
`;
}
tr.innerHTML = `
<td>
<div class="product-thumb-cell">
${imgHtml}
</div>
</td>
<td><b>${escapeHtml(p.sm_no || '-')}</b></td>
<td><b>${escapeHtml(p.brand || '-')}</b></td>
<td>
<div style="font-weight: 500; display:flex; align-items:center; gap:8px;">
${escapeHtml(p.description)}
${pdfLinkHtml}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; display: flex; flex-wrap: wrap; gap: 8px;">
<span><b>${state.currentLanguage === 'en' ? 'Category' : 'หมวดหมู่'}:</b> ${escapeHtml(p.category || '-')}</span>
${p.prod_group ? `<span>| <b>${state.currentLanguage === 'en' ? 'Group' : 'กลุ่ม'}:</b> ${escapeHtml(p.prod_group)}</span>` : ''}
${p.prod_system ? `<span>| <b>${state.currentLanguage === 'en' ? 'System' : 'ระบบ'}:</b> ${escapeHtml(p.prod_system)}</span>` : ''}
${p.weight ? `<span>| <b>${state.currentLanguage === 'en' ? 'Weight' : 'น้ำหนัก'}:</b> ${p.weight} ${state.currentLanguage === 'en' ? 'kg' : 'กก.'}</span>` : ''}
${p.supplier_name ? `<span>| <b>${state.currentLanguage === 'en' ? 'Distributor' : 'ตัวแทนจำหน่าย'}:</b> ${escapeHtml(p.supplier_name)}</span>` : ''}
${p.lead_time ? `<span>| <b>${state.currentLanguage === 'en' ? 'Lead Time' : 'ระยะเวลาจัดหา'}:</b> ${escapeHtml(p.lead_time)}</span>` : ''}
${['superadmin', 'excusive', 'admin', 'estimate', 'procurment', 'procurement_manager'].includes(role) ? `<span>| <b style="color: #4f46e5;">Supplier Price:</b> <span style="color: #4f46e5; font-weight: 600;">${formatCurrency(p.supplier_price || 0)}</span></span>` : ''}
</div>
</td>
<td>${escapeHtml(p.model || '-')}</td>
<td>${escapeHtml(p.type || '-')}</td>
<td class="text-right">${formatNumber(p.mt_cost)}</td>
<td class="text-right">${formatNumber(p.lb_cost)}</td>
<td class="text-center">${escapeHtml(p.unit || '-')}</td>
<td class="text-center">
<button class="btn btn-primary btn-sm btn-add-prod-to-takeoff" data-product-id="${p.id}">
<i class="fa-solid fa-plus"></i> ${t('table_action_add')}
</button>
</td>
`;
tr.querySelector('.btn-add-prod-to-takeoff').addEventListener('click', () => handleAddProductToTakeoff(p));
tbody.appendChild(tr);
});
}
async function handleAddProductToTakeoff(product) {
if (state.isSelectingForTemplate) {
addTemplateItemRowDOM(product);
showToast(state.currentLanguage === 'en'
? `Added "${product.description}" to template`
: `เพิ่ม "${product.description}" ลงในเทมเพลตแล้ว`);
return;
}
if (!state.selectedSectionId) return;
try {
// Fetch existing items in the section first to see if this product is already added
const resList = await authFetch(`${API_BASE}/sections/${state.selectedSectionId}/items`);
const items = await resList.json();
const existingItem = items.find(item => item.product_id == product.id);
if (existingItem) {
// Update quantity of the existing item
const payload = {
qty: (parseFloat(existingItem.qty) || 0) + 1,
discount: existingItem.discount || 0,
is_free_issue: existingItem.is_free_issue || 0,
cost_material: existingItem.cost_material,
cost_labour: existingItem.cost_labour,
price_material: existingItem.price_material,
price_labour: existingItem.price_labour,
brand: existingItem.brand,
description: existingItem.description,
type: existingItem.type,
model: existingItem.model,
unit: existingItem.unit,
prod_group: existingItem.prod_group
};
await authFetch(`${API_BASE}/items/${existingItem.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
markProjectModified(state.activeProjectId);
showToast(state.currentLanguage === 'en' ? 'Item quantity increased' : 'เพิ่มจำนวนสินค้าในส่วนย่อยเรียบร้อยแล้ว');
} else {
// Create a new item
const payload = {
product_id: product.id,
brand: product.brand,
description: product.description,
type: product.type,
model: product.model,
sm_no: product.sm_no || null,
qty: 1,
unit: product.unit,
cost_material: product.mt_cost,
cost_labour: product.lb_cost,
price_material: product.mt_qou,
price_labour: product.lb_qou,
discount: product.discount,
prod_group: product.prod_group
};
await authFetch(`${API_BASE}/sections/${state.selectedSectionId}/items`, {
method: 'POST',
body: JSON.stringify(payload)
});
markProjectModified(state.activeProjectId);
showToast(t('toast_item_added'));
}
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Operation failed' : 'ดำเนินการไม่สำเร็จ', 'error');
}
}
// ==========================================
// Catalog Manager (Database management & Upload)
// ==========================================
async function openCatalogManagerModal() {
document.getElementById('catalog-manager-search').value = '';
buildCategoryDropdown('catalog-manager-category');
buildGroupDropdown('catalog-manager-group');
buildSystemDropdown('catalog-manager-system');
await filterManagerProducts();
document.getElementById('modal-catalog-manager').classList.add('active');
}
function closeCatalogManagerModal() {
document.getElementById('modal-catalog-manager').classList.remove('active');
}
async function filterManagerProducts(keepPage = false) {
const searchVal = document.getElementById('catalog-manager-search').value;
const catVal = document.getElementById('catalog-manager-category').value;
const groupVal = document.getElementById('catalog-manager-group').value;
const systemVal = document.getElementById('catalog-manager-system').value;
let url = `${API_BASE}/products?`;
if (searchVal) url += `q=${encodeURIComponent(searchVal)}&`;
if (catVal) url += `category=${encodeURIComponent(catVal)}&`;
if (groupVal) url += `group=${encodeURIComponent(groupVal)}&`;
if (systemVal) url += `system=${encodeURIComponent(systemVal)}&`;
try {
const res = await authFetch(url);
const products = await res.json();
state.managerProducts = products;
if (!keepPage) {
state.managerCurrentPage = 1;
}
renderManagerProductsPagination();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load catalog warehouse' : 'เกิดข้อผิดพลาดในการโหลดรายการคลังสินค้า', 'error');
}
}
function renderManagerProductsPagination() {
const products = state.managerProducts || [];
const page = state.managerCurrentPage;
const pageSize = state.managerPageSize;
const totalItems = products.length;
const totalPages = Math.ceil(totalItems / pageSize) || 1;
if (state.managerCurrentPage > totalPages) state.managerCurrentPage = totalPages;
if (state.managerCurrentPage < 1) state.managerCurrentPage = 1;
const startIndex = (state.managerCurrentPage - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, totalItems);
const paginatedProducts = products.slice(startIndex, endIndex);
// Update pagination labels
document.getElementById('manager-page-start').textContent = totalItems === 0 ? 0 : startIndex + 1;
document.getElementById('manager-page-end').textContent = endIndex;
document.getElementById('manager-total-items').textContent = totalItems;
// Enable/disable buttons
document.getElementById('btn-manager-prev-page').disabled = state.managerCurrentPage <= 1;
document.getElementById('btn-manager-next-page').disabled = state.managerCurrentPage >= totalPages;
renderManagerProducts(paginatedProducts);
}
function renderManagerProducts(products) {
const tbody = document.getElementById('catalog-manager-tbody');
tbody.innerHTML = '';
if (products.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" style="padding: 30px; text-align: center; color: var(--text-muted);">${state.currentLanguage === 'en' ? 'No products found in warehouse' : 'ไม่พบสินค้าในระบบคลัง'}</td></tr>`;
return;
}
const role = state.currentUser ? state.currentUser.role : 'user';
products.forEach(p => {
const tr = document.createElement('tr');
let imgHtml = `<div class="thumbnail-placeholder"><i class="fa-regular fa-image"></i></div>`;
if (p.has_image) {
const imgUrl = withAuthToken(`${API_BASE}/products/${p.id}/image?t=${Date.now()}`);
imgHtml = `<img src="${imgUrl}" class="product-thumbnail" alt="Product" onclick="openLightbox('${imgUrl}')">`;
}
let pdfLinkHtml = '';
if (p.has_pdf) {
pdfLinkHtml = `
<div class="pdf-spec-link" title="${state.currentLanguage === 'en' ? 'Open Product Datasheet (PDF)' : 'เปิดดูคู่มือเทคนิคสินค้า (PDF Datasheet)'}"
data-product-id="${p.id}" data-description="${escapeHtml(p.description)}">
<i class="fa-solid fa-file-pdf text-danger" style="pointer-events: none;"></i>
</div>
`;
}
tr.innerHTML = `
<td>
<div class="product-thumb-cell">
${imgHtml}
</div>
</td>
<td><b>${escapeHtml(p.sm_no || '-')}</b></td>
<td><b>${escapeHtml(p.brand || '-')}</b></td>
<td>
<div style="font-weight: 500; display:flex; align-items:center; gap:8px;">
${escapeHtml(p.description)}
${pdfLinkHtml}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; display: flex; flex-wrap: wrap; gap: 8px;">
<span><b>${state.currentLanguage === 'en' ? 'Category' : 'หมวดหมู่'}:</b> ${escapeHtml(p.category || '-')}</span>
${p.prod_group ? `<span>| <b>${state.currentLanguage === 'en' ? 'Group' : 'กลุ่ม'}:</b> ${escapeHtml(p.prod_group)}</span>` : ''}
${p.prod_system ? `<span>| <b>${state.currentLanguage === 'en' ? 'System' : 'ระบบ'}:</b> ${escapeHtml(p.prod_system)}</span>` : ''}
${p.weight ? `<span>| <b>${state.currentLanguage === 'en' ? 'Weight' : 'น้ำหนัก'}:</b> ${p.weight} ${state.currentLanguage === 'en' ? 'kg' : 'กก.'}</span>` : ''}
${p.supplier_name ? `<span>| <b>${state.currentLanguage === 'en' ? 'Distributor' : 'ตัวแทนจำหน่าย'}:</b> ${escapeHtml(p.supplier_name)}</span>` : ''}
${p.lead_time ? `<span>| <b>${state.currentLanguage === 'en' ? 'Lead Time' : 'ระยะเวลาจัดหา'}:</b> ${escapeHtml(p.lead_time)}</span>` : ''}
</div>
</td>
<td>${escapeHtml(p.model || '-')}</td>
<td>${escapeHtml(p.type || '-')}</td>
<td class="text-right">
<div>${state.currentLanguage === 'en' ? 'Mat' : 'วัสดุ'}: <b>${formatNumber(p.mt_cost)}</b></div>
<div style="font-size:0.75rem; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'Lab' : 'ค่าแรง'}: <b>${formatNumber(p.lb_cost)}</b></div>
</td>
<td class="text-right text-primary-color">
<div>${state.currentLanguage === 'en' ? 'Mat' : 'วัสดุ'}: <b>${formatNumber(p.mt_qou)}</b></div>
<div style="font-size:0.75rem; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'Lab' : 'ค่าแรง'}: <b>${formatNumber(p.lb_qou)}</b></div>
</td>
<td class="text-center">${escapeHtml(p.unit || '-')}</td>
<td class="text-center" style="white-space: nowrap;">
<div class="row-actions">
<button class="btn-icon btn-edit-catalog-prod" data-product-id="${p.id}" title="${t('table_action_edit')}">
<i class="fa-solid fa-edit"></i>
</button>
<button class="btn-icon btn-clone-catalog-prod" data-product-id="${p.id}" title="${t('table_action_clone')}">
<i class="fa-solid fa-copy"></i>
</button>
<button class="btn-icon btn-danger-icon btn-delete-catalog-prod" data-product-id="${p.id}" title="${t('table_action_delete')}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
`;
const isCatalogWriter = (role === 'admin' || role === 'superadmin' || role === 'supervisor' || role === 'estimate');
if (!isCatalogWriter) {
tr.querySelector('.btn-edit-catalog-prod').style.display = 'none';
tr.querySelector('.btn-clone-catalog-prod').style.display = 'none';
tr.querySelector('.btn-delete-catalog-prod').style.display = 'none';
} else {
tr.querySelector('.btn-edit-catalog-prod').addEventListener('click', () => openProductDetailModal(p));
tr.querySelector('.btn-clone-catalog-prod').addEventListener('click', () => handleCloneCatalogProduct(p.id));
tr.querySelector('.btn-delete-catalog-prod').addEventListener('click', () => handleDeleteCatalogProduct(p.id));
}
tbody.appendChild(tr);
});
}
function switchProductDetailTab(targetCardId) {
// Hide all form cards in product detail modal
const cards = document.querySelectorAll('#modal-product-detail .form-card');
cards.forEach(card => {
card.style.display = 'none';
});
// Show the targeted form card
const targetCard = document.getElementById(targetCardId.replace('#', ''));
if (targetCard) {
targetCard.style.display = 'block';
}
// Set active class on corresponding tab
document.querySelectorAll('.product-detail-tabs .tab-item').forEach(tab => {
const href = tab.getAttribute('href');
if (href === targetCardId) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
}
async function openProductDetailModal(product = null) {
state.editingCatalogProduct = product;
state.imageFileToUpload = null;
state.pdfFileToUpload = null;
state.quoteFileToUpload = null;
const modal = document.getElementById('modal-product-detail');
const title = document.getElementById('product-detail-title');
const form = document.getElementById('form-product-detail');
const previewBox = document.getElementById('product-image-preview');
const pdfStatus = document.getElementById('product-pdf-status');
const quoteStatus = document.getElementById('product-supplier-quote-status');
form.reset();
// Reset active tab and show only basic info card
switchProductDetailTab('#card-basic-info');
const modalBody = document.querySelector('.product-detail-body');
if (modalBody) modalBody.scrollTop = 0;
document.getElementById('product-image-url').value = '';
document.getElementById('product-pdf-url').value = '';
previewBox.innerHTML = '<i class="fa-regular fa-image placeholder-icon"></i>';
pdfStatus.innerHTML = `<i class="fa-solid fa-file-pdf text-muted"></i> ${t('status_no_file')}`;
quoteStatus.innerHTML = `<i class="fa-solid fa-file-invoice text-muted"></i> ${t('status_no_quote')}`;
// Fetch and populate supplier dropdown
await fetchSuppliers();
const supplierDropdown = document.getElementById('detail-supplier-id');
supplierDropdown.innerHTML = `<option value="">${t('select_supplier_placeholder')}</option>`;
state.suppliers.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
supplierDropdown.appendChild(option);
});
if (product) {
title.textContent = t('modal_prod_detail_edit');
document.getElementById('product-detail-id').value = product.id;
document.getElementById('detail-sm-no').value = product.sm_no || '';
document.getElementById('detail-brand').value = product.brand || '';
document.getElementById('detail-description').value = product.description || '';
document.getElementById('detail-type').value = product.type || '';
document.getElementById('detail-model').value = product.model || '';
document.getElementById('detail-category').value = product.category || '';
document.getElementById('detail-group').value = product.prod_group || '';
document.getElementById('detail-system').value = product.prod_system || '';
document.getElementById('detail-unit').value = product.unit || '';
document.getElementById('detail-weight').value = product.weight || '';
document.getElementById('detail-mt-cost').value = product.mt_cost || '';
document.getElementById('detail-lb-cost').value = product.lb_cost || '';
document.getElementById('detail-mt-qou').value = product.mt_qou || '';
document.getElementById('detail-lb-qou').value = product.lb_qou || '';
document.getElementById('detail-discount').value = product.discount || '';
document.getElementById('detail-supplier-id').value = product.supplier_id || '';
document.getElementById('detail-supplier-price').value = product.supplier_price || '';
document.getElementById('detail-lead-time').value = product.lead_time || '';
updateSupplierWebsiteLink(product.supplier_id);
if (product.has_image) {
previewBox.innerHTML = `<img src="${withAuthToken(`${API_BASE}/products/${product.id}/image?t=${Date.now()}`)}" alt="Preview" style="max-width:100%; max-height:100%; object-fit:contain;">`;
}
if (product.has_pdf) {
pdfStatus.innerHTML = `
<div style="display:flex; align-items:center; gap:10px;">
<span style="color: var(--primary-color); font-weight:600; cursor:pointer;"
onclick="openPdfViewer(${product.id}, '${escapeJsString(product.description)}')">
<i class="fa-solid fa-file-pdf text-danger"></i> ${state.currentLanguage === 'en' ? 'Open Manual (PDF)' : 'เปิดดูคู่มือเทคนิค (PDF)'}
</span>
<button type="button" class="btn btn-danger-outline btn-sm" onclick="handleDeletePdfDirect(${product.id})">
<i class="fa-solid fa-trash-can"></i> ${state.currentLanguage === 'en' ? 'Delete' : 'ลบไฟล์'}
</button>
</div>
`;
}
if (product.has_supplier_quote) {
quoteStatus.innerHTML = `
<div style="display:flex; align-items:center; gap:10px;">
<span style="color: var(--primary-color); font-weight:600; cursor:pointer;"
onclick="openSupplierQuoteViewer(${product.id}, '${escapeJsString(product.description)}')">
<i class="fa-solid fa-file-invoice text-success"></i> ${state.currentLanguage === 'en' ? 'Open Quotation' : 'เปิดดูใบเสนอราคา'}
</span>
<button type="button" class="btn btn-danger-outline btn-sm" onclick="handleDeleteSupplierQuoteDirect(${product.id})">
<i class="fa-solid fa-trash-can"></i> ${state.currentLanguage === 'en' ? 'Delete' : 'ลบไฟล์'}
</button>
</div>
`;
}
} else {
title.textContent = t('modal_prod_detail_create');
document.getElementById('product-detail-id').value = '';
document.getElementById('detail-supplier-id').value = '';
document.getElementById('detail-supplier-price').value = '';
document.getElementById('detail-supplier-website-wrapper').style.display = 'none';
}
modal.classList.add('active');
}
function closeProductDetailModal() {
document.getElementById('modal-product-detail').classList.remove('active');
}
function handleImageSelection(e) {
const file = e.target.files[0];
if (!file) return;
state.imageFileToUpload = file;
const reader = new FileReader();
reader.onload = function (e) {
document.getElementById('product-image-preview').innerHTML = `<img src="${e.target.result}" alt="Preview" style="max-width:100%; max-height:100%; object-fit:contain;">`;
};
reader.readAsDataURL(file);
}
function handlePdfSelection(e) {
const file = e.target.files[0];
if (!file) return;
state.pdfFileToUpload = file;
document.getElementById('product-pdf-status').innerHTML = `
<span class="text-success"><i class="fa-solid fa-file-circle-check"></i> ${state.currentLanguage === 'en' ? 'Ready to upload' : 'พร้อมอัปโหลด'}: ${escapeHtml(file.name)}</span>
`;
}
async function handleProductDetailSubmit(e) {
e.preventDefault();
const id = document.getElementById('product-detail-id').value;
const payload = {
brand: document.getElementById('detail-brand').value,
description: document.getElementById('detail-description').value,
type: document.getElementById('detail-type').value,
model: document.getElementById('detail-model').value,
sm_no: document.getElementById('detail-sm-no').value,
category: document.getElementById('detail-category').value,
prod_group: document.getElementById('detail-group').value,
prod_system: document.getElementById('detail-system').value,
unit: document.getElementById('detail-unit').value,
weight: parseFloat(document.getElementById('detail-weight').value) || 0,
mt_cost: parseFloat(document.getElementById('detail-mt-cost').value) || 0,
lb_cost: parseFloat(document.getElementById('detail-lb-cost').value) || 0,
mt_qou: parseFloat(document.getElementById('detail-mt-qou').value) || 0,
lb_qou: parseFloat(document.getElementById('detail-lb-qou').value) || 0,
discount: parseFloat(document.getElementById('detail-discount').value) || 0,
supplier_id: document.getElementById('detail-supplier-id').value || null,
supplier_price: parseFloat(document.getElementById('detail-supplier-price').value) || 0,
lead_time: document.getElementById('detail-lead-time').value || null,
details: ''
};
try {
let product;
if (id) {
const res = await authFetch(`${API_BASE}/products/${id}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
product = await res.json();
showToast(t('toast_catalog_updated'));
} else {
const res = await authFetch(`${API_BASE}/products`, {
method: 'POST',
body: JSON.stringify(payload)
});
product = await res.json();
showToast(t('toast_catalog_updated'));
}
// Image Upload directly to database
if (state.imageFileToUpload) {
const formData = new FormData();
formData.append('image', state.imageFileToUpload);
await authFetch(`${API_BASE}/products/${product.id}/upload`, {
method: 'POST',
body: formData
});
showToast(state.currentLanguage === 'en' ? 'Product image uploaded' : 'อัปโหลดรูปภาพสินค้าเข้าระบบฐานข้อมูลแล้ว');
} else {
const imageUrl = document.getElementById('product-image-url').value.trim();
if (imageUrl) {
await authFetch(`${API_BASE}/products/${product.id}/upload-image-url`, {
method: 'POST',
body: JSON.stringify({ url: imageUrl })
});
showToast(state.currentLanguage === 'en' ? 'Product image downloaded and saved' : 'ดาวน์โหลดและบันทึกรูปภาพสินค้าเข้าระบบแล้ว');
}
}
// PDF Upload directly to database
if (state.pdfFileToUpload) {
const formData = new FormData();
formData.append('pdf', state.pdfFileToUpload);
await authFetch(`${API_BASE}/products/${product.id}/upload-pdf`, {
method: 'POST',
body: formData
});
showToast(state.currentLanguage === 'en' ? 'PDF manual uploaded' : 'อัปโหลดไฟล์ PDF คู่มือเข้าระบบฐานข้อมูลแล้ว');
} else {
const pdfUrl = document.getElementById('product-pdf-url').value.trim();
if (pdfUrl) {
await authFetch(`${API_BASE}/products/${product.id}/upload-pdf-url`, {
method: 'POST',
body: JSON.stringify({ url: pdfUrl })
});
showToast(state.currentLanguage === 'en' ? 'Technical manual downloaded and saved' : 'ดาวน์โหลดและบันทึกคู่มือเทคนิคเรียบร้อยแล้ว');
}
}
// Supplier Quote Upload directly to database
if (state.quoteFileToUpload) {
const formData = new FormData();
formData.append('quote', state.quoteFileToUpload);
await authFetch(`${API_BASE}/products/${product.id}/upload-supplier-quote`, {
method: 'POST',
body: formData
});
showToast(state.currentLanguage === 'en' ? 'Supplier quote document uploaded' : 'อัปโหลดเอกสารใบเสนอราคาผู้จัดจำหน่ายแล้ว');
}
closeProductDetailModal();
await fetchCategories();
await fetchGroups();
await fetchSystems();
buildCategoryDropdown('catalog-manager-category');
buildGroupDropdown('catalog-manager-group');
buildSystemDropdown('catalog-manager-system');
buildCategoryDropdown('explorer-category-select');
buildGroupDropdown('explorer-group-select');
buildSystemDropdown('explorer-system-select');
await filterManagerProducts(true);
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Error saving product information' : 'เกิดข้อผิดพลาดในการบันทึกข้อมูลสินค้า', 'error');
}
}
async function handleDeleteCatalogProduct(id) {
if (!confirm(t('confirm_delete_product_catalog'))) return;
try {
await authFetch(`${API_BASE}/products/${id}`, { method: 'DELETE' });
showToast(state.currentLanguage === 'en' ? 'Product deleted successfully' : 'ลบสินค้าสำเร็จ');
await filterManagerProducts();
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete product' : 'ไม่สามารถลบสินค้าได้', 'error');
}
}
async function handleCloneCatalogProduct(id) {
if (!confirm(state.currentLanguage === 'en' ? 'Are you sure you want to clone this product?' : 'คุณแน่ใจหรือไม่ว่าต้องการโคลนสินค้าชิ้นนี้?')) return;
try {
const res = await authFetch(`${API_BASE}/products/${id}/clone`, {
method: 'POST'
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to clone product');
}
showToast(state.currentLanguage === 'en' ? 'Product cloned successfully' : 'โคลนสินค้าสำเร็จแล้ว');
await filterManagerProducts();
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
}
}
async function handleDeletePdfDirect(productId) {
if (!confirm(t('confirm_delete_pdf_sheet'))) return;
try {
await authFetch(`${API_BASE}/products/${productId}/pdf`, { method: 'DELETE' });
showToast(t('toast_pdf_deleted'));
// Refresh detail form
const res = await authFetch(`${API_BASE}/products?q=`);
const list = await res.json();
const updatedProd = list.find(p => p.id === productId);
openProductDetailModal(updatedProd);
await filterManagerProducts();
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete file' : 'ไม่สามารถลบไฟล์ได้', 'error');
}
}
// ==========================================
// Embedded PDF Datasheet Viewer Modal
// ==========================================
function openPdfViewer(productId, productDesc) {
const viewerModal = document.getElementById('modal-pdf-viewer');
const iframe = document.getElementById('pdf-viewer-iframe');
const htmlContainer = document.getElementById('doc-viewer-html-container');
const title = document.getElementById('pdf-viewer-title');
const subtitle = document.getElementById('pdf-viewer-subtitle');
const deleteBtn = document.getElementById('btn-delete-pdf-from-viewer');
iframe.style.display = 'block';
if (htmlContainer) {
htmlContainer.style.display = 'none';
}
title.textContent = state.currentLanguage === 'en' ? `Product Datasheet: ${productDesc}` : `คู่มือสเปกสินค้า: ${productDesc}`;
if (subtitle) {
subtitle.textContent = t('modal_viewer_subtitle');
}
// Set dynamic URL loading the binary stream from database
const pdfUrl = withAuthToken(`${API_BASE}/products/${productId}/pdf`);
iframe.src = pdfUrl;
const newTabBtn = document.getElementById('btn-open-pdf-new-tab');
if (newTabBtn) {
newTabBtn.href = pdfUrl;
newTabBtn.style.display = 'inline-flex';
}
// Admin or Super Admin only delete capability
if (state.currentUser && (state.currentUser.role === 'admin' || state.currentUser.role === 'superadmin' || state.currentUser.role === 'supervisor' || state.currentUser.role === 'estimate')) {
deleteBtn.style.display = 'inline-flex';
// Remove previous listeners
const newBtn = deleteBtn.cloneNode(true);
deleteBtn.parentNode.replaceChild(newBtn, deleteBtn);
newBtn.addEventListener('click', async () => {
if (confirm(state.currentLanguage === 'en' ? `Are you sure you want to delete the PDF datasheet for "${productDesc}"?` : `คุณต้องการลบสเปกสินค้า PDF ของ "${productDesc}" ใช่หรือไม่?`)) {
try {
await authFetch(`${API_BASE}/products/${productId}/pdf`, { method: 'DELETE' });
showToast(t('toast_pdf_deleted'));
closePdfViewer();
await filterManagerProducts();
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete PDF datasheet' : 'ลบไฟล์สเปก PDF ไม่สำเร็จ', 'error');
}
}
});
} else {
deleteBtn.style.display = 'none';
}
const editBtn = document.getElementById('btn-edit-pdf-datasheet');
const saveBtn = document.getElementById('btn-save-pdf-datasheet');
const cancelBtn = document.getElementById('btn-cancel-edit-pdf-datasheet');
if (editBtn) {
const canWrite = state.currentUser && (state.currentUser.role === 'admin' || state.currentUser.role === 'superadmin' || state.currentUser.role === 'supervisor' || state.currentUser.role === 'estimate');
editBtn.style.display = canWrite ? 'inline-flex' : 'none';
saveBtn.style.display = 'none';
cancelBtn.style.display = 'none';
const newEditBtn = editBtn.cloneNode(true);
editBtn.parentNode.replaceChild(newEditBtn, editBtn);
newEditBtn.addEventListener('click', () => {
startPdfAnnotation(productId, withAuthToken(`${API_BASE}/products/${productId}/pdf`), productDesc);
});
}
viewerModal.classList.add('active');
}
function openProjectPdfViewer(projectId, type, filename) {
const viewerModal = document.getElementById('modal-pdf-viewer');
const iframe = document.getElementById('pdf-viewer-iframe');
const htmlContainer = document.getElementById('doc-viewer-html-container');
const title = document.getElementById('pdf-viewer-title');
const subtitle = document.getElementById('pdf-viewer-subtitle');
const deleteBtn = document.getElementById('btn-delete-pdf-from-viewer');
iframe.style.display = 'block';
if (htmlContainer) {
htmlContainer.style.display = 'none';
}
title.textContent = state.currentLanguage === 'en'
? (type === 'drawing' ? `Project Drawing: ${filename}` : `Project Specification: ${filename}`)
: (type === 'drawing' ? `แบบแปลนโครงการ: ${filename}` : `สเปกโครงการ: ${filename}`);
if (subtitle) {
subtitle.textContent = state.currentLanguage === 'en'
? (type === 'drawing' ? 'Project Drawing (PDF)' : 'Project Specification Sheet (PDF)')
: (type === 'drawing' ? 'แบบแปลนโครงการ (PDF)' : 'เอกสารสเปกโครงการ (PDF)');
}
const pdfUrl = withAuthToken(`${API_BASE}/projects/${projectId}/${type}`);
iframe.src = pdfUrl;
const newTabBtn = document.getElementById('btn-open-pdf-new-tab');
if (newTabBtn) {
newTabBtn.href = pdfUrl;
newTabBtn.style.display = 'inline-flex';
}
deleteBtn.style.display = 'none';
viewerModal.classList.add('active');
}
function closePdfViewer() {
const viewerModal = document.getElementById('modal-pdf-viewer');
const iframe = document.getElementById('pdf-viewer-iframe');
const htmlContainer = document.getElementById('doc-viewer-html-container');
const annoContainer = document.getElementById('pdf-annotation-container');
const editBtn = document.getElementById('btn-edit-pdf-datasheet');
const saveBtn = document.getElementById('btn-save-pdf-datasheet');
const cancelBtn = document.getElementById('btn-cancel-edit-pdf-datasheet');
// Clear src to stop browser resource loading in background
iframe.src = '';
const newTabBtn = document.getElementById('btn-open-pdf-new-tab');
if (newTabBtn) {
newTabBtn.href = '';
newTabBtn.style.display = 'none';
}
if (htmlContainer) {
htmlContainer.innerHTML = '';
}
iframe.style.display = 'block';
if (htmlContainer) htmlContainer.style.display = 'none';
if (annoContainer) annoContainer.style.display = 'none';
if (editBtn) editBtn.style.display = 'none';
if (saveBtn) saveBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'none';
// Reset editing state
annoPdfDoc = null;
annoDrawingData = {};
viewerModal.classList.remove('active');
}
// ==========================================
// PDF Annotation State & Actions (Highlighting & Pen Tool)
// ==========================================
let annoPdfDoc = null;
let annoCurrentPage = 1;
let annoTotalPages = 1;
let annoDrawingData = {}; // pageNum -> DataURL string
let annoIsDrawing = false;
let annoCurrentTool = 'highlight'; // 'highlight', 'pen', 'eraser'
let annoCurrentColor = '#ffff00';
let annoCurrentSize = 10;
let annoPdfUrl = '';
let annoProductId = null;
let annoLastX = 0;
let annoLastY = 0;
async function startPdfAnnotation(productId, url, productDesc) {
annoProductId = productId;
annoPdfUrl = url;
showToast(state.currentLanguage === 'en' ? 'Loading PDF for editing...' : 'กำลังโหลด PDF เพื่อไฮไลต์...');
try {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
annoPdfDoc = await pdfjsLib.getDocument(url).promise;
annoTotalPages = annoPdfDoc.numPages;
annoCurrentPage = 1;
annoDrawingData = {};
// Hide iframe, show annotation workbench
document.getElementById('pdf-viewer-iframe').style.display = 'none';
document.getElementById('pdf-annotation-container').style.display = 'flex';
// Update title
document.getElementById('pdf-viewer-title').textContent = state.currentLanguage === 'en' ? `Edit Datasheet: ${productDesc}` : `แก้ไขและไฮไลต์สเปก: ${productDesc}`;
// Toggle header buttons
document.getElementById('btn-edit-pdf-datasheet').style.display = 'none';
document.getElementById('btn-save-pdf-datasheet').style.display = 'inline-flex';
document.getElementById('btn-cancel-edit-pdf-datasheet').style.display = 'inline-flex';
document.getElementById('btn-delete-pdf-from-viewer').style.display = 'none';
// Reset tool defaults
setAnnoTool('highlight');
// Render page 1
await annoRenderPage(1);
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to load PDF for editing' : 'โหลด PDF เพื่อแก้ไขไม่สำเร็จ', 'error');
}
}
async function annoRenderPage(pageNum) {
annoCurrentPage = pageNum;
document.getElementById('anno-page-num').textContent = state.currentLanguage === 'en' ? `Page ${pageNum} / ${annoTotalPages}` : `หน้า ${pageNum} / ${annoTotalPages}`;
const page = await annoPdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.5 });
const pdfCanvas = document.getElementById('pdf-canvas');
const pdfCtx = pdfCanvas.getContext('2d');
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
const drawingCanvas = document.getElementById('drawing-canvas');
const drawingCtx = drawingCanvas.getContext('2d');
drawingCanvas.width = viewport.width;
drawingCanvas.height = viewport.height;
// Render PDF page into pdfCanvas
await page.render({ canvasContext: pdfCtx, viewport: viewport }).promise;
// Clear drawing canvas and load saved drawing for this page if any
drawingCtx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height);
if (annoDrawingData[pageNum]) {
const img = new Image();
img.onload = () => {
drawingCtx.drawImage(img, 0, 0);
};
img.src = annoDrawingData[pageNum];
}
}
async function saveAnnotatedPdf() {
showToast(state.currentLanguage === 'en' ? 'Generating PDF...' : 'กำลังประมวลผลไฟล์ PDF...');
try {
const { jsPDF } = window.jspdf;
const refCanvas = document.getElementById('pdf-canvas');
const pdf = new jsPDF({
orientation: refCanvas.width > refCanvas.height ? 'landscape' : 'portrait',
unit: 'px',
format: [refCanvas.width, refCanvas.height]
});
for (let i = 1; i <= annoTotalPages; i++) {
if (i > 1) {
pdf.addPage([refCanvas.width, refCanvas.height]);
}
const page = await annoPdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const tempCanvas = document.createElement('canvas');
tempCanvas.width = viewport.width;
tempCanvas.height = viewport.height;
const tempCtx = tempCanvas.getContext('2d');
// Draw PDF page content
await page.render({ canvasContext: tempCtx, viewport: viewport }).promise;
// Draw overlay highlights/annotations if any
if (annoDrawingData[i]) {
await new Promise((resolve) => {
const img = new Image();
img.onload = () => {
tempCtx.drawImage(img, 0, 0);
resolve();
};
img.src = annoDrawingData[i];
});
}
const imgData = tempCanvas.toDataURL('image/jpeg', 0.9);
pdf.addImage(imgData, 'JPEG', 0, 0, refCanvas.width, refCanvas.height);
}
const pdfBlob = pdf.output('blob');
// Upload the new PDF blob back to the server product endpoint
const formData = new FormData();
formData.append('pdf', pdfBlob, `datasheet-edited-${Date.now()}.pdf`);
showToast(state.currentLanguage === 'en' ? 'Uploading edited datasheet...' : 'กำลังอัปโหลดไฟล์คู่มือใหม่...');
const response = await authFetch(`${API_BASE}/products/${annoProductId}/upload-pdf`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(state.currentLanguage === 'en' ? 'Upload failed' : 'อัปโหลดไม่สำเร็จ');
}
showToast(state.currentLanguage === 'en' ? 'Datasheet successfully annotated and saved!' : 'บันทึกการไฮไลต์สเปกเรียบร้อยแล้ว!');
// Reload products view and close viewer
await filterManagerProducts();
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
closePdfViewer();
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to save annotated PDF' : 'เกิดข้อผิดพลาดในการบันทึกไฟล์ PDF', 'error');
}
}
function cancelPdfEdit() {
// Hide annotation container, show iframe
document.getElementById('pdf-annotation-container').style.display = 'none';
document.getElementById('pdf-viewer-iframe').style.display = 'block';
// Toggle header buttons
const deleteBtn = document.getElementById('btn-delete-pdf-from-viewer');
const role = state.currentUser ? state.currentUser.role : 'user';
const canWrite = ['admin', 'superadmin', 'supervisor', 'estimate'].includes(role);
document.getElementById('btn-edit-pdf-datasheet').style.display = canWrite ? 'inline-flex' : 'none';
document.getElementById('btn-save-pdf-datasheet').style.display = 'none';
document.getElementById('btn-cancel-edit-pdf-datasheet').style.display = 'none';
document.getElementById('btn-delete-pdf-from-viewer').style.display = canWrite ? 'inline-flex' : 'none';
// Reset title back
const title = document.getElementById('pdf-viewer-title');
title.textContent = state.currentLanguage === 'en' ? 'Product Datasheet' : 'เปิดดูคู่มือเทคนิคสินค้า';
// Clear state
annoPdfDoc = null;
annoDrawingData = {};
}
function initPdfAnnotationEvents() {
const drawingCanvas = document.getElementById('drawing-canvas');
if (!drawingCanvas) return;
const ctx = drawingCanvas.getContext('2d');
function getMousePos(e) {
const rect = drawingCanvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: ((clientX - rect.left) / rect.width) * drawingCanvas.width,
y: ((clientY - rect.top) / rect.height) * drawingCanvas.height
};
}
function startDrawing(e) {
e.preventDefault();
annoIsDrawing = true;
const pos = getMousePos(e);
annoLastX = pos.x;
annoLastY = pos.y;
}
function draw(e) {
if (!annoIsDrawing) return;
e.preventDefault();
const pos = getMousePos(e);
ctx.beginPath();
ctx.moveTo(annoLastX, annoLastY);
ctx.lineTo(pos.x, pos.y);
if (annoCurrentTool === 'highlight') {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = annoCurrentColor;
ctx.lineWidth = annoCurrentSize;
ctx.globalAlpha = 0.4;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
} else if (annoCurrentTool === 'pen') {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = annoCurrentColor;
ctx.lineWidth = annoCurrentSize;
ctx.globalAlpha = 1.0;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
} else if (annoCurrentTool === 'eraser') {
ctx.globalCompositeOperation = 'destination-out';
ctx.lineWidth = annoCurrentSize;
ctx.globalAlpha = 1.0;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
ctx.stroke();
annoLastX = pos.x;
annoLastY = pos.y;
}
function stopDrawing() {
if (annoIsDrawing) {
annoIsDrawing = false;
annoDrawingData[annoCurrentPage] = drawingCanvas.toDataURL();
}
}
drawingCanvas.addEventListener('mousedown', startDrawing);
drawingCanvas.addEventListener('mousemove', draw);
drawingCanvas.addEventListener('mouseup', stopDrawing);
drawingCanvas.addEventListener('mouseout', stopDrawing);
drawingCanvas.addEventListener('touchstart', startDrawing, { passive: false });
drawingCanvas.addEventListener('touchmove', draw, { passive: false });
drawingCanvas.addEventListener('touchend', stopDrawing);
document.getElementById('btn-anno-tool-highlight').addEventListener('click', () => {
setAnnoTool('highlight');
});
document.getElementById('btn-anno-tool-pen').addEventListener('click', () => {
setAnnoTool('pen');
});
document.getElementById('btn-anno-tool-eraser').addEventListener('click', () => {
setAnnoTool('eraser');
});
document.getElementById('btn-anno-clear').addEventListener('click', () => {
if (confirm(state.currentLanguage === 'en' ? 'Are you sure you want to clear all drawings on this page?' : 'คุณต้องการล้างรูปวาดทั้งหมดในหน้านี้ใช่หรือไม่?')) {
ctx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height);
delete annoDrawingData[annoCurrentPage];
}
});
const colorInput = document.getElementById('anno-color');
colorInput.addEventListener('input', (e) => {
annoCurrentColor = e.target.value;
});
const sizeSelect = document.getElementById('anno-size');
sizeSelect.addEventListener('change', (e) => {
annoCurrentSize = parseInt(e.target.value);
});
document.getElementById('btn-anno-prev').addEventListener('click', async () => {
if (annoCurrentPage > 1) {
annoDrawingData[annoCurrentPage] = drawingCanvas.toDataURL();
await annoRenderPage(annoCurrentPage - 1);
}
});
document.getElementById('btn-anno-next').addEventListener('click', async () => {
if (annoCurrentPage < annoTotalPages) {
annoDrawingData[annoCurrentPage] = drawingCanvas.toDataURL();
await annoRenderPage(annoCurrentPage + 1);
}
});
}
function setAnnoTool(tool) {
annoCurrentTool = tool;
document.getElementById('btn-anno-tool-highlight').classList.remove('active');
document.getElementById('btn-anno-tool-pen').classList.remove('active');
document.getElementById('btn-anno-tool-eraser').classList.remove('active');
if (tool === 'highlight') {
document.getElementById('btn-anno-tool-highlight').classList.add('active');
document.getElementById('anno-color').value = '#ffff00';
annoCurrentColor = '#ffff00';
} else if (tool === 'pen') {
document.getElementById('btn-anno-tool-pen').classList.add('active');
document.getElementById('anno-color').value = '#ff0000';
annoCurrentColor = '#ff0000';
} else if (tool === 'eraser') {
document.getElementById('btn-anno-tool-eraser').classList.add('active');
}
}
// Function to preview documents (PDF, Word, Excel) inside the app
function openDocumentPreview(docId, fileName, fileType, isTestScript = false) {
const viewerModal = document.getElementById('modal-pdf-viewer');
const iframe = document.getElementById('pdf-viewer-iframe');
const htmlContainer = document.getElementById('doc-viewer-html-container');
const title = document.getElementById('pdf-viewer-title');
const subtitle = document.getElementById('pdf-viewer-subtitle');
const deleteBtn = document.getElementById('btn-delete-pdf-from-viewer');
title.textContent = state.currentLanguage === 'en' ? `Preview File: ${fileName}` : `แสดงตัวอย่างไฟล์: ${fileName}`;
if (subtitle) {
subtitle.textContent = isTestScript
? (state.currentLanguage === 'en' ? 'Test Script Preview' : 'ตัวอย่างแนวทางการทดสอบ')
: (state.currentLanguage === 'en' ? 'Project Document Preview' : 'ตัวอย่างเอกสารโครงการ');
}
deleteBtn.style.display = 'none';
const type = fileType.toLowerCase();
const url = isTestScript ? `${API_BASE}/test-scripts/files/${docId}` : `${API_BASE}/documents/${docId}`;
if (type === 'pdf') {
iframe.style.display = 'block';
htmlContainer.style.display = 'none';
const pdfUrl = withAuthToken(url);
iframe.src = pdfUrl;
const newTabBtn = document.getElementById('btn-open-pdf-new-tab');
if (newTabBtn) {
newTabBtn.href = pdfUrl;
newTabBtn.style.display = 'inline-flex';
}
viewerModal.classList.add('active');
} else if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(type)) {
iframe.style.display = 'none';
htmlContainer.style.display = 'block';
htmlContainer.innerHTML = `<div style="text-align:center; padding:10px;"><img src="${withAuthToken(url)}" style="max-width:100%; max-height:70vh; object-fit:contain; border:1px solid var(--border-color); border-radius:var(--radius-sm);"></div>`;
viewerModal.classList.add('active');
} else if (['docx', 'xlsx', 'xls'].includes(type)) {
iframe.style.display = 'none';
htmlContainer.style.display = 'block';
htmlContainer.innerHTML = `<div style="padding:40px; text-align:center; color:var(--text-muted);"><i class="fa-solid fa-spinner fa-spin" style="font-size:2rem; margin-bottom:10px; color:var(--primary-color);"></i><p>กำลังโหลดเอกสาร...</p></div>`;
viewerModal.classList.add('active');
authFetch(url)
.then(res => {
if (!res.ok) throw new Error('Failed to load file');
return res.arrayBuffer();
})
.then(arrayBuffer => {
if (type === 'docx') {
// Render DOCX using Mammoth
mammoth.convertToHtml({ arrayBuffer: arrayBuffer })
.then(result => {
htmlContainer.innerHTML = `<div class="docx-preview-content" style="max-width:800px; margin:0 auto; color:var(--text-dark); line-height:1.6; font-size:0.95rem; text-align:left;">${result.value || '<p style="text-align:center; color:var(--text-muted);">ไม่มีเนื้อหาในเอกสารนี้</p>'}</div>`;
})
.catch(err => {
console.error(err);
htmlContainer.innerHTML = `<div style="padding:40px; text-align:center; color:var(--text-muted);"><i class="fa-solid fa-circle-exclamation" style="font-size:2rem; margin-bottom:10px; color:var(--danger);"></i><p>ไม่สามารถแปลงเอกสาร Word นี้ได้ กรุณาดาวน์โหลดเพื่อเปิดดู</p></div>`;
});
} else {
// Render Excel using SheetJS
const data = new Uint8Array(arrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
htmlContainer.innerHTML = '';
if (workbook.SheetNames.length > 0) {
const tabsContainer = document.createElement('div');
tabsContainer.style.display = 'flex';
tabsContainer.style.gap = '8px';
tabsContainer.style.marginBottom = '20px';
tabsContainer.style.borderBottom = '2px solid var(--border-color)';
tabsContainer.style.paddingBottom = '8px';
tabsContainer.style.overflowX = 'auto';
const sheetsContentContainer = document.createElement('div');
workbook.SheetNames.forEach((sheetName, index) => {
const tabBtn = document.createElement('button');
tabBtn.className = 'btn btn-sm ' + (index === 0 ? 'btn-primary' : 'btn-outline');
tabBtn.style.padding = '4px 12px';
tabBtn.style.fontSize = '0.8rem';
tabBtn.textContent = sheetName;
const sheet = workbook.Sheets[sheetName];
const htmlTable = (sheet && sheet['!ref'])
? XLSX.utils.sheet_to_html(sheet, { header: '', footer: '' })
: `<div style="padding: 20px; text-align: center; color: var(--text-muted);">${state.currentLanguage === 'en' ? 'No data in this sheet' : 'ไม่มีข้อมูลในชีทนี้'}</div>`;
const sheetDiv = document.createElement('div');
sheetDiv.style.display = index === 0 ? 'block' : 'none';
sheetDiv.style.overflow = 'auto';
sheetDiv.innerHTML = `<div class="excel-table-wrapper" style="width:100%; border: 1px solid var(--border-color); border-radius: var(--radius-sm); overflow:auto;">${htmlTable}</div>`;
sheetsContentContainer.appendChild(sheetDiv);
tabBtn.addEventListener('click', () => {
Array.from(tabsContainer.children).forEach(btn => {
btn.className = 'btn btn-sm btn-outline';
});
tabBtn.className = 'btn btn-sm btn-primary';
Array.from(sheetsContentContainer.children).forEach(div => {
div.style.display = 'none';
});
sheetDiv.style.display = 'block';
});
tabsContainer.appendChild(tabBtn);
});
htmlContainer.appendChild(tabsContainer);
htmlContainer.appendChild(sheetsContentContainer);
const styleEl = document.createElement('style');
styleEl.innerHTML = `
.excel-table-wrapper table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.excel-table-wrapper th, .excel-table-wrapper td {
border: 1px solid #e2e8f0;
padding: 8px 12px;
text-align: left;
white-space: nowrap;
}
.excel-table-wrapper tr:nth-child(even) {
background-color: #f8fafc;
}
`;
htmlContainer.appendChild(styleEl);
} else {
htmlContainer.innerHTML = `<p style="text-align:center; color:var(--text-muted); padding:40px;">ไม่มีหน้าชีทในเอกสาร Excel นี้</p>`;
}
}
})
.catch(err => {
console.error(err);
htmlContainer.innerHTML = `<div style="padding:40px; text-align:center; color:var(--text-muted);"><i class="fa-solid fa-circle-exclamation" style="font-size:2rem; margin-bottom:10px; color:var(--danger);"></i><p>ไม่สามารถดาวน์โหลดไฟล์เพื่อแสดงตัวอย่างได้ กรุณาดาวน์โหลดผ่านปุ่มด้านข้าง</p></div>`;
});
} else {
// Unsupported preview types (e.g. .doc, .zip, .rar, .dwg)
iframe.style.display = 'none';
htmlContainer.style.display = 'block';
htmlContainer.innerHTML = `
<div style="padding:40px; text-align:center; color:var(--text-muted);">
<i class="fa-solid fa-file-circle-exclamation" style="font-size:3rem; margin-bottom:15px; color:var(--warning);"></i>
<h4 style="margin-bottom:8px; color:var(--text-dark);">ไม่สามารถแสดงตัวอย่างไฟล์นี้ได้</h4>
<p>ไฟล์ประเภท .${type} ไม่รองรับการเปิดอ่านในหน้าต่างแอปโดยตรง กรุณาดาวน์โหลดเพื่อเปิดดู</p>
<a href="${url}" download class="btn btn-primary btn-sm" style="margin-top:15px; display:inline-flex; align-items:center; gap:8px;">
<i class="fa-solid fa-download"></i> ดาวน์โหลดไฟล์
</a>
</div>
`;
viewerModal.classList.add('active');
}
}
function openSectionPdfViewer(sectionId, sectionName) {
const viewerModal = document.getElementById('modal-pdf-viewer');
const iframe = document.getElementById('pdf-viewer-iframe');
const htmlContainer = document.getElementById('doc-viewer-html-container');
const title = document.getElementById('pdf-viewer-title');
const subtitle = document.getElementById('pdf-viewer-subtitle');
const deleteBtn = document.getElementById('btn-delete-pdf-from-viewer');
iframe.style.display = 'block';
if (htmlContainer) {
htmlContainer.style.display = 'none';
}
title.textContent = state.currentLanguage === 'en' ? `Section Spec: ${sectionName}` : `เอกสารสเปกส่วนย่อย: ${sectionName}`;
if (subtitle) {
subtitle.textContent = state.currentLanguage === 'en' ? 'Technical Specification Sheet for this Project Sub-section' : 'เอกสารรายละเอียดทางเทคนิคสำหรับส่วนย่อยโครงการนี้';
}
const pdfUrl = withAuthToken(`${API_BASE}/sections/${sectionId}/spec`);
iframe.src = pdfUrl;
const newTabBtn = document.getElementById('btn-open-pdf-new-tab');
if (newTabBtn) {
newTabBtn.href = pdfUrl;
newTabBtn.style.display = 'inline-flex';
}
const role = state.currentUser ? state.currentUser.role : 'user';
if (role === 'admin' || role === 'superadmin' || role === 'supervisor' || role === 'estimate') {
deleteBtn.style.display = 'inline-flex';
const newBtn = deleteBtn.cloneNode(true);
deleteBtn.parentNode.replaceChild(newBtn, deleteBtn);
newBtn.addEventListener('click', async () => {
if (confirm(state.currentLanguage === 'en' ? `Are you sure you want to delete the spec PDF for "${sectionName}"?` : `คุณต้องการลบสเปก PDF ของส่วนย่อย "${sectionName}" ใช่หรือไม่?`)) {
try {
const res = await authFetch(`${API_BASE}/sections/${sectionId}/spec`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
showToast(state.currentLanguage === 'en' ? 'Section spec PDF deleted successfully' : 'ลบเอกสารสเปกส่วนย่อยสำเร็จแล้ว');
closePdfViewer();
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to delete section spec' : 'ไม่สามารถลบเอกสารสเปกได้', 'error');
}
}
});
} else {
deleteBtn.style.display = 'none';
}
viewerModal.classList.add('active');
}
async function handleSectionSpecUpload(event, sectionId) {
const file = event.target.files[0];
if (!file) return;
if (file.type !== 'application/pdf') {
showToast(state.currentLanguage === 'en' ? 'Only PDF files are allowed' : 'อนุญาตเฉพาะไฟล์ PDF เท่านั้น', 'danger');
return;
}
const formData = new FormData();
formData.append('spec', file);
try {
showToast(state.currentLanguage === 'en' ? 'Uploading section spec...' : 'กำลังอัปโหลดเอกสารสเปก...', 'info');
const res = await authFetch(`${API_BASE}/sections/${sectionId}/spec`, {
method: 'POST',
body: formData
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Upload failed');
}
showToast(state.currentLanguage === 'en' ? 'Section spec PDF uploaded successfully' : 'อัปโหลดเอกสารสเปกส่วนย่อยสำเร็จแล้ว');
await fetchActiveProjectDetails();
renderWorkspace();
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
}
}
// ==========================================
// Project Approval Attachments Modal
// ==========================================
async function openApprovalAttachmentsModal() {
if (!state.activeProjectId || !state.activeProjectSummary) return;
const tbody = document.getElementById('attachments-list-tbody');
tbody.innerHTML = '';
// Query items in active project subsections
try {
const sections = state.activeProjectSummary.sections || [];
const uniqueProducts = new Map();
for (let sec of sections) {
const res = await authFetch(`${API_BASE}/sections/${sec.id}/items`);
const items = await res.json();
items.forEach(item => {
if (item.has_pdf && item.product_id) {
uniqueProducts.set(item.product_id, {
id: item.product_id,
brand: item.brand,
description: item.description,
type: item.type,
model: item.model
});
}
});
}
const productsList = Array.from(uniqueProducts.values());
const sectionSpecs = sections.filter(s => !!s.spec_name);
if (productsList.length === 0 && sectionSpecs.length === 0) {
const noAttachmentsText = state.currentLanguage === 'en'
? 'No products or subsections in this quotation have a technical manual (PDF Datasheet) attached.'
: 'ไม่มีสินค้าหรือส่วนย่อยใดในเอกสารเสนอราคานี้ที่มีเอกสารข้อมูลเทคนิค (PDF) แนบไว้';
tbody.innerHTML = `
<tr>
<td colspan="3" style="padding:30px; text-align:center; color:var(--text-muted);">
<i class="fa-solid fa-file-excel" style="font-size:1.5rem; margin-bottom:8px; display:block;">
</i>${noAttachmentsText}
</td>
</tr>`;
} else {
// Render section specs
sectionSpecs.forEach(sec => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><b>${state.currentLanguage === 'en' ? 'System Spec' : 'เอกสารแนบระบบงาน'}</b></td>
<td>
<div style="font-weight:500;">${escapeHtml(sec.name)} (Specification)</div>
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">
${state.currentLanguage === 'en' ? 'Filename' : 'ชื่อไฟล์'}: ${escapeHtml(sec.spec_name)}
</div>
</td>
<td class="text-center">
<div style="display:flex; justify-content:center; gap:8px;">
<button class="btn btn-outline btn-sm" onclick="openSectionPdfViewer(${sec.id}, '${escapeJsString(sec.name)}')">
<i class="fa-solid fa-eye"></i> ${state.currentLanguage === 'en' ? 'View' : 'เปิดดู'}
</button>
<a href="${withAuthToken(`${API_BASE}/sections/${sec.id}/spec`)}" download class="btn btn-primary btn-sm" style="text-decoration:none;">
<i class="fa-solid fa-download"></i> ${state.currentLanguage === 'en' ? 'Download' : 'โหลด'}
</a>
</div>
</td>
`;
tbody.appendChild(tr);
});
// Render product specs
productsList.forEach(p => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><b>${escapeHtml(p.brand || '-')}</b></td>
<td>
<div style="font-weight:500;">${escapeHtml(p.description)}</div>
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">
${state.currentLanguage === 'en' ? 'Model' : 'รุ่น'}: ${escapeHtml(p.model || '-')} |
${state.currentLanguage === 'en' ? 'Type' : 'ประเภท'}: ${escapeHtml(p.type || '-')}
</div>
</td>
<td class="text-center">
<div style="display:flex; justify-content:center; gap:8px;">
<button class="btn btn-outline btn-sm" onclick="openPdfViewer(${p.id}, '${escapeJsString(p.description)}')">
<i class="fa-solid fa-eye"></i> ${state.currentLanguage === 'en' ? 'View' : 'เปิดดู'}
</button>
<a href="${withAuthToken(`${API_BASE}/products/${p.id}/pdf`)}" download class="btn btn-primary btn-sm" style="text-decoration:none;">
<i class="fa-solid fa-download"></i> ${state.currentLanguage === 'en' ? 'Download' : 'โหลด'}
</a>
</div>
</td>
`;
tbody.appendChild(tr);
});
}
document.getElementById('modal-approval-attachments').classList.add('active');
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Error loading attachments list' : 'เกิดข้อผิดพลาดในการโหลดรายการเอกสารแนบ', 'error');
}
}
function closeApprovalAttachmentsModal() {
document.getElementById('modal-approval-attachments').classList.remove('active');
}
// ==========================================
// Project Document Vault & File Explorer Controls
// ==========================================
async function openProjectDocsModal() {
if (!state.activeProjectId || !state.activeProjectSummary) return;
// Set title
document.getElementById('project-docs-title').textContent =
state.currentLanguage === 'en'
? `Project Documents Vault: ${state.activeProjectSummary.name}`
: `คลังเอกสารประกอบโครงการ: ${state.activeProjectSummary.name}`;
// Reset folder path to root
state.currentDocFolderPath = '/';
document.getElementById('input-search-docs').value = ''; // clear search
// Hide write controls for contractor
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
const createFolderBtn = document.getElementById('btn-create-doc-folder');
const dropzone = document.getElementById('docs-drop-zone');
if (createFolderBtn) createFolderBtn.style.display = isContractor ? 'none' : 'inline-flex';
if (dropzone) dropzone.style.display = isContractor ? 'none' : 'flex';
// Load documents
await fetchActiveProjectDocs();
// Render
renderProjectDocs();
// Open modal
document.getElementById('modal-project-docs').classList.add('active');
}
function closeProjectDocsModal() {
document.getElementById('modal-project-docs').classList.remove('active');
}
async function fetchActiveProjectDocs() {
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/documents`);
if (!res.ok) throw new Error('Failed to fetch documents');
state.activeProjectDocs = await res.json();
updateProjectDocsBadge();
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to load project documents' : 'โหลดเอกสารโครงการไม่สำเร็จ', 'error');
}
}
function updateProjectDocsBadge() {
const badge = document.getElementById('project-docs-count-badge');
if (!badge) return;
const files = state.activeProjectDocs && state.activeProjectDocs.files ? state.activeProjectDocs.files : [];
if (files.length > 0) {
badge.textContent = files.length;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
}
function renderProjectDocs() {
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
const folders = state.activeProjectDocs.folders || [];
const files = state.activeProjectDocs.files || [];
const searchVal = document.getElementById('input-search-docs').value.toLowerCase().trim();
// 1. Render sidebar folder list
const folderList = document.getElementById('docs-folder-list');
folderList.innerHTML = '';
// Add Root "/" folder
const rootActive = state.currentDocFolderPath === '/';
const rootDiv = document.createElement('div');
rootDiv.className = `docs-folder-item ${rootActive ? 'active' : ''}`;
rootDiv.innerHTML = `
<span style="display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden;">
<i class="fa-solid fa-folder" style="color:#eab308; flex-shrink:0;"></i>
<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">Root (/)</span>
</span>
`;
rootDiv.addEventListener('click', () => {
state.currentDocFolderPath = '/';
renderProjectDocs();
});
folderList.appendChild(rootDiv);
// Helper function for hierarchical folder path comparison
function compareFolderPaths(pathA, pathB) {
const segmentsA = pathA.split('/').filter(p => !!p);
const segmentsB = pathB.split('/').filter(p => !!p);
const minLength = Math.min(segmentsA.length, segmentsB.length);
for (let i = 0; i < minLength; i++) {
const segA = segmentsA[i];
const segB = segmentsB[i];
if (segA.toLowerCase() !== segB.toLowerCase()) {
return segA.localeCompare(segB, undefined, { sensitivity: 'base', numeric: true });
}
}
return segmentsA.length - segmentsB.length;
}
// Sort folders hierarchically (groups subfolders under their parents)
folders.sort((a, b) => compareFolderPaths(a.folder_path, b.folder_path));
folders.forEach(f => {
const isActive = state.currentDocFolderPath === f.folder_path;
const div = document.createElement('div');
div.className = `docs-folder-item ${isActive ? 'active' : ''}`;
// Calculate depth of folder path for indentation (e.g. /CATALOG is 1, /CATALOG/customer is 2)
const parts = f.folder_path.split('/').filter(p => !!p);
const depth = parts.length;
div.style.paddingLeft = `${depth * 16 + 12}px`;
// Short name for displaying inside the folder list
const folderName = f.folder_path.substring(f.folder_path.lastIndexOf('/') + 1);
div.innerHTML = `
<span style="display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden;">
<i class="fa-solid fa-folder" style="color:#eab308; flex-shrink:0;"></i>
<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${escapeHtml(folderName)}">${escapeHtml(folderName)}</span>
</span>
<i class="fa-solid fa-trash-can folder-delete-btn" style="flex-shrink:0; ${isContractor ? 'display:none;' : ''}" title="${state.currentLanguage === 'en' ? 'Delete folder' : 'ลบโฟลเดอร์'}"></i>
`;
div.querySelector('span').addEventListener('click', (e) => {
const searchInput = document.getElementById('input-search-docs');
if (searchInput) searchInput.value = '';
state.currentDocFolderPath = f.folder_path;
renderProjectDocs();
});
div.querySelector('.folder-delete-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(state.currentLanguage === 'en'
? `Are you sure you want to delete folder "${folderName}" and ALL files inside it?`
: `คุณแน่ใจหรือไม่ว่าต้องการลบโฟลเดอร์ "${folderName}" และไฟล์ทั้งหมดที่อยู่ข้างใน?`)) {
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/folders?path=${encodeURIComponent(f.folder_path)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete failed');
showToast(state.currentLanguage === 'en' ? 'Folder deleted' : 'ลบโฟลเดอร์สำเร็จแล้ว');
if (state.currentDocFolderPath.startsWith(f.folder_path)) {
state.currentDocFolderPath = '/';
}
await fetchActiveProjectDocs();
renderProjectDocs();
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
}
}
});
folderList.appendChild(div);
});
// 2. Render breadcrumbs
const breadcrumbs = document.getElementById('docs-breadcrumb-container');
breadcrumbs.innerHTML = '';
const parts = state.currentDocFolderPath.split('/').filter(p => !!p);
// Add Root breadcrumb
const rootLink = document.createElement('span');
rootLink.style.cursor = 'pointer';
rootLink.style.color = 'var(--primary-color)';
rootLink.innerHTML = `<i class="fa-solid fa-house" style="margin-right:4px;"></i>Root`;
rootLink.addEventListener('click', () => {
const searchInput = document.getElementById('input-search-docs');
if (searchInput) searchInput.value = '';
state.currentDocFolderPath = '/';
renderProjectDocs();
});
breadcrumbs.appendChild(rootLink);
let pathAcc = '';
parts.forEach((part, index) => {
pathAcc += '/' + part;
const arrow = document.createElement('span');
arrow.style.color = 'var(--text-muted)';
arrow.innerHTML = ' <i class="fa-solid fa-chevron-right" style="font-size:0.75rem;"></i> ';
breadcrumbs.appendChild(arrow);
const partLink = document.createElement('span');
const isLast = index === parts.length - 1;
if (!isLast) {
partLink.style.cursor = 'pointer';
partLink.style.color = 'var(--primary-color)';
const targetPath = pathAcc;
partLink.addEventListener('click', () => {
const searchInput = document.getElementById('input-search-docs');
if (searchInput) searchInput.value = '';
state.currentDocFolderPath = targetPath;
renderProjectDocs();
});
} else {
partLink.style.color = 'var(--text-dark)';
partLink.style.fontWeight = '600';
}
partLink.textContent = part;
breadcrumbs.appendChild(partLink);
});
// 3. Render files/subfolders in table
const tbody = document.getElementById('project-docs-tbody');
tbody.innerHTML = '';
// Filter files in this current folder path
let currentFiles = files.filter(f => f.file_path === state.currentDocFolderPath);
// Filter folders that are direct children of this current folder path
let childFolders = [];
folders.forEach(f => {
if (f.folder_path === state.currentDocFolderPath) return;
if (state.currentDocFolderPath === '/') {
const relative = f.folder_path.substring(1);
if (!relative.includes('/')) {
childFolders.push(f);
}
} else {
if (f.folder_path.startsWith(state.currentDocFolderPath + '/')) {
const relative = f.folder_path.substring(state.currentDocFolderPath.length + 1);
if (!relative.includes('/')) {
childFolders.push(f);
}
}
}
});
// Apply search filter
if (searchVal) {
currentFiles = files.filter(f => f.file_name.toLowerCase().includes(searchVal));
childFolders = []; // hide subfolders in search results
}
// Render parent directory ".." row if not in root
if (state.currentDocFolderPath !== '/' && !searchVal) {
const tr = document.createElement('tr');
tr.className = 'folder-row';
tr.innerHTML = `
<td colspan="6" style="padding:12px 10px;">
<div style="display:flex; align-items:center; gap:10px; width:100%;">
<i class="fa-solid fa-arrow-turn-up" style="transform:rotate(-90deg); color:var(--primary-color); font-size:1.1rem; flex-shrink:0;"></i>
<span style="font-weight:600; font-size:0.95rem; color:var(--primary-color);">.. (Parent Directory)</span>
</div>
</td>
`;
tr.addEventListener('click', () => {
const searchInput = document.getElementById('input-search-docs');
if (searchInput) searchInput.value = '';
const lastSlash = state.currentDocFolderPath.lastIndexOf('/');
state.currentDocFolderPath = lastSlash === 0 ? '/' : state.currentDocFolderPath.substring(0, lastSlash);
renderProjectDocs();
});
tbody.appendChild(tr);
}
// Render child subfolders
childFolders.forEach(cf => {
const folderName = cf.folder_path.substring(cf.folder_path.lastIndexOf('/') + 1);
const tr = document.createElement('tr');
tr.className = 'folder-row';
tr.innerHTML = `
<td style="padding:10px; font-weight:500;">
<div style="display:flex; align-items:flex-start; gap:10px; width:100%;">
<i class="fa-solid fa-folder" style="color:#eab308; font-size:1.15rem; margin-top:2px; flex-shrink:0;"></i>
<span style="font-weight:600; font-size:0.95rem; color:var(--text-dark); word-break:break-word; line-height:1.4;" title="${escapeHtml(folderName)}">${escapeHtml(folderName)}</span>
</div>
</td>
<td style="padding:10px;" class="text-right">-</td>
<td style="padding:10px; color:var(--text-muted); font-size:0.8rem;">-</td>
<td style="padding:10px; color:var(--text-muted); font-size:0.8rem;">-</td>
<td style="padding:10px; color:var(--text-muted); font-size:0.8rem;">-</td>
<td style="padding:10px;" class="text-center">
<button class="btn btn-sm btn-outline btn-delete-folder-row" title="${state.currentLanguage === 'en' ? 'Delete folder' : 'ลบโฟลเดอร์'}" style="padding: 2px 6px; ${isContractor ? 'display:none;' : ''}">
<i class="fa-solid fa-trash-can text-danger"></i>
</button>
</td>
`;
tr.querySelector('td:first-child').addEventListener('click', () => {
const searchInput = document.getElementById('input-search-docs');
if (searchInput) searchInput.value = '';
state.currentDocFolderPath = cf.folder_path;
renderProjectDocs();
});
tr.querySelector('.btn-delete-folder-row').addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(state.currentLanguage === 'en'
? `Are you sure you want to delete folder "${folderName}" and ALL files inside it?`
: `คุณแน่ใจหรือไม่ว่าต้องการลบโฟลเดอร์ "${folderName}" และไฟล์ทั้งหมดที่อยู่ข้างใน?`)) {
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/folders?path=${encodeURIComponent(cf.folder_path)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete failed');
showToast(state.currentLanguage === 'en' ? 'Folder deleted' : 'ลบโฟลเดอร์สำเร็จแล้ว');
await fetchActiveProjectDocs();
renderProjectDocs();
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
}
}
});
tbody.appendChild(tr);
});
// Render files
if (currentFiles.length === 0 && childFolders.length === 0) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td colspan="6" style="padding: 30px; text-align: center; color: var(--text-muted);">
<i class="fa-solid fa-folder-open" style="font-size:2rem; margin-bottom:8px; display:block; color:#ccc;"></i>
${state.currentLanguage === 'en' ? 'No files or folders in this directory' : 'ไม่มีไฟล์หรือโฟลเดอร์ในสารบบนี้'}
</td>
`;
tbody.appendChild(tr);
} else {
currentFiles.forEach(f => {
const tr = document.createElement('tr');
tr.className = 'file-row';
let iconClass = 'fa-file';
let iconColor = 'var(--text-muted)';
const type = f.file_type.toLowerCase();
if (type === 'pdf') {
iconClass = 'fa-file-pdf';
iconColor = '#ef4444';
} else if (['xls', 'xlsx'].includes(type)) {
iconClass = 'fa-file-excel';
iconColor = '#22c55e';
} else if (['doc', 'docx'].includes(type)) {
iconClass = 'fa-file-word';
iconColor = '#2563eb';
} else if (type === 'dwg') {
iconClass = 'fa-file-lines';
iconColor = '#3b82f6';
} else if (['zip', 'rar'].includes(type)) {
iconClass = 'fa-file-zipper';
iconColor = '#a855f7';
}
const sizeStr = formatBytes(f.file_size);
const dateStr = parseDbDate(f.uploaded_at).toLocaleString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH');
const canChangeLevel = state.currentUser && (
state.currentUser.role === 'admin' ||
state.currentUser.role === 'superadmin' ||
(state.currentUser.username && f.uploaded_by && state.currentUser.username.toLowerCase() === f.uploaded_by.toLowerCase())
);
let levelCellHtml = '';
if (canChangeLevel) {
levelCellHtml = `
<select class="form-control change-doc-level-select" data-doc-id="${f.id}" style="padding: 2px 4px; font-size: 0.75rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background-color: var(--bg-panel); color: var(--text-main);">
<option value="public" ${f.doc_level === 'public' ? 'selected' : ''}>${t('doc_level_public')}</option>
<option value="internal" ${(!f.doc_level || f.doc_level === 'internal') ? 'selected' : ''}>${t('doc_level_internal')}</option>
<option value="confidential" ${f.doc_level === 'confidential' ? 'selected' : ''}>${t('doc_level_confidential')}</option>
<option value="executive" ${f.doc_level === 'executive' ? 'selected' : ''}>${t('doc_level_executive')}</option>
</select>
`;
} else {
const currentLevel = f.doc_level || 'internal';
let badgeClass = 'bg-success-light text-success';
if (currentLevel === 'confidential') badgeClass = 'bg-warning-light text-warning';
else if (currentLevel === 'executive') badgeClass = 'bg-danger-light text-danger';
else if (currentLevel === 'public') badgeClass = 'bg-info-light text-info';
levelCellHtml = `
<span class="badge ${badgeClass}" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600;">
${t('doc_level_' + currentLevel)}
</span>
`;
}
const canDelete = canChangeLevel;
tr.innerHTML = `
<td style="padding:10px;">
<div style="display:flex; align-items:flex-start; gap:10px; width:100%;">
<i class="fa-solid ${iconClass}" style="color:${iconColor}; font-size:1.15rem; margin-top:2px; flex-shrink:0;"></i>
<span class="file-name-preview-btn" data-id="${f.id}" data-name="${escapeHtml(f.file_name)}" data-type="${type}" style="font-weight:600; font-size:0.95rem; color:var(--primary-color); cursor:pointer; word-break:break-word; line-height:1.4;" title="${state.currentLanguage === 'en' ? 'Click to preview' : 'คลิกเพื่อแสดงตัวอย่าง'}">${escapeHtml(f.file_name)}</span>
</div>
</td>
<td style="padding:10px;" class="text-right">${sizeStr}</td>
<td style="padding:10px; color:var(--text-dark); font-size:0.8rem;">${dateStr}</td>
<td style="padding:10px;">${levelCellHtml}</td>
<td style="padding:10px; color:var(--text-dark); font-size:0.8rem;">${escapeHtml(f.uploaded_by)}</td>
<td style="padding:10px;" class="text-center">
<div style="display:flex; justify-content:center; gap:6px;">
<button class="btn btn-sm btn-outline btn-preview-file-row" data-id="${f.id}" data-name="${escapeHtml(f.file_name)}" data-type="${type}" title="${state.currentLanguage === 'en' ? 'Preview' : 'ดูตัวอย่าง'}" style="padding: 2px 6px;">
<i class="fa-solid fa-eye text-primary-color"></i>
</button>
<a href="${withAuthToken(`${API_BASE}/documents/${f.id}`)}" download class="btn btn-sm btn-outline" title="${state.currentLanguage === 'en' ? 'Download' : 'ดาวน์โหลด'}" style="padding: 2px 6px;">
<i class="fa-solid fa-download text-success"></i>
</a>
<button class="btn btn-sm btn-outline btn-delete-file-row" data-id="${f.id}" title="${state.currentLanguage === 'en' ? 'Delete' : 'ลบ'}" style="padding: 2px 6px; ${canDelete ? '' : 'display:none;'}">
<i class="fa-solid fa-trash-can text-danger"></i>
</button>
</div>
</td>
`;
// Bind click to preview triggers
tr.querySelectorAll('.file-name-preview-btn, .btn-preview-file-row').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const docId = el.getAttribute('data-id');
const name = el.getAttribute('data-name');
const fType = el.getAttribute('data-type');
openDocumentPreview(docId, name, fType);
});
});
tr.querySelector('.btn-delete-file-row').addEventListener('click', async (e) => {
if (confirm(state.currentLanguage === 'en'
? `Are you sure you want to delete file "${f.file_name}"?`
: `คุณต้องการลบไฟล์ "${f.file_name}" ใช่หรือไม่?`)) {
try {
const res = await authFetch(`${API_BASE}/documents/${f.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
showToast(state.currentLanguage === 'en' ? 'File deleted' : 'ลบไฟล์สำเร็จแล้ว');
await fetchActiveProjectDocs();
renderProjectDocs();
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
}
}
});
// Bind change listener for level dropdown
tr.querySelectorAll('.change-doc-level-select').forEach(el => {
el.addEventListener('change', async (e) => {
const docId = e.target.getAttribute('data-doc-id');
const newLevel = e.target.value;
try {
const res = await authFetch(`${API_BASE}/documents/${docId}/level`, {
method: 'PUT',
body: JSON.stringify({ doc_level: newLevel })
});
if (!res.ok) throw new Error('Failed to update document level');
showToast(state.currentLanguage === 'en' ? 'Document level updated' : 'อัปเดตระดับสิทธิ์เอกสารสำเร็จแล้ว');
await fetchActiveProjectDocs();
renderProjectDocs();
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
}
});
});
tbody.appendChild(tr);
});
}
}
function setupDocsDropzone() {
const dropzone = document.getElementById('docs-drop-zone');
const fileInput = document.getElementById('input-upload-project-docs');
if (!dropzone || !fileInput) return;
// Clicking dropzone triggers file input
dropzone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
handleDocsUpload(e.target.files);
fileInput.value = ''; // reset
});
// Drag and drop events
['dragenter', 'dragover'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('dragover');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
}, false);
});
dropzone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
handleDocsUpload(files);
}, false);
// Create virtual folder click binding
document.getElementById('btn-create-doc-folder').addEventListener('click', async () => {
const name = prompt(state.currentLanguage === 'en' ? 'Enter folder name:' : 'กรุณาใส่ชื่อโฟลเดอร์:');
if (!name) return;
const cleanedName = name.replace(/[\/\\?%*:|"<>]/g, '').trim();
if (!cleanedName) {
showToast(state.currentLanguage === 'en' ? 'Invalid folder name' : 'ชื่อโฟลเดอร์ไม่ถูกต้อง', 'danger');
return;
}
const pathPrefix = state.currentDocFolderPath === '/' ? '' : state.currentDocFolderPath;
const targetFolderPath = pathPrefix + '/' + cleanedName;
const folders = state.activeProjectDocs.folders || [];
const exists = folders.some(f => f.folder_path.toLowerCase() === targetFolderPath.toLowerCase());
if (exists) {
showToast(state.currentLanguage === 'en' ? 'Folder already exists' : 'โฟลเดอร์นี้มีอยู่แล้ว', 'danger');
return;
}
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/folders`, {
method: 'POST',
body: JSON.stringify({ folder_path: targetFolderPath })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to create folder');
}
showToast(state.currentLanguage === 'en' ? 'Folder created' : 'สร้างโฟลเดอร์สำเร็จแล้ว');
await fetchActiveProjectDocs();
renderProjectDocs();
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
}
});
// Real-time search filter binding
document.getElementById('input-search-docs').addEventListener('input', () => {
renderProjectDocs();
});
}
// ==========================================
// Project Progress Updates Logic
// ==========================================
async function openProjectProgressModal() {
if (!state.activeProjectId || !state.activeProjectSummary) return;
document.getElementById('project-progress-title').textContent =
state.currentLanguage === 'en'
? `Project Progress: ${state.activeProjectSummary.name}`
: `ความคืบหน้าโครงการ: ${state.activeProjectSummary.name}`;
// Reset state & form
clearProgressForm();
document.getElementById('progress-form-panel').style.display = 'none';
// Open modal
document.getElementById('modal-project-progress').classList.add('active');
// Fetch and render
await fetchAndRenderProgressList();
buildProgressSystemDropdown();
}
function closeProjectProgressModal() {
stopProgressCamera();
clearProgressForm();
const modal = document.getElementById('modal-project-progress');
modal.classList.remove('active');
modal.classList.remove('form-open');
}
// ==========================================
// FAT (FACTORY ACCEPTANCE TEST) CLIENT LOGIC
// ==========================================
let activeFatReportId = null;
async function openProjectFatModal() {
if (!state.activeProjectId || !state.activeProjectSummary) return;
// Open modal
document.getElementById('modal-project-fat').classList.add('active');
// Check role and toggle new report button visibility
const role = state.currentUser ? state.currentUser.role : 'user';
const newBtn = document.getElementById('btn-new-fat-trigger');
const allowedRoles = ['superadmin', 'admin', 'supervisor', 'qc', 'qa', 'contractor', 'ee_manager', 'me_manager', 'excusive', 'procurment', 'stock'];
if (newBtn) {
newBtn.style.display = allowedRoles.includes(role) ? 'block' : 'none';
}
// Reset Detail view
activeFatReportId = null;
document.getElementById('fat-detail-pane').innerHTML = `
<div style="text-align: center; color: var(--text-muted); margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px;">
<i class="fa-solid fa-clipboard-list" style="font-size: 3rem; color: var(--border-color);"></i>
<p data-i18n="fat_select_report_prompt">${state.currentLanguage === 'en' ? 'Please select a FAT report from the list on the left, or create a new one.' : 'กรุณาเลือกรายงาน FAT จากรายการด้านซ้าย หรือสร้างใหม่'}</p>
</div>
`;
await fetchAndRenderFatReportsList();
}
function closeProjectFatModal() {
document.getElementById('modal-project-fat').classList.remove('active');
}
async function openCreateFatModal() {
const modal = document.getElementById('modal-create-fat-report');
modal.classList.add('active');
// Set default testing date to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('fat-input-date').value = today;
// Clear type & text inputs
document.getElementById('fat-select-type').value = '';
document.getElementById('fat-input-container-new').value = '';
document.getElementById('fat-input-container-new').style.display = 'none';
// Default other fields
let containerVal = '';
let panelVal = '';
let locationVal = '';
let ratedVal = '';
let voltageVal = '';
let dateVal = today;
let instrModelVal = '';
let instrSnVal = '';
let instrExpVal = '';
// Gather existing containers
let existingContainers = [];
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/fat-reports`);
if (res.ok) {
const reports = await res.json();
if (reports && reports.length > 0) {
// Collect unique container names from reports
const reportContainers = reports.map(r => r.container_no).filter(Boolean).map(c => c.trim());
const savedContainers = JSON.parse(localStorage.getItem('project_containers_' + state.activeProjectId) || '[]');
existingContainers = Array.from(new Set([...reportContainers, ...savedContainers])).sort();
// Populate from the most recent FAT report (ordered DESC by created_at)
const latest = reports[0];
if (latest.container_no) containerVal = latest.container_no;
if (latest.panel_type) panelVal = latest.panel_type;
if (latest.location) locationVal = latest.location;
if (latest.rated) ratedVal = latest.rated;
if (latest.voltage) voltageVal = latest.voltage;
if (latest.testing_date) {
try {
const d = parseDbDate(latest.testing_date);
dateVal = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
} catch (e) {}
}
if (latest.instrument_model) instrModelVal = latest.instrument_model;
if (latest.instrument_sn) instrSnVal = latest.instrument_sn;
if (latest.instrument_expire) {
try {
const d = parseDbDate(latest.instrument_expire);
instrExpVal = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
} catch (e) {}
}
}
}
} catch (err) {
console.error("Failed to load last FAT report for auto-fill:", err);
}
// Override containerVal if filtered in the sidebar
const filterContainer = document.getElementById('fat-filter-container');
if (filterContainer && filterContainer.value) {
containerVal = filterContainer.value;
}
// Populate fat-select-container dropdown options
const selectContainer = document.getElementById('fat-select-container');
if (selectContainer) {
selectContainer.innerHTML = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = state.currentLanguage === 'en' ? '-- Select Container --' : '-- เลือกตู้คอนเทนเนอร์ --';
selectContainer.appendChild(defaultOpt);
existingContainers.forEach(c => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
selectContainer.appendChild(opt);
});
const newOpt = document.createElement('option');
newOpt.value = '__NEW__';
newOpt.textContent = state.currentLanguage === 'en' ? '+ Add New Container' : '+ เพิ่มหมายเลขตู้ใหม่';
selectContainer.appendChild(newOpt);
// Set selection
if (containerVal) {
if (existingContainers.includes(containerVal)) {
selectContainer.value = containerVal;
document.getElementById('fat-input-container-new').style.display = 'none';
} else {
selectContainer.value = '__NEW__';
document.getElementById('fat-input-container-new').style.display = 'block';
document.getElementById('fat-input-container-new').value = containerVal;
}
} else {
selectContainer.value = '';
document.getElementById('fat-input-container-new').style.display = 'none';
}
}
document.getElementById('fat-input-date').value = dateVal;
document.getElementById('fat-input-panel').value = panelVal;
document.getElementById('fat-input-location').value = locationVal;
document.getElementById('fat-input-rated').value = ratedVal;
document.getElementById('fat-input-voltage').value = voltageVal;
document.getElementById('fat-input-instr-model').value = instrModelVal;
document.getElementById('fat-input-instr-sn').value = instrSnVal;
document.getElementById('fat-input-instr-exp').value = instrExpVal;
}
function closeCreateFatModal() {
document.getElementById('modal-create-fat-report').classList.remove('active');
}
async function handleCreateFatSubmit(e) {
e.preventDefault();
if (!state.activeProjectId) return;
const report_type = document.getElementById('fat-select-type').value;
const testing_date = document.getElementById('fat-input-date').value;
const selectVal = document.getElementById('fat-select-container').value;
let container_no = '';
if (selectVal === '__NEW__') {
container_no = document.getElementById('fat-input-container-new').value.trim();
} else {
container_no = selectVal.trim();
}
if (!container_no) {
showToast(state.currentLanguage === 'en' ? 'Please select or enter a Container Number' : 'กรุณาเลือกหรือระบุหมายเลขตู้คอนเทนเนอร์', 'error');
return;
}
// Persist new container number to local storage
if (selectVal === '__NEW__' && container_no) {
const list = JSON.parse(localStorage.getItem('project_containers_' + state.activeProjectId) || '[]');
if (!list.includes(container_no)) {
list.push(container_no);
localStorage.setItem('project_containers_' + state.activeProjectId, JSON.stringify(list));
}
}
const panel_type = document.getElementById('fat-input-panel').value.trim();
const location = document.getElementById('fat-input-location').value.trim();
const rated = document.getElementById('fat-input-rated').value.trim();
const voltage = document.getElementById('fat-input-voltage').value.trim();
const instrument_model = document.getElementById('fat-input-instr-model').value.trim();
const instrument_sn = document.getElementById('fat-input-instr-sn').value.trim();
const instrument_expire = document.getElementById('fat-input-instr-exp').value.trim();
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/fat-reports`, {
method: 'POST',
body: JSON.stringify({
report_type,
testing_date,
container_no,
panel_type,
location,
rated,
voltage,
instrument_model,
instrument_sn,
instrument_expire
})
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'FAT Report created' : 'สร้างรายงาน FAT สำเร็จแล้ว');
closeCreateFatModal();
const newReport = await res.json();
await loadFatReportDetails(newReport.id);
} else {
const err = await res.json();
showToast(err.error || 'Failed to create report', 'error');
}
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Network error' : 'เกิดข้อผิดพลาดในการเชื่อมต่อ', 'error');
}
}
async function fetchAndRenderFatReportsList() {
const container = document.getElementById('fat-reports-list-container');
if (!container) return;
container.innerHTML = `<div style="text-align:center; padding: 20px;"><i class="fa-solid fa-spinner fa-spin" style="color:var(--primary-color);"></i></div>`;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/fat-reports`);
if (!res.ok) throw new Error('Failed to fetch');
const reports = await res.json();
// Populate the container filter dropdown
populateFatContainerDropdown(reports);
// Filter reports by selected container
const filterContainer = document.getElementById('fat-filter-container');
const selectedContainer = filterContainer ? filterContainer.value : '';
const filteredReports = selectedContainer ? reports.filter(r => r.container_no === selectedContainer) : reports;
container.innerHTML = '';
if (filteredReports.length === 0) {
container.innerHTML = `<div style="text-align:center; color:var(--text-muted); font-size:0.85rem; padding: 20px;">${state.currentLanguage === 'en' ? 'No reports found' : 'ไม่มีรายงาน FAT'}</div>`;
return;
}
const role = state.currentUser ? state.currentUser.role : 'user';
const allowedRoles = ['superadmin', 'admin', 'supervisor', 'qc', 'qa', 'contractor'];
const canDelete = allowedRoles.includes(role);
filteredReports.forEach(r => {
const item = document.createElement('div');
item.className = `fat-report-list-item ${activeFatReportId === r.id ? 'active' : ''}`;
const formattedDate = r.testing_date ? parseDbDate(r.testing_date).toLocaleDateString('th-TH') : '-';
const deleteBtnMarkup = canDelete ? `
<button class="btn-delete-fat" data-id="${r.id}" title="${state.currentLanguage === 'en' ? 'Delete Report' : 'ลบรายงาน'}">
<i class="fa-solid fa-trash-can"></i>
</button>
` : '';
item.innerHTML = `
<div style="font-weight:600; color:var(--text-dark); display:flex; justify-content:space-between; align-items:center;">
<span>${escapeHtml(r.report_type)}</span>
${deleteBtnMarkup}
</div>
<div style="font-size:0.75rem; color:var(--text-muted); margin-top: 4px;">
<div><i class="fa-regular fa-calendar" style="width:14px;"></i> ${formattedDate}</div>
<div style="margin-top: 2px;"><i class="fa-solid fa-box" style="width:14px;"></i> ${escapeHtml(r.container_no || '-')}</div>
</div>
`;
item.addEventListener('click', () => loadFatReportDetails(r.id));
const deleteBtn = item.querySelector('.btn-delete-fat');
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleDeleteFatReport(r.id);
});
}
container.appendChild(item);
});
} catch (err) {
console.error(err);
container.innerHTML = `<div style="text-align:center; color:var(--danger); font-size:0.85rem; padding: 20px;">Error loading list</div>`;
}
}
function populateFatContainerDropdown(reports) {
const dropdown = document.getElementById('fat-filter-container');
if (!dropdown) return;
const currentSelection = dropdown.value;
// Gather unique container numbers from reports
const reportContainers = reports.map(r => r.container_no).filter(Boolean).map(c => c.trim());
// Gather container numbers from local storage
const savedContainers = JSON.parse(localStorage.getItem('project_containers_' + state.activeProjectId) || '[]');
// Merge and filter duplicates
const allContainers = Array.from(new Set([...reportContainers, ...savedContainers])).sort();
dropdown.innerHTML = `<option value="">${state.currentLanguage === 'en' ? '-- All Containers --' : '-- ตู้ทั้งหมด --'}</option>`;
allContainers.forEach(c => {
const option = document.createElement('option');
option.value = c;
option.textContent = c;
dropdown.appendChild(option);
});
// Restore selection
if (allContainers.includes(currentSelection)) {
dropdown.value = currentSelection;
} else {
dropdown.value = '';
}
}
function handleAddContainerNo() {
const promptMsg = state.currentLanguage === 'en' ? 'Enter New Container Number (e.g. No.4):' : 'ระบุหมายเลขตู้คอนเทนเนอร์ใหม่ (เช่น No.4):';
const name = prompt(promptMsg);
if (!name || !name.trim()) return;
const containerNo = name.trim();
const list = JSON.parse(localStorage.getItem('project_containers_' + state.activeProjectId) || '[]');
if (!list.includes(containerNo)) {
list.push(containerNo);
localStorage.setItem('project_containers_' + state.activeProjectId, JSON.stringify(list));
}
// Set dropdown selection and refresh list
const dropdown = document.getElementById('fat-filter-container');
if (dropdown) {
// We will call fetchAndRenderFatReportsList which repopulates dropdown and filters
dropdown.value = containerNo;
// Wait, since dropdown.value is not in the DOM options list yet, we add it temporarily or rely on populate to restore it
const tempOpt = document.createElement('option');
tempOpt.value = containerNo;
tempOpt.textContent = containerNo;
dropdown.appendChild(tempOpt);
dropdown.value = containerNo;
}
fetchAndRenderFatReportsList();
}
async function handleDeleteFatReport(id) {
const role = state.currentUser ? state.currentUser.role : 'user';
const allowedRoles = ['superadmin', 'admin', 'supervisor', 'qc', 'qa', 'contractor'];
if (!allowedRoles.includes(role)) {
showToast(state.currentLanguage === 'en' ? 'Insufficient permissions' : 'ไม่มีสิทธิ์ในการลบรายงาน FAT', 'danger');
return;
}
if (!confirm(state.currentLanguage === 'en' ? 'Are you sure you want to delete this FAT report?' : 'คุณแน่ใจหรือไม่ว่าต้องการลบรายงาน FAT นี้?')) return;
try {
const res = await authFetch(`${API_BASE}/fat-reports/${id}`, {
method: 'DELETE'
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'FAT Report deleted' : 'ลบรายงาน FAT เรียบร้อยแล้ว');
if (activeFatReportId === id) {
activeFatReportId = null;
document.getElementById('fat-detail-pane').innerHTML = `
<div style="text-align: center; color: var(--text-muted); margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px;">
<i class="fa-solid fa-clipboard-list" style="font-size: 3rem; color: var(--border-color);"></i>
<p data-i18n="fat_select_report_prompt">${state.currentLanguage === 'en' ? 'Please select a FAT report from the list on the left, or create a new one.' : 'กรุณาเลือกรายงาน FAT จากรายการด้านซ้าย หรือสร้างใหม่'}</p>
</div>
`;
}
await fetchAndRenderFatReportsList();
} else {
showToast('Delete failed', 'error');
}
} catch (err) {
console.error(err);
}
}
async function loadFatReportDetails(reportId) {
activeFatReportId = reportId;
// Close mobile drawer if active
const sidebar = document.querySelector('#modal-project-fat .fat-sidebar');
if (sidebar) sidebar.classList.remove('drawer-active');
// Highlight list item without re-rendering everything
document.querySelectorAll('.fat-report-list-item').forEach(el => {
el.style.borderColor = 'var(--border-color)';
el.style.background = 'white';
});
// Re-render only list active state styles locally
const itemsList = document.querySelectorAll('.fat-report-list-item');
// Just fetch active fat items now
const detailPane = document.getElementById('fat-detail-pane');
detailPane.innerHTML = `<div style="text-align:center; padding: 100px 0;"><i class="fa-solid fa-spinner fa-spin" style="font-size: 2.5rem; color:var(--primary-color);"></i><p style="margin-top:10px; color:var(--text-muted);">กำลังโหลดรายละเอียด...</p></div>`;
try {
// Fetch report metadata
const resReports = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/fat-reports`);
const reports = await resReports.json();
const report = reports.find(r => r.id === reportId);
if (!report) throw new Error('Report not found');
// Fetch report items
const resItems = await authFetch(`${API_BASE}/fat-reports/${reportId}/items`);
const items = await resItems.json();
state.currentFatReportItems = items;
let testingDateVal = '';
if (report.testing_date) {
const d = parseDbDate(report.testing_date);
testingDateVal = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
const role = state.currentUser ? state.currentUser.role : 'user';
const allowedRoles = ['superadmin', 'admin', 'supervisor', 'qc', 'qa', 'contractor'];
const disabledAttr = allowedRoles.includes(role) ? '' : 'disabled';
const models = (report.instrument_model || '').split('|');
const sns = (report.instrument_sn || '').split('|');
const exps = (report.instrument_expire || '').split('|');
const maxLen = Math.max(models.length, sns.length, exps.length, 1);
let lastItemNo = '';
items.forEach(item => {
const isTestedBy = item.item_no && item.item_no.toLowerCase().includes('tested');
if (!isTestedBy && item.item_no && item.item_no.includes('.')) {
lastItemNo = item.item_no;
}
});
let calculatedTestedByItemNo = '3.6';
if (lastItemNo) {
const parts = lastItemNo.split('.');
if (parts.length === 2) {
const major = parseInt(parts[0]);
const minor = parseInt(parts[1]);
if (!isNaN(major) && !isNaN(minor)) {
calculatedTestedByItemNo = `${major}.${minor + 1}`;
}
}
}
const saveBtnMarkup = allowedRoles.includes(role) ? `
<button class="btn btn-primary btn-sm" id="btn-save-fat-active" style="display:flex; align-items:center; gap:6px;">
<i class="fa-solid fa-save"></i> ${state.currentLanguage === 'en' ? 'Save Results' : 'บันทึกข้อมูล'}
</button>
` : '';
detailPane.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom: 2px solid var(--border-color); padding-bottom: 12px; margin-bottom: 20px;">
<div>
<h2 style="margin:0; color:var(--text-dark);">${escapeHtml(report.report_type)}</h2>
<span style="font-size:0.85rem; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'FAT Checklist and testing records' : 'ตารางตรวจสอบและบันทึกผลการทดสอบ FAT'}</span>
</div>
<div style="display:flex; gap:8px;">
<button class="btn btn-success btn-sm" id="btn-print-fat-active" style="display:flex; align-items:center; gap:6px;">
<i class="fa-solid fa-print"></i> ${state.currentLanguage === 'en' ? 'Print Report' : 'พิมพ์รายงาน'}
</button>
${saveBtnMarkup}
</div>
</div>
<div class="a4-preview-page">
<div class="report-header">
<div class="report-title">${escapeHtml(report.report_type)} TESTING</div>
<div style="font-size:16px; font-weight:bold;">FACTORY ACCEPTANCE TEST REPORT</div>
</div>
<form id="form-fat-detail-header" style="margin-bottom: 0;">
<table class="meta-table">
<tr>
<td class="meta-label">PROJECT NAME:</td>
<td class="meta-val">${escapeHtml(state.activeProjectSummary.name || '-')}</td>
<td class="meta-label">TESTING DATE:</td>
<td class="meta-val">
<input type="date" id="det-fat-date" class="formal-input" value="${testingDateVal}" ${disabledAttr}>
</td>
</tr>
<tr>
<td class="meta-label">CONTAINER NO:</td>
<td class="meta-val">
<input type="text" id="det-fat-container" class="formal-input" value="${escapeHtml(report.container_no || '')}" ${disabledAttr} placeholder="-">
</td>
<td class="meta-label">PANEL TYPE:</td>
<td class="meta-val">
<input type="text" id="det-fat-panel" class="formal-input" value="${escapeHtml(report.panel_type || '')}" ${disabledAttr} placeholder="-">
</td>
</tr>
<tr>
<td class="meta-label">LOCATION:</td>
<td class="meta-val">
<input type="text" id="det-fat-location" class="formal-input" value="${escapeHtml(report.location || '')}" ${disabledAttr} placeholder="-">
</td>
<td class="meta-label">RATED:</td>
<td class="meta-val">
<input type="text" id="det-fat-rated" class="formal-input" value="${escapeHtml(report.rated || '')}" ${disabledAttr} placeholder="-">
</td>
</tr>
<tr>
<td class="meta-label">VOLTAGE:</td>
<td class="meta-val">
<input type="text" id="det-fat-voltage" class="formal-input" value="${escapeHtml(report.voltage || '')}" ${disabledAttr} placeholder="-">
</td>
<td class="meta-label">INSTRUMENT:</td>
<td class="meta-val">
<input type="text" class="formal-input fat-instr-model-input" value="${escapeHtml(models[0] || '')}" ${disabledAttr} placeholder="-">
</td>
</tr>
<tr>
<td class="meta-label">SERIAL NO:</td>
<td class="meta-val">
<input type="text" class="formal-input fat-instr-sn-input" value="${escapeHtml(sns[0] || '')}" ${disabledAttr} placeholder="-">
</td>
<td class="meta-label">EXPIRE DATE:</td>
<td class="meta-val" style="position: relative;">
<input type="text" class="formal-input fat-instr-exp-input" value="${escapeHtml(exps[0] || '')}" ${disabledAttr} placeholder="-">
</td>
</tr>
${(() => {
let rows = '';
for (let i = 1; i < maxLen; i++) {
rows += `
<tr class="instrument-extra-row">
<td class="meta-label"></td>
<td class="meta-val"></td>
<td class="meta-label">INSTRUMENT:</td>
<td class="meta-val">
<input type="text" class="formal-input fat-instr-model-input" value="${escapeHtml(models[i] || '')}" ${disabledAttr} placeholder="-">
</td>
</tr>
<tr class="instrument-extra-row">
<td class="meta-label">SERIAL NO:</td>
<td class="meta-val">
<input type="text" class="formal-input fat-instr-sn-input" value="${escapeHtml(sns[i] || '')}" ${disabledAttr} placeholder="-">
</td>
<td class="meta-label">EXPIRE DATE:</td>
<td class="meta-val" style="position: relative;">
<input type="text" class="formal-input fat-instr-exp-input" value="${escapeHtml(exps[i] || '')}" ${disabledAttr} placeholder="-">
${role !== 'user' ? `
<button type="button" class="btn-delete-instrument" style="position: absolute; right: -35px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 5px;" onclick="this.closest('tr').previousElementSibling.remove(); this.closest('tr').remove();">
<i class="fa-solid fa-trash-can" style="color:var(--danger)"></i>
</button>
` : ''}
</td>
</tr>
`;
}
return rows;
})()}
</table>
${role !== 'user' ? `
<div style="text-align: right; margin-top: 5px; margin-bottom: 15px;">
<button type="button" class="btn btn-outline btn-sm" id="btn-add-instrument-row">
<i class="fa-solid fa-plus"></i> เพิ่มเครื่องมือวัด (Add Instrument)
</button>
</div>
` : ''}
</form>
<table class="formal-checklist-table" id="table-fat-items">
<thead>
<tr>
<th width="48" class="text-center" style="text-align: center !important;">ลำดับ (Item)</th>
<th style="text-align: left !important; padding-left: 8px !important;">หัวข้อการทดสอบ / รายละเอียด (Test Condition / Description)</th>
<th width="35" class="text-center">Pass</th>
<th width="35" class="text-center">Fail</th>
<th width="196">หมายเหตุ / ค่าที่วัดได้ (Remarks / Value)</th>
</tr>
</thead>
<tbody>
${(() => {
let photoCounter = 0;
const photoIndexMap = {};
items.forEach(it => {
const isHeader = !it.item_no || (!it.item_no.includes('.') && !it.description);
if (!isHeader && it.photo) {
photoCounter++;
photoIndexMap[it.id] = photoCounter;
}
});
return items.map(item => {
const isHeader = !item.item_no || (!item.item_no.includes('.') && !item.description);
const rowBg = isHeader ? '#f2f2f2' : 'transparent';
const rowFontWeight = isHeader ? 'bold' : 'normal';
const isTestedByRow = item.item_no && item.item_no.toLowerCase().includes('tested');
const displayItemNo = isTestedByRow ? calculatedTestedByItemNo : item.item_no;
const photoIndex = photoIndexMap[item.id];
return `
<tr class="fat-item-row" data-id="${item.id}" style="background:${rowBg}; font-weight:${rowFontWeight};">
<td style="font-weight:600; text-align:center;">${escapeHtml(displayItemNo || '')}</td>
<td>
<div style="font-weight:bold; font-size:${report.report_type === 'AIR CONDITION INSPECTION' ? '15px' : '14px'};">
${escapeHtml(item.title || '')}
${item.description ? `<span style="font-weight:normal; font-size:${report.report_type === 'AIR CONDITION INSPECTION' ? '15px' : '13px'}; color:#444;">${item.title ? ' - ' : ''}${formatInlineDescription(item.description, item.id, disabledAttr)}</span>` : ''}
</div>
</td>
<td class="text-center">
${isHeader ? '' : `
<label class="print-chk-label">
<input type="checkbox" class="fat-chk-pass" ${item.result_pass === 1 ? 'checked' : ''} ${disabledAttr} onchange="handleFatCheckboxChange(this, 'pass')">
<span>✓</span>
</label>
`}
</td>
<td class="text-center">
${isHeader ? '' : `
<label class="print-chk-label">
<input type="checkbox" class="fat-chk-fail" ${item.result_fail === 1 ? 'checked' : ''} ${disabledAttr} onchange="handleFatCheckboxChange(this, 'fail')">
<span>✗</span>
</label>
`}
</td>
<td class="text-center" style="vertical-align: middle;">
${isHeader ? '' : `
<div style="display:flex; align-items:center; justify-content:center; gap:6px;">
${item.photos && item.photos.length > 0 ? `
<div style="display:flex; flex-direction:column; gap:4px; align-items:center;">
<div style="display:flex; flex-wrap:wrap; gap:4px; justify-content:center; max-width:200px;">
${item.photos.map((photoId, idx) => `
<div style="display:flex; align-items:center; gap:2px; background:var(--bg-hover); padding:2px 4px; border-radius:4px; border:1px solid var(--border-color);">
<span style="font-size:10px; font-weight:bold; color:var(--primary-color);">#${idx + 1}</span>
<button type="button" class="btn btn-outline btn-xs" onclick="viewFatPhoto('${photoId}')" style="padding:1px 3px; font-size:9px; line-height:1; height:auto;">ดู</button>
${allowedRoles.includes(role) ? `
<button type="button" class="btn btn-outline-danger btn-xs" onclick="deleteFatPhoto(${reportId}, ${item.id}, '${photoId}')" style="padding:1px 3px; font-size:9px; line-height:1; height:auto; color:var(--danger); border-color:var(--danger);"><i class="fa-solid fa-xmark"></i></button>
` : ''}
</div>
`).join('')}
</div>
${allowedRoles.includes(role) && item.photos.length < 10 ? `
<button type="button" class="btn btn-outline btn-xs" onclick="triggerFatPhotoUpload(${reportId}, ${item.id})" style="padding:2px 5px; font-size:10px; display:flex; align-items:center; gap:2px; height:auto; line-height:1;">
<i class="fa-solid fa-plus"></i> เพิ่มรูป (${item.photos.length}/10)
</button>
` : ''}
</div>
` : `
${allowedRoles.includes(role) ? `
<button type="button" class="btn btn-outline btn-xs" onclick="triggerFatPhotoUpload(${reportId}, ${item.id})" style="padding:3px 6px; font-size:11px; display:flex; align-items:center; gap:4px; height:auto; line-height:1.2;">
<i class="fa-solid fa-camera"></i> แนบรูป (0/10)
</button>
` : '<span style="color:#999; font-size:11px;">ไม่มีรูป</span>'}
`}
</div>
`}
</td>
</tr>
`;
}).join('');
})()}
</tbody>
</table>
<div style="margin-top: 50px; display: flex; justify-content: space-between; padding: 0 40px;">
<div style="text-align: center; width: 250px;">
<p style="font-weight: bold; margin-bottom: 50px; color: #000;">TESTED BY</p>
<div style="border-bottom: 1px solid #000; width: 200px; margin: 0 auto;"></div>
<p style="font-size: 14px; margin-top: 6px; color: #000;">(....................................................)</p>
<p style="font-size: 14px; color: #000; margin-top: 3px;">วันที่: ....../....../......</p>
</div>
<div style="text-align: center; width: 250px;">
<p style="font-weight: bold; margin-bottom: 50px; color: #000;">WITNESSED BY (CLIENT)</p>
<div style="border-bottom: 1px solid #000; width: 200px; margin: 0 auto;"></div>
<p style="font-size: 14px; margin-top: 6px; color: #000;">(....................................................)</p>
<p style="font-size: 14px; color: #000; margin-top: 3px;">วันที่: ....../....../......</p>
</div>
</div>
</div>
`;
// Bind button events
if (allowedRoles.includes(role)) {
document.getElementById('btn-save-fat-active').addEventListener('click', () => saveFatReportResults(reportId));
document.getElementById('btn-add-instrument-row').addEventListener('click', () => {
const metaTable = document.querySelector('.meta-table');
const tbody = metaTable.querySelector('tbody') || metaTable;
const newRows = document.createElement('tbody');
newRows.innerHTML = `
<tr class="instrument-extra-row">
<td class="meta-label"></td>
<td class="meta-val"></td>
<td class="meta-label">INSTRUMENT:</td>
<td class="meta-val">
<input type="text" class="formal-input fat-instr-model-input" value="" placeholder="-">
</td>
</tr>
<tr class="instrument-extra-row">
<td class="meta-label">SERIAL NO:</td>
<td class="meta-val">
<input type="text" class="formal-input fat-instr-sn-input" value="" placeholder="-">
</td>
<td class="meta-label">EXPIRE DATE:</td>
<td class="meta-val" style="position: relative;">
<input type="text" class="formal-input fat-instr-exp-input" value="" placeholder="-">
<button type="button" class="btn-delete-instrument" style="position: absolute; right: -35px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 5px;" onclick="this.closest('tr').previousElementSibling.remove(); this.closest('tr').remove();">
<i class="fa-solid fa-trash-can" style="color:var(--danger)"></i>
</button>
</td>
</tr>
`;
Array.from(newRows.children).forEach(child => tbody.appendChild(child));
});
}
document.getElementById('btn-print-fat-active').addEventListener('click', () => {
// Extract latest header values from DOM
const dateInput = document.getElementById('det-fat-date').value;
const containerInput = document.getElementById('det-fat-container').value.trim();
const panelInput = document.getElementById('det-fat-panel').value.trim();
const locationInput = document.getElementById('det-fat-location').value.trim();
const ratedInput = document.getElementById('det-fat-rated').value.trim();
const voltageInput = document.getElementById('det-fat-voltage').value.trim();
const instrModelInputs = document.querySelectorAll('.fat-instr-model-input');
const instrSnInputs = document.querySelectorAll('.fat-instr-sn-input');
const instrExpInputs = document.querySelectorAll('.fat-instr-exp-input');
const models = Array.from(instrModelInputs).map(input => input.value.trim());
const sns = Array.from(instrSnInputs).map(input => input.value.trim());
const exps = Array.from(instrExpInputs).map(input => input.value.trim());
const updatedReport = {
...report,
testing_date: dateInput,
container_no: containerInput,
panel_type: panelInput,
location: locationInput,
rated: ratedInput,
voltage: voltageInput,
instrument_model: models.join('|'),
instrument_sn: sns.join('|'),
instrument_expire: exps.join('|')
};
// Extract latest items from DOM (including inline inputs)
const rows = document.querySelectorAll('.fat-item-row');
const updatedItems = Array.from(rows).map(row => {
const id = parseInt(row.dataset.id);
const passChk = row.querySelector('.fat-chk-pass');
const failChk = row.querySelector('.fat-chk-fail');
const remarksInput = row.querySelector('.fat-input-remarks');
const inlineValInput = row.querySelector('.fat-inline-val');
const inlineBracketValInput = row.querySelector('.fat-inline-bracket-val');
const origItem = items.find(it => it.id === id) || {};
let description = origItem.description || '';
if (inlineValInput) {
const newVal = inlineValInput.value.trim();
const regex = /(\.\.\.+)\s*([0-9\.]*)\s*(\.\.\.+)/;
description = description.replace(regex, (match, p1, p2, p3) => {
return `${p1}${newVal}${p3}`;
});
} else if (inlineBracketValInput) {
const inputs = Array.from(row.querySelectorAll('.fat-inline-bracket-val'));
let replacementIndex = 0;
const regex = /\[([X0-9a-zA-Z\s\.\-\|]*)\]/g;
description = (origItem.description || '').replace(regex, (match, innerVal) => {
const input = inputs[replacementIndex++];
if (input) {
const newVal = input.value.trim();
const isPlaceholder = /^[X\|]+$/i.test(innerVal.trim());
const originalPlaceholder = isPlaceholder ? innerVal : 'XXXXXX';
const displayVal = newVal === '' ? originalPlaceholder : newVal;
return `[${displayVal}]`;
}
return match;
});
}
return {
...origItem,
id: id,
result_pass: passChk && passChk.checked ? 1 : 0,
result_fail: failChk && failChk.checked ? 1 : 0,
remarks: remarksInput ? remarksInput.value.trim() : '',
description: description
};
});
// If user has edit permissions, auto-save to DB before printing
const role = state.currentUser ? state.currentUser.role : 'user';
const allowedRoles = ['superadmin', 'admin', 'supervisor', 'qc', 'qa', 'contractor'];
if (allowedRoles.includes(role)) {
const header = {
testing_date: dateInput,
container_no: containerInput,
panel_type: panelInput,
location: locationInput,
rated: ratedInput,
voltage: voltageInput,
instrument_model: models.join('|'),
instrument_sn: sns.join('|'),
instrument_expire: exps.join('|')
};
authFetch(`${API_BASE}/fat-reports/${reportId}/items`, {
method: 'PUT',
body: JSON.stringify({ header, items: updatedItems })
}).then(res => {
if (res.ok) {
// Reload data in-memory to keep state in sync
loadFatReportDetails(reportId).then(() => {
printFatReport(updatedReport, updatedItems);
});
} else {
printFatReport(updatedReport, updatedItems);
}
}).catch(err => {
console.error('Auto-save error before print:', err);
printFatReport(updatedReport, updatedItems);
});
} else {
printFatReport(updatedReport, updatedItems);
}
});
} catch (err) {
console.error(err);
detailPane.innerHTML = `<div style="text-align:center; padding: 100px 0; color:var(--danger);">Error loading details</div>`;
}
}
function handleFatCheckboxChange(checkbox, type) {
const row = checkbox.closest('.fat-item-row');
if (type === 'pass' && checkbox.checked) {
row.querySelector('.fat-chk-fail').checked = false;
} else if (type === 'fail' && checkbox.checked) {
row.querySelector('.fat-chk-pass').checked = false;
}
}
window.formatInlineDescription = function(desc, itemId, disabledAttr) {
if (!desc) return '';
// 1. Dot pattern (e.g., ...2000...)
const dotRegex = /(\.\.\.+)\s*([0-9\.]*)\s*(\.\.\.+)/;
let match = desc.match(dotRegex);
if (match) {
const value = match[2];
const inputHtml = `<input type="text" class="fat-inline-val" data-item-id="${itemId}" value="${escapeHtml(value)}" ${disabledAttr} style="width: 60px; text-align: center; border: none; border-bottom: 1px dotted #000; font-weight: bold; background: transparent; color: inherit; font-size: inherit; font-family: inherit;">`;
return desc.replace(dotRegex, `${match[1]}${inputHtml}${match[3]}`);
}
// 2. Bracket pattern (e.g., [XXXXXXX]) - match all globally in a loop
const bracketRegex = /\[([X0-9a-zA-Z\s\.\-\|]*)\]/g;
let matchArr;
let index = 0;
const inputs = [];
while ((matchArr = bracketRegex.exec(desc)) !== null) {
let value = matchArr[1] || '';
const isPlaceholder = /^[X\|]+$/i.test(value.trim());
const displayVal = isPlaceholder ? '' : value;
const placeholder = isPlaceholder ? value : '';
const inputHtml = `<input type="text" class="fat-inline-bracket-val" data-item-id="${itemId}" data-index="${index}" value="${escapeHtml(displayVal)}" placeholder="${escapeHtml(placeholder)}" ${disabledAttr} style="width: 80px; text-align: center; border: none; border-bottom: 1px dotted #000; font-weight: bold; background: transparent; color: inherit; font-size: inherit; font-family: inherit;">`;
inputs.push(inputHtml);
index++;
}
if (inputs.length > 0) {
let replacementIndex = 0;
return desc.replace(/\[([X0-9a-zA-Z\s\.\-\|]*)\]/g, () => {
return inputs[replacementIndex++];
});
}
return escapeHtml(desc);
};
window.formatInlineDescriptionPrint = function(desc) {
if (!desc) return '';
// 1. Dot pattern
const dotRegex = /(\.\.\.+)\s*([0-9\.]*)\s*(\.\.\.+)/;
let match = desc.match(dotRegex);
if (match) {
const value = match[2];
return desc.replace(dotRegex, `${match[1]}<strong>${escapeHtml(value)}</strong>${match[3]}`);
}
// 2. Bracket pattern - match all globally in a loop
const bracketRegex = /\[([X0-9a-zA-Z\s\.\-\|]*)\]/g;
let matchArr;
const strongs = [];
while ((matchArr = bracketRegex.exec(desc)) !== null) {
let value = matchArr[1] || '';
const isPlaceholder = /^[X\|]+$/i.test(value.trim());
const displayVal = isPlaceholder ? '_______' : value;
strongs.push(`<strong>${escapeHtml(displayVal)}</strong>`);
}
if (strongs.length > 0) {
let replacementIndex = 0;
return desc.replace(/\[([X0-9a-zA-Z\s\.\-\|]*)\]/g, () => {
return strongs[replacementIndex++];
});
}
return escapeHtml(desc);
};
window.triggerFatPhotoUpload = function(reportId, itemId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.setAttribute('capture', 'environment'); // Open camera directly on mobile devices
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
showToast('กำลังบีบอัดรูปภาพ...', 'info');
const compressedFile = await compressFatPhoto(file, 100); // Target max 100KB
const formData = new FormData();
formData.append('image', compressedFile);
showToast('กำลังอัปโหลดรูปภาพ...', 'info');
const res = await fetch(`${API_BASE}/fat-reports/${reportId}/items/${itemId}/upload`, {
method: 'POST',
headers: {
...getAuthHeader()
},
body: formData
});
if (res.ok) {
showToast('อัปโหลดรูปภาพสำเร็จ');
await loadFatReportDetails(reportId);
} else {
const err = await res.json();
showToast(err.error || 'Failed to upload photo', 'error');
}
} catch (err) {
console.error(err);
showToast('Error uploading photo', 'error');
}
};
input.click();
};
function compressFatPhoto(file, maxSizeKB) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function(event) {
const img = new Image();
img.src = event.target.result;
img.onload = function() {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
const MAX_WIDTH = 1000;
const MAX_HEIGHT = 1000;
if (width > height) {
if (width > MAX_WIDTH) {
height = Math.round((height * MAX_WIDTH) / width);
width = MAX_WIDTH;
}
} else {
if (height > MAX_HEIGHT) {
width = Math.round((width * MAX_HEIGHT) / height);
height = MAX_HEIGHT;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
let quality = 0.8;
const checkSize = (blob) => {
if (blob.size <= maxSizeKB * 1024 || quality <= 0.2) {
const compressedFile = new File([blob], file.name.replace(/\.[^/.]+$/, "") + ".jpg", {
type: 'image/jpeg',
lastModified: Date.now()
});
resolve(compressedFile);
} else {
quality -= 0.1;
canvas.toBlob(checkSize, 'image/jpeg', quality);
}
};
canvas.toBlob(checkSize, 'image/jpeg', quality);
};
};
});
}
window.viewFatPhoto = function(photoId) {
const overlay = document.createElement('div');
overlay.style = 'position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); z-index:9999; display:flex; align-items:center; justify-content:center; cursor:pointer;';
overlay.innerHTML = `
<div style="position:relative; max-width:90%; max-height:90%;">
<img src="${withAuthToken(`${API_BASE}/fat-reports/photos/${photoId}`)}" style="max-width:100%; max-height:90vh; border:4px solid white; border-radius:4px; box-shadow:0 10px 25px rgba(0,0,0,0.5);">
<button style="position:absolute; top:-15px; right:-15px; background:red; color:white; border:none; border-radius:50%; width:30px; height:30px; font-weight:bold; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:16px;">×</button>
</div>
`;
overlay.onclick = () => overlay.remove();
document.body.appendChild(overlay);
};
window.deleteFatPhoto = async function(reportId, itemId, photoId) {
if (!confirm('คุณต้องการลบรูปภาพนี้ใช่หรือไม่?')) return;
try {
const url = `${API_BASE}/fat-reports/${reportId}/items/${itemId}/photo?photoId=${photoId}`;
const res = await authFetch(url, {
method: 'DELETE'
});
if (res.ok) {
showToast('ลบรูปภาพสำเร็จ');
await loadFatReportDetails(reportId);
} else {
showToast('Failed to delete photo', 'error');
}
} catch (err) {
console.error(err);
showToast('Error deleting photo', 'error');
}
};
async function saveFatReportResults(reportId) {
const dateInput = document.getElementById('det-fat-date').value;
const containerInput = document.getElementById('det-fat-container').value.trim();
const panelInput = document.getElementById('det-fat-panel').value.trim();
const locationInput = document.getElementById('det-fat-location').value.trim();
const ratedInput = document.getElementById('det-fat-rated').value.trim();
const voltageInput = document.getElementById('det-fat-voltage').value.trim();
const instrModelInputs = document.querySelectorAll('.fat-instr-model-input');
const instrSnInputs = document.querySelectorAll('.fat-instr-sn-input');
const instrExpInputs = document.querySelectorAll('.fat-instr-exp-input');
const models = Array.from(instrModelInputs).map(input => input.value.trim());
const sns = Array.from(instrSnInputs).map(input => input.value.trim());
const exps = Array.from(instrExpInputs).map(input => input.value.trim());
const instrModelInput = models.join('|');
const instrSnInput = sns.join('|');
const instrExpInput = exps.join('|');
const header = {
testing_date: dateInput,
container_no: containerInput,
panel_type: panelInput,
location: locationInput,
rated: ratedInput,
voltage: voltageInput,
instrument_model: instrModelInput,
instrument_sn: instrSnInput,
instrument_expire: instrExpInput
};
const rows = document.querySelectorAll('.fat-item-row');
const items = [];
rows.forEach(row => {
const id = parseInt(row.dataset.id);
const passChk = row.querySelector('.fat-chk-pass');
const failChk = row.querySelector('.fat-chk-fail');
const inlineValInput = row.querySelector('.fat-inline-val');
const inlineBracketValInput = row.querySelector('.fat-inline-bracket-val');
const origItem = state.currentFatReportItems.find(it => it.id === id) || {};
let description = origItem.description || '';
if (inlineValInput) {
const newVal = inlineValInput.value.trim();
const regex = /(\.\.\.+)\s*([0-9\.]*)\s*(\.\.\.+)/;
description = description.replace(regex, (match, p1, p2, p3) => {
return `${p1}${newVal}${p3}`;
});
} else if (inlineBracketValInput) {
const inputs = Array.from(row.querySelectorAll('.fat-inline-bracket-val'));
let replacementIndex = 0;
const regex = /\[([X0-9a-zA-Z\s\.\-\|]*)\]/g;
description = (origItem.description || '').replace(regex, (match, innerVal) => {
const input = inputs[replacementIndex++];
if (input) {
const newVal = input.value.trim();
const isPlaceholder = /^[X\|]+$/i.test(innerVal.trim());
const originalPlaceholder = isPlaceholder ? innerVal : 'XXXXXX';
const displayVal = newVal === '' ? originalPlaceholder : newVal;
return `[${displayVal}]`;
}
return match;
});
}
if (passChk && failChk) {
items.push({
id: id,
result_pass: passChk.checked ? 1 : 0,
result_fail: failChk.checked ? 1 : 0,
remarks: origItem.remarks || '',
description: description
});
}
});
try {
const res = await authFetch(`${API_BASE}/fat-reports/${reportId}/items`, {
method: 'PUT',
body: JSON.stringify({ header, items })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'FAT Report saved successfully' : 'บันทึกข้อมูลการทดสอบ FAT สำเร็จแล้ว');
await loadFatReportDetails(reportId);
} else {
showToast('Save failed', 'error');
}
} catch (err) {
console.error(err);
showToast('Error saving data', 'error');
}
}
const FAT_LOGO_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAtwAAACCCAYAAAB1sh/HAAAQAElEQVR4AeydB4BcV3X+f+e9mdm+6t1y3ZWbbONur4yphkiG4ARwElKcf0gkCCESxRDAlARTTYiUkCIFSBxCMyEBgqXQMUZrG3dblm2tLNuyrN62787Me/f/3Tc727WrZlsy7+mdd/u553z3zex3z7wZBXEcuzhOJY5TDOL4+MPApUeKQIpAikCKQIpAikCKwDGOQEB6pAikCKQIHBMIpEakCKQIpAikCKQIvDgRSAn3i3NdU69SBFIEUgRSBFIEUgQOF4F0XIrAUUYgJdxHGdCDUecM4j5xGqBPQfCi7IjTuVhtseqTnkqPxdMlNnpboWzn2Knv65wf5/sdiz6lNqUIpAikCKQIpAikCKQIHB0EUsJ9dHA8JC2DKebg/OhKfI/BMnqvF6C2b8rBth1Ovk9NmqQIpAikCKQIpAikCKQIvEgROGYJ94s5+hnEDi8WOUz5sq+jpeX7brS2cl25zwuZmhlm1m9C2bYDpb6jmSVjyn18XSopAikCKQKHjkA6IkUgRSBF4NhG4Jgl3GDgn71wDDtUP6zm+Cl6ZxzmSCSQK6aCS0TuMlJU1Xeqs8cEDU6kr/pYS5I1K9ta9scSi11iv2/zRjt/kfhyWVTs6+lzqaQIpAikCKQIpAikCKQIHFcIHMDYY4hwewI2IIFzIqWxxIm1xaJhRUkkKcoV1el6vJxyBS9yBCwiCuKSWEQcREmdHFUaYCLfgeoV/FU+oETGXX+KyX8TLuKoZqY+xrF1aG2c7MPhLTPzPhnlfyiH7Pd+OCL1iklwUW5kqqb0TBFIEUgRSBFIEUgRSBE4zhEIXjj7PdES2cSnMeaCPhE1U5Q0xpNNLyYeFmJxhlIfJ5PjYaLiMX16HyTyCRcmlsbyN3ZZiHOEcYUkg3i42kIcWaUBykiySjISZZOr6r2eRNTtBT+9X96nkjiR6Th0xNoMxFZes0BrF0q0nrLbuZycqYRYfkYanxB0v6aBvAn7xOeVTc8XIwKpTykCKQIpAikCKQK/Vgg8L6ym/IzukFTkUbQMT9DErwW6CJciuwS9YB0Etl/SqnioRHkLO7CggFlG7SJu5nDEiXAQR3nug+h6VLqU53N+fyA7PZt23j9JKIKZpVu0eh+B2wH2rORpySZwmzCekmzBbCuhbSPDHkkXGY01+W0itY6iouZOoiGaw8/H83T4uUri55YN3hbZhuwyjFD+htap9dsNwfaShNuxcBven5AdBPIpsL0EmXYCRfnNDD/e3w94XdLhsXPJzWHyU/M4OUrpKM0/UC7VptcUgRSBFIEUgRSBQ0Eg7Zsi8PwgcNiEe4DweNLjyZAIs0iSKJjIkciTiBciS7GykUgiTu2SogXEgZ/WRLoiguIurHs9ccdtFHZ/j54tX6Pn6X+n98lV9GxaWUqf/ld6nrmZ3q3fprj3R0Qdd2PFjRovMq55nMhaURITAYVkKvPTOZNFMU713l4zGaMeh3P68YPHOecJb1FV8j+ZxxErdd5hVcXeT00XB0XMeggi+dmzgWJrM/nd35WfX6H3iX+m97G/o+P+T9B678fYf8+Hab3by0dpv/cjdNz3Edoe+Vs6W/6Znie/TO+zX6d37w8pdt6HRVvA9kDQjQuMyIsnvpSPojIyRN77L2aaz6pm4PQVDufiRMr1zpNa36RxiY/CMRKKkTAs9TGQbygh0NigB5AdPY9T3H87xR3fp/fpr9PzxJfoefQLdD98E533f5rWez7B/ns/yf4HPkXbOtU9vpxu+d+96d/o3XILxd1riPb/EroexIqbMdtDkPhWIC/j/X3jTPeN7DMXyboipk2LJk/PFIEUgRSBFIEUgRSBFIFjGgExmCOxzxM2kR8RIESBXELeIikUEYtjLFJEWhKqPRAZRFHqTPQUYdttxFv+nZ7HPkXH/R9k353L6Gx+J/m73k3x7vcTi2xGD3wGL0Wl8X2fIrr7YxTuuZ6eu95BR/NS2u5+H+3rPk5+y5dwHbcTxls0b4GIkNiKsqaAmDyegEOsNGGRSg/vdCJ6sXzyaUkM5ySxRKpdXJS/MYGIOK6LMNpJ2HMP8Y6v0r3hs7TddwP77noP++9cSu9dy8jf+0GKD38a99g/kXnia1Q+/S2qnvk2VVu+TeXmW8g99Q2yT32NzKOrcA98nuK9nxI+H6Jw55/T9cu3J3j13P9hCk/8I/HO75DJ3y/P92PWSySCX7RCsgGIHUSBIxZpZcihBqFEvww0Ji2JX0HiY1AM5Q8YvZi1afOwGdqaKW7+CgWR5547Piib3kXP7e+k+8530qu1ie79OMjuYN1K+fBFKjf8G5WPf5mKx75E5pFV8OA/4u75LPFdH5VP76Fn7Z/Tcfuf0377UjruuJ7Oez8q3JYTb/0mmfZfEhZb8BHxWNHwfJCh4LLkJbxYj41rWLFkCQsWLBDmNkwWqH4JS1asYeNB+r9xzQrp8+OG61JZcyxYsoI1G8fStpE1K8a2Z8WKNQdtz7hmH6H/G1eMhpt8taHi8U1wHMv14cYKp3GxWLNxbCw2rmDBMFuWrBk+0fDyRlYsGGq/DRu0Zsmw9mFzmJXa+/0ePsVhlo/8/uqb+GhgK1VHc/2fM0yPQV8F3YhzdP+XMNbterD4m5XuR7NR0gUr+l9DI/QNahthcLniKOE7mv/DXnblGQfSEa/vBaw4lPeYAU39uWPtNdZv2CFmRuA5LpilCUbcA6PdM+PVDbpvRthxgLHJe+WS8f4+lmwc7xqM12F4e4lsuv5qp6wXX+HTWAzPUcCJ8EUZEe8wEgndQdx5F4UtX1W08+O03/UBuhXRjR9ZIVL5Tep230ZN50aqe7dSG++jOmilKrOX6qzySqvCVmpoo6a4k+qep5jQei/VW1ZDy5fpUnR4368+SIcip/H2/yXTu4FQhDd2GVxcIT5peJs5gqM8fmhq0hhQegyiSBSK1Gf24YqPUNz5PbpERPff+WE67rqB+JHPk9v8Nfn5Y6Z0PEht7zb52UaVyHFFmKci10Uu00suKEjypbwvhz3CoYOaTCu1wS7q4h3Udj5LddsjZLb/nGDjV4ge/ITw/Cva7vgI3Y/+rQj+/xB2byFTDAitiyDo1VpoHYxDOFyCmV9P7RpwmbwI3z6t4f10bf4vOu77NG13aj7hHq//LPb0f1K59yfU9DxMTWELNW431dZOpeyvzHRSmZX4NNNFVdhFddgp6aBKEeyaoEv991CtcXU9j1DZuhbb9r+4jV8iuv+TdGqe9ts/QMevPkrP4/+A2/E9Mp0PkBMWHJJPh+D+C9lVRHPJAsMaF7Fs1Sqam5tHsaZZ9atYtWwRjXqT8GT5gO/leuP3+hoXLZM+P240dapftYxFjY3YgiWsEFkc3GvjChFta2TRsrHtWbZswJ5hKjjo42j7P87EHt8Ex0Zjgd74D4ij1yPbViwRkRdO42KxqFFrs4AlR/pX1s/7HEi/33aENh6F+ytx7+CxZdlRxLYfh4NZ/8TQA1/6dY2H6XHl6xq+s2o0n1fxnTWj1R8Ddc8DvqsWjb3hOKooHOevsaOKxQugLHldr1pW+vvoX9uH/ccNxBgP3wMnxuMSFV6NSZFSEQBlEqIXRluIdv2UjnWfo/VORUDv/WuyImfV7XdRzTY8Gctli2QygTR5UijJiCAHIY5QZLkkCiTjFNlUJ8yFxJmYoCKiRkSuzm1jwv47yD7177Tf+yH23/cRCs/+F0G8GQucdMguJdIqs5TR9XBO0+TmfY2RTn8WcRZB4MnoToL2X9Dd8kUR3w/Sc9f7CDf+A9V7b6O+uIvKoEc+9ib2uCBDLP+kBYsjRcWLSr3EmMwzOWsxmKLpPnXyF20ecAFONkRhBu0oCDLSmStQEbQzodBC9fafYg//E+2/+gB773833ZuXE7XfJ/371D3GHGMcQieWT3FfJ9OnA94vkX4xeNh7Kx2P3pREnmPpzz55M9X7b6eW7QlprswUCEKNl22mdQvkA0ih1iw2U06i1Ml+1OZTJyx95L0YOCLlXZyRrRmycUClPimpVm2N66S+sJma/XdR+dS3KN73OTrWfoDW299F54avkNUGjBfToTfWBSLaq5oPzanmdQfov0ZEuXEZh6SveRXraehX6KMKjctWMdKkJpqa+rsNyTSvWsZ3WoZUHVzhaPs/bNYmGVyWYU1JsXmVNgwi3Ulh+KXPtmUHAFOqh49QuZlVyxoTIq/CC3aWffbpSCP6bDycjcFRuL8Se54nbL3/ZUnmHXYZc/2H9S3r8emwJhXHwPR483XNd1glj0Y7V924gjE3qKMNeq7rnid8ESqLDvRecTR9PM5eY0fT9WNTl17b2vDbgvK9f2hWBofWHcxM0U90mATxpwixW2KDWMTJRC6DeCv57atpu/8m9omgxZu+SG37g4rq7qIimxc5c0hLoicS6YxikdfkWWfpUhlJoPCq7+NFnCzpb2KgFhYw85OJeBdNU8dkNXOFIqV10VNkd/h5P0frus8Tt94ui9Q/FhtUDpE4+g4frfbSVxwnscRPH7UXX0TcNxGzPK5tHV2PrxTR/RuK6/6e6t0/UyR+q4hwHh+xDkXKQ0yE11+9BAQmuumR9455MU2vOnVST48NSk2iVOQTb3df/0BEOLAinn4HIuVE0iU8stleqsJuagvPUrHjJ/Q89Hl23PUpuvc8IdtjQLYKU2WS08mRAQFHEf8oTlHr4C0Iozbyu35M10Miuc03Ej/+RSoUfa6Jd8uvXjKy21yoNZSIZGtwYqaTLVggV2R/ZKibbCURM80jP5N1dHn8hiNZa1+njuZT9QklgdcR5IRBQKi2rMh7bdBBbXETuT130rvlNij28uI51rBE5Hg4sW1avJzVLeVPHPrSltUsXzzAdhffsHQQRe5DxP/hWbRqBFFuWryaliH6WmhZvXiAPDct5/qFfTpYw03Lhlmk9pI9a1m7dnR70Bwr+3WUdY2XHmX/h08nu29e620uiX/tO+E4CMbSiFU3jvLR7/i2lbFoWb2cppKm/qsncgsOh9D2aziCTNNyhvs9qo3LbtJqH8I8R+X+8vM9T9iOgsPBr7+3c5CMouvgMD3+fF0zeni7BEbzLdx6AMbdsHSt/jb0vT+4welqFpdG91+blreM3nftKO9r/aNGyzxP+Jan1gb9OX1dH2+vsTIuz1H6nN9Tel23DLlXnf5WDv1b2+9a8zIaD2PDFfQrOOhMDCKRYjuYK4hQxcSiU4YjE+8l2v0T9t9/Ix33fpjMlv9gSmE9tXSh+CWImLkkWhtqjFHU7HEY4EWN45w20O6UlQSaF1Eyz6edhYlZGRHGCWyi4ql/ovX+D9C9aw0Eu8RLY1mY5XAO84N0KUjygS5BgWzvTno3fZW2u98P61ZRte9e6hTxDbThiBWlNx/ylW1+KMWYsOAIi44g0puPIsmxFpZyOwd3mPoHUUZ6MmSKWYgGxjkRVA+t/yWT2jjPpPx+KlsfhsIurZHQj3MDnYflnBRFQS/++egw7sW1/GWhhQAAEABJREFUP077I39Pce1fweM3U9P1OLUi4FnN7ywjHE0avECspBBqmoBkPZ1aSqcaSpkhV7OBejPDvN0a5AmQBTGm9XMUNYcX72CMbjS0vErRPRZRqQ1ZRW09lqvgxXKsWbKIVcOc8X+I1q5cysKBgHOpR8NClq4c+IM2Grldc9Mymku9+6+LVzvWqnPDEH0NNCxcOUCeB/+R27iB4cFzT+4PaE9LC6tXi9BrjvKkB5sebf8Pal7huPLm4QS5mVuGsYhDsa1h4VLWupGkonnZdaMQ+YOy8qh3SmxcPZz2rDqkRwSOyv0lz15QbA9y/WXmuOfBYHrc+SrCd+OQN6Xhn2qNfK2MC9Rz2OGFwPe5fF2/KF5jz+F6Px+qG/QekfytbRn+d0KzjxqcUf0YZzBG26hNzkXajZbJEASuQjS6l6DjYboe/Rc67vso2We+zoT8k+TCXsxM5CkjYuanCjRWZWfiUIZ/7DlUPqM+gcRM9X3CaIfapBAvZqYUfLQ58qpdVnYEZBURDsTAK0TeavbcS/e9N5Lf/A2ythMoSkqnmUmNlQrjXdUtICAUOczFe2DHD2m994MU1n2W2n3N1LidBLIhxvCbh9i8n2KRZb2h4TIBcWBE0qVu5ZYDpp6AehncwQnJOIzwEoVaA81pJt0JeVdPYRALkJgYZ45s5QQyNfXEIsn0Tep1ejHzhkAcq69Yc6h1zBX3ED/9LbrufD9u479QVdhAXaaHUGQ8wBEIaIs1r5MlEnRomr51hIxcLmlVQ/+pSo2lX3yDw8z6xdvjBdmOC3GxZos1Qvea30UFQak/GAVhmw+zBFXTcEEdL4pjxB82eaUo8dqlQ5ixKg/yPIC+w+DBBzlhX7eGBhYuXMghW30Aew/bfw7haLiaa5uG9m9e3zJQcVi2LWRly/A36GOLnLDwGoZT7nUbNg74PVbuAJgc8v11AD1jr/tRxna89R8Lh+FtY2F6HPq68dZbGLJpX3wDNw97sTQf6icjwzE7WuWDw3fYbEfjXmpm2XUrOMhXzrD5xygewJ/j8jU2hpvHTVPDUtaOCFJo7W9SQPcQnAhG6+tEhuJERHqGdfD1zpkaYqIgIsq0Ee36Afsf+hjFTX9PXecj1AR5xAqhmFEaEwRFSSS6FGKmVBI4J6KmtqLKhSJxFEtcIk6kWQNFFB2RJ6mS2EtsImMONNZJvGmGiQg6zIlMm3T5lDwxIRUqT+xZR+ujX6f76TUExX3EisorwKz+sfqgI5a4RJzm9aIJ8BuLyIpKfSkmokdR5SfJb/hPOu7+BJnt36IufopsGBEFGfxjLaE0ZqKITBwrR7842RorKl7MFPAEMrYsTviaQ50ymsMTTIeTYVEiqpavmlTdnWxVPytJLF2eTFsQCVPVOT/W46K8sIw1PraAvOborZ2DVU1HijWfU4dBIjLrNIGGgzYEVtxKz6NfpPO+z1C593b51gHS4zFITJE+r9epzsw3yV6tCaoPRPRDEeVQOgOKOKWxF+nv1QQ9autR2hsbkSL8cSSblQpeAq17KJ9MOkD3izY2FsgfrauTyB0itTtJUbsa/5/q9FZUYHUzsWQjwfF/tKwf+odNHi2+ZqGuh3keLX0N85g/zIRVixaQ/KrHsPojKh4tew/LiBbWD2EVw5Qcrm2jEblbbmXjMPXHUnHIRmMsww4Xk+E6D1fPUcV2nPUfbvMhlvsxPe583cittwx9Yfj3pIalNzB0o7aKY+LLky8kvs2H93jBmLfS4fozXOnh6jmqr7HhRh2n5YXXs3xYcIZ1Gw7pPT0Y1XWRG7EmnIijS0ioI1beixMDsqBCw7IioK3kN32Lvfd/huzOn1DLPgKxsVjkygJHQCgxTETLC1EPhRg6XA1tNpXOzMm0V7yEtsoraa14qeQKWqsuUnqW+jTSU5xFVKgjEnEvEhBJd0QOT8RkQHJ60ho4zUGkciwx9YxF5h1YqNNR2/ugyOQ/kt/7C4p0UTRDDlHyLe5L/XiNkXYkzjlcFGgDIPInu637Cfav/2faW/6Oqp77qXQilhhx4DAzDB26qKThgQoDp4uzxMWQQDo9uQxdb0IiO6mlnal02Fw6K8+ltfIyWqtfQWuVsPA4VJ/LnuwptNoU8rKFKCYTGWFREwmT2D/jgmEWAD6NCcz744iFSa52BplMfTKvjGKIxI5IRNjpk4Cg+DRt61fQ8dgq+dWC5XogiErj/LzShbYPPnHJXMJV90Xoimi/QeDvDeV7CvW0uTm0VpzF/qoFtE18Db3Tf5PCrGsozPwt8tNfT+eE19BW7df5JezOncoe+dbtyXbcS2wSbc785soREMQZiU/7TLeM1jXEhZPJ1M7CeYPUdLyfa0Y8J7mYI+HbR0/fQq4f8Q7TTPKrHrrn/a+jrDiCb2yX1+3o2VvWeAjpmu+walj3xYPAP3zbGrh6WDSQ5vW0DJvrWCo2ndV4UOYcPiZD1R++nqOI7TjrP9TiQy+VMT3ufF1zE0O/vlF+T1rINYuH4rDqGGDczyu+i5ePJF6rFnEYj/QOBXJQ6fD9GaRE2cPXcxRfY7LjxXE2MG/+ME8O8T3dM7VhGsAcBM4h7oa4J/5QUYkjVKNZp4jWRro2/gc9D32WiV33iDp2JWP8INNAE5EjFMEOYzqtiv3BDNrqLqV3xu8SzvtLMud9hNyln6f25f9E3av+lfpX/TsTXvVl6l/5ZWqv/CeyTX+Dnf8+8vOW0DPt9eTDRpHOrOYtiIqBmYlweWIpO4MBN1TNkCOqoCaOqOl6jPaN/022d5Ps9GRZ4yxSV0t0gVE+YpFJT7gDVYRhN0HXvbQ/9C/ET3ybifEzVCR9Q7WS2IDKfl7z2GiQacORkEWXwacWFjBFt4tkaQ0msi97Bt1TFhGf+qdkLnw/FQs+S/WV/8iEVwgD+T/hVV9kwqu/SJ2wqVlwo7B6N71z/4C22pfTZidqo1JBMVuEjPfBMDP84fxFEqgcExJWzcYFdbIxwPcw81d18KfyOsnFW+hQZDtuuYWJwT4qgpxs1gZBhNxjYGbgcQqElRefl3+xfCtoE+Ej163ZCXRMuZz4jD8ic8nHZfe/MPGqLytdRfWCf6D68hVUN0muUP4V/0zdVaskK7X2n6bikg9p3BLaZ15FZ/ZUugoTiXtNxFprK6tdkJEPwlrkOlAE3zRnHEyXb3OILMuL8mg6i8Yhjq1hidbBzDA7gIz1rekR+oYoH7Pgv6jSMoJ0l4Y0r1qG/4k2M/+Teis4Cty7pHiEvUfof0nr0Gv5N3oXrRpaT5lYDKsuF0fYVm4YmTaMeHdex8E+tTFS29Gt2bjixmEbjSauvbrh8CY5BEzGnGCYnrH6HjG2h7v+Yxh1SJge476uGR4EWHwNC/t8XziScbOmr+2YSZ5TfOex9OblNA1z9jn9qcBD8GeYWUOLh6DniF9jQ2d+UZQazxq+6of2nh6MQMEzaxFOhUgJcAQJmY0JDMwTrbiAFZ+i84mb6d3w9yKzG8nGIZGP4uoPbygSpK4aHtMd1YlEzSea9WYqzrmemks/S/0ln6H6zL+i+pS3kp35Oqz2YqyqAas4FZdrgOx8gporCBQZzZ70x9Se9X7qLrmJ7MV/Q++Jv8W+3Fwi2YEmcaYLYx+BK1CUD2E2T27XTyk89UPZ24E80sBYUj5F4H3WVKczkG4XdkLnOtofWUW49atMCraSjTwBBAsdlIxAtBsFeSUle/wjHxBgsamH4Ql8Pq6np0K+nvT/qL74RiZc+llqz/sQFSf/KZnpr4OaS7Dq01EFrqJReJwrHC6nYtqbqG54JzUXfTohrrnzPkh+2kI6M3PpibLEUYTzz1UnBFmWWEBp/gyhjwJbtewzSfk0nKwqijyH1kFxyw8obvwGNbaT0LS2cYyPwpd7D6RO8zjkDIGLyAch+7NT6Jq9gOz5H6D+0lXkLvg4lSe9hbCuCcs1EmTnSuZgmdnCazYuOwdXeSJUzZOv55HRpqP6lLdTc/5fU3fl35O9/HO4+e+ia8ZraK88mU7dS3nhGAXyKXCE3k9F0jPVitpXTpcf4YB5L+bcKF9efD7d9aTb/6LD4F9GGT6/J9+LGi35He+jRrzLkxwN/5v1sa/JvrKUf0u7PEeSNrG8ZWU/sUiqXmwXTzSXLKBxaPgSmq7lcPn2cQHRc7n+xxqmR+zrGr6zauiqDv7UZ+Tz/6u48YX6FZ6hZj5/pVGf6V3F8/JTgc+fl+lMAwgclVwwQouBJ2xOBMfzWvE4Yot9pboamXgfXY99hcIj36CuuJ0w44gl+jtG6EwEsIKOqI62yvOh8c+ou/gT1J3/cSpOE7Gc2EScnSH9FThFSJ2IutMYJ/UQYYqeelEHgrgSo05zT8JlTxIpfQOTzvtras5cSmflWeRdBYH648lYMt67EkivHJCleJutiAuLhESyLaCysJuerT/D5bdIb8ZPmfSPNd4IxCVNUtS8GXyEPuh+iraH/41gy4+ppVM2iXAGvrP6OEfgiwIpQBml+EcjpMdpbidCi+ZuV3R/X8X5uHl/Rm3Tx6h7yXvIzfxNXOU8zVWHK2ZA4uf3xNlEZgMPiMQJG/Ckulp9pxLUzyd72u8y6ZJPUSuS6ua8ifbwVIqa14SFF2R9TCy/a7WRmSXLKlXl7QXkqNcZCRGnFvbcQ9ej/059cR9hGGiUJOlTVGekqYSlyT3TWgXk5HMFkTZX3bk55OZfp43DR6k65Y9wNadLYxUulg71j0SMkzn06QJevF+xI/SbEKVIR+D/YyKlLp4E4elkZvwGdWe9k/qmT1Jx6QdwJ72R7kwj+WIlRYSThfRkMkQ1M6BiKqHm4NflaGrC/+ZvWQ7J7UP82GtU3Q0L+34ZpQX/E2gjfk6vPKhZf3Qan4P/FOJI/C/bNlbatJjVLWs53O+pjqX6BW0bjXyN+C1x+T7412kO1eCjcX8d6pxHu/+hrP/zgenR9m+wvvF8XfMdhvDtpuWDfirUK1rI8CB38zH+/QRv9VGXhSsZ8T26VYt4Tn4q8MXwGjvqC3D8KQwOZLJ/nMC3BQnbMsSjxG130vHUt3FPfJV6t5k4k8WTzExUEDmL6XUhbYp6usbfpF6ksObMDxJMeS2RIptimOqrM5Y4UTEnUqjUz5GIyDGWpyQF1KtP1I9YJC+gmD1VhPM6chd+iJ6q88TjVGfqabqo91inn0qckqjjMQr77iAT7pfWsH+IE10MEi8q5av0FbfQ9fhXiXb8N9XBDkwbBAuCvv5qV38nDSQSgQgvqsMFBFFAVCyyPzOBwpzfZMJFn6TmjPeKML+c2M0GkUyElRen/pqW0Q+HU4TehI0FmkOktRhVks+ehs18MzUXfozKiz5E+5RX0h7U4IKcOL4fkyVfPZewSpsb77jGRZok9maLunqimuttpffpb5Ntf4hMUMBipx6GE0h+rRN7kv4+V8r4ayx/O3MTFaT+Q+ob3oFVXIqLJ4L8CJ36aj6ntTWTLl9W1cBpmAUDRY+XF+ixo9UAABAASURBVPUXMIKwQol0VZxNZs611F7yMWov/xDRKW+iLTyRnqIn81n5dQKmjYzZYF2D1B7v2eFvrj6aMug3pNcqP+KN/nnzuYGGhUtZudbfZ558L6bJzz1ERLqP5IHG58j/ZLMyxE5faGLxavmyVpHtBl8eKiM+Qhxu29DuQ0obNwz/UcX5zBtljiGDnu9C03JtNOT78z2v5nu+sT2c9ZeZh36Ogunx4+tGVgz9LUAYvsHQe/uIJ7GaD/yb3IcO4KGPeL7xLVu4cOVqFpcLfWnzsutYctMtNPeVX8jkhcLlhfT5uZy7ZcQ37A/tPf2AjEWvqcRu/ziE502ha6ew9SfJr3Rk3XYQMQtF0hIe6ES0g1r2T7iQqnM+RP1ZHyOY/FIQEXORYSJ5JiLmSVlJQsy8gJnaJYjQokitizMicL6trx4IzCm+GRNoPmc1VE69iprT/4T27BmguQM/Xv3GOwMRtIp8B53b79O43RoaKh00ypwKMWG0k+7N/0X+mVuY4PYT6J9L2KSay6f6msWIqSZiAsJUh4hnpKh0R+Y0MictZsI515OdeiWxTReZzMhfbSrwUsSPCTMabsYBD3P4OUxzmY/UB0XpKvgcUW4u2blvYOLFH4SGt7Er06BNTxWFSEprTiBQFFid8Zy2qClKhDuWN3mKXevp3d5MVdERhwWCWL44St1lj5nJ1pIgBU7rpypijQ6mX0DFyW8gH85WS4zX6+0LNG+g9TArjTMzyoeZJfp82cx8MiAq+irzvgpHp3UiriEOTiKY/ZvUXvA+6i76AF2TXkZ7MIPMhFM0tsKbpfT4P0e8KXJoz4UNR2CkvlU8N99r8uR7JWtdCyMe8171nYN+rnOkvUfm/3A8krJIUPIfwIz4jexmxnr2smHEc9gHi+XGEb/yQNOgZ/NH+QWY8X+Wb+QvapS/lJf4eIBLQjSbBn9CspjFyz3Rdri1Sxnxu+oH0FOuHrleB4tJWUMpfc6wLakfej3M9R+qZKB0qJgeN75uvJVhP04y4PSYuWaWHeJPpI2p7hAbh+MLB3tPjvM6HdeOhawcEf3Qe8qIT5HGVTSkw3H5GhviwYuxsJERMZSmQe/pB+FykPRJHl3wEckoIVBYqDTEpxAS+PaOR2h/9D+o6nmMrEgxsa+PcS6izdVSnPIaJl/4AXJz30jEKcRRSFGRWQKvK0OMSKAZZgGBIsXK4qXEmhyIyKG5SmIql05P9JxIXKDxAarXvI46Mie8ktwpiygW69AFU5TUZLXnbKYQbb/I9lJbBBSpIE/Y+rSyPQRSRxBDEp3Oa3QRgl6Krb+i97FvUxPtIEhsyoG6eVvoP0y5AGSbk69oTifJy872imlUzf9DRbXfSlx1CpEIuPPOakjssXUm3Jz3RmKYBVifgDH0CJM5/DxId+gqE/xDzWvO96yG2pdQe/YS6l6yhK66c+mIavG/UBJkKzERdDRnYDFBrD+w3p+wi65d2n93bsMyoWZ0oHaTpaFwDCTocN7OBO9QOEAsDApUk5t8IUV9alGa32l87Huj7piZ+g4IeH/Kgvz2fb048KDi0JA+kS3CQQ0EzumTgpi4WEFkpxHOfTP1l32UzJnLCKbM16iMdPmex780XH0tTUPcaD6iP14j9cGqG1ewccgcR7PQwIhf5ODgSfNIe4/M/7E9G+0P5BgR+YXXMDyCdVC/yjDiVx6g6dqrGQhwNzL8+zfjfiw//KN+OTp/vJB50/Ih/9Ok/3RkraL5K5cuPWSiremSc+R6Heb99Zxhm5h5gMshrv9oWg4H0+PE143Df3tb/o/cXPRt3tQ25DyETfaQcUej8ILg22f4wpW0jIg49LUdZnJ8v8YO0+ljfdi47+njOxDEYlGxyI1PwfCHExHyEot4OZHRkK20P/0/BCKiWVM01ImYWp5Y0ci8qyOcdRWTzl1KZuKVuKDKq8BHk4NQRE1sKnBIsy6Aigwcvm6oBIHhCTl9h8/7Mc4cCh5jQQAiyS48geoTXkVP3Ul0RpV0R9V0F6vpiXP0SGVJjJ64gu7Y11cprSCvf4Xu/UTdHXhyaUif6Ju0g/ct/zhtm75GVe9DVBTkpzCIZJPfijDK4WKRZ8sQZwKZ5bXPpHLeW6k85f+p7lQCRe1DDKkgYyYJldfmAy8hckZig0TZwadnsQSqKYkvmsqhQuOB5jWXwUTCI5tB7oTfFel+L27OQmzC6eCqMJNnFsgOh5/FCT+X301hz31UBPtlY17jIWnE9ykJugdQGX/IeAt9KRb5DXCVE4i0iUjWNSH+gXqpXhM45fzp7x0vJIotIeuxCH+iRfecT0kOP2Kw+EqHE1ZBGKL9AKE2bTF1BBMvYsZZf0xlzfk4bQTUxXc+/qVh5H++orArSw7324ej6WtexnXjfLFp48aN6ByC58Y1K1ixYs24v0ByRB+1jWbvqkWH7/8QD0Yp6A/kiKCU5hv92cuFDH9eddy12biGJSM+c1/MDUMeEG8YuUnRGh04SDjKR/0sZtAvGI7i6HNUNdp6yfZDv7+eK2zH8fuQ1n8cXQfdfDz4uoabhn2Ztml5C6VN2tqRactymob4f7BR5SGDjlLhBcK3z/qGpTdzVDn38f4a68PlRZNsXMGCcd/Tx/c2MDNQJNqJYDnniZHKRJjFiYhGUdh+H8VnfkIN3RgO/4XASISvI5pE79RXUXPun2P1FxGL9CICGIQOsxAT0UOHmWFmyh34NLMhfcyGlv1I8xcXy4JYfC2L1ZxDeOpb6J77BnpPegM9J75e+d+i+4Rr++TNdM+9RqJ6tXWpX5cipfH0BbhwQoloKuJrIr9mWTJxgcKTtxE8c7vIqCi27Hea08WaT+mQM2lwic3eLk8m24PJsucaqk79XaLMTBCuZr0QOPzhI+SJyAM54KtGiJmVdI6TlgZ6vVoRj7nLEVsl4YxXM+PCj1Iz41rQ+iBy7ufylBj5g6mU302m7Wly9OLRVMeSOl377dP6J/XyAfzGIyJQnVGA7h1UWLeQ8wOk0CfO26KMJhJyaB+CC0ptqsWU9eLzZTEz1Vu52J+a2aB6K9XLjigMiIMKnGmTMaRPqcvxe204wM9MNbJgyZqRJFiEbsRHW0OcH11f8zKvz/983+BYt0i2SPWSBUZjY6Nk0Bce1yyhcdEyli1bhP8FEv+722s2Dh7rJ93ImhULGPledA0LffNBScNR9n/8SReuHPkYjMdHcI8YvHCU5zRXLWocZW08lktY0LiIVcO0NC2/fgQeDSP+ExFYtchYoA3OEJS13ksWNDKMCzGaTp6XY/T18viNvEc8JisY9f6SrQm2SgefRwPbwfpGyx/K+o82/nDqjnlfR3yC0sSYPxU5Cik8qE9/Dge8gxjzQuFbMm3010Sp7XCuo+s7Zl5jIqD+NW1myd9q/3eq38vk/apUb6b3s9HeVPs7H8sZvXfJlxVLFmCNy2geYmoTi1cP++7LWJj0jRU98kTJE0qJ80UDizFikFj+WTqf+C4VPesIMr04kfFABLWoqLGru5Dac64jqrtAEcwMgSKYzutQLw7q0FyaiUQ4yENjNEesCHsxnEj9yX/M5Is/w4QLP8XECz/NxIs+zaSLB8tn1P5Z1Zf6TLjo40w7/x0E1ScTa4OhUKmsld8io1Hn4/Rs+h61bCXSjRKFwkZYhCLcYaz8YAs1xCW/EW14Ph3lM/TOvIrKs65V1HiWICzVO0XYnQt5bg6tkaLyUBSCjmwxg9MnDnHlCYpCT8SJgOuC0z+QwYnEuPxewt4u2W2Yx2BU4wwpBd0Dnks74eERCOmiZ/svcXsfUXOXcCqSfPqgNUE1TmJBoLpAI1Gq2TXWt5WEQYfmUH/6hQMeoccw9v7pPsOpX3mssi+Gs2EpN48Iu0LzqkUiwYbZgDQ2LmI4+RoBgdc3SsiledUyFjU2DtInki1SPfDIYfknvhSlHfblqZFjvU2NLBphTBPLrz94uo0/vL1H03+vc0xpOADJH7Th6B+/kJUjonkMWZsFyfp4LFcx9I0ZEWNFCYdEt8uKpXc0n7XBaUz0eXwlWu+B9ekb27Scm0fV2df+XCd+vY7o/iobKAyeE2zL+g+UHsr6H0jHodYf276uGf5bgE3XMvZPRTaM/JRm1Y2M80HaoYJ2CP1fKHz7TNRrYu0or+e+1kNPpO/mY/I1pk/brlvG4Pek5kHrvuYmBRwGvQkObhsVBP2NM7NBf5NGy4/2vjyqtsOvbF6Gf981K8+v93O99y4b7Gii3ZPttawc8idubEySYboEscikU6xSfBMfkAysjWLPDlxUICBP785mbOcdimgqsumyImlZ4mJIb24KtfPeTGbSJcRxLUULiMTpnItE+pB4UqQZDniaWgaLiuOcnvj5LsJDthmBotKEdRDOwgUSpfgv8g2WYLba+sRmacxckcQT5EdO9nob86JvPt1Lz7M/Jtu5DhPJDlQbEikvfwIvMYMPX4oVWY595FUG9daexoTT3oyrOJ3YVaurJRpAoCTUU1V9p5lJr/WVjiQRkU+Irrc/JA41Z5AXiQ40txEHvegqCfGEWdPKGvkSd+DoUbsjLMo+N8wWX/YEV7qd8k4LG8dad5UzFkH7w+x/eBWFnT/R+M3Sn0cXipob3TOhK+rTgh5JL/6RJCccRbvxVqLDbPB8Pl8WNY5ymplXr7sU/D3q9OkJSY3xYjoa9FG3E/k44E/uHcjZJr0B3DD4+eBSR//72S36A9BUKh7ctWkx187zXfXH6+bVjPXb277XCNH45Yf583pH2/8Rtg2v0B+0kX8gV7FowQqGRJj9ON+3ZTUHWptm32eEaF1WH4hs93XWmh/qGjUtXk3L2qU09Kl4oZIju78GWf1cYTtoilGzfl69Poa2HWD9h3Y6/JKf87m4j8azyM87pq9rGMm3R76nDJ9m5LPGzdxy64hXz/Bhz13Z+/lC4Fv2SK/nETCX2w4jPf5eY6N8sVAhiPUth+H8MTikabH/svlwsn3whoptQZSQKhNR6yXq2kDPvschyEJxH13bf0qWLZg+zic2EVWjO5OBWb9BZvZvUHBTxK4DkdpIBC4WITLMSsJRPqRWGh1GIJENrkQ4NZvKBrrifXHIpqFiqvejvA4vsYvlr5MEhBSJO9bT/cwaqmwfoX9kISGi0qHTxPLMvH40QykNREIzhYggjui0Kuykq8lOvwBHiWwz7PCPagyrOipFM8OsJMqA0XeUMuV5PenFPw4iCxMS3dernPh+XsrlcmpSaBaDiLZ/VCjUXHWFbir2fY/9d7+fjrv/msLTX4K22xU13ybcRbKDGKfofxxUiJDnCGOtldZMaGsjpi7OUTqslBzEVdPi5SC6Ht9dGpYmP7nnf+vak11x6VH8aaJJDYv7f2nCvwGMTr88ifW/IuL1LdaYUcm3r0/eSLQtWruSpeWfrWhY2P/b26s1V2n8aObInsWLWS5y6fz40U0ZZeAoVQ1H1/9RZhhaNdofyOZloz/vLjz8zyG26A9gBTBEAAAQAElEQVT6AdcmwbIPC3fgdRlsxMGsUWm9V9PS4lir0MqRQDx47iPNH4ztulkp/aEadn8Nnvw5wnbwFKPmD2X9R1VwGJXHoK+H9L9kDnZ5lMdKxv3y7+Dxz0X+hcK3z5fRHm3pazqs5Nh7jTUknw4ODj40Lb6B0gduarth8WH5eUwO8u/nPoi0XO+94i1rVy6l/OdxqL3y++blQwIyA5gM9Ax81iwQHRIpijvZv3u9qjqxQBHgthbwX66Le0SU1G4xkaKYvVWN1J30exSyUzFxplycJxcVRT5V8BXS8NydhhPx9yJWJ/YW4B/Z8IIn4PKklIYyYbjIB9WWTxOApjHmCvTsupugYwOhorFFssQi2WLj5a4j0pgY5G6RSqK606k++TUqzyCInHCI8YcnsGXx5edaZI5M8tfSTGbKe0mKyisib1SoT53EY6MGVet64NO3mwiyySeJk9/epyp9MlLfs4nclm9SvPvDtP1iGW3NN9D7yOfofvJLRPt+Ann/2MmzWNgKQQGX6DF9AkAiHNJh6u1Fya/B6X/reulK/0Ulp/t7uPh6kbmD/qWJhr7fztY43fN+/YbIWtUf8I3Eg93AQs210vc74PhBRJ0jP47Ufx8ZGurjUhoOYNbClcPxFaldeqDe0KA/6AdcG4/RysPBYuw18l9cW7l0oeY+gBN91SN8eV4i4WPb7hJMDvSHqs/wvuRoYXs01/+5wvRY8nUEXtosjvES6Fstn4horB32+ll74NcaLGTlsPeQtQcx0Qj7ynN4Ew4gRwvfEeu/cuEBZixXj/TRHTSeZR3D02PsNdYXGCm/x/ogQL/F2sS2DHkUpomzGvtbGYHnsPuhrHNoulJ3zoCOobmReB/MPXVQdvj3Lh9E8u+9QycdWRoLk77eQWBG6CIKmYKi20/Rse0eqqrrwBUptj5K0L0FdRKJjESYYnpdJW7Gywgnnihqm1E/KIqPIaoaBiFmxoEOlxA2R9JdnUo5f/WSqErafPtw8a3OxSXyoV7OEz+RYycptTm1xUmLidiplOS9Hk2lvHrJtKLadEIcqM6UKO3dTPdTP6PataNAuPwqYIoG6z6QzrIGrwW83lIuxOWKdGTqCKdfRlA1j9ikS911qosm09WfZoaZ+exRFq8zkM6SGBlM62Cyw0xtLpTBSmV1gOkfQkvWVUwgCidhkVEMI9lt4IIBSXrSf5gQ8e1OmKE2UwTbxL9zWo8K3Sc1rp26ngep3vlNbN3fEv3qb9j/i3ez/+d/QeedH6Lzgc/Ss/FLuD2roedeQtuKBT1AXnM72WSUGLiUEgtzElGH9EwRSBFIEUgRSBFIETiOEBjyq1VN430X4Dhy7AhNDUR1QB/5B3TTu+8uLL+HTLZetKpXBHwHIT14omyeCIlwRdnp1M6+FDFuBYBF8CzEQqUi22PbEoHrlUTSbWASdPQlyukcUlDZn4PqlPXDLBBplIhX4kWGKHUS9VcfXcGniehikqQCCg7EM7GwQBxEFKWn0PoYmf2PUSGSbUIkwNuojhz4CKwoXRqfnUXV7AXSNxHn5/A4SIccTQabGWaW5J/7i0izJ8cDEyU5v1GJtXvwv7bilAZVkwkmnEwxzhL4stYV520cLMnQ0sWcUi+eEDvN4EUY+e6aMlZ7IMkFMZXZbmrCHUzIP0pd6+1UbP4PMo9+Drv3r+i5/R3su/1dtN3zEXoe/zzRru9j3esIbT8ujPHriA4TiTeKyoG313/PoCxJZXpJEUgRSBFIEUgRSBE4BhFYw+DvAyy+YSkNx6CVL4RJgTOHZSIy0T66d6ylPpsjyE3ERLSD3j1k6cWJbKMIaOxCotrZZCacKltrJUYgMhkEAYMPT5KGS0xA3ipEcvPqugPcTqUiWm5/koJSt0/1Ph0sqmMfzrfTCiZRahL6xLwOL5TGJX19ORGN70uDaA9V0V78c9eIXnufQtdKz96HCQs7MPnpHP2HmWFm/eXBGRe7hLCGtaeQqT9HTTXyMEtggYRknMfFC8/xYWbJfOVpzEpls4HUKY8i0iZyHWenEc5+GV2ZOdpaZPCE2a+XQC6rGJYKFPOEuE98q/lLn/S1OZ/qHjER+ZzSHEaQDcjp/qq2TuoLW5jU2kz2qW9QePjztN5xvSLhf077vR+gsPXfcb0PAx0UhGFeEgvjKFIEPo4xs0S8nYNFA9IzRSBF4LhAIDUyRSBF4MWOwJoli1hVdnLxasZ9Aqfc99cgTZhybL3EHZsp7t9INpeFTBUu0kf+Pe0EkSKN5pEQHXUZgvpZBJWTiEXAyxFJ3zquiLNVxK0UnvoenXf+Nb13XU/P3e+WvCeR3rvfQ+89ZXmv8tdL3leSu32+3DYy7b7nffRIeu9+v/r7sQN9ujRHh6RLurvvu559D36O7v33ytwuEWOHFXfSu2cdmUwBEj8ZdDjlB4uK/WdI0VWTmzwPslPFZf3goL/12Mh4mwYs8Z74GkcdFbOuxCZfQG+cIw4dLuOSNA5inDl834GRAzk1DRSU8/qGSoQp+u8CRxyY8l5frLzEk2YyVOuThdqolYmdTzJhTzOZx/+Tzjs+QesvtX6PrCC7/w5y8R4sKGh8EZM17kAGyYb0TBFIEUgRSBFIEUgReOERaLzGf3mwiaamxaw+ltn2CwCVaJFhiny6vS2E3TsgK6KEJ5MiSC7CFGX00VETwXaWJVM9F0zR7UMkQF6HU3Q5v3U1tvlrZJ75JpnNXr6h9BuEm78+SL5K+MxXJDf3yX8SPq0+o4ofp76bJc/8B+FmjR3UL/P018k+/TWy0p/VvG6z5i8oyo13QGSzawfZjk1UWhFnDDt8n7KUmkz0z+diU+Q1rKVi6nmqqVFkPla176vkGDldHOASp5xWNIYwJNSKZ0Syw4o5VDf+Nt2503FFI1A/F4UQV2tMDuf/Gaha/hmmjBdUYtjh68tSbvIkOdB9ZYIkSMThH9Xxj+KY6kNFsGUMCoRTEXYq+v0UVXt+Qs+jN7HrzvfSveGfCXofhEyvbAg0vyySnn79Jpsk5XKapgikCKQIpAikCKQIvLAI+C+8J1+wXzvWFx1fWBtfqNkDUSqRmf3k9z9GJr+XMM6IUimSbRlcRlK2TCQJkaSgcgqOKjATgRrEgBg4zGyg0JcLLMY/H25dz5DLthNkewlFpsJMj9LhkicMi5KoTwqj9CmPyZMJo0EytG9Oc1RKKsIecmGBqpqcpB5noVzIQ6cId/degmIsSwPJgU6XNIj2UfJO19xUguoTcGRBPvtHHTiGDpkksyyRslklG+WLq8Rmv4ba8/+czqoz6S5kyGhzlYkikW8INNiw8rCjljpN7ZVZEGC6n7w4NI/WI1T0uz7uZWLb/RQe+gJ77/gs0a47yFgHpmi3I6/NQCQxryKV5xaBVHuKQIpAikCKQIpAisBRQkCsR5qKe4naN1JlXVAoYFE3QaaCMCdybRXq4CSe5Kh7ZgKRf/7ZF0Wi1XAQp4lSxUSdWwk6d5KJpC/OEsShJHMAGatt2BiFSQOXkd1eRKSLAxJHiqtqExFprl7le2qm46pn4ET2xLaT/+QnY53EGSMmOAhfSl2cIr5h1QzIziBW9N+cKzUcJ9co1GYqmELlCb9J5SU30DvnKjpD/8XPbsx6hA9oJ8bRPDxEiXilypihuXTBb/uccHSYYvG5wPSJwz6y226lp/lj9G5fQxy0YYHItlaJ9EgRSBFIEUgR+DVCIHU1ReD4RyDAPxpRFOHueQZHVUJA48J+keoJmAhlkRrCKIPnonEgkiailKGYcLGYDGaWiIfCrJy3JArpRERjEVsNwSgQd20lW9yDCw0sljjNGZdEnUrRV3T4ek+uinj7kr4MOsxBv3g9XiIUpu6rB02YiLibTA+SfNEmk61rxMIJEKvOFeje/xRFv9Ew8F+mdNoM9KseNI2MlE+OOI6JFe2PRAypmUmYrdZUDmcce4d3JLEq1NVLIB+8oYFszhB6P1w9mSmvYdLFHyM85120TbyYbuooCs7YFQWz1kFro4H4x4sCbVrQaHQIHmKpGyy+Tk3955A2U2e1OC1XnODvtDaRJBaaXgo4esDyZGR7ddBDReeD7L//n4l3/ExtkWzIYSabSI8UgRSBFIEUgRSBFIEUgeMDAc9HiXrbiQutmIsodu/CiYAb+ld7Ij2ZycTKO32kD73EPTtV6sX/SkccO2JPQCXeXedinHRApKIXlZVzVhTBbSXfvpnYdSdcVxVqiQkCl4gmZ4gw7BAB62+XReXxpbTc15BxBCL0ZbEwILEnlG8V1VRMOkPErcp309AeXH4vgexWpfo5yfinZpEFIVZRjwUisRaPP+g57jG+em/1QC/r2yHIfK1ZJfnK86k4Ywk1Cz6Hm/9X9ExZRKedRr5QSaSNk+fZ+pCA2Ay/3P55b4tEfv1mzOsqy8AUpVx//dD5Bb7ah+IdqovUqx4ttWFhSC5ToK5tHe2Pfocgvw0LPNkeOo70SBFIEUgRSBFIEUgRSBE4hhFICHdc6FBwuJOcCHVQbMV1PUso0hxOPh1X10DeZRHnwsUFIrXFrkNlR5CQ65J3TlFQJ+LqNM6LiRhZIKKtZlPeRTvIdz5BHOR9DYEnYsppGF7MRLAkqkpOs6HlpPIgL04K/YbApz5mqm0BhThDsWoumUnnEsX+S47gYu/3foJYeQMzI0gQYcRh6F/SHmBmOGFimUoR0BDvilmpfcTAY6jCzORfyX6zkr1mAVltSipirVU8gbDmEqrP+BPqXvo3VFx+I93nLqV7xmvprDiLzngaPVEVPfqkoxAKVb8JU7RZN4MADCQhYAw9MioKI7F1FyvvJKpJPrnA+Rwecy9mhpmJ0Dvh64jVbFJbbW2KcN9Bfu8D+Ge5dUnGpZcUgRSBYxKB1KgUgRSBFIEUgWEIiM6IJ+U7CeMedCEs7qOw72nR5girmUP1lHMpUE0cV5KNHK7tGejtUnte5CiSOrGihDgp9eQLETe1erILlpDZSAS40LODuGMrGWLxs1jEW/PagKgriUiXZsGZJRKDtIUalcGpg1N55Olry6JWJ8UW4DQ41iUyEUFFYysmXkxQcwqBifSZI460cci3EmgGjRrzdJrddyj55XOGS8hjiJkm6mv3LceHeLyEk99JIV98VpjFVOKiKZidTeX032bSme+n7vKbksh3xYUfwc17O71TX0975SW0h6fQ4SbRHecouJBIWHu8vWaEKqpDK4eYcyRCHwlnpw1NHATEBs5KPYfgpSpfHeieCZR3FhKpb664i/yudRpUwJJ/ahwyMC2kCKQIpAikCKQIpAikCAxG4NjJi3CbSHFREe0inj9mo1aiPY/jH7WACVSeeBX5mhOhmFEEPI91bIb9z4ryqL8ilk4ky4uYEKXDRESl1iuLsiLwsUh2j8j2JnJtO6jIOzIiYBYZgWQgdVhREqF6SbHUHsSByjEWR9JrxCJyTrMz5HAqlUVZ9TEvIpDZ2I/JEFdMovKEy4iDiZjIXBw44s437QAAEABJREFUpXmZLQInH8wZ4x1O48p9xBsTewMhZy4oVx93qZPfTsTYaZU8Lh4tE75e8GvIBCxzFtmJr6HilD+l5iUfoH7BZ5nwss9TrTRz4Sews66ne/braJt4Fm2ZE+iIptJbrCaKQlxU1P3lhFIGApUFlS/FoyHlSpWmpCz+04eMChWKphdaN0DchZkf3ddZfdMzRSBFIEUgRSBFIEUgReBYRkD0B1xQISIbYhFkFc+O/Ef3rfdhsSOacjHZua+nJ65CzdC7l+49vxBp7sFEowaTUFwW1M9cDpLIpEhRKEJLDz2d3fSGE+isOInWsIGOzGm0Z05VemqStmdPpS03IL5caj+Fruxpkga6cyfI1krNy4EPTelt8o+UoJ6hyhRz9Ey/Aps2H3NVoHpxOJzIOGpP+iuTpE4VHMThYqzQBVFeMf1QA7xGJcfL6UmrX/A+MTPMSoIHLVPAZfLCW0K3otd53RkBhaBOBPxUwvrLyMz8LTINf0J47lLqL/8ck5r+jdoF/0Tm4r+h85Q/YcfEV7JPa9YRhriwqLslFvkOtYEKNIXmwkZHS3b4pmQ9/APjKofC2+X3q3+nxI/zomx6HjECqYIUgRSBFIEUgRSBFIHnFoHAidkEmUk4VwfFChEhkaHup+ne8iPNvEtSTd0pb6R35itFyiupcJ2077yNuPcpCEU41aN0OumA5JECK2JWAKUKYtPjJlMz53VMvPLzVL7si9S89GaqXvpvVF35pX6p9PmXqezlyi+r/ovq80UqF/wruSv+laqXLyd31m9TCCs0T8yBD5c0KXCbpAXLEgXTqJ57tUhfgwxUpNV8HycbRQTVS3xOOp1yB3c6DLNeil1bhdlenCn6jh3c4Besl9PMXpQkp8ewLIPrfaPJJ4+NFyMOhJkkI1CzCQEuEsfyWRsyU3TcqCPOnIiru4BwxtVUnvZHTLr4r5j20puovvCv6Z72WjqZqmh3RreE0xoYgQu0+VGeUY5RoDT1DyL1lw1Oc3LM4016pAikCKQIpAgcGgJp7xSBFy0CASIvQWW9uPZUyBSJLCdSHcGWHxPvbSaM8wRVM5h49lvpqD9LxDQgt28jPc98n4BdWBBgsWEm8mZOaYRKIGIUSJzimmEsoqXodWbSywgkmYlNZCY3EU66ckAmKj/hSjKSYMIV2CTfvoBgyuVkJ11M1ONo3XQPQW+P9AfDFsSX5Yrmk4FqFyGULbHFFCMR7rmvIjdDZNDJjkD2JQQ5JszUYLkJ8ll1Do0LMUXmoaSPQYehf2b9NRXS4zqfoJjfpd6Gi6VAfegXjtLh5xwsA2rLz0DLVa2B6r0JSkqn0wapiL/6auft8h1VU0rko0isaX28oN4DovmEFbo3UGp+/SSOjHqFxMI5jqVZnwYEkozWPxARN1eUCoeLK9VvGlbZSMXcNzCl6RNk5/05HdnpREEBlytSzGh+TcPgw5fLomZkq9QTS39s0puZgBbMe0J6pAikCKQIpAikCKQIpAgcLwgE+qSeIFcL1ZNFPEXLFLnN+MdAup+ldeP3CAobKaLo96RLqJv/DtqzZ1PTs4/8k9+np/Uh/M8FFoMQJ3IWiIiZecbk3fepiRyZCKnz3AkXRzgXKe/TWKlIVBzjJMSerEnU7vsVFRovqM5sP9G+n7Dn/i9Qofkq6WWswyXzOzLSQ5ylt/pUqk/8LbBTRd8KIm+xxDS3SHJQjctMJNYmA6c6DnzI0qGNpjl6dlLcs4lM7G2K1F4W+abS0TnLOvvSQUqdbPaUOtInCWXy7R/D8F2EOJFsjISHF98X/0y1VsOTV98HrU5JGPMQUmr3GiWCSWoJpFdZ1ftT9Unia7xA4DdilqEYTyRfMZ/qs95K5vTfpTszSdFth7ZHfsRBiKmP6ZMNR7ZW96kd/EgNTM8UgRSBFIEUgRSBFIEUgRccgcCJKwW5GYR1J5N31QkZQoyq0vLw7M/oeOIrinLvIg6qyM58DXXnLaO7/iVEHc/Quf6rBN3rRNvaiEXLYxFn5xl8n1tmhpnhyZcX+g7nHGXpq+pPvA5P8IKgSMZ2UNjy3+y7+1PUtP+S2qAVTKTcGPUw8w2h2kz2ZNjPCeQa36wo+YUUXZXmDGRPiPVFdS2oIVMxQ/VVIAqoy7inmSV9nHRUFPbS8+wvobgJTGReEd7YldKyf4NTDnAM7jM8DxFep8NT61i2OjxGcezwewqLC9o8dCjfrvpooD2KyYhgB5E2A9F+edcJcQ9el1aWSGsQJ+slPcoPn9eXzQyzkeLXsixmw9tJxpAcDtPmLdbGqRhOpeKkV9BTcYqW0JXuMyzpVb7E8slLuZw0yzbDUXD1hDVzk/vQ297fJ82kCKQIvGAIpBOnCKQIpAikCBwcAoELYpGiyWQmz6dXpNpHQS0OCIOAWtdGz4Zv0/v0d0Qvd4n61ZE78TeovfS92PQLKG67i651IuTFp/F6olAEykLxJBFDi2WBkwycZmpX0ZM5JTp9e6Q0ElE0ETHww2JdMsXN9LZ8g+77vsCEjgepDXsTkhYbiH9JlPHDJaKMqgpkn49+ilRLZVdRJHrOK6g65beI/aMjIsQKZ2ucxpsGiYIS1FM57Uwiq9KmwNvrxbeVRX37TjPp9WOSLppbhoTWQ3HvXeT33EFoIsQih6g+EH6liWSZr+vTcaBkAA+NUn9f9lLqH6oyxJts0m3O2xETBSLSYRvW8St2PPIlOvffD57cYgh9DXX4/kHQRe+uX7F303ek5xlMisxiDKc+yHdTTrj1zZtU9l0GbOirGDUx1Q4XTZXoQ4hlqdSnH6HLYuEU4oppyAVM7cgKBh2mvBcZJAX+lF3qEzv5m5tObvI56lGrsVrgpJOK6ZkikCKQIpAikCKQIvDrjsAx738Qi55FVi0yM5+4croioJ7o5IlFIMMwpj7aRddDXyTf8i0C20McTMCmLmTShR+g6tSr2fPsr9i3/mayPU+JGonEiUiZZ1QEYKZzQNBhVir7eYsWJYQvFpmNwi4KmTwmEhl2303H+pvoeeTz1PauI2vdoChp7DTW60jEQGUvUgOxSJgizEaeQpwjrruACWe8hSh7qnpnRNbVX7lYfcD3BUcF5iP7uamqiXEm+xksGlA+fbUIr5MIHO8d/lLZo43B49/DKdJP4P3J4BKD/AAn/500eFEy7HTCyouvLqdmpjElKddbYJpSIv2FIE+kdckWtxJt/i57fvVZekWms9aheTWPaZR0oE8IiplI4xyFbb+g697Psf++v4W228nRTigIDCcXJC4eMieq9/aURRrHOZNJ1cenSgadfg4ne/KhbIu6sYLs1K3hRnYdaoOw0c2W2BfJnnjCCdoUziOIs3iyLm2DZkmzKQIpAikCKQIpAikCKQLHLgKiclmRTSMQ8cxNWkDeKsRzxIYsIlRUOFTUtD7/GPmH/4HuJ1YS9qxLiFFUcym1Z32AGRe+i6h9FzvWfxXXeb/GdBBbIFKUwfSP/sMpij0giNCbJ7ienGuujI+Axnvp2fYt2u7+KwJFt+vjnWSCnDRkJWBm+MNzMU8GfR5nmiskEGn0nK5YrKBLZLv6/D/H1V+MiyvEH9VHc1kypyigV+AHi7VlamYTT5hPT7Fa/XSW23z7MFH3pMbPHUmf06cA1UEPmd2/pOuxr5Hpfla+h8IwwPfxkgzQxcx0HXqaWeKT7+dlcGsg3b7s4hCijAyTaD2ybi/Rzp/Rfs/HyN/7fmr2/5j6SshWTRQ5DQiFhxczA38We4nb1jPTPUT10zfT+qsP6pODr+MKG9FiJvbGQYiZJYIOD4HZQFlVh3cKI3Ox7i+H0Um8dz3ZjmeSvVFB+h1udL3egBh0c6pHRBdTyM1YALmZwjUmkL1II+lxaAikvVMEUgRSBFIEUgRSBF4QBETrArJxLy6cTNUJr6a3aiYxOUThoBgQRDFhLqCS7RQe/hfaHvgYhR3/RRBvJ6qYSHjCG5h60UeonHY2e7Y/QGfH0zjrRaFMxBIlpasblgsUKc4WNUvcRZB/kvy2b1O4/yMU7/kMFbsfoJJuCDU2AAuc6JUpQ9/h+lIlqkZ8PFLf3kijqs6j6vx3wMzXYiLxobwBr0RjTKIh5dNciGXnUDP7SgpuGi4yNXlRf+WGn3EsFqjKwAyzmFjoeclZK7bpFjoe+3tyPffLnILvJTHi2EliEUWlIp/+8YiyFeXULMAkIL2UjmLkcCrHimZb0EsYbyHa82M6H/4kxebryWz+H6riXYRRQIU2S2KkAitEUGkUhC5DGAcQbSUo7MWJpGbCInV776d434203vN+ipu1Sehaj0X7iUVyY5F1TSsDTOIk/vR5n0q9El87mqip77TE7qSPdHp9gRUJ2h6lu+UHVEf7MPkaqbf3UMmI06mxqPujEDryYY583XnUnvgaYpuIISxHjEgrUgRSBFIEUgSOJwRSW1MEft0Q8JSxRJBcDZmpZ8KMyyjoY3uCIpHVirQqsioGlBGJrre92JYf0nrvjbQ//CmC3T8UqduNq55J/dxXM/3kl5PJTBHpC4WjiaGJoIo4iW2SEHBVIVIXBAXM9uMULS9u+Sodd3+C/F2fgCe/SWXv01ThPNfGRJRdKIoVxCCJRXidxpvaYxNdM2SfI44dBZHt9ppzqDh/GeHs14o8K2ItcqYOmKmjk00ioeafMZd4Pb45UkS/YtZFuAkX4r+Yh+8XV8n3UGa7RNChHAQRJhLoPAmWLaHmNYvxtoS2l3jTt+m49/O4PT+U5duwOMITWd/HFMU3+eD9wH8B0hWFT1GeKBVLNgMjj7M8RRWKfo5gP2HhaYq7v0/buk/Q9qu/IXz8P6nMt1Bp3YRO+gGrO5UwM1X6QmLpEkQ4WeA0Z9SxA+veCx5R+RYGJnx3UvHMj+i+95Psu+eDFJ78d6zjXrBdmEi5+LB0+Si9SQ/Kx8IhAunDYlQxSHx9QeVY9itxBWK8XeYtIGOduLZ7aH/gX8jsup1spguPQ0bYWGkE5SOO/ZwOkwFmmWSqDptJzbzfwmrOgjiHU70y9FlWHpqmKQIpAikCKQIpAikCKQLHLAKBt8ycJzqBItYnUXXK6ylkTxGBRdyzi1gE0LlYhVgkx6gT0Zvc2ULFxv8gf+df0SPCFj9zC1HvdixbTUW1iK7XaiJOIu2m6GxgXQSuFYu2Qff9ipDfStf6T4tA/gVdD36C3Lb/UeRzE7lMN2E20JwOmQSxrIucUomyZqarThFnPwUisD4C3FvM0V55IfUXvIOKOb8hojaZQAqCQP3LY2Q9iZAcZib1RYrS5SrnU33G1XRmpuBUCz04EUvn+zvp8JKM0kVFXUVAwYl0e8EhYhkJm11kt3+X9rs+QMfDH4X93yMbP0VoHZgVNczUNZsIlsWLUxprAyCOiccKayeIniS370f0bvh72n/1l7Te/T7Y9B/Udz9ENtuGZQoaq0l1Fq0Kq5qisvQxcDi1Gb0UOrcQ9+5DxuqMkg0AQUhlJk998Rmqdnyf4m98P6wAABAASURBVEOfoLv5HaW1fOrL0N4M8VPqtls2dRIHctoymLAKhGtpcQJN5iVM6p2FRKZ+IvwZ61GUX2O7H6Gn5Z/pueN95LZ/R5uxdo3xp0M98TZ6SWqcrxE+SqKMCH6uQGdQSeaEq6g+8bVE1JEM8J1TSRFIEUgRSBFIEUgRSBE4jhAIzDzJyYgUympXSzjlZWQbf4dum0EUif2IEZkZFohcGUT6iN8kFSLiuZ4Wwme+VYqUNr+ftoc/Q+eGf6Hw1L8RbfkKvVv+k24RuI7Hv0D7Q5+h/c5303bH2+m+6z3w6Jep2X0PdcUdVFovGQqEsYm8iVcFBYqipSqKpIucRU7GQSAbTFQtFukzETQXZ+hyk+ie/DImXryU7KxXEbk69XWyN1LqeweYKe0TVfafoUhiRiULK6mY/Urc3Nexn8maGRF2A83jZISLgyRvmFKSw8wws1I+MAL1MdlZ5bqo73oMnvgarXd8hI7m99Ox7u/o2fJ14v0/wrruhN51UHgU8hJF+X10Od77c3qf+W9tRP5OfPe9dK59P70PraBi64+Z3PM09YocZ7QekYvEOx1oaheHIsMTCGtnyOaQsnHOOZUdAT0UO54kE+/HZCMa5NvQEYUZbS0yVEhPVbyHyrZ7yWz6T3ru+xR7175Lm6F307Pu80RPfSOJ2LvE7vW44mawbYmYbZfG7bjoaVzvI7iOXxHvvo3eJ79F2/2fYF/zUnoeXk5V653UWJtIuGxn4PC2eImEWxzH0hXLJogsR1thIsWpi6g/++0UK05CjaRHikCKwAuEQDptikCKQIpAisARISAmCWaGj1wGImoE06lseAM259UU4gl4khuZw1MlF0MQFwkx/QsIFPXMqqWu8BST9v6Y3GMrCR78LNE9N1C4633k7/kQxQc+Cus+Rbjhn6h69nvU77+XCfkt1NChiKdXKGodRECASbMz8NFlE/XyglLPwmNvgxpj9StagIsdXUWRsumLqLvo/YTTFhFFM6UBTJFvU39zIUgrgw4zw6wkSE+IwyhA7gTqzvx9CpMvIx9XEkQ5nCelGcP/5KG3wYVGbN6iEqH1VwV0tVHQBBbjMqoJY4IwoEYbkrpCC5W7vw8bltNz9wfp+MUyum5fJkL9DjrueIc2IErXvp3uXyym65dvI3/ve3GPfJ5q4VTT9TD1bh+V2nzIW/yjKUJLdoUyQEZoMxAFkK+YSFgxE+dy8iPAmeHwh/JxL3HbNjLavoSyL5T9oWxTZ8KoSFB0BFEGi3NgFSLfBUW9tzCxbT0Vz/yc4rqVdN99A523L6HzF3+abJba7303bfd8nPZ7PkPrPZ+m9d6PKgL/Htqa30b7L/+YjtvfSq8i8pnHVzJx7+1MjHdj2ijFotuBM2SV7PRpoDyJrbFyskTYBrIlS2ehHpv+Ciaf83as6myZq3rZ79dUHSgdwqCUSa8pAikCKQIpAikCKQK/Jggcr24G3nAzw5Me8R48KSI3l7oz3kY09eV0xRUilGEiQVQp8pMBcxIRHgtAg2KJJ+yVolIViqrm4r1UKGpa7YV9VIXdVGaKZLMRQRiCJ32haayTqpIogye2TrVIT4mYicAGIrIWEogUmhimFU02ROyrOBXO+DPqz19GMPF81VXh1aojONkomon8YqzDpN9CTZ3BKVqcrZ3PlJe8g47Jl9JTDImjUKO9JRkR3hAn4xLi61OvWxIoLxPVT/6IUIJSf6otVFQ5k4mpCjqpczuoy29StPcBKvfeTXbXHeQklfvuprrnYaoLaov3iqgXyGpaCzKYZZHLFHD4TY9pY+KJc4AmEOaRBWTq5hCIcJvTINkij/BmaHoU3sY698o/1bqIOI6ETYwnrib9iAgjVb5vqHaS7UqG0Irkwi6qww5q2Etdz1bZvYHszrsINq8m+8TNZDbcTHbDf5DZ+FUyW/6Xql1rqWt7lLre7dTFrVRlevFTGCGhgLM4TvAzkOUmEa5+k6B1zWjzoL2K2mM63FR90vAG6i7+C2zyuTitox+PdwoHxJL0TBFIEUgRSBFIEUgRSBE4fhAIvKn+Y31Punw+kbgGq7mYupf8Bb3TrqQjrhYZ8pHoHkwjEtojAuXJjyeB5hmnRWJSIkMWE2YDkS0T0ZPEXqRVTckQZcc8Dc8BKR+mCU06IU+kf22ZGnomXUzNBddTfc67iCovgKiWwPdLBklBkh78xfuPyJyLK3CTX0bdhe+hY+qltKkcJ0Q0T+wKBLEjEHkMvWqR6VjiCbH4oq8ZEAemviaHvemhMAhjRD0jMpankm4qXU8iOXo1tSLs8i3AMO9HEFA+Yo1y+iTBp9GgiQIzKGbIVc/GKibJ+hjrI6NmRuDz3TuguFdzRpjzBN4wKwmaz0Tg8evmhYHDNDagoDHFRAJtTLLqU20Fqq2XiqBL0kZF2KZyN3URVMqWTD6H99lprG4YTCTZPyrixQk3L34Wnyai+U0RfBcU6RQ7b6+YR3jWHzPxkuuJ6heQpxaPr2kDYPLQj03lYBBI+6QIpAikCKQIpAikCBxLCAwwuz6rTOTQE0sf3A0mnc+E8z9A8YS3sN/NopgQQbErnYj8iUOBOcQRCRTq9WJKfZ0nVGKpmCeeLunGgQ9Tk8NfPa9yKpkFeFIp9USaq6tYRVv2bKzxbdRe/rdUzH4jRBNFDTXKihDkNUojfaRXucM5nXQEGp+bcCmTLv2QIq3X0OnmUBSZDER8TcZZDN4mZfGHZixnVewr+UT9ZBxeHAGRSHNklhBI5I9pHp8SZ7CiJ6qh9Mbgn9vBK/Ait4qOoODIROCjwIGByYhY/fKyi8xU4lAbomQMg46Y3vbtFLu3KcIcYVpXvx5+XWLnPfH6B8ugoRgm+8zbGYu661MAJ1G1PJE9Gm8uqz6ynQyqxP9yi8tpS5SLiWRoFAYU/QD8HH26Te6pzrS2goJAEXeddLjZdE+9murLbqDynD8lyp2MPgwhJ/AyIuRWcrpPySB9fTVpkiKQIpAikCJwjCKQmpUikCKQIBAk10EXJ8Iciw0FsUiZIttB3cXUn/9ushe+l9bKc+nKVxGLBIaeSIn7+McaYgtEKENfg7LEsRo8CwtdQsRIwrsOC6VTpAsv/XOq4IxiiOiVYZ7giehF0laMYtoLdbTbmUSn/D61V3yCunl/CVUvIbb6RIN/3MA0l/P2JjsAx8Efvm+s7iWxhER6G6sJ6i5hyvnvo+KcpXTUX0SnTcD/D5bONEb+md+R+OefNQZFj5G9/aI+coEBcXiSbIZSI/a+ipT6Yf6574RdhsLKg+c7IU3yxbkYc0UCiwik36k+r3m74nr2V5xB8bTXkzvx5arNSK8fH2LCweJYvWM8fnlXQ1exjqLqTNF666PBJsy9Pv+oShQ44iBCg8H5WoQvOBFdL1hR+aLq1E/2BbIzDCNCjbEg1rgIn5ppXj/e21A0LXtIJB2GQWg4+ejniDR3b5SlzU2ma+qV5C68nslNN5Kb8To1TyfIS5/0OI1zhKBU1iiJJLFEqTwkPVIEUgRSBFIEUgRSBFIEjgMERhDuxGYzJV4Qea7EZU+h9tS3MOHST+FO/gNaa08QARXpESkKCzEmAi4up74iZCJ2TvVSMHD6ssTXe4mVj9UqXiYSF5OQPZVNBFPhbOJCSHdcS3utiHXjH1Db9HHqX3ID4ZRXUsxOw4mUSwUmMhd4wqexJKSsZDNHdBixCKHzv0WeOZXMvLdRf9knyMy9jo7qc/A/VRcr+lvaQ5Qeu3DmvfFzezi9+PzBGuFIuKPTOM3rNTkT1ZTOwIMqVQWR+s54At25Bnpmvp7g3PdRt+DvmXTJX5Od0kQcV2DOlfRImcc4Urli1oVMuvgDFM5+K20zX0J7bho9rhpXLKqv1k99lSFW6ueNNSci0phvU4t0OL8mah/qjYwaWtFfykhRRuNCaRWSJbuISDYBxSw9iujvr5xM56zXElzyfmqa/pbKU/+cYtU8ik73GhLzIp9MUXQfQU92JiV80NajJNY/Z5pJEUgRSBFIEUgRSBFIETiWERCLGds8UxQzUFTUuXoR3iZqL3ovNRd/ivzJf0Zr9fl0ucn0RiJHscM8QXQxlnAhJ8UlcSJsXsSPKYsTuUuInsK9scYX8rV0xRNEChvIT3sdufk3UN/0GerPfz+ZGa+GcKa4YI6soq1BGGKKfCZRVRFuM8OsJBzGYVYaa0LDTHlyYptZxKvBKggmXUrlBW+n/vIbCM54F62KyrbmtOmIJ8n3DC6KIXbyX91dSRAOich3xhKnAU4TS4XTxiWOHHlh0umEReYEdteeS+fc1xNe+B4qL7+Ruks/TsUZ7yCc/DLZd4o0VwmJCCcd0pTgEAjkwDIEuROpPOFqJp57vSLIK6i87B+Iz3gf+2f+Jvur5tPOHAqFeixfSSzSHmveOC5Za9IsNZiZHEKH114ST8K9lHqW6gbyiGoHFIOQXgspRFV0Uc++sIG2qa+mcOZSai/+ApMu+wx1p70Vqz47sT3rRMoD4RBqPonz86JyIqojlA0ZTATciwrpmSKQInDUEUgVpgikCKQIpAg8Fwh4RjNCr5lhVhJEeAKyIpM5iKuIwpP00f81THzJB6hbcCPhRcvoPfkNtE08j87cHHqCCeRdFcWoUmQrSz4KREpNqUSR6R6VuxXl7C1W0VOcTFdwCj21F1A4SaTyvHcq4vnxJKKdmfdnxPVXUrTZxC4nsZKQw2STeXbsGbHzLhhQFg7hKI/pS/t0mRkWSOR3TnzS4moKwakwaSE1p7+XCZfdRGXTjbiXvJPek66R7xfSWXUi7cFUOplEF3VKK+lVZDqKjaKIbF4R83yUEQ4ZoqIJm5BubTS64hq6XS0dwTS6axronnQ5hblvxs5+JxUi15MWfIbJl3yMqoYl2LQ3EFWcqQ8B6sEz40QcgclWwWCBQ1lJIAkJhFsk26N4JnHucjKz30S19E5a8GlqXyofLv0onPUOek74bbomX0h39en0ZmbSG09QJLqOHpHlfJyjoPXKxwH5GOWNovzKJ/6E8iOr+mr5UE+Hm0xnMIPOipNprz+b7tlXiGD/HuEl76P2ZZ9lwks/S/257yc387dxudOJ3RSc5Yj94zWWkR+B7IbED/kCjvJhhtp0IT1SBFIEUgRSBFIEUgRe9Ai8yBwMDsYfH210lMiOiTw6ka1YEedg4svJnLqMCRd+ivoFX1AUeAU278MUTruO9hMW0THzajqmv44upZ0zF9E24/W0z3wTPXP/CHfGe8me/ylqrvg7qq/8PLUXf5TsvLfD9IUilY0i2lUJ3wpdQWTfEWv+OLky6PDmhyp72waLqg76HDyunHca7cWbEOLJnjghFmWJrQ6xVsLpb6Bq3juYcNGNTGj6B6quWEF48ccpnvMeek9/m4j4H9E++3fk85vomPE6umb9Bp0zJTMks95M15zf16cEf4w7889x5/4VFZd/hqor/4maBSuovfCvqTpjKeGctxBOeDXF8AyR3Ek4YR/uoSqPAAAQAElEQVTEMaGiwchUp6ivU8YhHMz124zq0OFrcBmCQDkX4yPxsdXiHxFi0isJTnwLufPeTVXTp6hf8K9UX/GvsmMl4UWfwc57P/H8v6Bw+v+j56Q/oHvuWyS/R+cJb0zWtuvEq+nSJqnjlDfSdcafUDhnGfH5HyJz6XJqXvpF6q/4N+ov/mfqz/solae8g9yUqwkyZxPHE0WjI1lXlJWxxLQxUOTaBVrnQHkvpjx9h2zXiL5CmrzYEdi4hiULtP560ZkZC5asYM3GQU6P1+67rlmCLVhBMuxg+vsx/aL5Na/ZEtb015UzG1mxwNs2Wlu5z9FMS/MtGWnIoElKfcy8XaPJaLaWfByh1+MmPeX6NUuE/4oExUHzPY/ZjStYMOo6PLc2bFyxADOjjMPYs5WwNBuKvb9vnz/kvA0LeGGWaiNrlpTwMvMYLGDBijWl197YwD0nrRvXLEHTPye6S0pfSKxLFgy9ll7/I+9V1Q9alwXqMOR+TF5bfr0Gy6D3ivHatcIrxtI/1Mi01IdA0JeOm5iVF0Yk1Cmv6KlCkwRxBS6cg6u6kOCEq8md/SfUvOSjTLr0H5ksmXLZF5h8yT8w+dIVTLnsM0y5/FNMuPivqZ7/LipO+wOCqa/V2EultJEgmkFYyJGJivjfZg5dKJIb4jSfTggMn7pxrT36HcwMvQvLzgyIeIfyO4wny54GrOpSgrqFVJ/wFiacvphJIp9TLvoY0y77FJOabmLSZX/HpIuXJxhMvuzvmXj554TPJ5l00Uepm/8eauf9KblZb4S6VxILxyg8VYRzMkFRBF8R8tiTZctDIGHgMBNpDVVnsewyNXhRMuj0ZpeKvk14EmA4wigkiKpx8VQITyRQ5DyYcBk267WYNkyZM95F5fz3U3P+h6m/5BNMvPTT8sOL92cFEy9bzoRL/46JF3+Kidow1J/xbupP/Quyis4z5WWENedj2XkUba4i8hM0YwBWwGmzEGvTIINJjyNG4EWmQH8krlvEquYBt5pXLWPRdSso/bEYr700buOGdTRdezUNGrViTH2l/qNfV/Gd4UR3463cMsi20ccdndqNa1Zo49HIsudpPrEmbNEqFq92rFx4dHw4PrVs5FYtclNTE6tG3AAH75G/bxtFcg5+xPHYU6/HBY0sGvyCpZnmZYu47gVh/1q7G1exnl+PY6z3iDVL9N4xaF2aVy1iyP3Ysp4x31rGaR9X/6/HEhyyl2JBI8c4VXlRMvIUyYOi6n0PiTmRKBUjqRIRjV0NMZOJg+nE2Zm43Gziitm47ImYNUrminxNIbJailapviGBrmYRTrqiICQii3mynUggUm+K6ooXxiUxnv/DZJuJMCJbwclGiRVxngQHvaoJFb2V726K8JihLjPUpo1IcCIucxJx9uREnFIyJ0AwW32ETzyNKJ4orTmQPkuIdVH4iZyGBfkek1VkOxvnCCUMPixWyYtTapIDn554Bxaog8TJVp8EGicC7L/QGElXoinOCnttKhQ9N1ev+WdqzCyczSE2rV0gfwKlPp/ICUQ2g4haYq0dyA7n8L9b7uRL6HpVk9f4WKK1DEpipn6kR4rAYARaWK+/AouXt+B0D3lpWd4EzetpSbqN1+47bRRhgmuvblDhYPqr24izicWLm0YQro233oIakEUjRhzVCkXpGhctY921q/Huj627gaVrXT9eqxer9+LV/WXnVjIuf9Z8CxKy3fJrTraF3ZqbWNa8mBtuvpamVTcedNTYb1T8/VqWZB2k7sV9ll5fTf2vV38ftrBaN23zsptG+YToxY3G8+qdXrMHfI9QdFr7DpqWr6YleR/Vmvj3hVXf6V8TH5TQ7nrQ+4RTfuC9Ysz2g9D/vGJxHE0m2jXSWh9FVmCVSJxIlGxYh1DlvmHqaBLf10TeAmJClQMRxCBCxM2JVDoCETzztNq0qKJf5rKqy0hMfXwXExlTX4swDTQTOXOxtEn8dBnNZ74PqlM/jtbhdcVSVhZfVnHYaaa5k6YQM9kskcEyRmX5hsugBolDPfGH8318RhbrTpafTv1jtccqanNBpJYiTqkJF3NG4ALhF0oC9Q9AZdVojIGuDDrMVCcccRXqp/mHtfuuZoaZ+azEYx8r9WeAaT1MVzPDH6b5veDrZKr5H8h2Rdko4i9SjtbDfL36oVovpnXyoy2WPl+v9gASzUhPrLKvVhbkKcSU/yDpfUB16ZkiMBiBhVwvprJumd+YG2ZG4zJx3NXlPwTjtUtXEoWezzzPt0U1x9an/gc4z7rGE66BP1DQR+SvOWvoiOSRlYGP1BcsGO0RmDHah2rrK52F33SsXdrYV34OE/3h9mR7/mpPthPQDjzZmL6uYYktEEH1UU9L1s4W6OP90kcTJZ3J+FJb8siF/nAPeWRkeHtp1MBV/ZcsKI03UzpE/0HMP6DpgLk131mlG+4aFjZczbVNzdxy62AHDjiMVYtkj7epTxatW8zq6wdtdY7U9uHYSN8Q7BLThmI/8lGsA92HZeyUenyF6/APdxL1Iy6NnJXsPlv06ig3NrBw6Vq9z5dfs+il4z+tGYSP9PcHwMt+KC2trb+HNIY+W4Tngv7OqkvuMd/eJ7p/y49/JVHXZkprkXy6UO4/FJd+dX6ORJ/6Dfa735Y+mwfb2zdtf6K+CxIdfTUql/wYbazmSfoq7Z9P+aTuQDb26R2RjPEekUSntWlculCf8vmBDSy8frkCBevY0Hc7tyiy0XTWgd9fxmw/CP2kx6gIeH40pMETokBEKewTG9I6UPD99FpQhdObqyNQwQJT3qs01ZdlcNmpvnS6WORLUirpKmbmEuIWSlegCj9eyaDTzKTfBtU8T9kBs/snDLwtScnb4yUpHODiZHeMJ6gkxHN4N6cKL2odxkRdKAovicV2BZH6Hdrp12m0EcPxN212EPknSTXCbyIU5SYRU8XAaVYqx2LUXga1KOv98AKlXn4tvZRK6DAbyKuYnikCAwhsWDfyo87yXwnfa7x2/8dgsQiT7+tlvP6+z2jS6AnXoMdKPJHnWq4e8jdKfywbF7GqWX/l+3Q0Ny9jUWP5Wcjx2vsGDU8WLmXl0nHI7/Axh1PesAJPtpv1p/isxvHmOzhfbrmuURHiPmOaV7Gs/3Gg8vhSm3/k4rqbBn/4fzDty4R1aXxyHaI/qeHA85fax76uocS3PVFu4Oprm2g+3EitbLuxn6x7347Edj/e32cl60diV6pff9N1Q7BfNAL75lJHXYfep6rQuf6mG0v4DnRT7VhnA0tvXs78WxbRqPf0ZIMllt/H6foGetvH830dN15X7tOc3DMrlgzyd9l12sj1qTuMZMQ90f/6LCkb6vfB2Fsa558XX3Ddem5oWUvp5XpwY4fOV9I1wsb+tSu1j7iO9x7RdBZD3qoa5jGfZta3eE0b0duiyrfSvznQpkJL5xsl47Wry5j61Z6eoyIQeEI2WLQ1xUSkyuLLngMO7mNmmJmaFDVVoyddsSKgsaKicRT11Zfn831KRHKwDg0rd0hST7ZxoToG+LzUq95IdMsePxYNiqOYYrGo+rhffJua1OxGkXiUunI/TaeBpTki6SvXD029b2Yme4aeZp5I+rqSnT5XsmVgfFInIhvFRQSs6Hax3x6GHYPHlpsijRgs5T5xXPIrFjax8l4Gt5XK3j8vJXvKOn1qZpiZz0ocsaLZTnaiKLQq8Gvh+tdDxQSnmLLepM8oF3VL/PNNPu/1IPLutJny4uvL4tTBiy/HsUvG+XIc+3kGyr5uuPgxqbzIEFB06Lplzfqkc/AjJbCq/Ad3vHbB4SOUi6/xhEmFg+ivXgc4GxLCVX6ON3mcJHkufKD7xhUiKCKryxUdLt+fLYrQy2L847/jtQ9oekFywnUZPrK9vKlEcoYSpaE2HZwvzTTPX973MbajZbnCn819jwOt+Q6raGIAqxZuOGudKEDfPGv62lvKr/th7fq0YmXf+0U/1oP1J2rGmD9pH+eS2LCY8u3TsPQGFjNo0zXG8OGPlHjfm8v37ZHantg1BnaJXc2sO+uGAez9fdi8jJvW+ADz2PdpMhyNpzx+UHS61Hjga4M2h2u11i2rWT5/HcsWNeLJ90BUeiEHs25c2/eab1EkVnbfwuqSLy0qH3j2IS0LV7bgb4lkLfq/iNCMbvKSLt0//vXZNGRNh/t9MPbC+psW0LhoHdfeLKwaymYczNjh8/mxR3jfehWHJKVHgVYtK29yNFgbxIFAwXjt6p+eh4VAmTFqsMNHN53Fol0RsQhYTEEkKI8LWkX7YjUXJaq1gvoCQQ9OEVEXREqLGpOlaEUijZU2lQsidb2SbmJfryGmhkhpnHBrR6T5HLHmgTiMFd3uUikmDjRX2Ks0aQUnO0RaY40NMF1jzSkhUF6nf47a1EcvKkTYzBWI9A9tBALZiGz0ebQpKHUpJjq9D5HGFUU+nWw09ScWgXbSqfHe3oL8jOSz+UcritIqchhpjiI9xFJmgfevW9jI30hjNU4qZJ/XIRuth2IgvxSiNk2L5nAuIJbtsSYw6cb1Cjd5J9cSj4I8/kuSzrqkpIj/xMFihxN5RfoLapdm/DPkcWD4LyTGFlPQHMU4j/9NbyfM4rBI0c8UFeRvRCwsiupOcqgsW7SHwfkvUarOpMOZ5pFPpvFOdXkZ5P01aSLUPSEsTfMgc536I51OemJvHw5LnAyQi8RqM/nv1znWfaHROI0x+Y4/1O4kJr0yQjNERFp3h3ygW57GwjjCCW/n5/RjcMk1vbzIEGhZT3PTcq5f2P8XjBLp6YvMjNeOj1A20f9J6bj9x8av4eryYyUbBz0XPmzM4htYOtjehdcnf/TXlaPy47UPU/d8Fj0xWSnbl65dzeLmZeN/0e0gfFl8zdK+j7Ehwa/PodIzoYOxamChx5fSkbQ36ROE/qVvGNKOjo0rlrBA79NmpveYBVx3iyqHnQeaf1i3UYr6SN8/+MoqFiX6/RyKsqpnedOl7EGfJd/77luNOhLbE2yGYN8wAhto4tqrFw5gv/AaFjPoGDJeazP8PlXX+dpp9MOv8qGcDQ0LWbrSP0oi8r16OfRvNjzhH2/dvO19MyeRWJWv7/MlKR+KJcP7DtKlpgb5rQ8u6H99qm643+OvVTOr1s2nqal5xCNH448V/x8F58O/b+XAaGd5o1tu27iBdbpHSu+NIzcGLtnYlDeX47VL6Zj61X7I56/HAFEp76jp4kmMEw2KQcViJqLY8zQ9z/yUvMR6nhARyksKau4iaHsS27+BoOMBsvvuICpsFMHqVFtIMcjiiWxcfIrubT+lY+sPKOYfJxB5jC0kJI91riPqeBhcp+iTIxO0E3Q9QrHrcbXvIWh/CvY/TNj2EJl9jxD2tFIIMsQiuaEIcqw3RScJ6NaY9XQ88z26d/1M+rZgYV7EUzbImlhkudD6AN1Pf5f8vtsIimoXuRWlFInbS9x1P5n2h6jY26I5H6EQbyOybsKoi7i4E9SWadNHn23roziXhAAAEABJREFUKPZuIhIRRfNnRUZz8Q7i1ntoe/pWbO/9wD4KmRhPksP8s8Lnfpzsp20DubanCYvbcNpUxCKcRTP1LxD0bpGOh3DFvfIvEOluJ25vIW57BNf+CNm9m7C2Byj2CAu2Yh2PKL9PxDSDCeewZxvxlh/R8+wagp51ZIVHaJVYtht6H4XOhwitl9hySCmu7VH1k91WlC9GRIgjILQe6H6Cju0/pWvLD7GujeTlS15rlaGDMNNN1LOJ7mfW0LPtR7jCM4TCORCJjrURglj+6PRRcTIgth3E3bL3CaLOLWow1Ro5t5+4cwNR231Yu0T42f67cXvvoxg9S0b+ZFqFZef9FLp1L0Sax9uW6O+bQ+sqhen5YkRAxO+mgc822bjmO6wa7OdY7b5v02DSpoFj9VfzmGfyHO86Nqy5lVsYplcDG+bNVzD7RlYMsdd/6U5/VOc1MF67VBwj50JWKiI6EJEdadaR+tI/vj+MvpE1t95Cc99USXvzLdzaj+XQdv8rKo3L0Mf3eud2Xm7mhvnl0X1KjiTxjwwdSN0hfHmybIL/RKSZptLmb80SjsT2BJshNgzDJpm0WeRvDWV4S6+b0vz94/uxFQleM3CfJsMP5yK/zBawZJDeATXNJI8vqM+R+D6gr5zzfvZ7yQr/DFC5adTU9x+Myzh+H5S9+rRBke21Nw/dWDzn9+io/o1S2XiW7rxVLFpS9lv3y03L9Frr+26LfPTrNuR9q2WQnvHax9M/SFWaHYpAYJ686A3MR2p9k/M1IpKFDV+m/efXs/f+v2bvPZ9m9+1/RWHL/2HWRab7afY+dAM77ljG3l98itbbP0Pbz95Lx7q/VwC0k1y8m94nv8m+n3+Evc2fZ98dN7HvF+8mevZ7IolthNE29j387+x86BuExXbpdMS9G9l19xfY+fQPRRJ/RcedH2HX7R9k251/zbZfvZtt974Xt38tWQtEEDPSAxkRtM7Hv8j+n3+Arjs/R9svPqa5PkncLqIpMhj2PEbvA5r7tr+m854V7Lv9bzTHCujaQDYjor/nAXb94iZ23fEB9tz5btplY+vP30X+2f/BMl3kN/+IXXcuS9r33XE9nT95J10P/i0mEhj3PMOeh74s/z9Cz12fZU/z+9j9wCfJet3Cs33rT9gr+7tu+yA7mz/ArrXvZ/cd76Gw85cEBt6LMP80bQ/+A/t+/H56nvhfqtlJcfsv2frzT7P3ZzfQ8ZNl7LnjHTzTfAM7132VaPvP2CI/u3bdR028j+iJm9nxC+m+6zPsuufzbLvt4/RuvkXEtg1sO/vXf4Xdv/wkaLNANi+iewfP3nWTNkC/UHuBOPYkOCYb7KNt+w/YevsnaLvtI7TJ3h23vw975sdUFXrIxnvp3PhN2fQButZ+ks7bPyy976V318+lJ9L6BRIj8L9AAiLxvuywnqd49h7hu+GbhMEe4jAgznew4/5v0PqzD2m9lrF77bvYdvuH2NL8DxR2/ZKeR/6F3T/9CLuFQesP3685P0z3dv0l1MaJIK95YknAr9vxa+Gvok/Lm8Rh9bG0mWmdTR/bim43LVfUWwiM0+4jgaWfA1Rff47T33cZWxpQEJZli8T0rr2avhjcwJAkitis9sbEVrM+e+l7LGG89gFNz3luzRLD9Af4gBMtXMnqxfJl2POt/f2P1Jfy+EbZIZz8L1bduF5RwvIE5fb+tW9kcLtfW90ZLBo8Xjg3lccfRDoWBmWCvLz/kRaH0/u4a1lOE80isxv7ZxhNz/AvTTYu03tW3+bviG0vYzPY98HYJZYJifKz1MK3cZF/3fRtEsvjFx3gPk3Gj7yM5ueQXn16Vw3Sa+ZfA8uEWOk1cMS+D5mwVGju/1J1I7ewGHleaui7Jmsx+F6/5UYaZZeZld5PNEZB5r7eQ5NDsrdhKSXOvSLZ6BzS2KHTHt2S7LrBf7yxqu/ZemvE3w4s7vtuS9+6+cd/zEqvx0a9xzWXcRmvfTz9pMeBEAhiF4vAgjmUGoHroLD5u+x/6ItEEyYybcF7mHHhEjW2s/WRr+G6HoP2pyjuWseEKVOoP/fVVJ67kGy2i64nv0/Qu5loVzP77v9CEjWdfulbmHnZ75Ip7pHOL0P3BvI9T5LfcS/11oNmxEeMC51PkNl7N7UV7RQ7H6OnfT0TTpjLtPmvon7OmYRbf4Xb9H940hUFOUzR056nv0/bI/9Grq6eqQv+iElnvozuretoffKnIv2b6HjsP2ht+W8q55zF1Je+k4mnXUJh54/Z3/I9scJn6d17L1HXU9Sf0Ej9/DdROe+VuI6NRE/9j9o30b3/AaJoO5NPfRn1Z7yBbE0B1/J13O6fi8x+ibanvkvtSacz7eXXU3vCRSK7P6Xtyf8hDp6lsOdnsnE7VfOamHTObzNpznkU99xH587bCOICWYqy5Zc4Rf+rFP3van+SmALUzmTi/Jcy4bRT6HK7yE09gZnzrmH6yZfQ0/EYoT4FqK3cTXHrj9l37z9gYQ9Tm/6A2Rf+AXW9e2h9/J8UUb5H8izBtnuo2v4Tep/9CbBfmD9KVpH22ppJIAsUbCekk2hfM60PLCeMt2mt38zUC/9Y0eUOdq7/Di5+iu5nhdkD/0RVTUD9K5ZRffHv6pOBx2nTPWLdm5M1dLrGuoO0G8GsiBM5jnu2wJ77qct2EDiRbWeQqWDSaZfpvnmtbK/QJyN5pgijafPfQFXdJLpbf0VQ083Us17NxNMuIFKUv/WBLxH0PCW9Dn+/+uf45UB6vugQaGDpzatZvrip37OmxctpWbu0j+yO1b5xlMc+xurfP8WYmdKjAU36uH4E3da4haxsWc0gc/H2rm5ZyUK1ouvY7UmnY+aycKV8YRU+MjbSqPF8HTliaM3Q8R6nm68B0dK+bkPbaVrOzdcP/CJMw9KbtSFo6uvbVPoVl5XXMJ91/b+80Nd4GMkabhJBblp+c9+X3wap6CMXzbfcygDlHtR+gGxT03JW31y6b4/c9qHYjMSuZMS1eu2U78Vk/v7XzdDxvrfXMXCf+ppDkr7O0utaktdrU1+NTwbrPnLfvcayaD59ElOey88z+B6BBm2Qy63lMTD/hpvxG3lfk+DS//r0NUPlUO31/ZezLHkcy+dXlxeAo32PckjHwpWldSkPalq8mpb+59qF4xG+b42tvzxrmg5HIHAiSc4cQeTUZsQiUO2P30bN5NOYetE7qZh+NcGJb2LS5X/FlIZrcEElnflnlRq5k5sIT3w5FSecTpDNkStIh6K/+55aQ1hdxYRL/pDsCb8puZaJ8xYR9eyjsO9RkfYtuEI72Vkn4rKTRPpiIv+Iihk19Q0iXdvI5+rInf5b0v0aKmecS5DJEuUqidUn8DFUb+cTa6iaciITL7oOZr+FCkVnJl/wZ2RmnELv7nto23Q7tSdfSt05f4Kb/nqqTv89KmedSrTnDujZQaHzKdk5iapTXkv2pCvJzZhHJlctwhwidort30n1pJeQE+GuOOGVuAlziCqF0d576N7yCyY1vJLac96JTf9Nas/+Y+rqp5Pf9Sus5wno2EJ2wlwy836DqjkvxSadpqh2lSK9vcKunbh3PXue+K5sPRN30sWyZTsomlxZ30jtab+leRqImUjF3FeSPe1NZKeeS8++XYQ1M8nW1dC66QfkMtUix0vIzX4DuRN/k8rTF0HnfgodT8m/zRT16UGmMkvXtl8J700UO0SOK6oTHbFQdxZojm46n/gJ2aiXyZf8CZnT/pjw1D9iymXvpLJxAXFhN22P/h+5yVOoO/9dZGcs0ibjzdSe8nKKrU9pw7LNc2ycCylYTCFAxN3ka4/m2042DMlOngVxFRZr1nAa1bNeS2ZOk/pWE0w6nYrTrqXq5NepT46oo5eKGS8lc+pvUXHO26k4+QpibRKKEq/Vc3aT1WCkx4sQgYaF/c+C+gjj2pVLaRjs5gHbG1i6du0ohGkcfYN1J/mFrHSD9IhwrR1RLhNqDZA9K9c63f8l8fYuHGzweO1SceDT++To/zt54I79LQtXyo5RBgyt9z6OprdU7/rG+zFrSz+/UNLfoPYD+qo2RYT7hvb1X8paNzpWa6+fx603rir1K18H61+rdU+wL49vYGHfM8JO67EyscvPWV4rnx/m05DxaLzfUDDKURo7xNdBvTwOztvTV5eU+x0tjfX36mBZq/4D90GD5l7bd4+s7fsFGj/u4G1nEDYjsSvrUrpW6691SOZn0DFovLdz6H2qcRrT71LfsIXJBqyvcMBE96jWZa3Ge71ehupuGNv3ZI3KOKDD2zJGWZ/ElOfy8zQk48v3iCh38pOEwmCIM7JxMC4NmiY5/VzD7hm924x/nw22r6S7dO80jO2r34ALpyGmjVY3zCfGPErzD9XpB6he6+LXw8tadeh3O2mW732YlNqXMnC/qsOY94vahdPSsfT7LqmMQCDwjzeECYsRyTQnsraRzp5nFRV+BVSeRH7rXXQ+9g3crvvJVOWwigy0tVBR3E/bhp+x757l7P/lF+jc3abuV1G07iRyXDv9XLL1lxPHUym6OggnYEEkmtRBft8mTOQsqJkOloOoQO8+kbcwi1VOwrXuo0pEr6fl/+i895/Zf//NdNdMhDmXi8hVSropdm6ip3snuTnnQM25ioBOxtkpZBqvpWqmyOAuRYNdJ9Unvo58xVx6HGAzyNhkeoMeXLSLoP1Zcr3b2fvIrbTe9Ul23/OPdEdZsie8lLhYIOx8kqDjCTof/watd8jPpx+lOPdciiLGQdBN7eyriO00itqsOPlXyE2k4PJEXdugazdF4bjzvn+mvflT7F33DXonTqdy+uVEliX/7A+J2/eQO/M1uGlnkunagnXvochEXBxS3LsVWUK26gTkIPT2iuDupKJmAlFvG/G+jVRMO4Ng4gWafwoxtbj6GtHoSsJ8rz4haKG3qoKKeS+nt3MX8dY1FNtbyNRNFcb1ItiB9Bf04cI2unevp2LyKWQnCrdoGlFQTc2sq5l26psp9u6lo+NJfQrwWlzNeUSugqJNJFeh+RQil6kQGv5f6DGOwb+AiToV5d+IhZO1QTgHF1RiIuWRy4BliNr30tmxi8qJJwGzcVrVQuszwrabyvqzZEM9eZtERWaO9BmFuFv9TMqVpGeKQIrA8YXAxhUMfOHRsMZFKKhM/8fcz4M3G1d8h7OuX/g8zHSUp3iBsDtu8TrK8KfqUgSOFgKKR4LFsUhNINJWFFHcRSbugroJxCJH7bseoXPzd2m//1/o2vg9MiKo4Z6nsepZIn8zCPc+IWlh2nmvo+7C6yj0VpFRlDJXP02KJyRELLBOOtq2kw8DMpUhxa6dZHOTCKsVbUZEP9pH3LaTTPVkyFYQd23H8/Du1t10d3SIq5/HlJe8h5rJF8umnAhzFfS2kom6CDPTcfEERco7FKXfJJL8OGGhnZ7O7SAGGOQmK6mhwpmivJvpbt9MtnomFIoUFeUO62cQhBX0bl9HRaGTWRf/kaKqb37SggkAABAASURBVKDQtY/e4n4Rv0ra97aL8EVUny4SevoSeruqRQQDgtxUtecJY+2oRbJ7RLJrhJvr0oagp0huylxyca8izD+nui7LjEvfSzj9CoL2x+jc9H2m+F9iERkOOzZK/x6Kmi+jjYjC8+RbnyCoqsMqRJDJyPa9kN9Npm46hZ4e4vxebKIwFjkOtXKBayfe/Qw9WflZVwV7tI42m/Ck3yKorCOvtXPtG8lNmIHL1IrgOmIR5ijqhu5uKipF2sOc6gN9UtFGoWM9+WiHiPE2KtQn9O3ZUJhnhO8+unZvJqMIu98gxUGAGYTCWKjgLMQpuh7t13w1WtPKU0TS/WYr0majKGvzIv+bcVq/3CS1hRNwbh9R22ME2U7CupOI5HPG7aa3/XHpriRXOZX0SBFIETh0BI6JEYra3bx8MU2DjBn6Mfeghuco27B05chPQJ6juY6q2hcIu+MWr6MKfqosReDoIRA4TxZFuPMhIkKOMDtNFFgEXEQn49qYfPbLmXnhqxUVnUSu1kcbncjsPph+AdUX/CW1572NrsxUuruKEEwiqKgjZzUUWltweLK0G7fj5/Ruvov66WcT5GaJK7cS5oxMVvOxi/z+28m3bRIZnQ+9++nqbCWY+zqmvPRDTL3yo0y89KOKWi+S17WYrNREBBUTKWYKioZvhuI2kb1naNtwM8/c9bcibk/gamehVlzno3jilundRtem/6K3ez+1Jyjy3tNJR2+O8JTfYuKF76H2jN+lvbuGqJjTHCGFti2apoa6cxYzY8EnmSqZdNa7CapeQqamjkKhQ6TxEcw6sOgxOjZ+HevMU+Wj4+27iSxLThHiiZd8jMqTr6a9LQuFem1uWul8+laK+5+lK8yy/bF76N7xrPzuJVLEPqYdV2ijt2s/2cknQm4SRg9OmxB6C1qDuQQ1s4mydfS0bcOKTxHaVgo7f0brk7eTm3Euueo5dLa2UlE7m2DCfKqmnyOuvoWwu1cR5UbZVimdMUVFlRFhN62Za30W690iXTvpffZ/ePa2D9O2Vfoqpwm/SvLd8jXaSRBspmfbd+nauo7qmZeREZkPe0Sei7tABDkoCDe3n7jnWbpkc3ZSNUGoTwvYT5BpFaadItdFehTBz4nwZytPwAmrsNBN755nCWsmEtTWYOyj+OyPNdfdVJ5wBpmqSbh8iyzWvRcYmkySnikCKQLHCwKewJUfB/Cfgo34mPt4ceQFsDPF7nBAX4j/DfCVx+GHGofj7XEy5tfazCAIQggVoVRiojk26Wwy0y6kY/0PFdX+Al2PfZ3dj/yI7kIlldPn09u9m7auXjL1Pjo9l3DyhWQmNLJn00MU254gnDiHzImX0vbUI+y/9+/ovf/T7PrVl4ky06k59Xeh5iwqppxBT3s7nQ9/ka71n6Ltvm8QZ2dTq3E9bbtFZmOqJ54JJjLmZlJ0UxXZzuAPc7EirkUCtQczL6H96TvpffgztD74CfxzzVUTRBBrTqJu+iuJq2ew68Fb6Fr3aXY/8Bl2tdxJ3ZwrqZn+Ujr2tkpvjoqq04iYQcXciyiEVex68lcipo/Ru/dZoooTCOpOp2DTKQZTyFsVxbCWytmvoJA9kX0Pfp344S+w98G/Z//Td1F78hVkp59Dx/7dxPI3kzsNfXZK1dzXKLLeSs+G/6O45252PnmXouivZeKCG5hz6V8y6bzfp8LqifeKtIpcF9uehK5urLYRF07FKNC+ZxuFIEtW+OZqz6Fi9mW0bX+Q9nv/gY4Hv8Ceu76IKXI98bRriAtGvmsnNkmR/PBEamZfSV725KlRhHwucVwhvpqR3qw2PbOpmnMp+/dvpvOez9H74CfZue5rVGdqqNc8TDufYPpL2Pfoz8jf83na7v87dj2gzcXEU6lRxL9Xn3Bsav5XkeV7KbY+zNa1KyjsvUufEGwhIxuCbY/Tft9y9t93E7vu/qJ8fJgg7qawf698m0lQOVN2OOJiG/nOPVjXft17/0n3nZ9ixz03E084hYnnXEXbM/fxxNp/02bqWXw0nfRIEUgRSBFIEUgRSBFIEThOEAicDHVBoChmTOhEvKtPZvL8t4skXkFb6xbad2yCmlOYev4SRU+voBjMEFl8HbmZL8XIEWXnUH/aG6kUMevp6VLdDGrP+QMmzLuGfHtB5PNZ6k48jxmX/CXBlJeLtM6lft4bmXjGm+ho66J1x9NUTDibmRf9JWH9BRRFECvnXkWYPD5SB3GAlAJOkdGyQDE3m2lneTuvpG1vF11dAdPP/B2mn/VnFBU1zU28mFkXLCE7ZR5tezbjn62eft7vMPHsP4bMSbjqBupOXUh2wpkioDmobGTyWb+NVZ9IlK9QpPVcuf2bOBHryOWIXYg5RxTnyEy+RAH+t1Mx6VRatz8ushgw47w/pO7cP1X8fQ5iiNSc9gYFj08iikybg4uZfMYirGI6Pb29VJ1wKTXz3kK+/ipF4i/HTb2KbOM1RLWnkokrKFglVSfJtukLFIWuRuyduHIuFdLpas4kCuYw6ezrmHTmVQmR79TmoGb2RUy/bBnZySLXVFM54xVUTbuSYlwney+i4vS3kjn1TVhNIyFZweoEa4SzSdTNu5b6M99MT76bzr3bmXDCZUy7fBmZ+nOJcycx+cK3MvHE12i9ttC9fz+TT3655nonVn8exYITaa4lU12NFfaIdLfgCvuE21RtYl6FQEn6xIUu8oUeApfBxUqnniHI3wzVswnjPJFVkJl5GZlpL6e7VxH9QsyUhtcz49IPEta9AgumUz/5VG0QJsnuGHCSF+GZupQikCKQIpAikCKQIvCiQyAoeWT6qN6JiHlSWUEw4UIqL7ieGS/9K2Ys+ACTLvgAFSe+CZc5gdzES5h1/ltFgs4miB0Wh+RmvZwZF72NyinzCYuVIodnkpv/NqYv+BsmvPTjVJ77lwSTLhdFqhRPykHlGdTM/zOmX/FxZi/4NHWXvF9R1CuImEXtzCuZed4SkdPTRcxyGhP3ESyjdBjFIARtDsLqC6i54C+Y9PIbmHbZR6lo/EOKVaerf0AhrCQz/XVMutT78DfMuuQjIqx/TFx1ttpy1J38MqbO/xPiilkgQg3TqDvtzcw49w9wNS9h4ll/ICL6m0ThFDLEZEW2M/I1UBpZLdnZr2bCpe9j0pUfZdolHyB32lspZs8QjrOZNO9NTDjjzclYZz2Qmcqkc/8flWf/MbWzXsuM8/6CTO18LAKTHxaeQO15f0rVKa/ARbVUzGxi4qVLyU09D6TRuWomnfIapp7zNix3unDKEFSdQ91Zf8mMl31Y6/Q+qi94P4Upr5Qvk6ioa2D6+cvI1l9C0QoUM5OoP+OtTNa8ce5k9YmJ/eSuSKxVj6saqD/z7Uy58lPy5+PUn/tuihMuFykneWbb6i+i9oL3MOkVf625Pkr1Oe8hrr2MfDxBxP5iTrz0T/VJQCOt7b0w60Jyk8/SZuRiai77EDVX/BWTLvkoUy/+oEj627EJ5+kDlRomnvfbTJx/rZaxHr+hyOROZMq5S6i/6KPUX/ZB6hZ8mMr5i3Uvnisbq6k59VVMO/cPcVVztflxwsWLkvRMEUgRSBFIEXhOEEiVpgikCBw9BAKvypNIT/ziIiJ8kYhWEbPJWHgmUe5siE8gtkrVZRWNrCFyU0WGK5X6OjVbjiiYBK5OY00ENUdgU8WjTiPInALRDOIoIMCRcQXEmIjjiSLw83DZRo2ZQSQyKy4rfllNlJmm+TIyTbZoVGAa60m28qZ8iBG6jK4GmicIGyA4kdhNJhOrys+lVMZrRhG0jJ/jRCyulYQaWwVMpiiSTVxFgO8cary3aRqmaGtk9aqtx2MTiuK6AJxsCAIjCHLqW0NsJ0D2FNl7svCoJeNinLAwfQoQx1MgzmJOQgWRm05gNcB0cLM1tk44iTTKD6eINJHwdjW4UK7YRLBJWFxNEBuxJ8XU4qjDyGqeACcbTbri7Dzi3DwskL441Dhk22TiQOuTyahvjozWjqBeazSVAMMsll6HKtS/CAI+ogbC0yB7ErFNIaN51Q19pgDyw7kJWHgGkbDEphEq0m9FRxzX42wCYVRJRf2ZzDhLkf3cafi1cMF04mAWcah7wc3BnOYXPpH3JZqme01jXY7YhL9Vaf5pmn+GbDuJyLQRirUeivhDRnZmgUrZbjI3o3wgSc8UgRSBFIEUgRSBFIEUgWMfgYS1iCYS4XAikwSqciZyZKgKMScRnFieuITsKKOy84nEk59S/6QvvuzbnM+hjtLjSNRKnQYkp3olKZ6gxpo5jtUnkIQaYxpjMiOQlFIzjXAOf/gv23jx+URUb15KBWKvT2VfdIl+eSf9Sd5pLrWZaQ5D82k6lV2/+L5+Hi9glkkEseBYBDRWP3SYmb9CrERYed+dn0vtLpFY49SG7+dTr88l8yVD/QBfLfH9fZ3v6VNVUUpLY3y5JFof6fb+xbHsVN73QPNrakHpS158b6+NBD9f8nP41IuZJfotiLEgAitIFOmOEXbSq5TYz+Uz4DWaoUN1Hlvfpvl9QxAGZCzUemXE3SdRPeNCKmrPJo7rSCYR3jIM8Lq8SIc3VjUlncr0nWpRN/VJdCuVf/hJJN5+p3El8Rb1DUqTFIEUgRSBFIEUgRSBFIHjAAGxZRKyLSqapKI6MjvsF7MAT46cCJCXQOzZl81M9WpT9LVETLOlsurVFd9XWTAoiWFWEmUo6SmVVQ1k+sTPHSgvuiVu5fUkBV183pNNZaVi0FhjUNmSPDrMLJnHzyVt+MPMEtvKebNS2esu1ZGMNzMNkR2uJIH5VAahajloBOonm/vazUzlkqDDzOfprzMzRjvMfL1EJ8gWSvqV9J/e55J9DjNLRL2Sk7750aZAZiVt6qK0NNzMShldy9mkn8db0Xd9xCA9WQLLKhX2Xl+ypoaZqb6UokNF1SnjSbDEzCBwiHHrdFgUi1rrkwKp8YQ+IKB0mMYNFe+PS0i0K3VJrj7vkr5JcZSLx2KU6rQqRSBFIEUgRSBFIEUgReCYRSAwM8IgJCMJRSoDDLOSBIEo0yAxMxCJCtTXLMTU38wwM4LApxqt+iDIqFxu93Vl8XVeAsD3HyyqMS+G2VBxYohmRhiGifi5QH1EGo0sZgGWSEjZtrINEODF13sxM8yGSlmvT0t9gqSPWCReTKRSQwiD0BdLogozI5RNQVDqbzZUr1m5rHHmxfcLZONg8X3QfCEmf4Kkn68bED9HWfxcZTEr6wsJgpIgf/vrMV8qiQVYIiGmOZyItakcWAY/Z6D1C+WLlyDwfUv9TH3REYiQB5YjUD/f7sXMMHxfVO8IVQ5AdbFqA0lW+YwkxMzUJ5CEygdKM4RhSYIgTOpckMWUD6UkNJI6s1B91S5sTGsdhlmV1Uh6pAikCKQIpAikCKQIHIsIpDaNRCDwVZ6+lMWXxxff+8C9zHz7aFIe49vK+UNLzQaP9fnB4nX5Mpj51At9h8976SsOSsxsUH9TS1mc8l6U6PQp0P1tAAAQAElEQVS1So7g9Bq8jKbC15dltPbR6sr9B6e+X7ns84PF15fLfX75Ki/l6gOmvlNyuwzr0VcvDJ2V2s35Oi++q68r533Z5210vKXD9/Bi/oK/DpekIb2kCKQIpAikCKQIpAikCBw3CHg21G+smWFm/eVDzZjZEY0fbT6zQ9dpZv2qzGxMm8ysv+/wjJmNGGs2sm74uKNZNntu5jNL9R7NdTp0XemIFIEUgRSBFIEUgRSBXxcEhhDuXxenUz9TBFIEUgRSBFIEUgT6EEiTFIEUgeccgZRwP+cQpxOkCKQIpAikCKQIpAikCKQI/DojcMiE+8+/97fc8sDP+cbdP+Sbd/9I8mO+fs8PJT/mq/f8WPW+/GO+pvwtd/9Y7T9R3U/4T5W/cc+PuOXuHyTlb979U76hcV+598fJuK/7tl8przFfV/23pP9r9/yfxv9Qun6k1Ovy8kPN9UO+dd/P+MD//hN/t/a/+ea9P+NbGvNV6fia5vnW3f+XzPOfyn/jnh/w1Xt/JB0/0bg1mvMHfOvun8iGHylVvdqSdqW3qO837/sxb//e8v574s3f/wjfbrmNrz76o+NKvvboj7n21o/yn4/+MLH7Wxt+xn+1/ByfHm35rw0/55uP/xSferz6wUszKQIpAikCKQIpAikCKQIpAhwy4X7Z7PP5nfNfwe9d8lp+95LXSK7iLRe/VnIVv3/xVaq/qj//O5dcpfZXq+7V/KFvu/g1/M4lv5GUf/eSV/F7GvdHF13FH6jtLb7tUuU15i2qv1b6f//ihRr/Wul9jVKvy8trpf+1XHvhK3nlSRfxxvNewRsvfDkLz7+S35Ge35VcKx1evN7fk67f1xxvufjV/K7yv6d53nzJq3mT+rxZ8vsXvYY/UPu1kmt8nwuvomnOOf23xtlTTuGNjS/j98+86riSt5z5arztbzmjZPcJddMJLaQmWzWuTKiopS5XPW4/r6s2W43/De0Lps/jTfNenszZD16aSRFIEXgOEEhVpgikCKQIpAgcbwgEZqUvz5kdXOpcPvHRuaLSuE8icD7vqZcjIqao60AddPX2squjnV8+uY7/e/QuvvvIL/new7fxyPYn2Nu1jzgZLzUOjfa6YhG5WPkI56JEn1MJ6XUSTQyuFwtiNu/YygNPrFd/p75F8rKtEMu+OJ+Uo7jAhq2b6I16Kaq+oLJzBeKoQOT7KHWa4dm9O2lTHgawQIfZQNns8PIF6e3s7mRPdys/2Xwva568k58+cx/fe0I4PHE731XavHUd3b3ddBfl12HOYzZgn0zHrFRu3vEIZ089hZeecC4LtKEYLi+b+xJeceL5vPqkC5ldOzUhzq868UJePvd8rhilf3n8yzVuQlUdD+59Iplr8JxmltSZPXepny+VFIEUgRSBFIEUgRSBFwCBdMqDRuCQI9xxOSguEgUmkhuUxFRyXhziwGRcQEF1j+1+llU/+2/+8cff4Kt3/S//+8BPqa2oZMfOnWzatZVvPXAbq1vu48bV/85tTzxEt8h0oLFK8Pw6iAP8z8yFvk5zu2ROksP/DJ1m0/wmuhyQU3vGZchaBdmgQsNDtnR08ExXO7c//gBPK93W1UEmyKhvFguyBJbDghwZsmQsQyDNodPlKJyFqMi92x/jnx78Dl957EesfuZX/O0D38Iqs/zTxluZM2M2v9j/KBMnT+GZwj6++0wz9+7ZyO+v+Tgfaf4Sd217RJuR/UfBEqmIHBNyNdRLJiqCPVz2dLexYd8WNrft5PP3fpP1e59iU+tWHt37NPUVNQzvXy5XZyupy1TholiTpGeKQIpAikCKQIpAikCKQIrAcAQ8vxxeN2Y5SCgpor0gpisyTJKPVV+0EgG3IGBHT4fI9R18+64f0UaeuSefzObOXSLU8PjTT1F0jrbWDrp78ty57XFueegnfOfx21gu4r1+zzO4MJBeIzKIk993NpX9XD4N8EfgfF5RbXOYKcruK81fYnzy/Xtu47trf8Sdj9xDu+vkV+vu5ufr7+Z/775NmwGNU9eixdIf4zXGyvtNQuwdU9vhnrGi9d/Z+Ivkeekb7vwyHUGe+okTqK2pZf2uJ/nvR35KQ246t7fcw2m5aezbt5cpVs3M7ES6414e7XqWmgl1/GT7A7z39n/hG4/9hL097Ydrzrjj/LPX3954G/doc7Dm6TvJVGTpLeb52Zb7ad76MB9r/jL7ejvG1ZN2GBWBtDJFIEUgRSBFIEUgReDXHAHPMw8JAnHcgf4iqA5PTx1h7ERaHeLcPLVvG19e+z3ue2od02rr2L1/L3FXnt+/9PW8/ZVv5uzGMzm/8VzesOAq/uClV3P5jEZ+c/4VTApqmDNrDv9930/49sO/gMAIzfBH6epziA67JOM0f1ISeTcsqfMXp3zBRcljLK+/8jc4+aRT6C4WOX/eOVxx7iV0xBEPPfsUGzY/QUjgVfhhGmUqQ5hE0zmsY1f7Lv5t3a08uP8pwuoc77/k96kQiw97IqaEtXzxqvfx7nN+hxsuuI7fnvsyrj3plVwx7VwumDiPN532ck6pncUN5/0uxY4epmbreO3pl9Mw82Q+uHYlzU/fe1g2jTfo3Gmn8dazF+EfD5k3cS4bdj5NR7GHc6eeyqtPvIgwCHHaRHx7w89pTYk36XEsIrCRFQsMswPJEtYchNlrlhgLVmw8iJ6Du2juJQv6516wZA3jaxg2ZsEK1ow6SP0WGFI5eELYuIYlqjcr+btgyfDxGrdksE0rxrdpPJ3jtfdbqLll2wibZcGKwTYtGG5zv4JBmY2sGTxGfo4KU/8I4dKHiVkJG7O+VPONPbZfyRFm5P8Qm4+l+6GEhb9fxsVivPUer70fReGxYNg9vGZJ/+vFrGSTmU8P7nXar3rUjOZL8R+EjPAYjr9vHbF+B3Of+oGpHC4CYpuHNtT1kVGXDAvRqwZQZNsQdTW2tu3mJw/dySmzTmJyfR0VYY63XvXbXHzKWcStPezevJ3tW7ezc/Mz7N66g65drVww/Qze9uo/4NI5Z/CjB27jmw+s5qeP3M2dTz1GkSghe+LUfcTYaTbDHz6HSoGXvp2A3wAEcUDWQs465TQ+/b1/5gPf/lvu2Lqem+/9IX/6LzfQkXN8877VPLRto2xG4kTilTqkKcZHujmM4/6dG/jaE78gW13JaZPmsGPfbiaGFfzxGa+lacq5VHTnuG9DC5t2bOfJvTv4xROPcM/mFh7bsYWdbW1s2rqNbdv2cEHNmfzpGa/ndSdexn1bHuVP/u+TPNq+hYc6nuVLD/8vURQdhnUHHlKbq2b5ff/Fn932tyxb+4905nt4VhuHf1z3XV77neuZVTMl+aWTj9797wRBcGBFaUuKwK8hAmuWNLJsVXO/582rFtE4km32t/vMiDHNy1h03QoGk6CNa1aIVEt3sx8xWDay4rpFDJqS5lWDx6t9gcYN6uDbx7ZJY8bTOWZ7yb4D24yI8zCbmgfbXBo//OpxWnRIfgzX8PyXvc3p/VDCfaz7odTj6F9T/AcwPTD+2pg2Dn8PGf99a0BzmjscBA6ZPbkRs/gaicGefBd//e1/ZOapp7LpmSe5/PQLuHJ+E9s2bueJZ7ayq7uTiuoaJirqPW3aTCqrq+mN8zyx/VnuffgBZk+YxfVvfCdvuug1nHd6I9+/52fc9dR6TCQvUjRbsyD2PcICX1GizKWcv3qpqZ5Azhyvv+yVnDJxFvW5Si447QzW79vIl+/5XzLVVb6bJBDR9hH6GD9HLF9UeUjnNx/7Eb/1/Q+TzWYoFoo8vHUD1za8nMnBZNY+9hiP7dxClyvQGxWZUFlFbSbHjJo6ZtdPpCaTJQtkMxm6owLPdOzhV09sYMfuNj5w/u/zgfN+h9888XK2iAR3hhFfWvd99T5652wR6nMUzT5vwqm88aQreNeF11KdqUgi7O8877fpLvRyw73/RmABmeQzANIjReAYQ6CBpWv1LqCduZOsXizzFq/W20W5biULVXXUz40ruHEVNC1fTYvmda6FZO5V3zlwRH3EGEfLchncfAu3lhm3IoCNi5ax7trVLG8abnUL65th8fKWfv9afKfm9bT4rhtv5ZbmJpavLrd7m6Rk3YYhhN53HZBxdErzmHN6RWPZfDA+ex2DpW+MwGV1i0t8TXAaC9u+8YtXl/r7e6Ff1i6loa/9OUv6bE7vByE81v2wcGWynv1r41azuKmJxauP8HWa4q9XqrD351j4r/kOqxj8HqH3oNXLaVp1I4f8AZ+fK5WDQiA4qF7jdjIRVuPOR++katpE/vF7NzNtymROrJ3NA4+sZ6+ItjOju6OLjHrOnTGLaXUTmFRRR3VlHbkwoxdfwCNPPgW7u/id8xZx/7oHqZtez/fv/hn7ezsJNZ4kDj2uMUkHvd1y0qRpfPTqt/HuC6/l7695Nx95+R+y4PRL+N/7fsArzriQK0+/MOmLbFJcmyKHdzQ/+zA/e/Yhll7wJv7tkdXE+YjPvPTtPLNjDw9t30xXMc9+RbC37d5Nda6C+soaJtXUcfK0WUysrqO+qobJKteqLUNAT1c3Pflenmnbyy83rOcNJ1/Ja2edy6+2Psr21l1sy+/n2y0/52gdHqtrGl/K8pe/k09csRj/E4h/cf4b+cIr38Ws6sl8+P6bmRzW8rXXfJiqbMXRmjbVkyLw/CMw4mPUFZQ57mEZ07KeZhZzw9KFlMhcAwuv1x8u1rHhQIpHjIGGpddIC4OOszyhZu3SxkF15exCrherX7esETO990oalzFAVhqWstatRSb1PWbTyKJVsPzmpZRsZJRjHJ3arow5Z6JxDJsPyudEycAlGdOU2L2wz/CGpZ6ojU/KVi0q4WJWTgc9qiBStmRBuV7pgiUlkiGCYraglPdWqN8CjR/nwwrfc0ASm9P7oQTIGPdDqUP/dc2SRdpc3szKI90Vp/jrlVqGdTz850Nj3wurPIRm1rf0F9LMUUYgOCJ9rjRab1ns7Onkl5seYkHj+bzy7It53Tkv5ZGWx+kMHYUgImdw0slzqBeJDqoCspUBUZ3h6hw1tTlCF1JZVc2j254i3+tYdNErqKusZMqEKdwj0mn4fxz04Xtv2vUUXYWe/pHP9uxnxc+/SC523PDqxUyrqCVGNNsi0Vwj1D+So8+xJD/2xe/QH9r3JGfMOIXJiqi/+dSX8dZzrubBpzexvaOVfFcPCs7jH8VonDWHuTOmsa2whw1tz7ChdTMtPm3bwuNK9+TbqKrIUYwjakS+w6K2AXHMbRvWcca0efzhmVcRqbzw5Mv46bP388QebVDGNu+gWjft38rbfvw5dne3Dun/8Ttv5i/W/j2NNbP4+m98mPlTTxnSnhZSBI4vBNawZMTHqMtoXLCCA3Hjg/Kv6SwaB3dsmMd8mg/8hyuJ7g0mjRvxhGNV07VcXf77t3ApK5eWC4OV9+X1ntDcl+1PDsjwfY9mbrlpjc8cWMbTOV77WDYfjM/DLNuo+fCYsIJ+gixyPPqzEZfKgQAAEABJREFU7sMGH7Do74FlDHpKBZpXscw/ziMbW5ZTyrORFddpF6NPEQ6ZBKb3Qwn9se6HUo/SVRudRav8JmWM+73U8+CuKf4lnMbCf+E1LEb3faOYkjaVZkbjjeuZrw/CSoNHu6Z1R4pAcEQKbGD0w5sf12ZpPhueeZoL555F+/5WWottREGBbe07yFsvz2x9ivsfeYD7HriPe9Y9wL2P3M/9jz7IprbttLS2sDfYT1hbw+Ztz3DuzHk8/HgLbYr07iu00+MiTTZoQpXGO+/fsZl33PI5nu7eS0GdP/nDL4vQb+LT17yXc6adTCS6jah2rLYYI3Ac8nHHtnXct3MDmcjY3raHhSecT0d3N9ta99Gj9KH9G/npznv4WfsDPFTYjHYW+Ge8yYRQXYHLBLhcSGSaPJflvn0t/GzH/dy28wF+uech9vW2YRZw96bH+b3GV9Gujc1XH/0hl809h0fath6yvaMNsCDgm0/exiu+tZTbtjxArI/GP3v31/jcw7cwIzeRf3/1X3HZrLNHG5rWpQgcPwis+Q6rUMS0/1GLvo9Rmwc9ynGUvdm4YgFm1icLBqKnffNs7HtOe9G65bQc7CMPirxet6xZEe3yIyPyQ0Rx1bLrhulv6H/MpmV5k3jljUn7qDaNp3O89j5/DiYZzedRbUqUreemxkEEuXkVizw5TtoOfBn5SEl5g7OQlXp/84GSsnhs6Hscp2HpzSxnGY3WqOtybh5r08OhHwf2s6RrNGxKLWNcD3ptjsX7YSMrbtSrcvn1gyKzY/h6hE0p/mUA9TrQ7nJxU1+5abE+SboGmvvKafKcIBAcsVbxRK9jX8cenli/kXxPnhNnzWVH+z7W7X+KH6+7jSrXy2wR0peddR6/Mf9Srmq8mFc1voRr5l3Ib845j9nFLAUX07zhHn76xC/ZH4l4iwSeMH0Os+omke/uxD/r7ec5FAkzGX665R6WfucmPnHHf3LL/T/gT664ltfNuwLTfOaKWBxIjAjD6d+h6Pd9H9r7JOfPPoOH9m3iKUWKz5l5JnsU2X6yeyufffw/2W37ec38y3lf11ze+POnmTflRC57cCtXBNO49Ks/YcETbTQ9tJ0rZs7n1F88wLkdNbyq5gwaak6kU5uNTz36Ff7nqZ+zq6ONTJjhZSecx0tmzeMlk0/j4Z1HFJfz5idSE1Ywt2IKm4t7eMsPPs6r/2sZn3j46zTWzuFnb1yhyPapSb/0MjYCaeuxjUASMdVflGWLGjGzRBoXLVPNGNHog3Gpj6z1d924gXUi9mc1QsPStZSJnfOPefQH8TYqqr2AxkW3wA0tuIMl234S/7F503KuLz9nobqGpTewmAP7Mbh9VJvG0zleu2wY/zywz6PZ1DBPH3k3r+OsERukZYwXrB/Llo0rlrCgb/3NFnDdLYN7N3D1tU1JxeIbltK/XEnNQV7S++EggVK35PsGRzG6LZXlzZPPJpK+HhMYRlwalrJyrViP34CuXclSBt63RvRNK44KAsGharG+Af1pX6YYxmzPttKy82kqc5XsjTr4zr0/5OqXL2LhtLPo+M6PqcvUkS1GPLl2LS3/8z/Em59m+x33EP3kLi7MzuHlZ1zBo7uf4H/W/ZS9Xe2cOm0OUydP5vGnW9iliDF9c/WZ0J+UOH/JlVi1pTJceuJZXHjy2fx086+46Rdf4tKT5vOXTb9HxvfRTQYh/guSJsWKNyd5NR3S2Z3v4ZHdm6jJVibPovvBuSDD97ev5fG2zfz+Sa/hpXMvIHxsIz0//RlFdSjc/wDFjg46/+vbdP/Xf5Pfv598pJadu5j0n99kyh13M7FyAq+fdQUnV8/kK5vX0NbTgcwkK9IdKOL91cd+xPauvRyNY0bNZH74259n0YyL6Ih6uLd9E2fXzOVrv/Fh5tROPRpTpDpSBF5wBBIChyLcfV/AGyDC7vCfHW08SxoVdV2yhtL2V6TyJk/i5zPvgGxNUb0FjSziBlpEwlcOIs4c7NHsSWdpRj9kYxK99zmJPqL3RHJF/7MXsmnFjayiCb8JUI/Rz7F0+hHjtfs+B5TD8DnBdjSF4/gx2pBynbBpXOb3OH1Ew93MDfOby61K13CT//Rg8WJWLVpy4C++queoZ+Mxcz+wqmygfD727oeScRtvvYXm4Y+AlJoO75rif3C46VMRv+lcUn6P2LgmeYSqmbHetw5OddrrwAiUWOqB2w+6JZ8vckr1DC4+6XRCkUcLRWErAuJCniCXAbW3PvY0va0d4o1FXG/Erg1bmNN4GkFHO4VNG8hkApHeHHt7uok8sXRQELmsqp/gn8QYwxZHjDqrR3I1kmedTxSR/JvXvoOZ4RRmBhO4ceHbmVlVT9FFxIpwBBaihDgwTGMtuSpzCGdBsfFLZp3Fn85/HYFs9UOjwPH/5i2kceIJfOXJ/6Nl99M8NrGOLVMm0fLDH7L1ta+kU5hk3ngNbvGfwIMP425dQ/4VLyV84xuoqaqR30VWb29mQ/sWfueEV1NbUe1V09HbxYUz5vGxy/8fRfz2Iqk+4svs2inc/Bsf4qpZF3B65Sz+/TUfYN6kuUesN1WQInDMILDwGhbTzLJBzy2a6VWvKOeKAe46xNw1S9QuMj2kcnBBUaIbFqti1SIaE12NyRcUWXzNgT8iX3MT4nTQP0ZzJGNHPnLCaMfC61muIOyqIZF60aum5Yp6a0DZz/72Rhb5CZuupf8ZcYYd4+ocZ85h6kYUD8fnhqu5tknr1e+H6ROBZTQ3jeHHiImHVpQ+5dAGqbGMeSM3spimvm7Js/SLV7Ny5UpWL1a/BSsYfGuk90P5HjvC+yHBeyO33tJM07VXc8C9adJv4JLif5TwT15b/i2oETO9FhoXJe9JTcufn0d7+DU9jphwuz7gMlFA48x5tCly658zPrV+Fn9y5Rv56V0/5392PMTm+bP58Z0/4bGdzzLt4gs4781v5OmHHiC/bxuNf/RG9p46ldUP3saUsJpr5r+CKVUT6RVZnz5hoqK6FUyePIU+Tt0340ASKlt2JMQUDe6kaDF5RWsvm3MGH1n0Tv7mVW/nJTMa2Ll/Lz1xTCSy+tTubfT29mh0SXWc5A7t8rLZ57Fx1zP86tn1inQ/yfqdG6jLVXPu5Ab+9aXXc/nMs/n6w6v5j+ltfO2qeXx/fwtf2f0IP9j9FI/89tVsnj6Z3W/9PfjaLVR9/wc8ceJEbmmaxo933EmBIm879Rp+59TXMGfSVIrFIg/qE4D27k5ufuwHnDe14dCMHdTb/2b4Z+7+WlLzvSfWJukPn/4VC2afw9+9/J2UyfbKh77Lxv3PJu3/1fJzfvT0Pewe9uXKpDG9pAgc8wgsZGXLahY3NQ1Y2rSY5S1rOZJHdReubGH54gGdTSJsLSsXDswxLFcifcMqD6nYwNKbVw+bc/mgZ8DLfg4oTWxau3QMYjOezvHaB+YaLXd4PmvOtcOwbVrO6jF/bWW02QfqGpbeLCLd1FfRVPolmJXXMJ91bFixRJulxazuW7uFK3WvNC/jugPtxvq0DE/S+2E4ImOX5x/4o6CxBx6gNcX/AMAMqfavLd3f5ZeCtpz/n70zAY+qyvL4/75KUpVUKpB93/eEQAhJQMgCAgFaAVkGIeioY2sLOtgz46jTLuCMbbfL12PzDdJurV87RGCwtW1UkEVJIGAIAUJolixAEkI2IGRPqGXueaRCQoIkQCPLqe8779537z3n3Pt7VfJ/11eVx98uxo5r+Q9hr/h80h8Bq07tr29AbaJrlKeTGwqK9wN6O1TW1cBZo0WaZxwemTALFimi92vM2OtqwYGW09hyvAhfVh2Gz30ZKNmdj+IDhVJMCoz2icG84RmI0PvCbD6PTk0HSmpOoKWtFfbCtitT38IklbhZXGg3ynLT/jzIAGhqakFx9TGUVhRjeEC03IsGPvh2DX4o3Yczrefw/qbPcKazGQr5S7ORQvxClIEfR7lHoLShEi2WDsyISsNLeZ/A4OAAs9zlt7W1wyiXCDwWdS/m+I9DhEsILG7u0AsbNDQ14XhlBaprqtFq0KP+9WVokzcBgR+swfSC03hQicIU55FS+AbJ3frzCPfwwSeHvsbBsyfw3YkCHDtThVSvGFztq6b1LLac2qu6768rUcs9Z0rwen4WdBo79XxrRQE2ntiNjw9+o56fl2v6smw7cquK1HM+MIGblcC0dy2wdAmnXnMMm4Z3d+y4+Fw1PbvY476V/Hr+o0Pn/cbpHRRPv3sx5g6Zt0fIXiPppPfzynKeFqvt6Ef40z+MFsiQ5HrRwqZdkvNp9Mop+7ufz5TxrzQnNbD06b2OvjF/tF8NQoe+cx7cmimG1WSsnmzlTcOPP4Ejr69cbx9e1nCS0rTueDu6fgmGfCT7p9+V7wvrlyvJgdotvUQIvx+IS5dd6f3SNQySOf1Gft9rIq/tjn7e291+fSvqTVDf5ktaZNzuayyvn0zc67Nx6ehe37OwfhaplO+JPo4ydn9zvhIL2X8zfR4vIJDvb7mWC4/WWT8LF3r4+PchcNWC27qzbS0j/eU7U2vCseZTyCrYAK3BHqb289CadEgLjcf46ETEBUTBoB0Ky3kN9FondHgHwSNtIkxV1XCvqEegoodth/x4+vpj57ECfFiwHusPZCM1JgF2qizuH4IiRbapq0sjxzW1t+Lb/btwpqMZZo0GDS2NUthWouLMafgGB6OwpAhajQ0cbXSoPF2jeipScJsV62rUpgEd6IuMD0ZNwR8PfIWl2z/Axop8rNz3Z0R5BaKz87wU0Ua0mTrhJXfqI918Ee7ggxB5Q+GuGwoXgyPcnAywdBrRorfHyQf/AbUPZqLTzQ3m6lpAmEFPqdwVEo3q9lr8audH2H7yAF7fv1ru/ncg1l0yx9W9MgKTsTzlKfVXSR6Lm64GmR86ARtmvwUnrV4993f0wKqpL2F+1ET1PFkK/IVRGYh2CVTP76wDr5YJMAEmwAR+SgIlv/8CMf9++f979FPOjXMzgSsRUK404NL+PpJUNljkzqyfwRnuzq5YV7QZn5fm4NvinQj3D0F7WzvaW1rg4qCDg1kLmC3wcHWBu6MeHR0tqHV3gU3aJIiIaHQotgjx8YONkw4r8tYh/2wxWtobEB8aDSPMl05FPZdaGxqLkDIb6osWNCM5Dd6ubghy80GIsxeeyXgY4e5+0NvZYG78RMxMmizFsAlTElMR6UniUUh/BULGwVW8pgYlY1ZQCs6bTXDVGrDmWDZeyH0PCYHhGGJjj6a2NnTQr6LYKNBIM8pxDlo7ONnbo7WzA41yB79ZMmo5dw5tsr81LhrnRo3AUBdXZMSMRP7pIjz+/dvwc/KARViQ7h6HF5MfuoqZXnRRhECUFM7jfOPg4TAUJjm/SGd/xEsRHz7UTx0Y7uyn/qGbYa7B6nnIUG8kekaC2umuWG3kAxNgAkyACdxYAndoNvrDR/zUwx168W+DZStXuwbR5Sh1G6z1zKR7MMZrOAI8PPEnuXlPYIoAAA6GSURBVDu9o/YQIgLC0NFulKKzA4oGsNNpQF+ObGlpRkN7k7RGNLe3w6RREBocAoOPO174fDmaTO3w0zjjl+MfgJutHpruLF2JuwqLLE2yj0S3rKKpsw32NnYY7heOxvOdoB1oG6MZdnYOMEsxS892uzq7Q9FpEeofCI2NDVqNcltdCnqzFLOARUajSIOzl8Y8hGVjHoanwRU+elcsP/A57l73NKrNpzEmLFaK2iEQJgu0UvTr9Q6w09mh1XQe7WYjOmVuE10JCZN23gMdhiI9OBIBksUT372FWV8vg7ejK0b6ReGewDH4YsZrcLV3GtwEe4z+qmwnXs79EMtyP8L6slzsrjmML0qy1d3uFfs+x3uFX6KurQFZhzfj8U1v4nd71mL82qdRWFcKeo77N3mr8OHBr3pE5CoTYAJMgAkwASbABJjA5QiQzLtc38DbLYBJCkcfnRNWzH0Ovrau2HOmHP/8xev47G8bERQeCM+hbhiidYCt3OFua+5Ec2snzrcZYW/RwE/u5MaPiEM9zmBJ1jJ8c3IXWjs68eSY+ZgZnQaL2YwLO6oyUT+zol1fqWbVnpO1Fcg5uhef7tyI9775FLuOFWJNwSZ8vS8bO4t24y+5G5C15XNs3LMNa3d8g/yyv6FN7jJb5B63Sc4FUnCrga7i8K8J9+OZEfOw99QRaBQFBY1luO/rl/BmQRYqjXUwO1oQJXfwRwWGIdE/Ar4GF3jrnRHvE4LkgEjE+gXC28sFioMJ/1u8EWPWLMYXlbmwkSJ9Z8UBDDXr8FHGf4B2p69iet0usW5BSPMbgQmBI9Vdbjf7IRjlGQW9jQ4j3EPV392ubW3ApIBEPBQzFWO8Y/Bc0gLQFymnBY3GtODRSPUd3h2PK0yACTABJsAEmAATYAKXJ3BdBDc9iWFWhPpze5FDvfGbGUsw3DEAp9rr8PK3/4OVW9eiuu00vHx9EOjvi4SoYRgdG4+IkFB4BwahQWPEXw58j0VrX8O2mr0YorHDo3K3fNHYWarQtkgRrIrqy+w9W6QOF119Qd6BSAobhqmJaXhk2v0I9wnFrHFTMTxqBKIjYjEuYSymjc3AxPgUzLtrKhKDo2HQ6mQGE4RilCWkyYCXZ/ajPfMiJmDvAx/iv1OeRKDWHfQ4xqjgWPi5e2FX3SF8cOQrrK/YgbeL1iL77H58WZ2DnPr92FS9G8/uWon15bvw9qG/QtHbQdhq4CoMeDRiGgpkzDfSFkFvq/vR/APpDHLyVsV0um88opwDsObwVrwtd7GLTh/DBwfWo7C+DH8s+gor9v0Z5U212HD8BwQN8YatYoMhWkfQYyeR0m8guXgME2ACTIAJMAEmwATudALK9QAgLJA71woUqbzpWeBhQ/3x4f0v4vER0+Fu5wIfKTaNeoGtR/Kwfnc23stdi8/2bUFO8R7kFv+AojPFONfWBDtFh3FeI/CneS/j+XELoVfspPg1w6hAxtZ0Ser+ZqxAAxu1Q6fRwkXnCGdbPXL27MT2g7tRWFiAsmMl2LgvF1/u2oot+dkoPlaMIXb2MEizkwKfIiiwxfV46W3t8Ujsz7Bn4fv47dhf4HBlKXJKCmAQOgS4+eBIxyn8ofQbGPQG5LccQ2rwSPh5+OCBhJ+pu9fuwhHbju7BipQl2PPA+/hd+lNwsx9yTVOjL4RWNNeioqkGJxqru628sQYzQ1MwOzxdvTl4Kn424tyCMTdiPMb5DkOAkwcmBoyCRiiolOK7py/VTzXX41TraQiNvEjXNEN2ZgJMgAkwASbABP6uBDj4T0bguqkki5TDQggpGAES3UEGT/x26hL8QYpnR6Mtyk6cQGtjA4StBtmlBWjsaMS55kY0tzSh7mQV2uob8OvJv8AnC36NNP9E+m4ljFLJK1Jy25qFjE47z+j3ZYEJlWdrcfx0NUpry6WdRGlNOZydneErBa6bszsCvPwQ4OyJxJBYRASEwU4K7SN1VThadxJl0kql1Z6rh0KZLP2mGXQj7QjT4xj/Nmo+5gSnYE5YCoYZAnGXUzh+n7gI7hoDZnqORlNT84U/CNRmxASvOPwqaSFW37MUs8JS4aw1DDpvfw7TAkajtvUsDp0px5GzFd12+Gy5KpjPW0xoNrbhbEcTmjpbca6jGSazGW3GThhlSeL6aENlt581xgG5K26n2OIuz5j+0nIbE2ACTIAJMAEmwATueALKdSEgpBhWLBBmWZJglSLZYjZBZ9YgLTAO/zhmGubET8Lk2DGICwjG4vRMjPIIx4iASMT7ReOxCffjhTmLcV9cKjy1jlJsm6XwNclIMiA0gBTygBkyDS59maUY9HbxgLPBCUdrqnCs/hTK6qtQ1XAaGo0NmtpbUdneiAZZCrkL29TZLoVlpyokK+qqUSZFegn5SPFNAlOnaCHoGZVLE13juaO9I1wcnDHWZxjmREzAvMgJyAhKwiPDfoYIF39MCkrGDLnTnOwbB0edHjaKXPc15uzpHucWgnuC70JGYFIfo2e1J/ongB4TaZRi+0xbIzzkXH0c3eCqM8gd7gRM7sfPGmt6yFj4GTx6pruV6zx3JsAEmAATYAJMgAlcVwLKYKNpYRWC1pJCKFIgSzmsEWqpERoIEozyHPKlURR46p0Q7x+Be2LGYW5sKibFjUZGdBImRiUhcIg7HKQ4lkMhhAJFmpAxNJRLCAghANkGWUC2C7UC2Fhs1J8ENNjpMDlmNDJiEjBJlpNjkjAxJhETIkciwSsE586dhdbeFq5OQ+DqoMfE0DjcHRUvxyZgatQoOY9E6Z+E5OBY2MrYQs4XXa+Dcgf3s+JtWHVo0y1lWYc2g+aedXhg86bxfy3NRfHZSuytK0ZBzVHsrDqI1Ue2gvpWDWD9xIlydqHjggkwASbABK6JADszASZwuxBQBruQnMqDWLX3e3ya9z1W796KNXlbZfkdsmR91e7NGIhldY2jkuzKPltk/M0yD+WSefO+w9o927DlRBHWFWbj093UT7ZZHUcxyVbnbcbmo3tQ19GInKICFJUXY0NBLtYVfC/H9R5Pc/iU4uZvw87yg91Y1k3/L8wJT8fC6Mm3lGVGT8L/3fufeCA6Y0DzpvGPD5+O55MX4s20xfh53L14YsRMvJ76BKhvIOsnTsSrGx5XmAATYAJMgAkwASbABDBowb189pNYOHI8FiSPx/yku3F/8t2ynIBMWV+YNAkDscyucVSSXdlnoow/Sea5W9p4zE+egHmj0vHazJ/jX1JmY0HSRNlPNkmWF21+sqwnT8Zzdy/Asnv/CU+MnYGl9z2KTNmWqfrIfjmXTGk0hwUUNzEdK2b/kt8aTIAJMAEmwASYABNgAkzguhAYtOC+Llk5CBNgAkzg9iPAK2ICTIAJMAEm0C8BFtz9YuFGJsAEmAATYAJMgAncqgR43jcbARbcN9sV4fkwASbABJgAE2ACTIAJ3FYEBiS4y8rKIITA4sWLey1++/btsNrUqVN79dEJ+ZFR3WpvvPGGWiU/tdLjQG00nozq1LV69WqQT1hYGKguhFBzCiFgHUftVCdbvny5OlfyF0Jg27Zt3ec0jmJSHxnV165dq8ahtZH/888/r46nOvX3Z7RWIUSfLopJRv1UXjrg0jZaF425tJ3ayKzz7VnSvIS4yID6iA3FojiUW4gLc6M+IfofS3FovNUon7VOJfkSGyGEyp/6bzfj9TABJsAEmAATYAJM4EYQGJDgzsvLg8ViwTPPPAMSdCTwSKC++uqrKCwsRG5uLl588UVVmAkh8Morr6iidcOGDXjrrbfUOgk4WtDx48dVwUx+QghVRFM7GcWhkvJRST5+fn549tlnkZGRAaqXlpaqOWk+1nE0dt26daiqqkJCQoI6V4pPYw4ePKie05js7Gwq+hjFTktLU/1dXFzU/vz8/F7zVhvlgYQqrZViW8X9O++8o3LJyspS50b9lF8IAauAJ0FM/cSOGMpQGDt2bPdNTM926iPRSyXlKy8vpypo/iEhIVi0aBF8fHxALKjj448/RkBAAFVVmzJlilqSH82TuNBY4kdjKW9PdurgrgPNka5rcnIyPD09u1qhsqBrDn4xASbABJjA9SbA8ZgAE7jNCQxIcJNQE6L3DndmZqYqsuvq6lTh2JNTTU2Neurm5qbuHqsnXQfyI0HXdaoWJKypMnfuXCowf/78XiUJvZUrV6rCPjQ0FEajURWAR44cUceREKUKCXYyIUT3GGoXQqCyshKUmwQstT388MNqDBKVJEIpBo2hPrL6+npVqNPa6dxqJHitvtY2Kunmgsrhw4dT0W2NjY3d9f4qJPRJ5Fr7rCxSUlLUGwzKRwKZ+mn+JMSJxfr160EsSFSnpqaqYtw6/40bN6o3PeQnhEB1dbU6ltjQWMo3v4sx+VC7EELlS/OhGwZi7u/vT2lBN0k5OTkqP7WBD0yACTABJsAEmAATYAIDJjAgwU3ij3ZKSVSSlZSUgNrIli5d2l2nnWgaRzu+VM6bNw80nupWgUc+1EaCztpu7SNxSUazp3HWUo0nd9it8ZcsWQLyteamfuqzGvVZx/TMQzEpPpW0BhqXnp6uzp9i0DwoBrWTnzU/lT3N6tszB/VTDIpNRv4Uh9qopLhUJ19aP42ncZSzZzudUx8Z9V9aUhvFs+amuHROMciXYtO5lQ3V+xtrjUs+1hhU0jnloDjEivwpNrWRkR8bE2ACTIAJMAEmwASYwMAJDEhwDzwcj2QCTIAJ3P4EeIVMgAkwASbABAZDgAX3YGjxWCbABJgAE2ACTIAJ3DwEeCa3CAEW3LfIheJpMgEmwASYABNgAkyACdyaBFhw35rXjWc9GAI8lgkwASbABJgAE2ACPyEBFtw/IXxOzQSYABNgAncWAV4tE2ACdyYBFtx35nXnVTMBJsAEmAATYAJMgAncIAI3oeC+QSvnNEyACTABJsAEmAATYAJM4AYQYMF9AyBzCibABG5RAjxtJsAEmAATYALXgcD/AwAA//9PCaA6AAAABklEQVQDAOTnrxaemW5TAAAAAElFTkSuQmCC';
function printFatReport(report, items) {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const win = window.open('', '_blank');
const testingDateStr = report.testing_date ? parseDbDate(report.testing_date).toLocaleDateString('th-TH') : '-';
const models = (report.instrument_model || '').split('|');
const sns = (report.instrument_sn || '').split('|');
const exps = (report.instrument_expire || '').split('|');
const maxLen = Math.max(models.length, sns.length, exps.length, 1);
let lastItemNo = '';
items.forEach(item => {
const isTestedBy = item.item_no && item.item_no.toLowerCase().includes('tested');
if (!isTestedBy && item.item_no && item.item_no.includes('.')) {
lastItemNo = item.item_no;
}
});
let calculatedTestedByItemNo = '3.6';
if (lastItemNo) {
const parts = lastItemNo.split('.');
if (parts.length === 2) {
const major = parseInt(parts[0]);
const minor = parseInt(parts[1]);
if (!isNaN(major) && !isNaN(minor)) {
calculatedTestedByItemNo = `${major}.${minor + 1}`;
}
}
}
win.document.write(`
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FAT Report - ${escapeHtml(report.report_type)}</title>
<style>
body {
font-family: "TH Sarabun New", "Angsana New", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
padding: 20px;
padding-top: 0;
color: #000;
font-size: 11px;
line-height: 1.03;
font-weight: bold;
zoom: 95%;
}
.report-header {
text-align: center;
margin-bottom: 5px;
border-bottom: 2px solid #000;
padding-bottom: 4px;
}
.report-title {
font-size: 22px;
font-weight: bold;
margin: 2px 0;
text-transform: uppercase;
}
.report-subtitle {
font-size: 18px;
font-weight: bold;
margin: 2px 0;
}
.meta-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 6px;
table-layout: fixed;
}
.meta-table td {
padding: 1px 4px;
border: 1px solid #000;
}
.meta-table td:nth-child(1) {
width: 120px;
font-weight: bold;
background: #f2f2f2;
}
.meta-table td:nth-child(2) {
/* Column 2 is flexible */
}
.meta-table td:nth-child(3) {
width: 120px;
font-weight: bold;
background: #f2f2f2;
}
.meta-table td:nth-child(4) {
width: 240px;
}
.checklist-table {
width: 100%;
border-collapse: collapse;
margin-top: 5px;
margin-bottom: 5px;
table-layout: fixed;
}
.checklist-table th, .checklist-table td {
border: 1px solid #000;
padding: 1px 2px;
text-align: left;
}
.checklist-table th {
background: #f2f2f2;
font-weight: bold;
text-align: center;
}
.checklist-table th:nth-child(1), .checklist-table td:nth-child(1) {
width: 40px;
text-align: center;
}
.checklist-table th:nth-child(2), .checklist-table td:nth-child(2) {
/* Column 2 is flexible */
}
.checklist-table th:nth-child(3), .checklist-table td:nth-child(3) {
width: 45px;
text-align: center;
}
.checklist-table th:nth-child(4), .checklist-table td:nth-child(4) {
width: 45px;
text-align: center;
}
.checklist-table th:nth-child(5), .checklist-table td:nth-child(5) {
width: 150px;
}
.checklist-table td div {
font-size: 11px !important;
}
.checklist-table td span {
font-size: 11px !important;
font-weight: bold !important;
}
.checklist-table.scale-large th {
font-size: 13px !important;
}
.checklist-table.scale-large td div,
.checklist-table.scale-large td span {
font-size: 13px !important;
}
.text-center {
text-align: center !important;
}
.sign-section {
margin-top: 5px;
display: flex;
justify-content: space-between;
padding: 0 40px;
}
.sign-section div p:first-child {
margin-bottom: 15px !important;
}
@page {
margin-top: 12mm;
margin-bottom: 8mm;
margin-left: 0.4in;
margin-right: 0.4in;
}
@media print {
body {
padding-top: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
padding-bottom: 0 !important;
margin: 0 !important;
}
.print-page-wrapper {
height: 260mm !important;
position: relative !important;
}
.no-print { display: none; }
}
.print-page-wrapper {
padding: 20px;
padding-top: 0;
height: 260mm;
position: relative;
box-sizing: border-box;
page-break-after: always;
}
.page-footer {
position: absolute;
bottom: -15mm;
right: 20px;
width: calc(100% - 40px);
text-align: right;
font-size: 11px;
font-weight: bold;
border-top: 1px dashed #ccc;
padding-top: 4px;
}
.scale-90 {
zoom: 90% !important;
}
</style>
</head>
<body>
<div class="print-page-wrapper ${report.report_type === 'CRAC INSPECTION' ? 'scale-90' : ''}">
<div class="report-header" style="text-align: center; border-bottom: 2px solid #000; padding-bottom: 4px; margin-bottom: 5px;">
<img src="${FAT_LOGO_BASE64}" style="width: 100%; max-height: 80px; object-fit: contain; margin-bottom: 5px;">
<div class="report-title">${escapeHtml(report.report_type)} TESTING</div>
<div class="report-subtitle">FACTORY ACCEPTANCE TEST REPORT</div>
</div>
<table class="meta-table">
<tr>
<td class="meta-label">PROJECT NAME:</td>
<td class="meta-val">${escapeHtml(state.activeProjectSummary.name || '-')}</td>
<td class="meta-label">TESTING DATE:</td>
<td class="meta-val">${testingDateStr}</td>
</tr>
<tr>
<td class="meta-label">CONTAINER NO:</td>
<td class="meta-val">${escapeHtml(report.container_no || '-')}</td>
<td class="meta-label">PANEL TYPE:</td>
<td class="meta-val">${escapeHtml(report.panel_type || '-')}</td>
</tr>
<tr>
<td class="meta-label">LOCATION:</td>
<td class="meta-val">${escapeHtml(report.location || '-')}</td>
<td class="meta-label">RATED:</td>
<td class="meta-val">${escapeHtml(report.rated || '-')}</td>
</tr>
<tr>
<td class="meta-label">VOLTAGE:</td>
<td class="meta-val">${escapeHtml(report.voltage || '-')}</td>
<td class="meta-label">INSTRUMENT:</td>
<td class="meta-val">${escapeHtml(models[0] || '-')}</td>
</tr>
<tr>
<td class="meta-label">SERIAL NO:</td>
<td class="meta-val">${escapeHtml(sns[0] || '-')}</td>
<td class="meta-label">EXPIRE DATE:</td>
<td class="meta-val">${escapeHtml(exps[0] || '-')}</td>
</tr>
${(() => {
let rows = '';
for (let i = 1; i < maxLen; i++) {
rows += `
<tr>
<td class="meta-label"></td>
<td class="meta-val"></td>
<td class="meta-label">INSTRUMENT:</td>
<td class="meta-val">${escapeHtml(models[i] || '-')}</td>
</tr>
<tr>
<td class="meta-label">SERIAL NO:</td>
<td class="meta-val">${escapeHtml(sns[i] || '-')}</td>
<td class="meta-label">EXPIRE DATE:</td>
<td class="meta-val">${escapeHtml(exps[i] || '-')}</td>
</tr>
`;
}
return rows;
})()}
</table>
<table class="checklist-table ${report.report_type === 'AIR CONDITION INSPECTION' ? 'scale-large' : ''}" style="margin-top: 15px;">
<thead>
<tr>
<th width="30" style="text-align: center;">ลำดับ (ITEM)</th>
<th style="text-align: left; padding-left: 8px;">TEST CONDITION / DESCRIPTION</th>
<th width="30">PASS</th>
<th width="30">FAIL</th>
<th width="140">PHOTO / REMARK</th>
</tr>
</thead>
<tbody>
${(() => {
let photoCounter = 0;
const photoIndexMap = {}; // Maps itemId -> array of overall photo numbers (e.g. [1, 2])
items.forEach(it => {
const isHeader = !it.item_no || (!it.item_no.includes('.') && !it.description);
if (!isHeader && it.photos && it.photos.length > 0) {
photoIndexMap[it.id] = [];
it.photos.forEach(() => {
photoCounter++;
photoIndexMap[it.id].push(photoCounter);
});
}
});
return items.filter(item => {
if (item.item_no && item.item_no.toLowerCase().includes('tested')) {
return false;
}
return true;
}).map(item => {
const isHeader = !item.item_no || (!item.item_no.includes('.') && !item.description);
const bgStyle = isHeader ? 'style="background:#f2f2f2; font-weight:bold;"' : '';
const isTestedByRow = item.item_no && item.item_no.toLowerCase().includes('tested');
const displayItemNo = isTestedByRow ? calculatedTestedByItemNo : item.item_no;
const photoIndices = photoIndexMap[item.id] || [];
const photoStr = photoIndices.length > 0 ? `รูปภาพที่ ${photoIndices.join(', ')}` : '';
const remarkStr = item.remarks || '';
const photoVal = [photoStr, remarkStr].filter(Boolean).join(' / ');
return `
<tr ${bgStyle}>
<td class="text-center" style="font-weight:bold;">${escapeHtml(displayItemNo || '')}</td>
<td>
<div style="font-weight:bold; font-size:${report.report_type === 'AIR CONDITION INSPECTION' ? '13px' : '11px'}; color:#000;">
${escapeHtml(item.title || '')}
${item.description ? `<span style="font-weight:bold; font-size:${report.report_type === 'AIR CONDITION INSPECTION' ? '13px' : '11px'}; color:#000;">${item.title ? ' - ' : ''}${formatInlineDescriptionPrint(item.description)}</span>` : ''}
</div>
</td>
<td class="text-center" style="font-size:16px; font-weight:bold; color:#000;">
${item.result_pass === 1 ? '✓' : ''}
</td>
<td class="text-center" style="font-size:16px; font-weight:bold; color:#000;">
${item.result_fail === 1 ? '✗' : ''}
</td>
<td class="text-center" style="font-weight:bold;">${photoVal}</td>
</tr>
`;
}).join('');
})()}
</tbody>
</table>
<div class="sign-section">
<div style="text-align: center; width: 250px;">
<p style="font-weight: bold; margin-bottom: 50px;">TESTED BY</p>
<div style="border-bottom: 1px solid #000; width: 200px; margin: 0 auto;"></div>
<p style="font-size: 14px; margin-top: 6px; color: #000;">(....................................................)</p>
<p style="font-size: 14px; color: #000; margin-top: 3px;">วันที่: ....../....../......</p>
</div>
<div style="text-align: center; width: 250px;">
<p style="font-weight: bold; margin-bottom: 50px;">WITNESSED BY (CLIENT)</p>
<div style="border-bottom: 1px solid #000; width: 200px; margin: 0 auto;"></div>
<p style="font-size: 14px; margin-top: 6px; color: #000;">(....................................................)</p>
<p style="font-size: 14px; color: #000; margin-top: 3px;">วันที่: ....../....../......</p>
</div>
<div class="page-footer">หน้าที่ 1</div>
</div>
</div>
${(() => {
const photosToPrint = [];
items.forEach(it => {
if (it.photos && it.photos.length > 0) {
it.photos.forEach((photoId, idx) => {
photosToPrint.push({
item_no: it.item_no,
title: it.title,
photoId: photoId,
index: idx + 1,
total: it.photos.length
});
});
}
});
if (photosToPrint.length === 0) return '';
const photoChunks = [];
for (let i = 0; i < photosToPrint.length; i += 4) {
photoChunks.push(photosToPrint.slice(i, i + 4));
}
let curPage = 1;
let html = '';
photoChunks.forEach((chunk, chunkIdx) => {
curPage++;
html += `
<div class="print-page-wrapper">
<div class="report-header" style="margin-bottom: 20px; text-align: center; border-bottom: 2px solid #000; padding-bottom: 4px;">
<img src="${FAT_LOGO_BASE64}" style="width: 100%; max-height: 80px; object-fit: contain; margin-bottom: 5px;">
<div class="report-title">${escapeHtml(report.report_type)} TESTING</div>
<div class="report-subtitle">FACTORY ACCEPTANCE TEST REPORT</div>
</div>
<h2 style="text-align: center; border-bottom: 2px solid #000; padding-bottom: 8px; margin-bottom: 20px; font-size: 16px;">
ATTACHED PHOTOS (รูปภาพประกอบรายงานการทดสอบ)
</h2>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px;">
${chunk.map((item, idx) => {
const overallPhotoIndex = (chunkIdx * 4) + idx + 1;
return `
<div style="border: 1px solid #000; padding: 6px; text-align: center; break-inside: avoid; background: #fff;">
<div style="font-weight: bold; font-size: 12px; margin-bottom: 6px; text-align: left; border-bottom: 1px solid #000; padding-bottom: 2px;">
รูปภาพที่ ${overallPhotoIndex}: ข้อ ${item.item_no || ''} ${escapeHtml(item.title || '')} (${item.index}/${item.total})
</div>
<div style="height: 200px; display: flex; align-items: center; justify-content: center; background: #fafafa;">
<img src="${withAuthToken(`${window.location.origin}${API_BASE}/fat-reports/photos/${item.photoId}`)}" style="max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid #000;">
</div>
</div>
`;
}).join('')}
</div>
<div class="page-footer">หน้าที่ ${curPage}</div>
</div>
`;
});
return html;
})()}
</body>
</html>
`);
win.document.close();
win.focus();
const imgs = win.document.querySelectorAll('img');
if (imgs.length > 0) {
let loadedCount = 0;
const triggerPrint = () => {
loadedCount++;
if (loadedCount === imgs.length) {
win.print();
if (!isMobile) win.close();
}
};
imgs.forEach(img => {
if (img.complete) {
triggerPrint();
} else {
img.onload = triggerPrint;
img.onerror = triggerPrint;
}
});
setTimeout(() => {
if (loadedCount < imgs.length) {
win.print();
if (!isMobile) win.close();
}
}, 1500);
} else {
win.print();
if (!isMobile) win.close();
}
}
async function printAllFatReports() {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
try {
const resReports = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/fat-reports`);
if (!resReports.ok) throw new Error('Failed to fetch reports');
const reports = await resReports.json();
// Filter by currently selected container in the sidebar
const filterVal = document.getElementById('fat-filter-container') ? document.getElementById('fat-filter-container').value : '';
const filteredReports = filterVal ? reports.filter(r => r.container_no === filterVal) : reports;
if (filteredReports.length === 0) {
alert(state.currentLanguage === 'en' ? 'No reports to print' : 'ไม่มีรายงานสำหรับพิมพ์');
return;
}
const reportsWithItems = [];
for (const report of filteredReports) {
const resItems = await authFetch(`${API_BASE}/fat-reports/${report.id}/items`);
if (resItems.ok) {
const items = await resItems.json();
reportsWithItems.push({ report, items });
}
}
// Sort reports chronologically by creation date (oldest first)
reportsWithItems.reverse();
const win = window.open('', '_blank');
if (!win) {
alert('Please allow popups to print');
return;
}
let pageCounter = 0;
let bodyHtml = '';
reportsWithItems.forEach((rObj, reportIdx) => {
const report = rObj.report;
const items = rObj.items;
pageCounter++;
const testingDateStr = report.testing_date ? parseDbDate(report.testing_date).toLocaleDateString('th-TH') : '-';
const models = report.instrument_model ? report.instrument_model.split(',') : [];
const sns = report.instrument_sn ? report.instrument_sn.split(',') : [];
const exps = report.instrument_expire ? report.instrument_expire.split(',') : [];
const maxLen = Math.max(models.length, sns.length, exps.length, 1);
let calculatedTestedByItemNo = '4';
const lastNoRow = items.filter(it => it.item_no && it.item_no.includes('.')).pop();
if (lastNoRow && lastNoRow.item_no) {
const parts = lastNoRow.item_no.split('.');
if (parts.length === 2) {
const major = parseInt(parts[0]);
const minor = parseInt(parts[1]);
if (!isNaN(major) && !isNaN(minor)) {
calculatedTestedByItemNo = `${major}.${minor + 1}`;
}
}
}
let metaRows = `
<tr>
<td class="meta-label">PROJECT NAME:</td>
<td class="meta-val">${escapeHtml(state.activeProjectSummary.name || '-')}</td>
<td class="meta-label">TESTING DATE:</td>
<td class="meta-val">${testingDateStr}</td>
</tr>
<tr>
<td class="meta-label">CONTAINER NO:</td>
<td class="meta-val">${escapeHtml(report.container_no || '-')}</td>
<td class="meta-label">PANEL TYPE:</td>
<td class="meta-val">${escapeHtml(report.panel_type || '-')}</td>
</tr>
<tr>
<td class="meta-label">LOCATION:</td>
<td class="meta-val">${escapeHtml(report.location || '-')}</td>
<td class="meta-label">RATED:</td>
<td class="meta-val">${escapeHtml(report.rated || '-')}</td>
</tr>
<tr>
<td class="meta-label">VOLTAGE:</td>
<td class="meta-val">${escapeHtml(report.voltage || '-')}</td>
<td class="meta-label">INSTRUMENT:</td>
<td class="meta-val">${escapeHtml(models[0] || '-')}</td>
</tr>
<tr>
<td class="meta-label">SERIAL NO:</td>
<td class="meta-val">${escapeHtml(sns[0] || '-')}</td>
<td class="meta-label">EXPIRE DATE:</td>
<td class="meta-val">${escapeHtml(exps[0] || '-')}</td>
</tr>
`;
for (let i = 1; i < maxLen; i++) {
metaRows += `
<tr>
<td class="meta-label"></td>
<td class="meta-val"></td>
<td class="meta-label">INSTRUMENT:</td>
<td class="meta-val">${escapeHtml(models[i] || '-')}</td>
</tr>
<tr>
<td class="meta-label">SERIAL NO:</td>
<td class="meta-val">${escapeHtml(sns[i] || '-')}</td>
<td class="meta-label">EXPIRE DATE:</td>
<td class="meta-val">${escapeHtml(exps[i] || '-')}</td>
</tr>
`;
}
const filteredItems = items.filter(item => {
if (item.item_no && item.item_no.toLowerCase().includes('tested')) {
return false;
}
return true;
});
let reportPhotoCounter = 0;
const photoIndexMap = {}; // Maps itemId -> array of overall photo numbers (e.g. [1, 2])
filteredItems.forEach(it => {
const isHeader = !it.item_no || (!it.item_no.includes('.') && !it.description);
if (!isHeader && it.photos && it.photos.length > 0) {
photoIndexMap[it.id] = [];
it.photos.forEach(() => {
reportPhotoCounter++;
photoIndexMap[it.id].push(reportPhotoCounter);
});
}
});
// Checklist page
bodyHtml += `
<div class="print-page-wrapper ${report.report_type === 'CRAC INSPECTION' ? 'scale-90' : ''}">
<div class="report-header" style="text-align: center; border-bottom: 2px solid #000; padding-bottom: 4px; margin-bottom: 5px;">
<img src="${FAT_LOGO_BASE64}" style="width: 100%; max-height: 80px; object-fit: contain; margin-bottom: 5px;">
<div class="report-title">${escapeHtml(report.report_type)} TESTING</div>
<div class="report-subtitle">FACTORY ACCEPTANCE TEST REPORT</div>
</div>
<table class="meta-table">
${metaRows}
</table>
<table class="checklist-table ${report.report_type === 'AIR CONDITION INSPECTION' ? 'scale-large' : ''}">
<thead>
<tr>
<th width="40" style="text-align: center;">ลำดับ (ITEM)</th>
<th style="text-align: left; padding-left: 8px;">TEST CONDITION / DESCRIPTION</th>
<th width="45">PASS</th>
<th width="45">FAIL</th>
<th width="150">PHOTO / REMARK</th>
</tr>
</thead>
<tbody>
${filteredItems.map(item => {
const isHeader = !item.item_no || (!item.item_no.includes('.') && !item.description);
const bgStyle = isHeader ? 'style="background:#f2f2f2; font-weight:bold;"' : '';
const isTestedByRow = item.item_no && item.item_no.toLowerCase().includes('tested');
const displayItemNo = isTestedByRow ? calculatedTestedByItemNo : item.item_no;
const photoIndices = photoIndexMap[item.id] || [];
const photoStr = photoIndices.length > 0 ? `รูปภาพที่ ${photoIndices.join(', ')}` : '';
const remarkStr = item.remarks || '';
const photoVal = [photoStr, remarkStr].filter(Boolean).join(' / ');
return `
<tr ${bgStyle}>
<td class="text-center" style="font-weight:bold;">${escapeHtml(displayItemNo || '')}</td>
<td>
<div style="font-weight:bold; font-size:${report.report_type === 'AIR CONDITION INSPECTION' ? '13px' : '11px'}; color:#000;">
${escapeHtml(item.title || '')}
${item.description ? `<span style="font-weight:bold; font-size:${report.report_type === 'AIR CONDITION INSPECTION' ? '13px' : '11px'}; color:#000;">${item.title ? ' - ' : ''}${formatInlineDescriptionPrint(item.description)}</span>` : ''}
</div>
</td>
<td class="text-center" style="font-size:16px; font-weight:bold; color:#000;">
${item.result_pass === 1 ? '✓' : ''}
</td>
<td class="text-center" style="font-size:16px; font-weight:bold; color:#000;">
${item.result_fail === 1 ? '✗' : ''}
</td>
<td class="text-center" style="font-weight:bold;">${photoVal}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
<div class="sign-section">
<div style="text-align: center; width: 250px;">
<p style="font-weight: bold; margin-bottom: 50px;">TESTED BY</p>
<div style="border-bottom: 1px solid #000; width: 200px; margin: 0 auto;"></div>
<p style="font-size: 14px; margin-top: 6px; color: #000;">(....................................................)</p>
<p style="font-size: 14px; color: #000; margin-top: 3px;">วันที่: ....../....../......</p>
</div>
<div style="text-align: center; width: 250px;">
<p style="font-weight: bold; margin-bottom: 50px;">WITNESSED BY (CLIENT)</p>
<div style="border-bottom: 1px solid #000; width: 200px; margin: 0 auto;"></div>
<p style="font-size: 14px; margin-top: 6px; color: #000;">(....................................................)</p>
<p style="font-size: 14px; color: #000; margin-top: 3px;">วันที่: ....../....../......</p>
</div>
</div>
<div class="page-footer">หน้าที่ ${pageCounter}</div>
</div>
`;
// Photo pages
const photosToPrint = [];
filteredItems.forEach(it => {
if (it.photos && it.photos.length > 0) {
it.photos.forEach((photoId, idx) => {
photosToPrint.push({
item_no: it.item_no,
title: it.title,
photoId: photoId,
index: idx + 1,
total: it.photos.length
});
});
}
});
if (photosToPrint.length > 0) {
const photoChunks = [];
for (let i = 0; i < photosToPrint.length; i += 4) {
photoChunks.push(photosToPrint.slice(i, i + 4));
}
photoChunks.forEach((chunk, chunkIdx) => {
pageCounter++;
bodyHtml += `
<div class="print-page-wrapper">
<div class="report-header" style="margin-bottom: 20px; text-align: center; border-bottom: 2px solid #000; padding-bottom: 4px;">
<img src="${FAT_LOGO_BASE64}" style="width: 100%; max-height: 80px; object-fit: contain; margin-bottom: 5px;">
<div class="report-title">${escapeHtml(report.report_type)} TESTING</div>
<div class="report-subtitle">FACTORY ACCEPTANCE TEST REPORT</div>
</div>
<h2 style="text-align: center; border-bottom: 2px solid #000; padding-bottom: 8px; margin-bottom: 20px; font-size: 16px;">
ATTACHED PHOTOS (รูปภาพประกอบรายงานการทดสอบ)
</h2>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px;">
${chunk.map((item, idx) => {
const overallPhotoIndex = (chunkIdx * 4) + idx + 1;
return `
<div style="border: 1px solid #000; padding: 6px; text-align: center; break-inside: avoid; background: #fff;">
<div style="font-weight: bold; font-size: 12px; margin-bottom: 6px; text-align: left; border-bottom: 1px solid #000; padding-bottom: 2px;">
รูปภาพที่ ${overallPhotoIndex}: ข้อ ${item.item_no || ''} ${escapeHtml(item.title || '')} (${item.index}/${item.total})
</div>
<div style="height: 200px; display: flex; align-items: center; justify-content: center; background: #fafafa;">
<img src="${withAuthToken(`${window.location.origin}${API_BASE}/fat-reports/photos/${item.photoId}`)}" style="max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid #000;">
</div>
</div>
`;
}).join('')}
</div>
<div class="page-footer">หน้าที่ ${pageCounter}</div>
</div>
`;
});
}
});
win.document.write(`
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FAT Report Package - Project ${escapeHtml(state.activeProjectSummary.name || '')}</title>
<style>
body {
font-family: "TH Sarabun New", "Angsana New", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
padding: 0;
margin: 0;
color: #000;
font-weight: bold;
zoom: 95%;
}
.print-page-wrapper {
padding: 20px;
padding-top: 0;
height: 260mm;
position: relative;
box-sizing: border-box;
page-break-after: always;
}
.report-header {
text-align: center;
margin-bottom: 5px;
border-bottom: 2px solid #000;
padding-bottom: 4px;
}
.report-title {
font-size: 22px;
font-weight: bold;
margin: 2px 0;
text-transform: uppercase;
}
.report-subtitle {
font-size: 18px;
font-weight: bold;
margin: 2px 0;
}
.meta-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 6px;
table-layout: fixed;
}
.meta-table td {
padding: 1px 4px;
border: 1px solid #000;
}
.meta-table td:nth-child(1) {
width: 120px;
font-weight: bold;
background: #f2f2f2;
}
.meta-table td:nth-child(3) {
width: 120px;
font-weight: bold;
background: #f2f2f2;
}
.meta-table td:nth-child(4) {
width: 240px;
}
.checklist-table {
width: 100%;
border-collapse: collapse;
margin-top: 5px;
margin-bottom: 5px;
table-layout: fixed;
}
.checklist-table th, .checklist-table td {
border: 1px solid #000;
padding: 1px 2px;
text-align: left;
}
.checklist-table th {
background: #f2f2f2;
font-weight: bold;
text-align: center;
}
.checklist-table th:nth-child(1), .checklist-table td:nth-child(1) {
width: 40px;
text-align: center;
}
.checklist-table th:nth-child(3), .checklist-table td:nth-child(3) {
width: 45px;
text-align: center;
}
.checklist-table th:nth-child(4), .checklist-table td:nth-child(4) {
width: 45px;
text-align: center;
}
.checklist-table th:nth-child(5), .checklist-table td:nth-child(5) {
width: 150px;
}
.checklist-table td div {
font-size: 11px !important;
}
.checklist-table td span {
font-size: 11px !important;
font-weight: bold !important;
}
.checklist-table.scale-large th {
font-size: 13px !important;
}
.checklist-table.scale-large td div,
.checklist-table.scale-large td span {
font-size: 13px !important;
}
.text-center {
text-align: center !important;
}
.sign-section {
margin-top: 5px;
display: flex;
justify-content: space-between;
padding: 0 40px;
}
.sign-section div p:first-child {
margin-bottom: 15px !important;
}
.page-footer {
position: absolute;
bottom: -15mm;
right: 20px;
width: calc(100% - 40px);
text-align: right;
font-size: 11px;
font-weight: bold;
border-top: 1px dashed #ccc;
padding-top: 4px;
}
.scale-90 {
zoom: 90% !important;
}
@page {
margin-top: 12mm;
margin-bottom: 8mm;
margin-left: 0.4in;
margin-right: 0.4in;
}
@media print {
.print-page-wrapper {
padding-top: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
padding-bottom: 0 !important;
margin: 0 !important;
height: 260mm !important;
position: relative !important;
}
.no-print { display: none; }
}
</style>
</head>
<body>
${bodyHtml}
</body>
</html>
`);
win.document.close();
win.focus();
const imgs = win.document.querySelectorAll('img');
if (imgs.length > 0) {
let loadedCount = 0;
const triggerPrint = () => {
loadedCount++;
if (loadedCount === imgs.length) {
win.print();
if (!isMobile) win.close();
}
};
imgs.forEach(img => {
if (img.complete) {
triggerPrint();
} else {
img.onload = triggerPrint;
img.onerror = triggerPrint;
}
});
setTimeout(() => {
if (loadedCount < imgs.length) {
win.print();
if (!isMobile) win.close();
}
}, 2500);
} else {
win.print();
if (!isMobile) win.close();
}
} catch (err) {
console.error(err);
alert(state.currentLanguage === 'en' ? 'Failed to generate print package' : 'เกิดข้อผิดพลาดในการสร้างเอกสารพิมพ์');
}
}
// ==========================================
// TEST SCRIPT CLIENT LOGIC
// ==========================================
let activeTestScriptReportId = null;
async function openProjectTestScriptModal() {
if (!state.activeProjectId || !state.activeProjectSummary) return;
// Open modal
document.getElementById('modal-project-test-script').classList.add('active');
// Reset Detail view
activeTestScriptReportId = null;
document.getElementById('test-script-detail-pane').innerHTML = `
<div style="text-align: center; color: var(--text-muted); margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px;">
<i class="fa-solid fa-file-contract" style="font-size: 3rem; color: var(--border-color);"></i>
<p data-i18n="test_script_select_prompt">${state.currentLanguage === 'en' ? 'Please select a test script item from the list on the left.' : 'กรุณาเลือกรายการแนวทางการทดสอบจากรายการด้านซ้าย'}</p>
</div>
`;
await fetchAndRenderTestScriptsList();
}
function closeProjectTestScriptModal() {
document.getElementById('modal-project-test-script').classList.remove('active');
}
async function fetchAndRenderTestScriptsList() {
const container = document.getElementById('test-script-list-container');
if (!container) return;
container.innerHTML = `<div style="text-align:center; padding: 20px;"><i class="fa-solid fa-spinner fa-spin" style="color:var(--primary-color);"></i></div>`;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/test-scripts`);
if (!res.ok) throw new Error('Failed to fetch');
const scripts = await res.json();
container.innerHTML = '';
if (scripts.length === 0) {
container.innerHTML = `<div style="text-align:center; color:var(--text-muted); font-size:0.85rem; padding: 20px;">${state.currentLanguage === 'en' ? 'No test scripts found (Please create a FAT report first)' : 'ไม่มีแนวทางการทดสอบ (กรุณาสร้างรายงาน FAT ก่อน)'}</div>`;
return;
}
scripts.forEach(s => {
const item = document.createElement('div');
item.className = `test-script-list-item ${activeTestScriptReportId === s.id ? 'active' : ''}`;
// Format creation date
const dateStr = s.created_at ? parseDbDate(s.created_at).toLocaleDateString('th-TH') : '-';
item.innerHTML = `
<div style="font-weight:600; color:var(--text-dark); display:flex; justify-content:space-between; align-items:center; gap:8px;">
<span style="word-break:break-word; text-align:left;">${escapeHtml(s.report_type)}</span>
<span class="badge" style="font-size:0.75rem; padding: 2px 8px; border-radius: 12px; background: ${s.file_count > 0 ? '#10b981' : '#6b7280'}; color: white; flex-shrink:0;">
<i class="fa-solid fa-paperclip"></i> ${s.file_count}
</span>
</div>
<div style="font-size:0.75rem; color:var(--text-muted); text-align:left; margin-top:2px;">
<i class="fa-regular fa-calendar" style="margin-right:4px;"></i> ${dateStr}
</div>
`;
item.addEventListener('click', () => {
// Remove active class from all items
container.querySelectorAll('.test-script-list-item').forEach(el => el.classList.remove('active'));
item.classList.add('active');
loadTestScriptDetails(s.id, s.report_type);
});
container.appendChild(item);
});
} catch (err) {
console.error(err);
container.innerHTML = `<div style="text-align:center; color:var(--danger); font-size:0.85rem; padding: 20px;">${state.currentLanguage === 'en' ? 'Failed to load test scripts' : 'โหลดแนวทางการทดสอบไม่สำเร็จ'}</div>`;
}
}
async function loadTestScriptDetails(reportId, reportType) {
activeTestScriptReportId = reportId;
// Close mobile drawer if active
const sidebar = document.querySelector('#modal-project-test-script .test-script-sidebar');
if (sidebar) sidebar.classList.remove('drawer-active');
const pane = document.getElementById('test-script-detail-pane');
if (!pane) return;
pane.innerHTML = `<div style="text-align:center; padding: 40px;"><i class="fa-solid fa-spinner fa-spin fa-2x" style="color:var(--primary-color);"></i></div>`;
try {
const res = await authFetch(`${API_BASE}/test-scripts/${reportId}/files`);
if (!res.ok) throw new Error('Failed to fetch files');
const files = await res.json();
pane.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:15px; margin-bottom:20px; gap:15px;">
<h4 style="margin:0; font-size:1.25rem; color:var(--primary-color); display:flex; align-items:center; gap:8px; word-break:break-all;">
<i class="fa-solid fa-file-contract"></i> ${escapeHtml(reportType)}
</h4>
<div style="flex-shrink:0;">
<input type="file" id="test-script-file-uploader" style="display:none;" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp">
<button class="btn btn-primary btn-sm" id="btn-upload-test-script-trigger" style="display:flex; align-items:center; gap:6px;">
<i class="fa-solid fa-cloud-arrow-up"></i> ${state.currentLanguage === 'en' ? 'Upload File' : 'อัปโหลดไฟล์'}
</button>
</div>
</div>
<div id="test-script-files-table-container">
${renderTestScriptFilesTable(files)}
</div>
`;
// Register upload button handlers
const uploader = document.getElementById('test-script-file-uploader');
const triggerBtn = document.getElementById('btn-upload-test-script-trigger');
if (triggerBtn && uploader) {
triggerBtn.addEventListener('click', () => uploader.click());
uploader.addEventListener('change', (e) => handleTestScriptFilesUpload(e, reportId, reportType));
}
// Register preview click handlers
pane.querySelectorAll('.test-script-file-preview-btn, .btn-preview-test-script').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const fileId = el.getAttribute('data-id');
const name = el.getAttribute('data-name');
const fType = el.getAttribute('data-type');
openDocumentPreview(fileId, name, fType, true);
});
});
} catch (err) {
console.error(err);
pane.innerHTML = `<div style="text-align:center; color:var(--danger); padding:40px;">${state.currentLanguage === 'en' ? 'Error loading files' : 'เกิดข้อผิดพลาดในการโหลดไฟล์'}</div>`;
}
}
function renderTestScriptFilesTable(files) {
if (files.length === 0) {
return `
<div style="text-align:center; padding:40px; color:var(--text-muted); display:flex; flex-direction:column; align-items:center; gap:8px;">
<i class="fa-regular fa-folder-open" style="font-size:3rem; color:var(--border-color);"></i>
<p data-i18n="test_script_no_files">${state.currentLanguage === 'en' ? 'No files uploaded for this test script.' : 'ไม่มีไฟล์สำหรับแนวทางการทดสอบนี้'}</p>
</div>
`;
}
return `
<table class="table" style="width:100%; border-collapse:collapse; margin-top:10px;">
<thead>
<tr style="border-bottom:2px solid var(--border-color); text-align:left;">
<th style="padding:10px 8px;" data-i18n="test_script_file_name_label">${state.currentLanguage === 'en' ? 'File Name' : 'ชื่อไฟล์'}</th>
<th style="padding:10px 8px; width:120px;" data-i18n="test_script_file_size_label">${state.currentLanguage === 'en' ? 'File Size' : 'ขนาดไฟล์'}</th>
<th style="padding:10px 8px; width:180px;" data-i18n="test_script_uploaded_at_label">${state.currentLanguage === 'en' ? 'Uploaded At' : 'วันที่อัปโหลด'}</th>
<th style="padding:10px 8px; width:120px; text-align:center;">Action</th>
</tr>
</thead>
<tbody>
${files.map(f => {
const dateStr = f.uploaded_at ? parseDbDate(f.uploaded_at).toLocaleString('th-TH') : '-';
const sizeStr = formatBytes(f.file_size);
const ext = f.file_type.toLowerCase();
// Determine file icon
let iconClass = 'fa-file';
let iconColor = 'var(--text-muted)';
if (ext === 'pdf') {
iconClass = 'fa-file-pdf';
iconColor = '#ef4444';
} else if (['doc', 'docx'].includes(ext)) {
iconClass = 'fa-file-word';
iconColor = '#3b82f6';
} else if (['xls', 'xlsx'].includes(ext)) {
iconClass = 'fa-file-excel';
iconColor = '#22c55e';
} else if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext)) {
iconClass = 'fa-file-image';
iconColor = '#f59e0b';
}
return `
<tr style="border-bottom:1px solid var(--border-color);">
<td style="padding:10px 8px; vertical-align:middle; text-align:left;">
<div style="display:flex; align-items:center; gap:8px;">
<i class="fa-solid ${iconClass}" style="font-size:1.2rem; color:${iconColor};"></i>
<span class="test-script-file-preview-btn" data-id="${f.id}" data-name="${escapeHtml(f.file_name)}" data-type="${ext}" style="font-weight:500; color:var(--primary-color); cursor:pointer; word-break:break-all;" title="${state.currentLanguage === 'en' ? 'Click to preview' : 'คลิกเพื่อแสดงตัวอย่าง'}">${escapeHtml(f.file_name)}</span>
</div>
</td>
<td style="padding:10px 8px; vertical-align:middle; text-align:left; font-size:0.85rem; color:var(--text-muted);">${sizeStr}</td>
<td style="padding:10px 8px; vertical-align:middle; text-align:left; font-size:0.85rem; color:var(--text-muted);">${dateStr}</td>
<td style="padding:10px 8px; vertical-align:middle; text-align:center;">
<div style="display:inline-flex; gap:6px;">
<button class="btn btn-outline btn-xs btn-preview-test-script" data-id="${f.id}" data-name="${escapeHtml(f.file_name)}" data-type="${ext}" style="padding:3px 6px; font-size:0.75rem;" title="${state.currentLanguage === 'en' ? 'Preview' : 'แสดงตัวอย่าง'}">
<i class="fa-solid fa-eye text-primary-color"></i>
</button>
<a href="${withAuthToken(`${API_BASE}/test-scripts/files/${f.id}`)}" download class="btn btn-outline btn-xs" style="padding:3px 6px; font-size:0.75rem;" title="${state.currentLanguage === 'en' ? 'Download' : 'ดาวน์โหลด'}">
<i class="fa-solid fa-download text-success"></i>
</a>
<button class="btn btn-danger-outline btn-xs" style="padding:3px 6px; font-size:0.75rem;" onclick="handleDeleteTestScriptFile(${f.id}, ${f.fat_report_id}, '${escapeHtml(f.file_name)}')">
<i class="fa-solid fa-trash text-danger"></i>
</button>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
async function handleTestScriptFilesUpload(e, reportId, reportType) {
const files = e.target.files;
if (files.length === 0) return;
// Show loading indicator
const container = document.getElementById('test-script-files-table-container');
if (container) {
container.innerHTML = `<div style="text-align:center; padding: 40px;"><i class="fa-solid fa-spinner fa-spin fa-2x" style="color:var(--primary-color);"></i><p style="margin-top:10px; font-size:0.85rem; color:var(--text-muted);">กำลังอัปโหลดไฟล์...</p></div>`;
}
try {
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
const res = await authFetch(`${API_BASE}/test-scripts/${reportId}/files`, {
method: 'POST',
body: formData
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to upload file');
}
}
showToast(state.currentLanguage === 'en' ? 'Upload successful' : 'อัปโหลดไฟล์เรียบร้อยแล้ว');
// Reload details and list
await loadTestScriptDetails(reportId, reportType);
await fetchAndRenderTestScriptsList();
} catch (err) {
console.error(err);
showToast(err.message || (state.currentLanguage === 'en' ? 'Upload failed' : 'เกิดข้อผิดพลาดในการอัปโหลด'), 'error');
await loadTestScriptDetails(reportId, reportType);
}
}
window.handleDeleteTestScriptFile = async function(fileId, reportId, fileName) {
if (!confirm(state.currentLanguage === 'en' ? `Are you sure you want to delete file "${fileName}"?` : `คุณแน่ใจหรือไม่ว่าต้องการลบไฟล์ "${fileName}"?`)) {
return;
}
try {
const res = await authFetch(`${API_BASE}/test-scripts/files/${fileId}`, {
method: 'DELETE'
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'File deleted' : 'ลบไฟล์สำเร็จแล้ว');
// Reload active view and list
const activeItem = document.querySelector('.test-script-list-item.active');
const reportType = activeItem ? activeItem.querySelector('div span').textContent : '';
await loadTestScriptDetails(reportId, reportType);
await fetchAndRenderTestScriptsList();
} else {
const err = await res.json();
showToast(err.error || 'Failed to delete file', 'error');
}
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Network error' : 'เกิดข้อผิดพลาดในการเชื่อมต่อ', 'error');
}
};
async function fetchAndRenderProgressList() {
const listContainer = document.getElementById('progress-timeline-list');
listContainer.innerHTML = `<div style="padding:40px; text-align:center; color:var(--text-muted);"><i class="fa-solid fa-spinner fa-spin" style="font-size:2rem; margin-bottom:10px; color:var(--primary-color);"></i><p>${state.currentLanguage === 'en' ? 'Loading data...' : 'กำลังโหลดข้อมูล...'}</p></div>`;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/progress`);
if (!res.ok) throw new Error('Failed to load progress list');
const progressList = await res.json();
state.projectProgressList = progressList;
renderProgressTimeline();
} catch (err) {
console.error(err);
listContainer.innerHTML = `<div style="padding:40px; text-align:center; color:var(--danger);"><i class="fa-solid fa-circle-exclamation" style="font-size:2rem; margin-bottom:10px;"></i><p>${state.currentLanguage === 'en' ? 'Failed to load progress list' : 'ไม่สามารถโหลดข้อมูลความคืบหน้าได้'}</p></div>`;
}
}
function renderProgressTimeline() {
const listContainer = document.getElementById('progress-timeline-list');
listContainer.innerHTML = '';
if (!state.projectProgressList || state.projectProgressList.length === 0) {
listContainer.innerHTML = `
<div style="padding: 40px 20px; text-align: center; color: var(--text-muted);">
<i class="fa-solid fa-chart-line" style="font-size: 2.5rem; margin-bottom: 12px; display: block; color: var(--border-color);"></i>
${state.currentLanguage === 'en' ? 'No progress updates recorded yet.' : 'ยังไม่มีบันทึกความคืบหน้าในโครงการนี้'}
</div>
`;
return;
}
state.projectProgressList.forEach(item => {
const card = document.createElement('div');
card.className = 'progress-card';
const updateDate = parseDbDate(item.timestamp).toLocaleString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH');
const progressDescHtml = `
<div class="progress-card-section">
<div class="progress-card-section-label">${state.currentLanguage === 'en' ? 'Description' : 'รายละเอียดความคืบหน้า'}</div>
<div class="progress-card-section-content" style="white-space: pre-wrap;">${escapeHtml(item.progress_desc || '')}</div>
</div>
`;
const issuesHtml = item.issues ? `
<div class="progress-card-section" style="border-left: 3px solid var(--danger); padding-left: 8px; margin-top:8px;">
<div class="progress-card-section-label" style="color:var(--danger);">${state.currentLanguage === 'en' ? 'Issues & Obstacles' : 'ปัญหาและอุปสรรค'}</div>
<div class="progress-card-section-content" style="white-space: pre-wrap;">${escapeHtml(item.issues)}</div>
</div>
` : '';
const solutionsHtml = item.solutions ? `
<div class="progress-card-section" style="border-left: 3px solid var(--success); padding-left: 8px; margin-top:8px;">
<div class="progress-card-section-label" style="color:var(--success);">${state.currentLanguage === 'en' ? 'Action Plan / Solutions' : 'แนวทางแก้ไข'}</div>
<div class="progress-card-section-content" style="white-space: pre-wrap;">${escapeHtml(item.solutions)}</div>
</div>
` : '';
const suggestionsHtml = item.suggestions ? `
<div class="progress-card-section" style="border-left: 3px solid var(--primary-color); padding-left: 8px; margin-top:8px;">
<div class="progress-card-section-label" style="color:var(--primary-color);">${state.currentLanguage === 'en' ? 'Suggestions' : 'ข้อเสนอแนะ'}</div>
<div class="progress-card-section-content" style="white-space: pre-wrap;">${escapeHtml(item.suggestions)}</div>
</div>
` : '';
let actionButtonsHtml = '';
const isCreator = state.currentUser && state.currentUser.id === item.created_by;
const isAdmin = state.currentUser && (state.currentUser.role === 'admin' || state.currentUser.role === 'superadmin');
const isSupervisor = state.currentUser && (state.currentUser.role === 'supervisor' || state.currentUser.role === 'estimate');
if (isAdmin || isSupervisor || isCreator) {
actionButtonsHtml = `
<div class="progress-card-actions">
<button class="btn btn-sm btn-outline btn-edit-progress-card" data-id="${item.id}" title="${state.currentLanguage === 'en' ? 'Edit' : 'แก้ไข'}" style="padding: 2px 6px;">
<i class="fa-solid fa-edit text-primary-color" style="font-size:0.8rem;"></i>
</button>
<button class="btn btn-sm btn-outline btn-delete-progress-card" data-id="${item.id}" title="${state.currentLanguage === 'en' ? 'Delete' : 'ลบ'}" style="padding: 2px 6px;">
<i class="fa-solid fa-trash-can text-danger" style="font-size:0.8rem;"></i>
</button>
</div>
`;
}
let bodyContentHtml = '';
if (item.image_name) {
const imageUrl = withAuthToken(`${API_BASE}/projects/${state.activeProjectId}/progress/${item.id}/image?_ts=${Date.now()}`);
const titleAttr = item.image_title ? `title="${escapeHtml(item.image_title)}"` : '';
bodyContentHtml = `
<div class="progress-card-body-wrapper">
<div class="progress-card-left-col">
<div class="progress-card-image-box">
<img src="${imageUrl}" class="progress-card-img" ${titleAttr} onclick="openLightbox('${imageUrl}')" alt="${escapeHtml(item.image_title || 'Progress Image')}">
${item.image_title ? `<div class="progress-card-img-caption">${escapeHtml(item.image_title)}</div>` : ''}
</div>
</div>
<div class="progress-card-right-col">
${progressDescHtml}
${issuesHtml}
${solutionsHtml}
${suggestionsHtml}
</div>
</div>
`;
} else {
bodyContentHtml = `
<div class="progress-card-body-wrapper no-image">
<div class="progress-card-right-col">
${progressDescHtml}
${issuesHtml}
${solutionsHtml}
${suggestionsHtml}
</div>
</div>
`;
}
const sysName = item.system_name || (state.currentLanguage === 'en' ? 'General' : 'ทั่วไป');
card.innerHTML = `
<div class="progress-card-header">
<div class="progress-card-meta">
<span class="progress-meta-item datetime">
<i class="fa-solid fa-calendar-day"></i> ${updateDate}
</span>
<span class="progress-meta-item creator">
<i class="fa-solid fa-user-circle"></i> ${state.currentLanguage === 'en' ? 'By' : 'โดย'}: ${escapeHtml(item.creator_name || 'System')}
</span>
<span class="progress-meta-item system-badge">
<i class="fa-solid fa-gears"></i> ${escapeHtml(sysName)}
</span>
</div>
${actionButtonsHtml}
</div>
${bodyContentHtml}
`;
const editBtn = card.querySelector('.btn-edit-progress-card');
if (editBtn) {
editBtn.addEventListener('click', () => {
openProgressEditForm(item);
});
}
const deleteBtn = card.querySelector('.btn-delete-progress-card');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
deleteProgressCard(item.id);
});
}
listContainer.appendChild(card);
});
}
function openProgressEditForm(item) {
clearProgressForm();
document.getElementById('progress-edit-id').value = item.id;
document.getElementById('progress-form-title').innerHTML = `<i class="fa-solid fa-edit"></i> ${state.currentLanguage === 'en' ? 'Edit Progress Record' : 'แก้ไขข้อมูลความคืบหน้า'}`;
document.getElementById('progress-image-title').value = item.image_title || '';
document.getElementById('progress-desc').value = item.progress_desc || '';
document.getElementById('progress-issues').value = item.issues || '';
document.getElementById('progress-solutions').value = item.solutions || '';
document.getElementById('progress-suggestions').value = item.suggestions || '';
// Set system dropdown
buildProgressSystemDropdown();
const systemSelect = document.getElementById('progress-system-select');
const customInput = document.getElementById('progress-system-custom');
const itemSystem = item.system_name || 'ทั่วไป';
let exists = false;
for (let i = 0; i < systemSelect.options.length; i++) {
if (systemSelect.options[i].value === itemSystem) {
systemSelect.value = itemSystem;
exists = true;
break;
}
}
if (!exists) {
systemSelect.value = 'CUSTOM';
customInput.value = itemSystem;
customInput.style.display = 'block';
customInput.required = true;
} else {
customInput.value = '';
customInput.style.display = 'none';
customInput.required = false;
}
if (item.timestamp) {
const d = parseDbDate(item.timestamp);
const tzoffset = d.getTimezoneOffset() * 60000;
const localISOTime = (new Date(d - tzoffset)).toISOString().slice(0, 16);
document.getElementById('progress-timestamp').value = localISOTime;
}
if (item.image_name) {
const placeholder = document.getElementById('progress-image-preview-placeholder');
const container = document.getElementById('progress-image-preview-container');
const img = document.getElementById('progress-image-preview-img');
img.src = withAuthToken(`${API_BASE}/projects/${state.activeProjectId}/progress/${item.id}/image?_ts=${Date.now()}`);
placeholder.style.display = 'none';
container.style.display = 'block';
}
document.getElementById('progress-form-panel').style.display = 'flex';
document.getElementById('modal-project-progress').classList.add('form-open');
document.getElementById('progress-form-panel').scrollIntoView({ behavior: 'smooth' });
}
async function deleteProgressCard(id) {
if (confirm(state.currentLanguage === 'en'
? 'Are you sure you want to delete this progress update?'
: 'คุณต้องการลบบันทึกความคืบหน้านี้ใช่หรือไม่?')) {
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/progress/${id}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete failed');
showToast(state.currentLanguage === 'en' ? 'Progress update deleted' : 'ลบบันทึกความคืบหน้าแล้ว');
await fetchAndRenderProgressList();
const currentEditId = document.getElementById('progress-edit-id').value;
if (currentEditId == id) {
document.getElementById('progress-form-panel').style.display = 'none';
document.getElementById('modal-project-progress').classList.remove('form-open');
clearProgressForm();
}
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
}
}
}
function clearProgressForm() {
document.getElementById('form-project-progress').reset();
document.getElementById('progress-edit-id').value = '';
document.getElementById('progress-form-title').innerHTML = `<i class="fa-solid fa-plus"></i> ${state.currentLanguage === 'en' ? 'Record New Progress' : 'บันทึกความคืบหน้าใหม่'}`;
document.getElementById('progress-system-custom').style.display = 'none';
document.getElementById('progress-system-custom').required = false;
clearProgressImagePreview();
}
async function toggleProgressCamera() {
const captureInput = document.getElementById('progress-camera-capture-input');
if (captureInput) {
captureInput.click();
} else {
showToast(state.currentLanguage === 'en' ? 'Camera not supported' : 'ระบบกล้องไม่รองรับบนอุปกรณ์นี้', 'danger');
}
}
function stopProgressCamera() {
const video = document.getElementById('progress-camera-video');
const container = document.getElementById('progress-camera-container');
if (state.progressCameraStream) {
state.progressCameraStream.getTracks().forEach(track => track.stop());
state.progressCameraStream = null;
}
video.srcObject = null;
container.style.display = 'none';
}
function snapProgressPhoto() {
const video = document.getElementById('progress-camera-video');
if (!state.progressCameraStream) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext('2d');
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(async (blob) => {
const timestampStr = new Date().toISOString().replace(/[:.]/g, '-');
const file = new File([blob], `captured-${timestampStr}.jpg`, { type: 'image/jpeg' });
await processAndSetProgressImage(file);
toggleProgressCamera();
}, 'image/jpeg', 0.9);
}
function setupProgressImageDropzone() {
const dropzone = document.getElementById('progress-image-dropzone');
const fileInput = document.getElementById('progress-image-input');
if (!dropzone || !fileInput) return;
dropzone.addEventListener('click', (e) => {
if (e.target.closest('#btn-remove-progress-image')) return;
fileInput.click();
});
fileInput.addEventListener('change', async (e) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
await processAndSetProgressImage(file);
}
});
['dragenter', 'dragover'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('hover');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('hover');
}, false);
});
dropzone.addEventListener('drop', async (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
await processAndSetProgressImage(file);
} else {
showToast(state.currentLanguage === 'en' ? 'Only images are supported' : 'รองรับเฉพาะไฟล์รูปภาพเท่านั้น', 'danger');
}
}
}, false);
const removeBtn = document.getElementById('btn-remove-progress-image');
if (removeBtn) {
removeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
clearProgressImagePreview();
});
}
// Mobile native camera capture listener
const captureInput = document.getElementById('progress-camera-capture-input');
if (captureInput) {
captureInput.addEventListener('change', async (e) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
await processAndSetProgressImage(file);
}
});
}
}
function displayProgressImagePreview(file) {
const placeholder = document.getElementById('progress-image-preview-placeholder');
const container = document.getElementById('progress-image-preview-container');
const img = document.getElementById('progress-image-preview-img');
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target.result;
placeholder.style.display = 'none';
container.style.display = 'block';
};
reader.readAsDataURL(file);
}
function clearProgressImagePreview() {
const placeholder = document.getElementById('progress-image-preview-placeholder');
const container = document.getElementById('progress-image-preview-container');
const img = document.getElementById('progress-image-preview-img');
const fileInput = document.getElementById('progress-image-input');
state.progressSelectedFile = null;
fileInput.value = '';
img.src = '';
placeholder.style.display = 'block';
container.style.display = 'none';
}
function setCurrentProgressTimestamp() {
const input = document.getElementById('progress-timestamp');
if (input && !input.value) {
const now = new Date();
const tzoffset = now.getTimezoneOffset() * 60000;
const localISOTime = (new Date(now - tzoffset)).toISOString().slice(0, 16);
input.value = localISOTime;
}
}
async function handleProgressFormSubmit(e) {
e.preventDefault();
const descInput = document.getElementById('progress-desc');
if (!descInput.value.trim()) {
showToast(state.currentLanguage === 'en' ? 'Please fill in the progress description' : 'กรุณากรอกคำบรรยายความคืบหน้า', 'danger');
return;
}
const saveBtn = document.getElementById('btn-save-progress-submit');
const originalText = saveBtn ? saveBtn.innerHTML : 'บันทึก';
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.innerHTML = state.currentLanguage === 'en' ? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...' : '<i class="fa-solid fa-spinner fa-spin"></i> กำลังบันทึก...';
}
const idInput = document.getElementById('progress-edit-id');
const progressId = idInput.value;
const formData = new FormData();
formData.append('image_title', document.getElementById('progress-image-title').value.trim());
formData.append('progress_desc', descInput.value.trim());
formData.append('issues', document.getElementById('progress-issues').value.trim());
formData.append('solutions', document.getElementById('progress-solutions').value.trim());
formData.append('suggestions', document.getElementById('progress-suggestions').value.trim());
// Add system_name field
const systemSelect = document.getElementById('progress-system-select');
let systemName = systemSelect.value;
if (systemName === 'CUSTOM') {
const customInput = document.getElementById('progress-system-custom');
systemName = customInput.value.trim() || 'ทั่วไป';
}
formData.append('system_name', systemName);
const timestampVal = document.getElementById('progress-timestamp').value;
if (timestampVal) {
formData.append('timestamp', parseDbDate(timestampVal).toISOString());
}
if (state.progressSelectedFile) {
formData.append('image', state.progressSelectedFile);
}
try {
let url = `${API_BASE}/projects/${state.activeProjectId}/progress`;
let method = 'POST';
if (progressId) {
url += `/${progressId}`;
method = 'PUT';
}
const res = await authFetch(url, {
method: method,
body: formData
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to save progress update');
}
showToast(state.currentLanguage === 'en' ? 'Progress saved successfully' : 'บันทึกความคืบหน้าสำเร็จแล้ว');
document.getElementById('progress-form-panel').style.display = 'none';
document.getElementById('modal-project-progress').classList.remove('form-open');
clearProgressForm();
await fetchAndRenderProgressList();
} catch (err) {
console.error(err);
showToast(err.message, 'danger');
} finally {
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
}
}
}
// Testing controls setup
if (window.location.search.includes('test=true')) {
const testContainer = document.getElementById('test-docs-controls');
if (testContainer) {
testContainer.style.display = 'flex';
document.getElementById('btn-test-upload-dwg').addEventListener('click', () => window.TEST_UPLOAD_DOC('layout_plan.dwg'));
document.getElementById('btn-test-upload-pdf').addEventListener('click', () => window.TEST_UPLOAD_DOC('specifications.pdf'));
document.getElementById('btn-test-upload-zip').addEventListener('click', () => window.TEST_UPLOAD_DOC('archive_data.zip'));
document.getElementById('btn-test-upload-xlsx').addEventListener('click', () => window.TEST_UPLOAD_DOC('schedule.xlsx'));
document.getElementById('btn-test-upload-docx').addEventListener('click', () => window.TEST_UPLOAD_DOC('document.docx'));
}
}
async function handleDocsUpload(files) {
if (!files || files.length === 0) return;
const allowedExtensions = ['pdf', 'xls', 'xlsx', 'dwg', 'rar', 'zip', 'doc', 'docx'];
const maxSizeBytes = 50 * 1024 * 1024; // 50MB
const validFiles = [];
for (let file of files) {
const ext = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase();
if (!allowedExtensions.includes(ext)) {
showToast(state.currentLanguage === 'en'
? `File "${file.name}" is not supported. Allowed: PDF, DOC, DOCX, XLS, XLSX, DWG, RAR, ZIP`
: `ไม่รองรับไฟล์ "${file.name}" (อนุญาตเฉพาะ PDF, DOC, DOCX, XLS, XLSX, DWG, RAR, ZIP)`, 'danger');
return;
}
if (file.size > maxSizeBytes) {
showToast(state.currentLanguage === 'en'
? `File "${file.name}" exceeds the 50MB size limit.`
: `ไฟล์ "${file.name}" มีขนาดเกิน 50MB`, 'danger');
return;
}
validFiles.push(file);
}
if (validFiles.length === 0) return;
showToast(state.currentLanguage === 'en' ? 'Uploading files...' : 'กำลังอัปโหลดไฟล์...', 'info');
let successCount = 0;
const failedFiles = [];
for (let file of validFiles) {
const formData = new FormData();
formData.append('document', file);
formData.append('file_path', state.currentDocFolderPath);
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/documents`, {
method: 'POST',
body: formData
});
if (res.ok) {
successCount++;
} else {
const err = await res.json().catch(() => ({}));
console.error(`Failed to upload ${file.name}:`, err.error);
failedFiles.push(file.name);
}
} catch (err) {
console.error(err);
failedFiles.push(file.name);
}
}
if (successCount > 0) {
showToast(state.currentLanguage === 'en'
? `Successfully uploaded ${successCount} files`
: `อัปโหลดไฟล์สำเร็จแล้ว ${successCount} ไฟล์`);
} else if (failedFiles.length === 0) {
showToast(state.currentLanguage === 'en' ? 'Failed to upload files' : 'อัปโหลดไฟล์ไม่สำเร็จ', 'error');
}
if (failedFiles.length > 0) {
const msg = state.currentLanguage === 'en'
? `Warning: The following files failed to upload:\n- ${failedFiles.join('\n- ')}`
: `คำเตือน: ไฟล์ต่อไปนี้อัปโหลดไม่สำเร็จ:\n- ${failedFiles.join('\n- ')}`;
alert(msg);
}
await fetchActiveProjectDocs();
renderProjectDocs();
}
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// ==========================================
// Print reports formatting triggers
// ==========================================
function triggerPrintReport(mode) {
if (!state.activeProjectSummary) return;
// Clear print mode classes
document.body.classList.remove('print-mode-quotation', 'print-mode-bom', 'print-mode-free-issue');
if (mode === 'quotation') {
document.body.classList.add('print-mode-quotation');
document.getElementById('print-doc-title-text').textContent = t('print_title_quote');
} else if (mode === 'bom') {
document.body.classList.add('print-mode-bom');
document.getElementById('print-doc-title-text').textContent = t('print_title_bom');
} else if (mode === 'free-issue') {
document.body.classList.add('print-mode-free-issue');
document.getElementById('print-doc-title-text').textContent = t('print_title_free_issue');
}
document.getElementById('print-date').textContent = new Date().toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH');
document.getElementById('print-doc-number').textContent = `QT-2026-${state.activeProjectSummary.id.toString().padStart(4, '0')}`;
const displayProjectPrintName = (state.activeProjectSummary.external_name)
? state.activeProjectSummary.external_name
: state.activeProjectSummary.name;
document.getElementById('print-project-name').textContent = displayProjectPrintName;
document.getElementById('print-client-name').textContent = state.activeProjectSummary.client_name || '-';
document.getElementById('print-project-desc').textContent = state.activeProjectSummary.description || '-';
document.getElementById('print-val-mt-quote').textContent = formatCurrency(state.activeProjectSummary.price_material);
document.getElementById('print-val-lb-quote').textContent = formatCurrency(state.activeProjectSummary.price_labour);
document.getElementById('print-val-total-quote').textContent = formatCurrency(state.activeProjectSummary.price_total);
document.getElementById('print-val-total-cost').textContent = formatCurrency(state.activeProjectSummary.cost_total);
document.getElementById('print-val-profit').textContent = `${formatCurrency(state.activeProjectSummary.profit)} (${state.activeProjectSummary.profit_margin_percent.toFixed(2)}%)`;
// Populate sections breakdown table
const breakdownContainer = document.getElementById('print-sections-breakdown-container');
if (breakdownContainer) {
let sectionsHtml = '';
if (mode === 'quotation') {
// Detailed items quotation report without individual item pricing (Product Name and Quantity only)
sectionsHtml = `
<h3 class="print-section-title" style="margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #1e293b; padding-bottom: 4px; font-size: 1.1rem; color: #1e293b; font-family: var(--font-heading);">
${state.currentLanguage === 'en' ? 'Quotation Items Details' : 'รายละเอียดใบเสนอราคาสินค้า'}
</h3>
<table class="print-breakdown-table" style="width: 100%; border-collapse: collapse; margin-bottom: 25px; font-size: 0.85rem; font-family: var(--font-body);">
<thead>
<tr style="background-color: #f1f5f9; border-bottom: 2px solid #1e293b; font-weight: 700; color: #1e293b;">
<th style="padding: 8px; text-align: left; border: 1px solid #cbd5e1;">${state.currentLanguage === 'en' ? 'Product Name' : 'ชื่อสินค้า'}</th>
<th style="padding: 8px; text-align: center; border: 1px solid #cbd5e1; width: 200px;">${state.currentLanguage === 'en' ? 'Quantity' : 'จำนวน'}</th>
</tr>
</thead>
<tbody>
`;
const sections = state.activeProjectSummary.sections || [];
sections.forEach(sec => {
const items = sec.items || [];
if (items.length > 0) {
// Show system section name header row
sectionsHtml += `
<tr style="background-color: #f8fafc; font-weight: 700; border-bottom: 1px solid #cbd5e1;">
<td colspan="2" style="padding: 8px; text-align: left; border: 1px solid #cbd5e1; color: var(--primary);">
[${state.currentLanguage === 'en' ? 'System' : 'ระบบงาน'}]: ${escapeHtml(sec.name)}
</td>
</tr>
`;
items.forEach(item => {
const displayDesc = item.brand ? `[${escapeHtml(item.brand)}] ${escapeHtml(item.description)}` : escapeHtml(item.description);
sectionsHtml += `
<tr style="border-bottom: 1px solid #cbd5e1;">
<td style="padding: 8px; text-align: left; border: 1px solid #cbd5e1; padding-left: 20px;">${displayDesc}</td>
<td style="padding: 8px; text-align: center; border: 1px solid #cbd5e1;">${item.qty} ${escapeHtml(item.unit)}</td>
</tr>
`;
});
}
});
sectionsHtml += `
</tbody>
</table>
`;
} else {
// General breakdown (BOM or Free Issue summary)
const isInternal = (mode === 'bom'); // bom shows internal cost & profit, quotation/free-issue does not
sectionsHtml = `
<h3 class="print-section-title" style="margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #1e293b; padding-bottom: 4px; font-size: 1.1rem; color: #1e293b; font-family: var(--font-heading);">
${state.currentLanguage === 'en' ? 'Summary by System' : 'สรุปประมาณราคาแยกตามระบบงาน'}
</h3>
<table class="print-breakdown-table" style="width: 100%; border-collapse: collapse; margin-bottom: 25px; font-size: 0.85rem; font-family: var(--font-body);">
<thead>
<tr style="background-color: #f1f5f9; border-bottom: 2px solid #1e293b; font-weight: 700; color: #1e293b;">
<th style="padding: 8px; text-align: left; border: 1px solid #cbd5e1;">${state.currentLanguage === 'en' ? 'System Name' : 'ระบบงาน'}</th>
<th style="padding: 8px; text-align: right; border: 1px solid #cbd5e1; width: 150px;">${state.currentLanguage === 'en' ? 'Material Quote' : 'เสนอราคาค่าวัสดุ'}</th>
<th style="padding: 8px; text-align: right; border: 1px solid #cbd5e1; width: 150px;">${state.currentLanguage === 'en' ? 'Labour Quote' : 'เสนอราคาค่าแรง'}</th>
<th style="padding: 8px; text-align: right; border: 1px solid #cbd5e1; width: 150px; background-color: #f8fafc;">${state.currentLanguage === 'en' ? 'Total Quote' : 'เสนอราคารวม'}</th>
${isInternal ? `
<th style="padding: 8px; text-align: right; border: 1px solid #cbd5e1; width: 150px; color: #0284c7;">${state.currentLanguage === 'en' ? 'Total Cost' : 'ต้นทุนรวม'}</th>
<th style="padding: 8px; text-align: right; border: 1px solid #cbd5e1; width: 120px; color: #059669;">${state.currentLanguage === 'en' ? 'Profit' : 'กำไร'}</th>
` : ''}
</tr>
</thead>
<tbody>
`;
const sections = state.activeProjectSummary.sections || [];
sections.forEach(sec => {
const priceMat = sec.price_material || 0;
const priceLab = sec.price_labour || 0;
const priceTotal = sec.price_total || 0;
const costTotal = sec.cost_total || 0;
const profit = sec.profit || 0;
sectionsHtml += `
<tr style="border-bottom: 1px solid #cbd5e1;">
<td style="padding: 8px; text-align: left; font-weight: 600; border: 1px solid #cbd5e1; color: #334155;">${escapeHtml(sec.name)}</td>
<td style="padding: 8px; text-align: right; border: 1px solid #cbd5e1;">${formatCurrency(priceMat)}</td>
<td style="padding: 8px; text-align: right; border: 1px solid #cbd5e1;">${formatCurrency(priceLab)}</td>
<td style="padding: 8px; text-align: right; font-weight: 700; border: 1px solid #cbd5e1; background-color: #f8fafc; color: #0f172a;">${formatCurrency(priceTotal)}</td>
${isInternal ? `
<td style="padding: 8px; text-align: right; border: 1px solid #cbd5e1; color: #0f172a;">${formatCurrency(costTotal)}</td>
<td style="padding: 8px; text-align: right; font-weight: 700; border: 1px solid #cbd5e1; color: ${profit >= 0 ? '#15803d' : '#b91c1c'};">${formatCurrency(profit)}</td>
` : ''}
</tr>
`;
});
// Overall Grand Total row
const grandPriceMat = state.activeProjectSummary.price_material || 0;
const grandPriceLab = state.activeProjectSummary.price_labour || 0;
const grandPriceTotal = state.activeProjectSummary.price_total || 0;
const grandCostTotal = state.activeProjectSummary.cost_total || 0;
const grandProfit = state.activeProjectSummary.profit || 0;
sectionsHtml += `
<tr style="background-color: #e2e8f0; font-weight: 700; border-top: 2px solid #1e293b;">
<td style="padding: 10px 8px; text-align: left; border: 1px solid #cbd5e1; color: #0f172a;">${state.currentLanguage === 'en' ? 'Grand Total' : 'ยอดรวมทั้งสิ้น'}</td>
<td style="padding: 10px 8px; text-align: right; border: 1px solid #cbd5e1;">${formatCurrency(grandPriceMat)}</td>
<td style="padding: 10px 8px; text-align: right; border: 1px solid #cbd5e1;">${formatCurrency(grandPriceLab)}</td>
<td style="padding: 10px 8px; text-align: right; border: 1px solid #cbd5e1; background-color: #cbd5e1; color: #0f172a; font-size: 0.95rem;">${formatCurrency(grandPriceTotal)}</td>
${isInternal ? `
<td style="padding: 10px 8px; text-align: right; border: 1px solid #cbd5e1; color: #0f172a;">${formatCurrency(grandCostTotal)}</td>
<td style="padding: 10px 8px; text-align: right; border: 1px solid #cbd5e1; color: ${grandProfit >= 0 ? '#15803d' : '#b91c1c'}; font-size: 0.95rem;">${formatCurrency(grandProfit)}</td>
` : ''}
</tr>
</tbody>
</table>
`;
}
breakdownContainer.innerHTML = sectionsHtml;
}
setTimeout(() => {
window.print();
}, 150);
}
// ==========================================
// Helper Functions
// ==========================================
async function fetchCategories() {
try {
const res = await authFetch(`${API_BASE}/categories`);
state.categories = await res.json();
} catch (err) {
console.error('Error fetching categories', err);
}
}
async function fetchGroups() {
try {
const res = await authFetch(`${API_BASE}/groups`);
state.groups = await res.json();
} catch (err) {
console.error('Error fetching groups', err);
}
}
async function fetchSystems() {
try {
const res = await authFetch(`${API_BASE}/systems`);
state.systems = await res.json();
} catch (err) {
console.error('Error fetching systems', err);
}
}
function buildCategoryDropdown(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
const prevVal = select.value;
const firstOption = select.options[0];
select.innerHTML = '';
if (firstOption) select.appendChild(firstOption);
state.categories.forEach(cat => {
const option = document.createElement('option');
option.value = cat;
option.textContent = cat;
select.appendChild(option);
});
if (prevVal && Array.from(select.options).some(opt => opt.value === prevVal)) {
select.value = prevVal;
}
}
function buildGroupDropdown(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
const prevVal = select.value;
const firstOption = select.options[0];
select.innerHTML = '';
if (firstOption) select.appendChild(firstOption);
const groups = state.groups || [];
groups.forEach(group => {
const option = document.createElement('option');
option.value = group;
option.textContent = group;
select.appendChild(option);
});
if (prevVal && Array.from(select.options).some(opt => opt.value === prevVal)) {
select.value = prevVal;
}
}
function buildSystemDropdown(selectId) {
const select = document.getElementById(selectId);
if (!select) return;
const prevVal = select.value;
const firstOption = select.options[0];
select.innerHTML = '';
if (firstOption) select.appendChild(firstOption);
const systems = state.systems || [];
systems.forEach(system => {
const option = document.createElement('option');
option.value = system;
option.textContent = system;
select.appendChild(option);
});
if (prevVal && Array.from(select.options).some(opt => opt.value === prevVal)) {
select.value = prevVal;
}
}
function openLightbox(src) {
const lightbox = document.getElementById('modal-lightbox');
const img = document.getElementById('lightbox-img');
img.src = src;
lightbox.classList.add('active');
}
function formatCurrency(val) {
const num = parseFloat(val) || 0;
return num.toLocaleString('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ฿';
}
function formatNumber(val) {
const num = parseFloat(val) || 0;
return num.toLocaleString('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function showToast(msg, type = 'success') {
const toast = document.getElementById('toast');
const msgEl = document.getElementById('toast-msg');
const iconEl = document.getElementById('toast-icon');
msgEl.textContent = msg;
if (type === 'success') {
iconEl.className = 'fa-solid fa-circle-check toast-icon';
iconEl.style.color = 'var(--success)';
} else {
iconEl.className = 'fa-solid fa-circle-xmark toast-icon error';
iconEl.style.color = 'var(--danger)';
}
toast.classList.add('active');
if (toast.timeoutId) clearTimeout(toast.timeoutId);
toast.timeoutId = setTimeout(() => {
toast.classList.remove('active');
}, 3000);
}
function escapeHtml(string) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return String(string).replace(/[&<>"']/g, function (m) { return map[m]; });
}
function escapeJsString(str) {
return String(str)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r');
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// ==========================================
// 6. PROPOSAL LETTER EDITOR MODAL HANDLERS
// ==========================================
function openProposalEditor() {
if (!state.activeProjectId || !state.activeProjectSummary) return;
const editorModal = document.getElementById('modal-proposal-editor');
// Set metadata fields
document.getElementById('prop-doc-no').textContent = `QT-2026-${state.activeProjectSummary.id.toString().padStart(4, '0')}`;
// Formatting current date dynamically
const options = { day: 'numeric', month: 'long', year: 'numeric' };
document.getElementById('prop-doc-date').textContent = new Date().toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH', options);
document.getElementById('prop-client-name').textContent = state.activeProjectSummary.client_name || (state.currentLanguage === 'en' ? 'Not Specified' : 'ไม่ระบุชื่อลูกค้า');
document.getElementById('prop-project-name').textContent = state.activeProjectSummary.name;
document.getElementById('prop-submitter-name').textContent = state.currentUser ? translateRole(state.currentUser.role) + ' (' + state.currentUser.username + ')' : t('letter_sig_pm');
// Populate summary table
const tbody = document.getElementById('prop-table-tbody');
tbody.innerHTML = '';
const sections = state.activeProjectSummary.sections || [];
if (sections.length === 0) {
const noSectionsText = state.currentLanguage === 'en' ? 'No systems to summarize in this report.' : 'ยังไม่มีข้อมูลระบบงานเพื่อสรุปรายงาน';
tbody.innerHTML = `
<tr>
<td colspan="4" style="padding: 15px; text-align: center; color: var(--text-muted);">
${noSectionsText}
</td>
</tr>
`;
} else {
sections.forEach(sec => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td style="padding: 8px 10px; border-bottom: 1px solid var(--border-color); text-align: left;">
${escapeHtml(sec.name)}
</td>
<td style="padding: 8px 10px; border-bottom: 1px solid var(--border-color); text-align: right;">
${formatCurrency(sec.price_material)}
</td>
<td style="padding: 8px 10px; border-bottom: 1px solid var(--border-color); text-align: right;">
${formatCurrency(sec.price_labour)}
</td>
<td style="padding: 8px 10px; border-bottom: 1px solid var(--border-color); text-align: right; font-weight: 600;">
${formatCurrency(sec.price_total)}
</td>
`;
tbody.appendChild(tr);
});
}
// Set summary totals
document.getElementById('prop-total-mt').textContent = formatCurrency(state.activeProjectSummary.price_material);
document.getElementById('prop-total-lb').textContent = formatCurrency(state.activeProjectSummary.price_labour);
document.getElementById('prop-total-all').textContent = formatCurrency(state.activeProjectSummary.price_total);
// Set digital stamp display based on project approval status
const isApproved = state.activeProjectSummary.status === 'อนุมัติแล้ว';
document.getElementById('prop-approval-stamp').style.display = isApproved ? 'block' : 'none';
editorModal.classList.add('active');
}
function closeProposalEditor() {
document.getElementById('modal-proposal-editor').classList.remove('active');
}
async function handleProjectApprovalToggle() {
if (!state.activeProjectId || !state.activeProjectSummary) return;
const isApproved = state.activeProjectSummary.status === 'อนุมัติแล้ว';
const actionEndpoint = isApproved ? 'pending' : 'approve';
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/${actionEndpoint}`, {
method: 'POST'
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to toggle project status');
}
const resData = await res.json();
showToast(
isApproved
? (state.currentLanguage === 'en' ? 'Project approval cancelled' : 'ยกเลิกการอนุมัติเรียบร้อยแล้ว')
: t('toast_project_approved')
);
await fetchActiveProjectDetails();
renderWorkspace();
await fetchProjects();
} catch (err) {
showToast(err.message, 'error');
}
}
// ==========================================
// Supplier Quotation and CRUD Modal Handlers
// ==========================================
function openSupplierQuoteViewer(productId, productDesc) {
const viewerModal = document.getElementById('modal-pdf-viewer');
const iframe = document.getElementById('pdf-viewer-iframe');
const htmlContainer = document.getElementById('doc-viewer-html-container');
const title = document.getElementById('pdf-viewer-title');
const subtitle = document.getElementById('pdf-viewer-subtitle');
const deleteBtn = document.getElementById('btn-delete-pdf-from-viewer');
iframe.style.display = 'block';
if (htmlContainer) {
htmlContainer.style.display = 'none';
}
title.textContent = state.currentLanguage === 'en' ? `Supplier Quotation: ${productDesc}` : `ใบเสนอราคาซัพพลายเออร์: ${productDesc}`;
if (subtitle) {
subtitle.textContent = state.currentLanguage === 'en' ? 'Supplier Quote Document' : 'เอกสารใบเสนอราคาผู้จัดจำหน่าย';
}
const pdfUrl = withAuthToken(`${API_BASE}/products/${productId}/supplier-quote`);
iframe.src = pdfUrl;
const newTabBtn = document.getElementById('btn-open-pdf-new-tab');
if (newTabBtn) {
newTabBtn.href = pdfUrl;
newTabBtn.style.display = 'inline-flex';
}
deleteBtn.style.display = 'none';
viewerModal.classList.add('active');
}
async function handleDeleteSupplierQuoteDirect(productId) {
if (!confirm(state.currentLanguage === 'en' ? 'Are you sure you want to delete this quote document?' : 'ลบเอกสารใบเสนอราคานี้ใช่หรือไม่?')) return;
try {
await authFetch(`${API_BASE}/products/${productId}/supplier-quote`, { method: 'DELETE' });
showToast(state.currentLanguage === 'en' ? 'Supplier quote document deleted' : 'ลบใบเสนอราคาผู้จัดจำหน่ายเรียบร้อย');
// Refresh product details
const res = await authFetch(`${API_BASE}/products?q=`);
const list = await res.json();
const updatedProd = list.find(p => p.id === productId);
openProductDetailModal(updatedProd);
await filterManagerProducts();
if (state.activeProjectId) {
await fetchActiveProjectDetails();
renderWorkspace();
}
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete file' : 'ไม่สามารถลบไฟล์ได้', 'error');
}
}
async function openSupplierManagerModal() {
document.getElementById('supplier-manager-search').value = '';
await fetchSuppliers();
renderSuppliersList(state.suppliers);
document.getElementById('modal-supplier-manager').classList.add('active');
}
function closeSupplierManagerModal() {
document.getElementById('modal-supplier-manager').classList.remove('active');
}
async function fetchSuppliers() {
try {
const res = await authFetch(`${API_BASE}/suppliers`);
state.suppliers = await res.json();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load suppliers' : 'ไม่สามารถโหลดข้อมูลผู้จัดจำหน่ายได้', 'error');
}
}
function renderSuppliersList(suppliersList) {
const tbody = document.getElementById('supplier-manager-tbody');
tbody.innerHTML = '';
if (suppliersList.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="padding:30px; text-align:center; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'No suppliers found' : 'ไม่พบข้อมูลผู้จัดจำหน่าย'}</td></tr>`;
return;
}
const role = state.currentUser ? state.currentUser.role : 'user';
suppliersList.forEach(s => {
const tr = document.createElement('tr');
let webLinkHtml = '-';
if (s.website) {
webLinkHtml = `
<a href="${escapeHtml(s.website)}" target="_blank" class="btn btn-outline btn-xs" style="display:inline-flex; align-items:center; gap:4px;">
<i class="fa-solid fa-earth-americas"></i> ${state.currentLanguage === 'en' ? 'Visit Website' : 'เปิดเว็บไซต์'}
</a>
`;
}
tr.innerHTML = `
<td><b>${escapeHtml(s.name)}</b></td>
<td>${escapeHtml(s.contact_info || '-').replace(/\n/g, '<br>')}</td>
<td>${webLinkHtml}</td>
<td class="text-center">
<div style="display:flex; justify-content:center; gap:8px;">
<button class="btn btn-outline btn-sm btn-edit-supplier" data-supplier-id="${s.id}">
<i class="fa-solid fa-edit"></i> <span class="btn-text">${t('table_action_edit')}</span>
</button>
<button class="btn btn-danger-outline btn-sm btn-delete-supplier" data-supplier-id="${s.id}">
<i class="fa-solid fa-trash"></i> <span class="btn-text">${t('table_action_delete')}</span>
</button>
</div>
</td>
`;
const editBtn = tr.querySelector('.btn-edit-supplier');
const deleteBtn = tr.querySelector('.btn-delete-supplier');
if (role === 'user') {
editBtn.style.display = 'none';
deleteBtn.style.display = 'none';
} else {
editBtn.addEventListener('click', () => openSupplierDetailModal(s));
if (role !== 'admin') {
deleteBtn.style.display = 'none';
} else {
deleteBtn.addEventListener('click', () => handleDeleteSupplier(s.id));
}
}
tbody.appendChild(tr);
});
}
function filterSuppliers() {
const query = document.getElementById('supplier-manager-search').value.toLowerCase().trim();
if (!query) {
renderSuppliersList(state.suppliers);
return;
}
const filtered = state.suppliers.filter(s =>
s.name.toLowerCase().includes(query) ||
(s.contact_info && s.contact_info.toLowerCase().includes(query)) ||
(s.website && s.website.toLowerCase().includes(query))
);
renderSuppliersList(filtered);
}
function openSupplierDetailModal(supplier = null) {
const modal = document.getElementById('modal-supplier-detail');
const title = document.getElementById('supplier-detail-title');
const form = document.getElementById('form-supplier-detail');
form.reset();
if (supplier) {
title.textContent = t('modal_supplier_detail_edit');
document.getElementById('supplier-detail-id').value = supplier.id;
document.getElementById('supplier-name').value = supplier.name;
document.getElementById('supplier-contact').value = supplier.contact_info || '';
document.getElementById('supplier-website').value = supplier.website || '';
} else {
title.textContent = t('modal_supplier_detail_create');
document.getElementById('supplier-detail-id').value = '';
}
modal.classList.add('active');
}
function closeSupplierDetailModal() {
document.getElementById('modal-supplier-detail').classList.remove('active');
}
async function handleSupplierDetailSubmit(e) {
e.preventDefault();
const id = document.getElementById('supplier-detail-id').value;
const payload = {
name: document.getElementById('supplier-name').value,
contact_info: document.getElementById('supplier-contact').value,
website: document.getElementById('supplier-website').value
};
try {
if (id) {
await authFetch(`${API_BASE}/suppliers/${id}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
showToast(t('toast_supplier_updated'));
} else {
await authFetch(`${API_BASE}/suppliers`, {
method: 'POST',
body: JSON.stringify(payload)
});
showToast(t('toast_supplier_created'));
}
closeSupplierDetailModal();
await fetchSuppliers();
renderSuppliersList(state.suppliers);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to save supplier details' : 'ไม่สามารถบันทึกข้อมูลผู้จัดจำหน่ายได้', 'error');
}
}
async function handleDeleteSupplier(id) {
if (!confirm(t('confirm_delete_supplier'))) return;
try {
await authFetch(`${API_BASE}/suppliers/${id}`, { method: 'DELETE' });
showToast(t('toast_supplier_deleted'));
await fetchSuppliers();
renderSuppliersList(state.suppliers);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete supplier' : 'ไม่สามารถลบผู้จัดจำหน่ายได้', 'error');
}
}
const CONTRACTOR_WRITE_ROLES = ['superadmin', 'admin', 'procurment', 'procurement_manager'];
async function openContractorManagerModal() {
document.getElementById('contractor-manager-search').value = '';
await fetchContractors();
renderContractorsList(state.contractors);
document.getElementById('modal-contractor-manager').classList.add('active');
}
function closeContractorManagerModal() {
document.getElementById('modal-contractor-manager').classList.remove('active');
}
async function fetchContractors() {
try {
const res = await authFetch(`${API_BASE}/contractors`);
state.contractors = await res.json();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load contractors' : 'ไม่สามารถโหลดข้อมูลผู้รับเหมาได้', 'error');
}
}
function renderContractorsList(contractorsList) {
const tbody = document.getElementById('contractor-manager-tbody');
tbody.innerHTML = '';
if (contractorsList.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="padding:30px; text-align:center; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'No contractors found' : 'ไม่พบข้อมูลผู้รับเหมา'}</td></tr>`;
return;
}
const role = state.currentUser ? state.currentUser.role : 'user';
const canWrite = CONTRACTOR_WRITE_ROLES.includes(role);
contractorsList.forEach(c => {
const tr = document.createElement('tr');
let webLinkHtml = '-';
if (c.website) {
webLinkHtml = `
<a href="${escapeHtml(c.website)}" target="_blank" class="btn btn-outline btn-xs" style="display:inline-flex; align-items:center; gap:4px;">
<i class="fa-solid fa-earth-americas"></i> ${state.currentLanguage === 'en' ? 'Visit Website' : 'เปิดเว็บไซต์'}
</a>
`;
}
tr.innerHTML = `
<td><b>${escapeHtml(c.name)}</b></td>
<td>${c.username ? `<i class="fa-solid fa-user"></i> ${escapeHtml(c.username)}` : '-'}</td>
<td>${escapeHtml(c.contact_info || '-').replace(/\n/g, '<br>')}</td>
<td>${webLinkHtml}</td>
<td class="text-center">
<div style="display:flex; justify-content:center; gap:8px;">
<button class="btn btn-outline btn-sm btn-edit-contractor" data-contractor-id="${c.id}">
<i class="fa-solid fa-edit"></i> ${t('table_action_edit')}
</button>
<button class="btn btn-danger-outline btn-sm btn-delete-contractor" data-contractor-id="${c.id}">
<i class="fa-solid fa-trash"></i> ${t('table_action_delete')}
</button>
</div>
</td>
`;
const editBtn = tr.querySelector('.btn-edit-contractor');
const deleteBtn = tr.querySelector('.btn-delete-contractor');
if (!canWrite) {
editBtn.style.display = 'none';
deleteBtn.style.display = 'none';
} else {
editBtn.addEventListener('click', () => openContractorDetailModal(c));
deleteBtn.addEventListener('click', () => handleDeleteContractor(c.id));
}
tbody.appendChild(tr);
});
}
function filterContractors() {
const query = document.getElementById('contractor-manager-search').value.toLowerCase().trim();
if (!query) {
renderContractorsList(state.contractors);
return;
}
const filtered = state.contractors.filter(c =>
c.name.toLowerCase().includes(query) ||
(c.username && c.username.toLowerCase().includes(query)) ||
(c.contact_info && c.contact_info.toLowerCase().includes(query)) ||
(c.website && c.website.toLowerCase().includes(query))
);
renderContractorsList(filtered);
}
async function populateContractorLinkAccountSelect(currentUserId, currentUsername) {
const select = document.getElementById('contractor-user-id');
select.innerHTML = `<option value="">${state.currentLanguage === 'en' ? '-- No linked account --' : '-- ไม่เชื่อมบัญชี --'}</option>`;
try {
const res = await authFetch(`${API_BASE}/users`);
if (!res.ok) throw new Error('forbidden');
const users = await res.json();
users.filter(u => u.role === 'contractor').forEach(u => {
const opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.username;
select.appendChild(opt);
});
} catch (err) {
// Caller may not have user-management permission; fall back to showing only the currently linked account, if any.
if (currentUserId && currentUsername) {
const opt = document.createElement('option');
opt.value = currentUserId;
opt.textContent = currentUsername;
select.appendChild(opt);
}
}
select.value = currentUserId || '';
}
async function openContractorDetailModal(contractor = null) {
const modal = document.getElementById('modal-contractor-detail');
const title = document.getElementById('contractor-detail-title');
const form = document.getElementById('form-contractor-detail');
form.reset();
await populateContractorLinkAccountSelect(contractor?.user_id, contractor?.username);
if (contractor) {
title.textContent = t('modal_contractor_detail_edit');
document.getElementById('contractor-detail-id').value = contractor.id;
document.getElementById('contractor-name').value = contractor.name;
document.getElementById('contractor-contact').value = contractor.contact_info || '';
document.getElementById('contractor-website').value = contractor.website || '';
} else {
title.textContent = t('modal_contractor_detail_create');
document.getElementById('contractor-detail-id').value = '';
}
modal.classList.add('active');
}
function closeContractorDetailModal() {
document.getElementById('modal-contractor-detail').classList.remove('active');
}
async function handleContractorDetailSubmit(e) {
e.preventDefault();
const id = document.getElementById('contractor-detail-id').value;
const payload = {
name: document.getElementById('contractor-name').value,
contact_info: document.getElementById('contractor-contact').value,
website: document.getElementById('contractor-website').value,
user_id: document.getElementById('contractor-user-id').value || null
};
try {
if (id) {
await authFetch(`${API_BASE}/contractors/${id}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
showToast(t('toast_contractor_updated'));
} else {
await authFetch(`${API_BASE}/contractors`, {
method: 'POST',
body: JSON.stringify(payload)
});
showToast(t('toast_contractor_created'));
}
closeContractorDetailModal();
await fetchContractors();
renderContractorsList(state.contractors);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to save contractor details' : 'ไม่สามารถบันทึกข้อมูลผู้รับเหมาได้', 'error');
}
}
async function handleDeleteContractor(id) {
if (!confirm(t('confirm_delete_contractor'))) return;
try {
await authFetch(`${API_BASE}/contractors/${id}`, { method: 'DELETE' });
showToast(t('toast_contractor_deleted'));
await fetchContractors();
renderContractorsList(state.contractors);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete contractor' : 'ไม่สามารถลบผู้รับเหมาได้', 'error');
}
}
const RFQ_STATUS_LABELS = {
draft: { th: 'ฉบับร่าง', en: 'Draft', bg: '#e2e8f0', color: '#475569' },
sent: { th: 'ส่งแล้ว', en: 'Sent', bg: '#dbeafe', color: '#1d4ed8' },
submitted: { th: 'ส่งราคาแล้ว', en: 'Submitted', bg: '#fef3c7', color: '#92400e' },
approved: { th: 'อนุมัติแล้ว', en: 'Approved', bg: '#d1fae5', color: '#065f46' },
rejected: { th: 'ปฏิเสธ', en: 'Rejected', bg: '#fee2e2', color: '#991b1b' }
};
function renderRfqStatusBadge(status) {
const info = RFQ_STATUS_LABELS[status] || RFQ_STATUS_LABELS.draft;
const label = state.currentLanguage === 'en' ? info.en : info.th;
return `<span style="font-size:0.75rem; padding:4px 10px; border-radius:12px; font-weight:600; background-color:${info.bg}; color:${info.color};">${label}</span>`;
}
async function openProjectRfqModal() {
if (!state.activeProjectId) return;
document.getElementById('rfq-list-search').value = '';
applyLanguage();
await fetchAndRenderRfqList();
document.getElementById('modal-project-rfq').classList.add('active');
}
function closeProjectRfqModal() {
document.getElementById('modal-project-rfq').classList.remove('active');
}
async function fetchAndRenderRfqList() {
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/contractor-rfqs`);
state.rfqList = await res.json();
renderRfqList(state.rfqList);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load RFQs' : 'ไม่สามารถโหลดข้อมูล RFQ ได้', 'error');
}
}
function renderRfqList(rfqList) {
const tbody = document.getElementById('rfq-list-tbody');
tbody.innerHTML = '';
if (rfqList.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="padding:30px; text-align:center; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'No RFQs found' : 'ไม่พบข้อมูล RFQ'}</td></tr>`;
return;
}
const role = state.currentUser ? state.currentUser.role : 'user';
const canWrite = ['superadmin', 'admin', 'supervisor', 'procurment', 'procurement_manager'].includes(role);
rfqList.forEach(r => {
const tr = document.createElement('tr');
const issueDate = r.issue_date ? parseDbDate(r.issue_date).toLocaleString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH') : '-';
const dueDate = r.due_date ? parseDbDate(r.due_date).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH') : '-';
tr.innerHTML = `
<td><b>${escapeHtml(r.rfq_number)}</b></td>
<td>${escapeHtml(r.contractor_name)}</td>
<td>${issueDate}</td>
<td>${dueDate}</td>
<td>${renderRfqStatusBadge(r.status)}</td>
<td>${escapeHtml(r.notes || '-')}</td>
<td class="text-center">
<div style="display:flex; justify-content:center; gap:8px;">
<button class="btn btn-outline btn-sm btn-edit-rfq" data-rfq-id="${r.id}">
<i class="fa-solid fa-edit"></i>
</button>
<button class="btn btn-danger-outline btn-sm btn-delete-rfq" data-rfq-id="${r.id}" style="${canWrite ? '' : 'display:none;'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
`;
tr.querySelector('.btn-edit-rfq').addEventListener('click', () => openRfqDetailModal(r.id));
const deleteBtn = tr.querySelector('.btn-delete-rfq');
if (canWrite) {
deleteBtn.addEventListener('click', () => handleDeleteRfq(r.id));
}
tbody.appendChild(tr);
});
}
function filterRfqList() {
const query = document.getElementById('rfq-list-search').value.toLowerCase().trim();
if (!query) {
renderRfqList(state.rfqList);
return;
}
const filtered = state.rfqList.filter(r =>
r.rfq_number.toLowerCase().includes(query) ||
r.contractor_name.toLowerCase().includes(query) ||
(r.notes && r.notes.toLowerCase().includes(query))
);
renderRfqList(filtered);
}
async function openCreateRfqModal() {
document.getElementById('form-create-rfq').reset();
const select = document.getElementById('create-rfq-contractor');
select.innerHTML = '';
await fetchContractors();
state.contractors.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
select.appendChild(opt);
});
document.getElementById('modal-create-rfq').classList.add('active');
}
function closeCreateRfqModal() {
document.getElementById('modal-create-rfq').classList.remove('active');
}
async function handleCreateRfqSubmit(e) {
e.preventDefault();
const payload = {
contractor_id: document.getElementById('create-rfq-contractor').value,
due_date: document.getElementById('create-rfq-due-date').value || null,
notes: document.getElementById('create-rfq-notes').value
};
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/contractor-rfqs`, {
method: 'POST',
body: JSON.stringify(payload)
});
const newRfq = await res.json();
showToast(t('toast_rfq_created'));
closeCreateRfqModal();
await fetchAndRenderRfqList();
openRfqDetailModal(newRfq.id);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to create RFQ' : 'ไม่สามารถสร้าง RFQ ได้', 'error');
}
}
async function handleDeleteRfq(id) {
if (!confirm(t('confirm_delete_rfq'))) return;
try {
await authFetch(`${API_BASE}/contractor-rfqs/${id}`, { method: 'DELETE' });
showToast(t('toast_rfq_deleted'));
await fetchAndRenderRfqList();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete RFQ' : 'ไม่สามารถลบ RFQ ได้', 'error');
}
}
let currentRfqDetail = null;
async function openRfqDetailModal(rfqId) {
try {
const res = await authFetch(`${API_BASE}/contractor-rfqs/${rfqId}`);
currentRfqDetail = await res.json();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load RFQ detail' : 'ไม่สามารถโหลดข้อมูล RFQ ได้', 'error');
return;
}
document.getElementById('rfq-detail-id').value = currentRfqDetail.id;
document.getElementById('rfq-detail-issue-date').value = currentRfqDetail.issue_date ? parseDbDate(currentRfqDetail.issue_date).toLocaleString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH') : '';
document.getElementById('rfq-detail-due-date').value = currentRfqDetail.due_date ? currentRfqDetail.due_date.substring(0, 10) : '';
document.getElementById('rfq-detail-status').value = currentRfqDetail.status || 'draft';
document.getElementById('rfq-detail-notes').value = currentRfqDetail.notes || '';
const fileNameEl = document.getElementById('rfq-quote-file-name');
const removeBtn = document.getElementById('btn-rfq-remove-quote-file');
if (currentRfqDetail.quote_file_name) {
fileNameEl.textContent = currentRfqDetail.quote_file_name;
removeBtn.style.display = 'inline-flex';
} else {
fileNameEl.textContent = state.currentLanguage === 'en' ? 'No file selected' : 'ไม่ได้เลือกไฟล์';
removeBtn.style.display = 'none';
}
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
const submitBtn = document.getElementById('btn-rfq-submit');
if (submitBtn) {
submitBtn.style.display = isContractor ? 'inline-flex' : 'none';
}
const modalTitleEl = document.querySelector('#modal-rfq-detail .modal-header h3');
if (modalTitleEl) {
const baseTitle = state.currentLanguage === 'en' ? 'Quotation Form' : 'ตารางราคาต่อหน่วยของผู้รับเหมา';
const revSuffix = (currentRfqDetail.revision_number > 0) ? ` (REV ${currentRfqDetail.revision_number})` : '';
modalTitleEl.textContent = `${baseTitle} - ${currentRfqDetail.rfq_number}${revSuffix}`;
}
renderRfqItemsGrouped(currentRfqDetail.items || []);
document.getElementById('modal-rfq-detail').classList.add('active');
}
function closeRfqDetailModal() {
document.getElementById('modal-rfq-detail').classList.remove('active');
currentRfqDetail = null;
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
if (isContractor) {
applyRoleBasedUI();
}
}
function renderRfqItemsGrouped(items) {
const tbody = document.getElementById('rfq-items-tbody');
tbody.innerHTML = '';
const groups = new Map();
items.forEach(item => {
const key = item.section_name || (state.currentLanguage === 'en' ? 'Uncategorized' : 'ไม่ระบุระบบงาน');
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
});
groups.forEach((groupItems, sectionName) => {
const groupRow = document.createElement('tr');
groupRow.innerHTML = `
<td colspan="7" style="background: var(--bg-hover); font-weight:600;">
<i class="fa-solid fa-folder text-primary-color"></i> ${escapeHtml(sectionName)}
<span class="rfq-group-subtotal" style="float:right; font-weight:600; color:var(--primary-color);"></span>
</td>
`;
tbody.appendChild(groupRow);
groupItems.forEach(item => {
const tr = document.createElement('tr');
tr.dataset.itemId = item.id;
tr.dataset.qty = item.qty;
const lineTotal = item.qty * ((item.material_rate || 0) + (item.labour_rate || 0));
tr.innerHTML = `
<td class="text-center"><input type="checkbox" class="rfq-item-included" ${item.is_included ? 'checked' : ''}></td>
<td>
<div>${escapeHtml(item.description || '-')}</div>
<small style="color:var(--text-muted);">${state.currentLanguage === 'en' ? 'Brand' : 'แบรนด์'}: ${escapeHtml(item.brand || '-')}</small>
</td>
<td class="text-center">${formatNumber(item.qty)}</td>
<td class="text-center">${escapeHtml(item.unit || '-')}</td>
<td><input type="number" step="0.01" min="0" class="table-number-input rfq-item-material-rate" value="${item.material_rate || 0}"></td>
<td><input type="number" step="0.01" min="0" class="table-number-input rfq-item-labour-rate" value="${item.labour_rate || 0}"></td>
<td class="text-right rfq-item-line-total">${formatCurrency(lineTotal)}</td>
`;
tbody.appendChild(tr);
const recalcAndRerender = () => recalculateRfqTotals();
tr.querySelector('.rfq-item-included').addEventListener('change', recalcAndRerender);
tr.querySelector('.rfq-item-material-rate').addEventListener('input', recalcAndRerender);
tr.querySelector('.rfq-item-labour-rate').addEventListener('input', recalcAndRerender);
});
});
recalculateRfqTotals();
}
function recalculateRfqTotals() {
let grandTotal = 0;
let currentGroupTotal = 0;
let lastGroupRow = null;
const rows = Array.from(document.getElementById('rfq-items-tbody').children);
rows.forEach(row => {
if (row.dataset.itemId === undefined) {
// Group header row: flush the previous group's subtotal, then start a new one
if (lastGroupRow) {
lastGroupRow.querySelector('.rfq-group-subtotal').textContent = `${state.currentLanguage === 'en' ? 'Total' : 'รวม'}: ${formatCurrency(currentGroupTotal)}`;
}
currentGroupTotal = 0;
lastGroupRow = row;
return;
}
const included = row.querySelector('.rfq-item-included').checked;
const qty = parseFloat(row.dataset.qty) || 0;
const materialRate = parseFloat(row.querySelector('.rfq-item-material-rate').value) || 0;
const labourRate = parseFloat(row.querySelector('.rfq-item-labour-rate').value) || 0;
const lineTotal = qty * (materialRate + labourRate);
const lineTotalEl = row.querySelector('.rfq-item-line-total');
if (lineTotalEl) {
lineTotalEl.textContent = formatCurrency(lineTotal);
lineTotalEl.style.opacity = included ? '1' : '0.5';
}
if (included) {
currentGroupTotal += lineTotal;
grandTotal += lineTotal;
}
});
if (lastGroupRow) {
lastGroupRow.querySelector('.rfq-group-subtotal').textContent = `${state.currentLanguage === 'en' ? 'Total' : 'รวม'}: ${formatCurrency(currentGroupTotal)}`;
}
document.getElementById('rfq-detail-total-amount').textContent = `${formatCurrency(grandTotal)} ฿`;
}
function collectRfqItemsPayload() {
const rows = Array.from(document.getElementById('rfq-items-tbody').children).filter(row => row.dataset.itemId !== undefined);
return rows.map(row => ({
id: parseInt(row.dataset.itemId),
is_included: row.querySelector('.rfq-item-included').checked,
material_rate: parseFloat(row.querySelector('.rfq-item-material-rate').value) || 0,
labour_rate: parseFloat(row.querySelector('.rfq-item-labour-rate').value) || 0
}));
}
async function handleSaveRfqDraft() {
if (!currentRfqDetail) return;
const payload = {
status: document.getElementById('rfq-detail-status').value,
due_date: document.getElementById('rfq-detail-due-date').value || null,
notes: document.getElementById('rfq-detail-notes').value,
items: collectRfqItemsPayload()
};
try {
await authFetch(`${API_BASE}/contractor-rfqs/${currentRfqDetail.id}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
showToast(t('toast_rfq_updated'));
const isContractor = state.currentUser && state.currentUser.role === 'contractor';
if (isContractor) {
applyRoleBasedUI();
} else {
await fetchAndRenderRfqList();
}
closeRfqDetailModal();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to save RFQ' : 'ไม่สามารถบันทึก RFQ ได้', 'error');
}
}
function handleRfqSubmit() {
if (!currentRfqDetail) return;
// Clear password input and focus it
const pwdInput = document.getElementById('rfq-confirm-password');
if (pwdInput) {
pwdInput.value = '';
}
// Display custom confirm modal
document.getElementById('modal-rfq-confirm').classList.add('active');
if (pwdInput) {
pwdInput.focus();
}
}
async function submitRfqQuotationConfirmed() {
if (!currentRfqDetail) return;
const pwdInput = document.getElementById('rfq-confirm-password');
const password = pwdInput ? pwdInput.value : '';
if (!password.trim()) {
showToast(state.currentLanguage === 'en' ? 'Password cannot be empty' : 'กรุณากรอกรหัสผ่านเพื่อยืนยัน', 'error');
return;
}
const payload = {
password: password,
items: collectRfqItemsPayload()
};
// Disable button to prevent double-submit
const confirmBtn = document.getElementById('btn-submit-rfq-confirm');
if (confirmBtn) confirmBtn.disabled = true;
try {
const res = await authFetch(`${API_BASE}/contractor-rfqs/${currentRfqDetail.id}/submit`, {
method: 'POST',
body: JSON.stringify(payload)
});
if (!res.ok) {
const errData = await res.json();
let errMsg = errData.error || 'Failed to submit RFQ';
if (errMsg === 'Invalid password') {
errMsg = state.currentLanguage === 'en'
? 'Incorrect password. Submission failed.'
: 'รหัสผ่านไม่ถูกต้อง การส่งใบเสนอราคาไม่สำเร็จ';
}
throw new Error(errMsg);
}
const data = await res.json();
showToast(state.currentLanguage === 'en'
? `Quotation submitted successfully! (Revision ${data.revision_number})`
: `ส่งใบเสนอราคาเสร็จสิ้นแล้ว! (ปรับปรุงครั้งที่ ${data.revision_number})`, 'success');
document.getElementById('modal-rfq-confirm').classList.remove('active');
closeRfqDetailModal();
} catch (err) {
showToast(err.message, 'error');
} finally {
if (confirmBtn) confirmBtn.disabled = false;
}
}
function printRfqQuotation() {
if (!currentRfqDetail) return;
// Get project name from active project summary
const projectName = state.activeProjectSummary
? (state.activeProjectSummary.external_name || state.activeProjectSummary.name)
: '';
// Collect current values from table inputs
const rows = Array.from(document.getElementById('rfq-items-tbody').children);
let currentSectionName = '';
const groupedItems = [];
let currentGroup = null;
rows.forEach(row => {
if (row.dataset.itemId === undefined) {
// Group header row
const folderIcon = row.querySelector('.fa-folder');
if (folderIcon) {
currentSectionName = row.innerText.split('\n')[0].replace('รวม:', '').replace('Total:', '').trim();
currentGroup = {
sectionName: currentSectionName,
items: []
};
groupedItems.push(currentGroup);
}
} else {
// Item row
const isIncluded = row.querySelector('.rfq-item-included').checked;
if (isIncluded) {
const descDiv = row.children[1].querySelector('div');
const brandSmall = row.children[1].querySelector('small');
const desc = descDiv ? descDiv.textContent : '';
const brandText = brandSmall ? brandSmall.textContent.replace('แบรนด์: ', '').replace('Brand: ', '').trim() : '-';
const qty = parseFloat(row.dataset.qty) || 0;
const unit = row.children[3].textContent;
const materialRate = parseFloat(row.querySelector('.rfq-item-material-rate').value) || 0;
const labourRate = parseFloat(row.querySelector('.rfq-item-labour-rate').value) || 0;
const totalRate = qty * (materialRate + labourRate);
if (currentGroup) {
currentGroup.items.push({
description: desc,
brand: brandText,
qty: qty,
unit: unit,
material_rate: materialRate,
labour_rate: labourRate,
total: totalRate
});
}
}
}
});
// Clean up empty groups
const finalGroups = groupedItems.filter(g => g.items.length > 0);
const win = window.open('', '_blank');
if (!win) {
alert(state.currentLanguage === 'en' ? 'Please allow popups to print' : 'กรุณาอนุญาตให้แสดงหน้าต่างป๊อปอัปเพื่อสั่งพิมพ์');
return;
}
const title = state.currentLanguage === 'en' ? `Quotation - ${currentRfqDetail.rfq_number}` : `ใบเสนอราคา - ${currentRfqDetail.rfq_number}`;
// HTML build
let html = `
<html>
<head>
<title>${title}</title>
<style>
body {
font-family: 'Sarabun', 'Inter', sans-serif;
margin: 40px;
color: #333;
background-color: #fff;
}
.header-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
}
.header-title {
font-size: 1.6rem;
font-weight: 700;
color: #1e3a8a;
margin-bottom: 4px;
}
.header-meta {
font-size: 0.9rem;
line-height: 1.6;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
font-size: 0.9rem;
}
.data-table th {
background-color: #f1f5f9;
color: #334155;
font-weight: 600;
border: 1px solid #cbd5e1;
padding: 8px 10px;
text-align: left;
}
.data-table td {
border: 1px solid #cbd5e1;
padding: 8px 10px;
vertical-align: top;
}
.group-header {
background-color: #f8fafc;
font-weight: bold;
color: #0f172a;
}
.text-center { text-align: center; }
.text-right { text-align: right; }
.totals-section {
width: 100%;
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.totals-table {
width: 320px;
border-collapse: collapse;
font-size: 0.95rem;
}
.totals-table td {
padding: 6px 10px;
}
.totals-table tr.grand-total {
font-weight: 700;
font-size: 1.1rem;
color: #1e3a8a;
border-top: 2px double #1e3a8a;
border-bottom: 2px double #1e3a8a;
}
.notes-section {
margin-top: 30px;
font-size: 0.85rem;
border-top: 1px dashed #cbd5e1;
padding-top: 15px;
color: #64748b;
}
@media print {
body { margin: 20px; }
.no-print { display: none; }
}
</style>
</head>
<body>
<table class="header-table">
<tr>
<td>
<div class="header-title">${state.currentLanguage === 'en' ? 'Contractor Quotation' : 'ใบเสนอราคาผู้รับเหมา'}</div>
<div class="header-meta">
<strong>${state.currentLanguage === 'en' ? 'Project Name' : 'ชื่อโครงการ'}:</strong> ${escapeHtml(projectName)}<br>
<strong>${state.currentLanguage === 'en' ? 'Contractor' : 'ผู้รับเหมา'}:</strong> ${escapeHtml(currentRfqDetail.contractor_name || '-')}<br>
</div>
</td>
<td class="text-right" style="vertical-align: top;">
<div class="header-meta">
<strong>${state.currentLanguage === 'en' ? 'RFQ No.' : 'เลขที่ใบขอราคา'}:</strong> ${escapeHtml(currentRfqDetail.rfq_number)} ${currentRfqDetail.revision_number > 0 ? '(REV ' + currentRfqDetail.revision_number + ')' : ''}<br>
<strong>${state.currentLanguage === 'en' ? 'Issue Date' : 'วันที่ส่งราคา'}:</strong> ${currentRfqDetail.issue_date ? parseDbDate(currentRfqDetail.issue_date).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH') : '-'}<br>
<strong>${state.currentLanguage === 'en' ? 'Due Date' : 'กำหนดส่งราคา'}:</strong> ${currentRfqDetail.due_date ? parseDbDate(currentRfqDetail.due_date).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH') : '-'}<br>
</div>
</td>
</tr>
</table>
<table class="data-table">
<thead>
<tr>
<th style="width: 5%;" class="text-center">#</th>
<th style="width: 45%;">${state.currentLanguage === 'en' ? 'Description' : 'ระบบงาน / รายละเอียดสินค้า'}</th>
<th style="width: 10%;" class="text-center">${state.currentLanguage === 'en' ? 'Qty' : 'จำนวน'}</th>
<th style="width: 10%;" class="text-center">${state.currentLanguage === 'en' ? 'Unit' : 'หน่วย'}</th>
<th style="width: 15%;" class="text-right">${state.currentLanguage === 'en' ? 'Material (฿)' : 'ราคาวัสดุ (฿)'}</th>
<th style="width: 15%;" class="text-right">${state.currentLanguage === 'en' ? 'Labour (฿)' : 'ราคาค่าแรง (฿)'}</th>
<th style="width: 15%;" class="text-right">${state.currentLanguage === 'en' ? 'Total (฿)' : 'ราคารวม (฿)'}</th>
</tr>
</thead>
<tbody>
`;
let itemIndex = 1;
let grandMaterialTotal = 0;
let grandLabourTotal = 0;
let grandTotal = 0;
finalGroups.forEach(group => {
html += `
<tr class="group-header">
<td colspan="7"><i class="fa-solid fa-folder"></i> ${escapeHtml(group.sectionName)}</td>
</tr>
`;
group.items.forEach(item => {
const rowTotal = item.total;
grandMaterialTotal += item.qty * item.material_rate;
grandLabourTotal += item.qty * item.labour_rate;
grandTotal += rowTotal;
html += `
<tr>
<td class="text-center">${itemIndex++}</td>
<td>
<div>${escapeHtml(item.description)}</div>
<small style="color: #64748b; font-size: 0.8rem;">${state.currentLanguage === 'en' ? 'Brand' : 'แบรนด์'}: ${escapeHtml(item.brand)}</small>
</td>
<td class="text-center">${formatNumber(item.qty)}</td>
<td class="text-center">${escapeHtml(item.unit)}</td>
<td class="text-right">${formatCurrency(item.material_rate)}</td>
<td class="text-right">${formatCurrency(item.labour_rate)}</td>
<td class="text-right">${formatCurrency(rowTotal)}</td>
</tr>
`;
});
});
html += `
</tbody>
</table>
<div class="totals-section">
<table class="totals-table">
<tr>
<td>${state.currentLanguage === 'en' ? 'Total Material' : 'รวมค่าวัสดุ'}:</td>
<td class="text-right">${formatCurrency(grandMaterialTotal)} ฿</td>
</tr>
<tr>
<td>${state.currentLanguage === 'en' ? 'Total Labour' : 'รวมค่าแรง'}:</td>
<td class="text-right">${formatCurrency(grandLabourTotal)} ฿</td>
</tr>
<tr class="grand-total">
<td>${state.currentLanguage === 'en' ? 'Grand Total' : 'ยอดรวมทั้งสิ้น'}:</td>
<td class="text-right">${formatCurrency(grandTotal)} ฿</td>
</tr>
</table>
</div>
`;
if (currentRfqDetail.notes && currentRfqDetail.notes.trim()) {
html += `
<div class="notes-section">
<strong>${state.currentLanguage === 'en' ? 'Notes / Details' : 'หมายเหตุ / รายละเอียด'}:</strong><br>
<div style="white-space: pre-wrap; margin-top: 6px;">${escapeHtml(currentRfqDetail.notes)}</div>
</div>
`;
}
html += `
</body>
</html>
`;
win.document.write(html);
win.document.close();
win.print();
}
async function handleRfqQuoteFileSelected(e) {
if (!currentRfqDetail || !e.target.files.length) return;
const formData = new FormData();
formData.append('quote', e.target.files[0]);
try {
await authFetch(`${API_BASE}/contractor-rfqs/${currentRfqDetail.id}/quote-file`, {
method: 'POST',
body: formData
});
showToast(t('toast_rfq_quote_uploaded'));
await openRfqDetailModal(currentRfqDetail.id);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to upload quote file' : 'ไม่สามารถอัปโหลดไฟล์ใบเสนอราคาได้', 'error');
} finally {
e.target.value = '';
}
}
async function handleRfqRemoveQuoteFile() {
if (!currentRfqDetail) return;
if (!confirm(t('confirm_delete_rfq_quote_file'))) return;
try {
await authFetch(`${API_BASE}/contractor-rfqs/${currentRfqDetail.id}/quote-file`, { method: 'DELETE' });
showToast(t('toast_rfq_quote_deleted'));
await openRfqDetailModal(currentRfqDetail.id);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to remove quote file' : 'ไม่สามารถลบไฟล์ใบเสนอราคาได้', 'error');
}
}
async function openActivityLogModal() {
document.getElementById('activity-log-search').value = '';
document.getElementById('activity-log-date-from').value = '';
document.getElementById('activity-log-date-to').value = '';
state.activityLogPage = 1;
await fetchAndRenderActivityLog();
document.getElementById('modal-activity-log').classList.add('active');
}
function closeActivityLogModal() {
document.getElementById('modal-activity-log').classList.remove('active');
}
async function fetchAndRenderActivityLog() {
const search = document.getElementById('activity-log-search').value.trim();
const dateFrom = document.getElementById('activity-log-date-from').value;
const dateTo = document.getElementById('activity-log-date-to').value;
const params = new URLSearchParams({
page: state.activityLogPage,
limit: state.activityLogLimit
});
if (search) params.set('search', search);
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
try {
const res = await authFetch(`${API_BASE}/activity-logs?${params.toString()}`);
const data = await res.json();
state.activityLogTotal = data.total;
renderActivityLogList(data.logs);
updateActivityLogPaginationUI();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load activity log' : 'ไม่สามารถโหลดประวัติการใช้งานได้', 'error');
}
}
function renderActivityLogList(logs) {
const tbody = document.getElementById('activity-log-tbody');
tbody.innerHTML = '';
if (logs.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="padding:30px; text-align:center; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'No activity found' : 'ไม่พบประวัติการใช้งาน'}</td></tr>`;
return;
}
logs.forEach(log => {
const tr = document.createElement('tr');
const time = log.created_at ? parseDbDate(log.created_at).toLocaleString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH') : '-';
tr.innerHTML = `
<td style="font-size:0.85rem; color:var(--text-muted);">${time}</td>
<td><b>${escapeHtml(log.username || '-')}</b></td>
<td><span style="font-family:monospace; font-size:0.75rem; background:var(--bg-hover); padding:2px 8px; border-radius:10px;">${escapeHtml(log.action)}</span></td>
<td>${escapeHtml(log.details || '-')}</td>
`;
tbody.appendChild(tr);
});
}
function updateActivityLogPaginationUI() {
const totalPages = Math.max(Math.ceil(state.activityLogTotal / state.activityLogLimit), 1);
const startIndex = state.activityLogTotal === 0 ? 0 : (state.activityLogPage - 1) * state.activityLogLimit + 1;
const endIndex = Math.min(state.activityLogPage * state.activityLogLimit, state.activityLogTotal);
document.getElementById('activity-log-page-start').textContent = startIndex;
document.getElementById('activity-log-page-end').textContent = endIndex;
document.getElementById('activity-log-total-items').textContent = state.activityLogTotal;
document.getElementById('btn-activity-log-prev-page').disabled = state.activityLogPage <= 1;
document.getElementById('btn-activity-log-next-page').disabled = state.activityLogPage >= totalPages;
}
function filterActivityLog() {
state.activityLogPage = 1;
fetchAndRenderActivityLog();
}
function updateSupplierWebsiteLink(supplierId) {
const wrapper = document.getElementById('detail-supplier-website-wrapper');
const linkBtn = document.getElementById('detail-supplier-website');
if (!supplierId) {
wrapper.style.display = 'none';
return;
}
const supplier = state.suppliers.find(s => s.id === parseInt(supplierId));
if (supplier && supplier.website) {
linkBtn.href = supplier.website;
wrapper.style.display = 'block';
} else {
wrapper.style.display = 'none';
}
}
function handleSupplierQuoteSelection(e) {
const file = e.target.files[0];
if (!file) return;
state.quoteFileToUpload = file;
document.getElementById('product-supplier-quote-status').innerHTML = `
<span class="text-success"><i class="fa-solid fa-file-circle-check"></i> ${state.currentLanguage === 'en' ? 'Ready to upload' : 'พร้อมอัปโหลด'}: ${escapeHtml(file.name)}</span>
`;
}
// ==========================================
// 8. ADMIN PANEL CONTROL CONTROLLER
// ==========================================
async function openAdminPanelModal() {
const role = state.currentUser ? state.currentUser.role : 'user';
const isAdmin = role === 'admin' || role === 'superadmin';
const usersBtn = document.getElementById('tab-users-btn');
const permBtn = document.getElementById('tab-permissions-btn');
// User management and project-permission tabs stay admin/superadmin-only.
usersBtn.style.display = isAdmin ? 'inline-block' : 'none';
permBtn.style.display = isAdmin ? 'inline-block' : 'none';
if (isAdmin) {
switchAdminTab('users');
await fetchAdminUsers();
} else {
switchAdminTab('permissions');
}
// Populate select project list
const selectProj = document.getElementById('admin-select-project');
selectProj.innerHTML = '';
// Non-admin roles see only their allowed projects (pre-filtered by the API in state.projects)
let filteredProjects = state.projects || [];
if (role === 'supervisor' || role === 'estimate') {
filteredProjects = filteredProjects.filter(p => p.created_by === state.currentUser.id);
}
filteredProjects = [...filteredProjects].sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
if (filteredProjects.length > 0) {
filteredProjects.forEach(proj => {
const opt = document.createElement('option');
opt.value = proj.id;
opt.textContent = `${proj.name} (${proj.client_name || '-'})`;
selectProj.appendChild(opt);
});
// Load permissions for first project
fetchProjectPermissions(filteredProjects[0].id);
} else {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = state.currentLanguage === 'en' ? '-- No projects --' : '-- ไม่มีโครงการ --';
selectProj.appendChild(opt);
// Clear supervisors list if no projects
document.getElementById('admin-supervisors-list').innerHTML = `<div class="text-center text-muted">${state.currentLanguage === 'en' ? 'No projects available to manage permissions' : 'ไม่มีโครงการให้สามารถจัดการสิทธิ์ได้'}</div>`;
}
document.getElementById('modal-admin-panel').classList.add('active');
}
function closeAdminPanelModal() {
document.getElementById('modal-admin-panel').classList.remove('active');
}
function switchAdminTab(tab) {
const usersBtn = document.getElementById('tab-users-btn');
const permBtn = document.getElementById('tab-permissions-btn');
const usersContent = document.getElementById('tab-users-content');
const permContent = document.getElementById('tab-permissions-content');
usersBtn.classList.remove('active');
permBtn.classList.remove('active');
usersContent.style.display = 'none';
permContent.style.display = 'none';
usersBtn.style.borderBottomColor = 'transparent';
permBtn.style.borderBottomColor = 'transparent';
if (tab === 'users') {
usersBtn.classList.add('active');
usersBtn.style.borderBottomColor = 'var(--primary-color)';
usersContent.style.display = 'block';
} else {
permBtn.classList.add('active');
permBtn.style.borderBottomColor = 'var(--primary-color)';
permContent.style.display = 'block';
}
}
async function fetchAdminUsers() {
try {
const res = await authFetch(`${API_BASE}/users`);
const users = await res.json();
renderAdminUsers(users);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to fetch user accounts' : 'ไม่สามารถดึงข้อมูลบัญชีผู้ใช้งานได้', 'error');
}
}
function renderAdminUsers(users) {
const tbody = document.getElementById('admin-users-tbody');
tbody.innerHTML = '';
const sortedUsers = [...users].sort((a, b) => (a.username || '').localeCompare(b.username || '', undefined, { sensitivity: 'base' }));
sortedUsers.forEach(user => {
const tr = document.createElement('tr');
// Prevent editing current admin role to avoid self lockout
const isSelf = state.currentUser && state.currentUser.id === user.id;
const disabledAttr = isSelf ? 'disabled' : '';
let roleSelectHtml = '';
if (user.role === 'superadmin') {
roleSelectHtml = `<span class="badge bg-warning-light text-warning" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600;">${t('role_superadmin')}</span>`;
} else {
roleSelectHtml = `
<select class="form-control change-role-select" data-user-id="${user.id}" ${disabledAttr} style="padding: 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background-color: var(--bg-panel); color: var(--text-main);">
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>${t('role_admin')}</option>
<option value="supervisor" ${user.role === 'supervisor' ? 'selected' : ''}>${t('role_supervisor')}</option>
<option value="estimate" ${user.role === 'estimate' ? 'selected' : ''}>${t('role_estimate')}</option>
<option value="user" ${user.role === 'user' ? 'selected' : ''}>${t('role_user')}</option>
<option value="sale" ${user.role === 'sale' ? 'selected' : ''}>${t('role_sale')}</option>
<option value="design" ${user.role === 'design' ? 'selected' : ''}>${t('role_design')}</option>
<option value="production" ${user.role === 'production' ? 'selected' : ''}>${t('role_production')}</option>
<option value="qc" ${user.role === 'qc' ? 'selected' : ''}>${t('role_qc')}</option>
<option value="qa" ${user.role === 'qa' ? 'selected' : ''}>${t('role_qa')}</option>
<option value="stock" ${user.role === 'stock' ? 'selected' : ''}>${t('role_stock')}</option>
<option value="procurment" ${user.role === 'procurment' ? 'selected' : ''}>${t('role_procurment')}</option>
<option value="support" ${user.role === 'support' ? 'selected' : ''}>${t('role_support')}</option>
<option value="excusive" ${user.role === 'excusive' ? 'selected' : ''}>${t('role_excusive')}</option>
<option value="draft" ${user.role === 'draft' ? 'selected' : ''}>${t('role_draft')}</option>
<option value="sale_manager" ${user.role === 'sale_manager' ? 'selected' : ''}>${t('role_sale_manager')}</option>
<option value="design_manager_mech" ${user.role === 'design_manager_mech' ? 'selected' : ''}>${t('role_design_manager_mech')}</option>
<option value="design_manager_elec" ${user.role === 'design_manager_elec' ? 'selected' : ''}>${t('role_design_manager_elec')}</option>
<option value="consultant_mech" ${user.role === 'consultant_mech' ? 'selected' : ''}>${t('role_consultant_mech')}</option>
<option value="consultant_elec" ${user.role === 'consultant_elec' ? 'selected' : ''}>${t('role_consultant_elec')}</option>
<option value="factory_manager" ${user.role === 'factory_manager' ? 'selected' : ''}>${t('role_factory_manager')}</option>
<option value="account_manager" ${user.role === 'account_manager' ? 'selected' : ''}>${t('role_account_manager')}</option>
<option value="procurement_manager" ${user.role === 'procurement_manager' ? 'selected' : ''}>${t('role_procurement_manager')}</option>
<option value="contractor" ${user.role === 'contractor' ? 'selected' : ''}>${t('role_contractor')}</option>
</select>
`;
}
let deleteBtnHtml = '';
if (user.role === 'superadmin') {
deleteBtnHtml = `<span style="color:var(--text-muted); font-size:0.85rem;">System</span>`;
} else if (isSelf) {
deleteBtnHtml = `
<span style="color:var(--text-muted); font-size:0.85rem;">${state.currentLanguage === 'en' ? 'Logged In' : 'ผู้ใช้นี้เข้าใช้งานอยู่'}</span>
`;
} else {
deleteBtnHtml = `
<button class="btn-icon btn-danger-icon btn-sm btn-delete-user" data-user-id="${user.id}">
<i class="fa-solid fa-trash-can"></i>
</button>
`;
}
let statusHtml = '';
if (user.role === 'superadmin') {
statusHtml = `<span class="badge bg-success-light text-success" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600;">${state.currentLanguage === 'en' ? 'Active' : 'ใช้งานอยู่'}</span>`;
} else if (isSelf) {
const isActive = user.is_active !== 0;
statusHtml = `<span class="badge ${isActive ? 'bg-success-light text-success' : 'bg-danger-light text-danger'}" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600;">${isActive ? (state.currentLanguage === 'en' ? 'Active' : 'ใช้งานอยู่') : (state.currentLanguage === 'en' ? 'Inactive' : 'ระงับใช้งาน')}</span>`;
} else {
const isActive = user.is_active !== 0;
statusHtml = `
<button class="btn-icon btn-sm btn-toggle-user-status" data-user-id="${user.id}" data-current-active="${isActive ? '1' : '0'}" title="${isActive ? (state.currentLanguage === 'en' ? 'Click to deactivate' : 'คลิกเพื่อระงับการใช้งาน') : (state.currentLanguage === 'en' ? 'Click to activate' : 'คลิกเพื่อเปิดใช้งาน')}" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600; border:1px solid; cursor:pointer; ${isActive ? 'background-color:var(--bg-success-light,#dcfce7); color:var(--success,#16a34a); border-color:var(--success,#16a34a);' : 'background-color:var(--bg-danger-light,#fee2e2); color:var(--danger,#dc2626); border-color:var(--danger,#dc2626);'}">
${isActive ? (state.currentLanguage === 'en' ? 'Active' : 'ใช้งานอยู่') : (state.currentLanguage === 'en' ? 'Inactive' : 'ระงับใช้งาน')}
</button>
`;
}
let projectAccessHtml = '';
if (user.role === 'superadmin' || user.role === 'excusive') {
projectAccessHtml = `<span class="badge bg-success-light text-success" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600;">${state.currentLanguage === 'en' ? 'All Projects' : 'ทุกโครงการ'}</span>`;
} else {
if (user.projectAccess && user.projectAccess.length > 0) {
const list = user.projectAccess.map(pName => `• ${escapeHtml(pName)}`).join('<br>');
projectAccessHtml = `<div style="font-size:0.8rem; line-height:1.4; max-height:80px; overflow-y:auto; padding:2px;">${list}</div>`;
} else {
projectAccessHtml = `<span style="color:var(--text-muted); font-size:0.8rem;">-</span>`;
}
}
const isSuperAdmin = user.role === 'superadmin';
const passwordInputDisabled = (isSuperAdmin && state.currentUser?.role !== 'superadmin') ? 'disabled' : '';
const passwordHtml = `
<div style="display:flex; align-items:center; gap:6px;">
<input type="text" class="form-control user-password-input" data-user-id="${user.id}" ${passwordInputDisabled} value="${escapeHtml(user.plain_password || '********')}" style="padding: 4px 8px; font-size: 0.85rem; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background-color: var(--bg-panel); color: var(--text-main); width: 110px;">
<button class="btn btn-outline btn-sm btn-update-user-password" data-user-id="${user.id}" ${(isSuperAdmin && state.currentUser?.role !== 'superadmin') ? 'disabled' : ''} title="${state.currentLanguage === 'en' ? 'Update password' : 'อัปเดตเซ็ตรหัสผ่านใหม่'}" style="padding:2px 6px; display:inline-flex; align-items:center; justify-content:center;">
<i class="fa-solid fa-check" style="font-size:0.75rem;"></i>
</button>
</div>
`;
let accessGroupHtml = '';
if (user.role === 'superadmin' || user.role === 'excusive') {
accessGroupHtml = `<span class="badge" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600; background-color:#fee2e2; color:#dc2626;">${t('access_executive')}</span>`;
} else if (user.role === 'admin' || user.role === 'supervisor' || user.role === 'estimate') {
accessGroupHtml = `<span class="badge" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600; background-color:#fef3c7; color:#d97706;">${t('access_confidential')}</span>`;
} else if (user.role === 'contractor') {
accessGroupHtml = `<span class="badge" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600; background-color:#e2e8f0; color:#475569;">${t('access_low')}</span>`;
} else {
accessGroupHtml = `<span class="badge" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600; background-color:#dcfce7; color:#16a34a;">${t('access_general')}</span>`;
}
tr.innerHTML = `
<td class="font-bold">${escapeHtml(user.username)}</td>
<td>${roleSelectHtml}</td>
<td>${accessGroupHtml}</td>
<td>${passwordHtml}</td>
<td>${projectAccessHtml}</td>
<td>${parseDbDate(user.created_at).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH')}</td>
<td class="text-center">${statusHtml}</td>
<td class="text-center">${deleteBtnHtml}</td>
`;
tbody.appendChild(tr);
});
// Bind listeners for change-role-select
tbody.querySelectorAll('.change-role-select').forEach(select => {
select.addEventListener('change', async (e) => {
const userId = e.target.dataset.userId;
const newRole = e.target.value;
await handleRoleChange(userId, newRole);
});
});
// Bind listeners for btn-delete-user
tbody.querySelectorAll('.btn-delete-user').forEach(btn => {
btn.addEventListener('click', async (e) => {
const userId = e.currentTarget.dataset.userId;
await handleDeleteUser(userId);
});
});
// Bind listeners for btn-update-user-password
tbody.querySelectorAll('.btn-update-user-password').forEach(btn => {
btn.addEventListener('click', async (e) => {
const userId = e.currentTarget.dataset.userId;
const input = tbody.querySelector(`.user-password-input[data-user-id="${userId}"]`);
const newPassword = input ? input.value : '';
if (!newPassword) {
alert(state.currentLanguage === 'en' ? 'Password cannot be empty' : 'รหัสผ่านต้องไม่ว่างเปล่า');
return;
}
if (!confirm(state.currentLanguage === 'en' ? `Are you sure you want to change password for this user?` : `คุณแน่ใจหรือไม่ว่าต้องการเปลี่ยนรหัสผ่านสำหรับผู้ใช้งานรายนี้?`)) return;
try {
const res = await authFetch(`${API_BASE}/users/${userId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword })
});
if (res.ok) {
showToast(state.currentLanguage === 'en' ? 'Password updated successfully' : 'อัปเดตรหัสผ่านสำเร็จ');
await fetchAdminUsers();
} else {
const err = await res.json();
alert(err.error || 'Failed to update password');
}
} catch (err) {
console.error(err);
alert('Connection error');
}
});
});
// Bind listeners for btn-toggle-user-status
tbody.querySelectorAll('.btn-toggle-user-status').forEach(btn => {
btn.addEventListener('click', async (e) => {
const userId = e.currentTarget.dataset.userId;
const currentlyActive = e.currentTarget.dataset.currentActive === '1';
await handleToggleUserStatus(userId, !currentlyActive);
});
});
}
async function handleToggleUserStatus(userId, newIsActive) {
const confirmMsg = newIsActive
? (state.currentLanguage === 'en' ? 'Reactivate this account?' : 'ต้องการเปิดใช้งานบัญชีนี้ใช่หรือไม่?')
: (state.currentLanguage === 'en' ? 'Deactivate this account? The user will not be able to log in.' : 'ต้องการระงับการใช้งานบัญชีนี้ใช่หรือไม่? ผู้ใช้จะไม่สามารถเข้าสู่ระบบได้');
if (!confirm(confirmMsg)) return;
try {
await authFetch(`${API_BASE}/users/${userId}/status`, {
method: 'PUT',
body: JSON.stringify({ is_active: newIsActive })
});
showToast(newIsActive
? (state.currentLanguage === 'en' ? 'Account activated' : 'เปิดใช้งานบัญชีเรียบร้อยแล้ว')
: (state.currentLanguage === 'en' ? 'Account deactivated' : 'ระงับการใช้งานบัญชีเรียบร้อยแล้ว'));
await fetchAdminUsers();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to update account status' : 'ไม่สามารถเปลี่ยนสถานะบัญชีได้', 'error');
}
}
async function openAnnouncementsManagerModal() {
document.getElementById('modal-announcements-manager').classList.add('active');
await fetchAndRenderAnnouncements();
}
function closeAnnouncementsManagerModal() {
document.getElementById('modal-announcements-manager').classList.remove('active');
}
async function handleAdminPanelRefresh() {
showToast(state.currentLanguage === 'en' ? 'Refreshing...' : 'กำลังรีเฟรชข้อมูล...');
try {
await fetchAdminUsers();
// Repopulate projects
const selectProj = document.getElementById('admin-select-project');
const currentSelectedId = selectProj ? selectProj.value : null;
await fetchProjects();
if (selectProj) {
selectProj.innerHTML = '';
let filteredProjects = state.projects || [];
if (state.currentUser && (state.currentUser.role === 'supervisor' || state.currentUser.role === 'estimate')) {
filteredProjects = filteredProjects.filter(p => p.created_by === state.currentUser.id);
}
if (filteredProjects.length > 0) {
filteredProjects.forEach(proj => {
const opt = document.createElement('option');
opt.value = proj.id;
opt.textContent = `${proj.name} (${proj.client_name || '-'})`;
if (proj.id === currentSelectedId) opt.selected = true;
selectProj.appendChild(opt);
});
const newSelectedId = selectProj.value;
if (newSelectedId) {
await fetchProjectPermissions(newSelectedId);
}
} else {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = state.currentLanguage === 'en' ? '-- No projects --' : '-- ไม่มีโครงการ --';
selectProj.appendChild(opt);
document.getElementById('admin-supervisors-list').innerHTML = `<div class="text-center text-muted">${state.currentLanguage === 'en' ? 'No projects available to manage permissions' : 'ไม่มีโครงการให้สามารถจัดการสิทธิ์ได้'}</div>`;
}
}
showToast(state.currentLanguage === 'en' ? 'Data refreshed' : 'รีเฟรชข้อมูลเรียบร้อยแล้ว');
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to refresh' : 'ไม่สามารถรีเฟรชข้อมูลได้', 'error');
}
}
async function fetchAndRenderAnnouncements() {
try {
const res = await authFetch(`${API_BASE}/announcements`);
const announcements = await res.json();
state.allAnnouncements = announcements;
renderAnnouncementsList(announcements);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load announcements' : 'ไม่สามารถโหลดข้อมูลประกาศได้', 'error');
}
}
function renderAnnouncementsList(announcements) {
const tbody = document.getElementById('announcements-manager-tbody');
tbody.innerHTML = '';
if (announcements.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="padding:30px; text-align:center; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'No announcements yet' : 'ยังไม่มีประกาศ'}</td></tr>`;
return;
}
announcements.forEach(a => {
const tr = document.createElement('tr');
const isActive = a.is_active !== 0;
tr.innerHTML = `
<td><b>${escapeHtml(a.title)}</b></td>
<td>${escapeHtml(a.created_by_name || '-')}</td>
<td>${parseDbDate(a.created_at).toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH')}</td>
<td class="text-center">${a.ack_count}</td>
<td class="text-center">
<button class="btn-icon btn-sm btn-toggle-announcement-active" data-announcement-id="${a.id}" data-current-active="${isActive ? '1' : '0'}" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600; border:1px solid; cursor:pointer; ${isActive ? 'background-color:var(--bg-success-light,#dcfce7); color:var(--success,#16a34a); border-color:var(--success,#16a34a);' : 'background-color:var(--bg-danger-light,#fee2e2); color:var(--danger,#dc2626); border-color:var(--danger,#dc2626);'}">
${isActive ? (state.currentLanguage === 'en' ? 'Active' : 'เปิดใช้งาน') : (state.currentLanguage === 'en' ? 'Inactive' : 'ปิดใช้งาน')}
</button>
</td>
<td class="text-center">
<div style="display:flex; justify-content:center; gap:8px;">
<button class="btn btn-outline btn-sm btn-edit-announcement" data-announcement-id="${a.id}">
<i class="fa-solid fa-edit"></i>
</button>
<button class="btn btn-danger-outline btn-sm btn-delete-announcement" data-announcement-id="${a.id}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
`;
tr.querySelector('.btn-edit-announcement').addEventListener('click', () => {
const announcement = state.allAnnouncements.find(x => x.id === a.id);
openAnnouncementDetailModal(announcement);
});
tr.querySelector('.btn-delete-announcement').addEventListener('click', () => handleDeleteAnnouncement(a.id));
tr.querySelector('.btn-toggle-announcement-active').addEventListener('click', () => {
const announcement = state.allAnnouncements.find(x => x.id === a.id);
handleToggleAnnouncementActive(announcement);
});
tbody.appendChild(tr);
});
}
function openAnnouncementDetailModal(announcement = null) {
const modal = document.getElementById('modal-announcement-detail');
const title = document.getElementById('announcement-detail-title');
const form = document.getElementById('form-announcement-detail');
form.reset();
if (announcement) {
title.textContent = state.currentLanguage === 'en' ? 'Edit Announcement' : 'แก้ไขประกาศ';
document.getElementById('announcement-detail-id').value = announcement.id;
document.getElementById('announcement-detail-title-input').value = announcement.title;
document.getElementById('announcement-detail-body-input').value = announcement.body;
document.getElementById('announcement-detail-active').checked = announcement.is_active !== 0;
} else {
title.textContent = state.currentLanguage === 'en' ? 'Add New Announcement' : 'เพิ่มประกาศใหม่';
document.getElementById('announcement-detail-id').value = '';
document.getElementById('announcement-detail-active').checked = true;
}
modal.classList.add('active');
}
function closeAnnouncementDetailModal() {
document.getElementById('modal-announcement-detail').classList.remove('active');
}
async function handleAnnouncementDetailSubmit(e) {
e.preventDefault();
const id = document.getElementById('announcement-detail-id').value;
const title = document.getElementById('announcement-detail-title-input').value.trim();
const body = document.getElementById('announcement-detail-body-input').value.trim();
const isActive = document.getElementById('announcement-detail-active').checked;
try {
if (id) {
await authFetch(`${API_BASE}/announcements/${id}`, {
method: 'PUT',
body: JSON.stringify({ title, body, is_active: isActive })
});
showToast(state.currentLanguage === 'en' ? 'Announcement updated' : 'อัปเดตประกาศเรียบร้อยแล้ว');
} else {
await authFetch(`${API_BASE}/announcements`, {
method: 'POST',
body: JSON.stringify({ title, body })
});
showToast(state.currentLanguage === 'en' ? 'Announcement created' : 'สร้างประกาศเรียบร้อยแล้ว');
}
closeAnnouncementDetailModal();
await fetchAndRenderAnnouncements();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to save announcement' : 'ไม่สามารถบันทึกประกาศได้', 'error');
}
}
async function handleToggleAnnouncementActive(announcement) {
try {
await authFetch(`${API_BASE}/announcements/${announcement.id}`, {
method: 'PUT',
body: JSON.stringify({ title: announcement.title, body: announcement.body, is_active: announcement.is_active === 0 })
});
await fetchAndRenderAnnouncements();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to update announcement status' : 'ไม่สามารถเปลี่ยนสถานะประกาศได้', 'error');
}
}
async function handleDeleteAnnouncement(id) {
if (!confirm(state.currentLanguage === 'en' ? 'Delete this announcement?' : 'ต้องการลบประกาศนี้ใช่หรือไม่?')) return;
try {
await authFetch(`${API_BASE}/announcements/${id}`, { method: 'DELETE' });
showToast(state.currentLanguage === 'en' ? 'Announcement deleted' : 'ลบประกาศเรียบร้อยแล้ว');
await fetchAndRenderAnnouncements();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete announcement' : 'ไม่สามารถลบประกาศได้', 'error');
}
}
// ==========================================
// Department Reports Module
// ==========================================
const REPORT_TABS = [
{ key: 'electrical-design', th: 'ออกแบบระบบไฟฟ้า', en: 'Electrical Design', icon: 'fa-bolt' },
{ key: 'mechanical-design', th: 'ออกแบบระบบเครื่องกล', en: 'Mechanical Design', icon: 'fa-fan' },
{ key: 'sales', th: 'ฝ่ายขาย', en: 'Sales', icon: 'fa-handshake' },
{ key: 'production', th: 'ฝ่ายผลิต', en: 'Production', icon: 'fa-industry' },
{ key: 'qc', th: 'ฝ่ายควบคุมคุณภาพ', en: 'Quality Control', icon: 'fa-clipboard-check' },
{ key: 'planning', th: 'ฝ่ายวางแผน', en: 'Planning', icon: 'fa-calendar-days' },
{ key: 'procurement', th: 'ฝ่ายจัดซื้อ', en: 'Procurement', icon: 'fa-cart-shopping' },
{ key: 'accounting', th: 'ฝ่ายบัญชี', en: 'Accounting', icon: 'fa-calculator' },
{ key: 'executive', th: 'ฝ่ายบริหาร', en: 'Executive', icon: 'fa-crown' },
];
async function checkReportsButtonVisibility() {
const btn = document.getElementById('btn-open-reports');
try {
const res = await authFetch(`${API_BASE}/reports/available`);
const data = await res.json();
state.availableReports = data.available || [];
btn.style.display = state.availableReports.length > 0 ? 'inline-flex' : 'none';
} catch (err) {
btn.style.display = 'none';
}
}
async function openReportsModal() {
const tabBar = document.getElementById('reports-tab-bar');
tabBar.innerHTML = '';
if (!state.availableReports || state.availableReports.length === 0) {
await checkReportsButtonVisibility();
}
REPORT_TABS.filter(tab => state.availableReports.includes(tab.key)).forEach((tab, idx) => {
const btn = document.createElement('button');
btn.className = 'btn btn-outline btn-sm';
btn.dataset.reportKey = tab.key;
btn.innerHTML = `<i class="fa-solid ${tab.icon}"></i> ${state.currentLanguage === 'en' ? tab.en : tab.th}`;
btn.addEventListener('click', () => switchReportTab(tab.key));
tabBar.appendChild(btn);
if (idx === 0) switchReportTab(tab.key);
});
document.getElementById('modal-reports').classList.add('active');
}
function closeReportsModal() {
document.getElementById('modal-reports').classList.remove('active');
}
async function switchReportTab(key) {
document.querySelectorAll('#reports-tab-bar button').forEach(btn => {
btn.classList.toggle('btn-primary', btn.dataset.reportKey === key);
btn.classList.toggle('btn-outline', btn.dataset.reportKey !== key);
});
const content = document.getElementById('reports-content-area');
content.innerHTML = `<div style="text-align:center; padding:40px; color:var(--text-muted);"><i class="fa-solid fa-spinner fa-spin" style="font-size:2rem;"></i></div>`;
try {
const res = await authFetch(`${API_BASE}/reports/${key}`);
const data = await res.json();
content.innerHTML = renderReportContent(key, data);
} catch (err) {
content.innerHTML = `<div style="text-align:center; padding:40px; color:var(--danger);">${state.currentLanguage === 'en' ? 'Failed to load report' : 'ไม่สามารถโหลดรายงานได้'}</div>`;
}
}
function renderSummaryCards(items) {
return `
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:20px;">
${items.map(item => `
<div style="background:var(--bg-hover); border-radius:var(--radius-md); padding:14px 16px;">
<div style="font-size:0.78rem; color:var(--text-muted); margin-bottom:4px;">${escapeHtml(item.label)}</div>
<div style="font-size:1.3rem; font-weight:700; color:${item.color || 'var(--text-main)'};">${item.value}</div>
</div>
`).join('')}
</div>
`;
}
function renderDataTable(title, columns, rows) {
if (!rows || rows.length === 0) {
return `<div style="margin-bottom:24px;"><h4 style="margin-bottom:10px;">${escapeHtml(title)}</h4><div style="padding:20px; text-align:center; color:var(--text-muted); background:var(--bg-hover); border-radius:var(--radius-sm);">${state.currentLanguage === 'en' ? 'No data' : 'ไม่มีข้อมูล'}</div></div>`;
}
return `
<div style="margin-bottom:24px;">
<h4 style="margin-bottom:10px;">${escapeHtml(title)}</h4>
<div class="explorer-table-wrapper">
<table class="data-table">
<thead><tr>${columns.map(c => `<th>${escapeHtml(c.label)}</th>`).join('')}</tr></thead>
<tbody>
${rows.map(row => `<tr>${columns.map(c => `<td>${c.format ? c.format(row[c.key], row) : escapeHtml(String(row[c.key] ?? '-'))}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
</div>
</div>
`;
}
function renderReportContent(key, data) {
const isEn = state.currentLanguage === 'en';
switch (key) {
case 'electrical-design':
case 'mechanical-design':
return renderSummaryCards([
{ label: isEn ? 'Total Sections' : 'ระบบงานทั้งหมด', value: data.summary.total_sections },
{ label: isEn ? 'Projects Involved' : 'จำนวนโครงการ', value: data.summary.total_projects },
{ label: isEn ? 'Missing Spec Upload' : 'ยังไม่มีไฟล์สเปก', value: data.summary.sections_missing_spec, color: data.summary.sections_missing_spec > 0 ? 'var(--danger)' : 'var(--success)' },
]) + renderDataTable(isEn ? 'Sections' : 'รายการระบบงาน',
[
{ key: 'project_name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'section_name', label: isEn ? 'Section' : 'ระบบงาน' },
{ key: 'item_count', label: isEn ? 'Items' : 'จำนวนสินค้า' },
{ key: 'has_spec', label: isEn ? 'Spec Uploaded' : 'มีไฟล์สเปก', format: v => v ? `<span class="text-success"><i class="fa-solid fa-check"></i></span>` : `<span class="text-danger"><i class="fa-solid fa-xmark"></i></span>` },
], data.sections);
case 'sales':
return renderSummaryCards([
{ label: isEn ? 'Total Projects' : 'โครงการทั้งหมด', value: data.summary.total_projects },
{ label: isEn ? 'Total Bid Value' : 'มูลค่าเสนอราคารวม', value: formatCurrency(data.summary.total_bid_value) },
]) + renderDataTable(isEn ? 'Top Clients by Value' : 'ลูกค้าตามมูลค่า',
[{ key: 'client', label: isEn ? 'Client' : 'ลูกค้า' }, { key: 'value', label: isEn ? 'Value' : 'มูลค่า', format: formatCurrency }],
data.summary.top_clients)
+ renderDataTable(isEn ? 'All Projects' : 'โครงการทั้งหมด',
[
{ key: 'name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'client_name', label: isEn ? 'Client' : 'ลูกค้า' },
{ key: 'status', label: isEn ? 'Status' : 'สถานะ' },
{ key: 'price_total', label: isEn ? 'Bid Price' : 'ราคาเสนอ', format: formatCurrency },
], data.projects);
case 'production':
return renderSummaryCards([
{ label: isEn ? 'Total Updates' : 'อัปเดตทั้งหมด', value: data.summary.total_updates },
{ label: isEn ? 'Open Issues' : 'ปัญหาที่ยังเปิดอยู่', value: data.summary.open_issue_count, color: data.summary.open_issue_count > 0 ? 'var(--danger)' : 'var(--success)' },
{ label: isEn ? 'Stale Projects (14+ days)' : 'โครงการที่ไม่มีอัปเดต 14 วัน+', value: data.summary.stale_project_count, color: data.summary.stale_project_count > 0 ? 'var(--danger)' : 'var(--success)' },
]) + renderDataTable(isEn ? 'Open Issues' : 'ปัญหาที่ยังเปิดอยู่',
[
{ key: 'project_name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'system_name', label: isEn ? 'System' : 'ระบบงาน' },
{ key: 'issues', label: isEn ? 'Issue' : 'ปัญหา' },
{ key: 'created_at', label: isEn ? 'Date' : 'วันที่', format: v => parseDbDate(v).toLocaleDateString(isEn ? 'en-US' : 'th-TH') },
], data.open_issues)
+ renderDataTable(isEn ? 'Recent Updates' : 'อัปเดตล่าสุด',
[
{ key: 'project_name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'progress_desc', label: isEn ? 'Description' : 'รายละเอียด' },
{ key: 'creator_name', label: isEn ? 'By' : 'โดย' },
{ key: 'created_at', label: isEn ? 'Date' : 'วันที่', format: v => parseDbDate(v).toLocaleDateString(isEn ? 'en-US' : 'th-TH') },
], data.recent_updates.slice(0, 30));
case 'qc':
return renderSummaryCards([
{ label: isEn ? 'Total FAT Reports' : 'รายงาน FAT ทั้งหมด', value: data.summary.total_reports },
{ label: isEn ? 'Total Pass' : 'ผ่านทั้งหมด', value: data.summary.total_pass, color: 'var(--success)' },
{ label: isEn ? 'Total Fail' : 'ไม่ผ่านทั้งหมด', value: data.summary.total_fail, color: data.summary.total_fail > 0 ? 'var(--danger)' : 'var(--success)' },
]) + renderDataTable(isEn ? 'Failed Items Needing Attention' : 'รายการที่ไม่ผ่านและต้องแก้ไข',
[
{ key: 'project_name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'report_type', label: isEn ? 'Report Type' : 'ประเภทรายงาน' },
{ key: 'title', label: isEn ? 'Item' : 'รายการ' },
{ key: 'remarks', label: isEn ? 'Remarks' : 'หมายเหตุ' },
], data.failed_items)
+ renderDataTable(isEn ? 'FAT Reports' : 'รายงาน FAT ทั้งหมด',
[
{ key: 'project_name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'report_type', label: isEn ? 'Type' : 'ประเภท' },
{ key: 'pass_count', label: isEn ? 'Pass' : 'ผ่าน' },
{ key: 'fail_count', label: isEn ? 'Fail' : 'ไม่ผ่าน' },
], data.reports);
case 'planning':
return `<div style="padding:10px 14px; background:var(--bg-hover); border-radius:var(--radius-sm); font-size:0.85rem; color:var(--text-muted); margin-bottom:16px;"><i class="fa-solid fa-circle-info"></i> ${escapeHtml(data.note)}</div>`
+ renderSummaryCards([
{ label: isEn ? 'Total Projects' : 'โครงการทั้งหมด', value: data.summary.total_projects },
]) + renderDataTable(isEn ? 'Upcoming RFQ Due Dates' : 'กำหนดส่ง RFQ ที่ใกล้ถึง',
[
{ key: 'rfq_number', label: 'RFQ' },
{ key: 'project_name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'contractor_name', label: isEn ? 'Contractor' : 'ผู้รับเหมา' },
{ key: 'due_date', label: isEn ? 'Due Date' : 'กำหนดส่ง' },
{ key: 'status', label: isEn ? 'Status' : 'สถานะ' },
], data.upcoming_rfqs)
+ renderDataTable(isEn ? 'All Projects' : 'โครงการทั้งหมด',
[
{ key: 'name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'status', label: isEn ? 'Status' : 'สถานะ' },
{ key: 'current_revision', label: isEn ? 'Revision' : 'เวอร์ชัน' },
], data.projects);
case 'procurement':
return renderSummaryCards([
{ label: isEn ? 'Total RFQs' : 'RFQ ทั้งหมด', value: data.summary.total_rfqs },
{ label: isEn ? 'Total Suppliers' : 'ผู้จัดจำหน่ายทั้งหมด', value: data.summary.total_suppliers },
]) + renderDataTable(isEn ? 'Spend by Contractor' : 'ยอดใช้จ่ายตามผู้รับเหมา',
[{ key: 'contractor', label: isEn ? 'Contractor' : 'ผู้รับเหมา' }, { key: 'total', label: isEn ? 'Total' : 'ยอดรวม', format: formatCurrency }],
data.summary.spend_by_contractor)
+ renderDataTable(isEn ? 'All RFQs' : 'RFQ ทั้งหมด',
[
{ key: 'rfq_number', label: 'RFQ' },
{ key: 'project_name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'contractor_name', label: isEn ? 'Contractor' : 'ผู้รับเหมา' },
{ key: 'status', label: isEn ? 'Status' : 'สถานะ' },
{ key: 'total_amount', label: isEn ? 'Amount' : 'ยอดเงิน', format: formatCurrency },
], data.rfqs);
case 'accounting':
return renderSummaryCards([
{ label: isEn ? 'Total Cost' : 'ต้นทุนรวม', value: formatCurrency(data.summary.total_cost) },
{ label: isEn ? 'Total Price' : 'ราคาขายรวม', value: formatCurrency(data.summary.total_price) },
{ label: isEn ? 'Total Profit' : 'กำไรรวม', value: formatCurrency(data.summary.total_profit), color: data.summary.total_profit >= 0 ? 'var(--success)' : 'var(--danger)' },
]) + renderDataTable(isEn ? 'Project P&L' : 'กำไร-ขาดทุนรายโครงการ',
[
{ key: 'name', label: isEn ? 'Project' : 'โครงการ' },
{ key: 'client_name', label: isEn ? 'Client' : 'ลูกค้า' },
{ key: 'cost_total', label: isEn ? 'Cost' : 'ต้นทุน', format: formatCurrency },
{ key: 'price_total', label: isEn ? 'Price' : 'ราคาขาย', format: formatCurrency },
{ key: 'profit', label: isEn ? 'Profit' : 'กำไร', format: formatCurrency },
{ key: 'profit_margin_percent', label: isEn ? 'Margin %' : 'อัตรากำไร %', format: v => v.toFixed(1) + '%' },
], data.projects);
case 'executive':
return renderSummaryCards([
{ label: isEn ? 'Total Projects' : 'โครงการทั้งหมด', value: data.summary.total_projects },
{ label: isEn ? 'Portfolio Value' : 'มูลค่าพอร์ตโฟลิโอ', value: formatCurrency(data.summary.total_portfolio_value) },
{ label: isEn ? 'Total Profit' : 'กำไรรวม', value: formatCurrency(data.summary.total_profit), color: data.summary.total_profit >= 0 ? 'var(--success)' : 'var(--danger)' },
{ label: isEn ? 'Overall Margin' : 'อัตรากำไรรวม', value: data.summary.overall_margin_percent.toFixed(1) + '%' },
]) + renderDataTable(isEn ? 'Recent Activity' : 'กิจกรรมล่าสุด',
[
{ key: 'username', label: isEn ? 'User' : 'ผู้ใช้' },
{ key: 'action', label: isEn ? 'Action' : 'การกระทำ' },
{ key: 'details', label: isEn ? 'Details' : 'รายละเอียด' },
{ key: 'created_at', label: isEn ? 'Time' : 'เวลา', format: v => parseDbDate(v).toLocaleString(isEn ? 'en-US' : 'th-TH') },
], data.recent_activity);
default:
return '';
}
}
// ==========================================
// Internal Chat Module
// ==========================================
async function checkChatUnreadBadge() {
if (state.currentUser && state.currentUser.role === 'contractor') {
return;
}
try {
const res = await authFetch(`${API_BASE}/chat/unread-count`);
const data = await res.json();
const badge = document.getElementById('chat-unread-badge');
if (data.total_unread > 0) {
badge.textContent = data.total_unread > 99 ? '99+' : data.total_unread;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
} catch (err) {
// Silent - badge polling shouldn't be noisy on failure
}
}
function startChatUnreadPolling() {
if (state.currentUser && state.currentUser.role === 'contractor') {
if (state.chatUnreadPollInterval) {
clearInterval(state.chatUnreadPollInterval);
state.chatUnreadPollInterval = null;
}
return;
}
if (state.chatUnreadPollInterval) clearInterval(state.chatUnreadPollInterval);
checkChatUnreadBadge();
state.chatUnreadPollInterval = setInterval(checkChatUnreadBadge, 25000);
}
async function openChatModal() {
document.getElementById('modal-chat').classList.add('active');
document.getElementById('chat-thread-active').style.display = 'none';
document.getElementById('chat-thread-empty').style.display = 'flex';
state.activeChatConversationId = null;
// Clear search values
document.getElementById('chat-search-input').value = '';
document.getElementById('chat-message-search-input').value = '';
document.getElementById('chat-message-search-container').style.display = 'none';
document.getElementById('btn-chat-message-search-clear').style.display = 'none';
// Initialize emoji picker
if (!state.emojiPickerInitialized) {
initEmojiPicker();
state.emojiPickerInitialized = true;
}
await fetchAndRenderConversations();
}
function closeChatModal() {
document.getElementById('modal-chat').classList.remove('active');
stopChatThreadPolling();
}
async function fetchAndRenderConversations() {
try {
const res = await authFetch(`${API_BASE}/chat/conversations`);
const conversations = await res.json();
state.chatConversations = conversations;
// Filter conversations based on search
const query = (document.getElementById('chat-search-input').value || '').toLowerCase().trim();
let filtered = conversations;
if (query) {
filtered = conversations.filter(c =>
(c.other_username && c.other_username.toLowerCase().includes(query)) ||
(c.last_message && c.last_message.toLowerCase().includes(query))
);
}
renderConversationsList(filtered);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load conversations' : 'ไม่สามารถโหลดรายการสนทนาได้', 'error');
}
}
function renderConversationsList(conversations) {
const container = document.getElementById('chat-conversations-list');
container.innerHTML = '';
if (conversations.length === 0) {
container.innerHTML = `<div style="padding:20px; text-align:center; color:var(--text-muted); font-size:0.85rem;">${state.currentLanguage === 'en' ? 'No conversations found' : 'ไม่พบรายการสนทนา'}</div>`;
return;
}
conversations.forEach(conv => {
const item = document.createElement('div');
const isActive = state.activeChatConversationId === conv.id;
// Circular profile initials avatar
const name = conv.is_group === 1 ? conv.name : (conv.other_username || '-');
const initial = name.charAt(0).toUpperCase();
// Generate a stable color index based on username string
let charCodeSum = 0;
for (let i = 0; i < name.length; i++) charCodeSum += name.charCodeAt(i);
const colors = [
'linear-gradient(135deg, #3b82f6, #1d4ed8)', // Blue
'linear-gradient(135deg, #ec4899, #be185d)', // Pink
'linear-gradient(135deg, #10b981, #047857)', // Green
'linear-gradient(135deg, #f59e0b, #b45309)', // Amber
'linear-gradient(135deg, #8b5cf6, #6d28d9)', // Purple
'linear-gradient(135deg, #ef4444, #b91c1c)' // Red
];
const avatarBg = colors[charCodeSum % colors.length];
let lastMsgPreview = '';
if (conv.last_message) {
if (conv.last_message.startsWith('[sticker:')) {
lastMsgPreview = `[${state.currentLanguage === 'en' ? 'Sticker' : 'สติกเกอร์'}]`;
} else if (conv.last_message.startsWith('[image:')) {
lastMsgPreview = `[${state.currentLanguage === 'en' ? 'Image' : 'รูปภาพ'}]`;
} else {
lastMsgPreview = escapeHtml(conv.last_message).slice(0, 30);
}
} else {
lastMsgPreview = state.currentLanguage === 'en' ? 'No messages yet' : 'ยังไม่มีข้อความ';
}
item.style.cssText = `
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
${isActive ? 'background-color: var(--primary-light, #dbeafe); border-left: 4px solid var(--primary-color); padding-left: 12px;' : ''}
`;
item.className = 'chat-conversations-list-item';
const showOnlineStatus = conv.is_group !== 1 && conv.other_is_online === 1;
item.innerHTML = `
<div class="chat-avatar-wrapper">
<div style="width: 40px; height: 40px; border-radius: 50%; background: ${avatarBg}; color: white; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 1.1rem; flex-shrink: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); user-select: none;">
${initial}
</div>
${showOnlineStatus ? `<span class="online-status-dot"></span>` : ''}
</div>
<div style="flex: 1; min-width: 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px;">
<b style="font-size: 0.92rem; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; max-width: 140px;">
${escapeHtml(name)}
</b>
${conv.unread_count > 0 ? `
<span style="background: var(--danger, #ef4444); color: white; font-size: 0.65rem; font-weight: 700; min-width: 18px; height: 18px; border-radius: 9px; display: flex; align-items: center; justify-content: center; padding: 0 5px; flex-shrink: 0; box-shadow: 0 1px 3px rgba(239, 68, 68, 0.3);">
${conv.unread_count}
</span>
` : ''}
</div>
<div style="font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
${lastMsgPreview}
</div>
</div>
`;
item.addEventListener('click', () => openConversationThread(conv.id, name));
container.appendChild(item);
});
}
function stopChatThreadPolling() {
if (state.chatThreadPollInterval) {
clearInterval(state.chatThreadPollInterval);
state.chatThreadPollInterval = null;
}
}
async function openConversationThread(conversationId, otherUsername) {
state.activeChatConversationId = conversationId;
document.getElementById('chat-thread-empty').style.display = 'none';
document.getElementById('chat-thread-active').style.display = 'flex';
const activeConv = (state.chatConversations || []).find(c => c.id === conversationId);
state.activeChatIsGroup = activeConv ? activeConv.is_group === 1 : false;
state.activeChatCreatorId = activeConv ? activeConv.creator_id : null;
const manageGroupBtn = document.getElementById('btn-manage-group');
if (state.activeChatIsGroup) {
manageGroupBtn.style.display = 'flex';
} else {
manageGroupBtn.style.display = 'none';
}
const displayName = activeConv && activeConv.is_group === 1 ? activeConv.name : otherUsername;
document.getElementById('chat-thread-header').textContent = displayName;
// Reset message search values on chat thread change
document.getElementById('chat-message-search-input').value = '';
document.getElementById('chat-message-search-container').style.display = 'none';
document.getElementById('btn-chat-message-search-clear').style.display = 'none';
renderConversationsList(state.chatConversations || []);
await fetchAndRenderChatMessages();
await authFetch(`${API_BASE}/chat/conversations/${conversationId}/read`, { method: 'PUT' });
checkChatUnreadBadge();
stopChatThreadPolling();
state.chatThreadPollInterval = setInterval(async () => {
await fetchAndRenderChatMessages();
await authFetch(`${API_BASE}/chat/conversations/${conversationId}/read`, { method: 'PUT' });
checkChatUnreadBadge();
}, 5000);
}
async function fetchAndRenderChatMessages() {
if (!state.activeChatConversationId) return;
try {
const res = await authFetch(`${API_BASE}/chat/conversations/${state.activeChatConversationId}/messages`);
const messages = await res.json();
renderChatMessages(messages);
} catch (err) {
// Silent on polling failure
}
}
const stickerEmojis = {
love: '❤️',
haha: '😂',
cry: '😭',
thumbsup: '👍',
party: '🎉',
clap: '👏',
angry: '😡',
wow: '😮',
ok: '👌',
wink: '😉',
pray: '🙏',
sweat: '😅'
};
function renderChatMessages(messages) {
const container = document.getElementById('chat-thread-messages');
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 40;
container.innerHTML = '';
// Get active thread search term
const searchTerm = (document.getElementById('chat-message-search-input').value || '').toLowerCase().trim();
messages.forEach(msg => {
const isMine = state.currentUser && msg.sender_id === state.currentUser.id;
const isSticker = msg.body.startsWith('[sticker:') && msg.body.endsWith(']');
const isImage = msg.body.startsWith('[image:') && msg.body.endsWith(']');
const isAudio = msg.body.startsWith('[audio:') && msg.body.endsWith(']');
let isMatch = true;
if (searchTerm) {
if (isSticker || isImage || isAudio) {
isMatch = false;
} else {
isMatch = msg.body.toLowerCase().includes(searchTerm);
}
}
if (!isMatch) return; // Hide message if it doesn't match the search filter
const bubble = document.createElement('div');
bubble.style.cssText = `max-width:70%; align-self:${isMine ? 'flex-end' : 'flex-start'}; display:flex; flex-direction:column; gap:2px;`;
let bubbleContentHtml = '';
if (isSticker) {
const stickerName = msg.body.slice(9, -1);
const emojiChar = stickerEmojis[stickerName] || '❓';
bubbleContentHtml = `<div class="chat-sticker-bubble" style="font-size:4rem; user-select:none; animation: pop 0.3s ease;">${emojiChar}</div>`;
} else if (isImage) {
const imgPath = msg.body.slice(7, -1);
bubbleContentHtml = `
<div style="background:transparent; border-radius:12px; overflow:hidden; border:1px solid var(--border-color); cursor:pointer;" onclick="window.open('/${imgPath}', '_blank')">
<img class="chat-image-preview" src="/${imgPath}" style="max-width:100%; max-height:220px; display:block; object-fit:cover;">
</div>
`;
} else if (isAudio) {
const audioPath = msg.body.slice(7, -1);
bubbleContentHtml = `
<div style="background:${isMine ? 'var(--primary-color)' : 'var(--bg-hover)'}; padding:6px 12px; border-radius:16px; ${isMine ? 'border-bottom-right-radius:2px;' : 'border-bottom-left-radius:2px;'} box-shadow: 0 1px 2px rgba(0,0,0,0.05); display:flex; align-items:center;">
<audio controls src="/${audioPath}" style="max-width:240px; outline:none; filter: ${isMine ? 'invert(1)' : 'none'};"></audio>
</div>
`;
} else {
let displayText = escapeHtml(msg.body);
if (searchTerm) {
// Highlight matches using case-insensitive replace
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
displayText = displayText.replace(regex, '<mark class="chat-highlight">$1</mark>');
}
bubbleContentHtml = `<div style="background:${isMine ? 'var(--primary-color)' : 'var(--bg-hover)'}; color:${isMine ? 'white' : 'var(--text-main)'}; padding:10px 14px; border-radius:16px; ${isMine ? 'border-bottom-right-radius:2px;' : 'border-bottom-left-radius:2px;'} white-space:pre-wrap; word-break:break-word; font-size:0.9rem; box-shadow: 0 1px 2px rgba(0,0,0,0.05);">${displayText}</div>`;
}
// Align with timezone
const localTime = parseDbDate(msg.created_at);
const timeString = localTime ? localTime.toLocaleTimeString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH', { hour: '2-digit', minute: '2-digit' }) : '';
const senderNameHtml = (!isMine && state.activeChatIsGroup) ? `<div style="font-size:0.75rem; font-weight:bold; color:var(--text-muted); margin-bottom:2px; margin-left:4px; user-select:none;">${escapeHtml(msg.sender_name)}</div>` : '';
bubble.innerHTML = `
${senderNameHtml}
${bubbleContentHtml}
<div style="font-size:0.68rem; color:var(--text-muted); ${isMine ? 'text-align:right;' : ''}">${timeString}</div>
`;
container.appendChild(bubble);
});
if (wasAtBottom || messages.length <= 1) {
container.scrollTop = container.scrollHeight;
}
}
async function handleChatSendSubmit(e) {
e.preventDefault();
const input = document.getElementById('chat-message-input');
const body = input.value.trim();
if (!body || !state.activeChatConversationId) return;
input.value = '';
try {
await authFetch(`${API_BASE}/chat/conversations/${state.activeChatConversationId}/messages`, {
method: 'POST',
body: JSON.stringify({ body })
});
await fetchAndRenderChatMessages();
await fetchAndRenderConversations();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to send message' : 'ไม่สามารถส่งข้อความได้', 'error');
input.value = body;
}
}
// Send Sticker helper
async function sendChatSticker(name) {
if (!state.activeChatConversationId) return;
const body = `[sticker:${name}]`;
try {
await authFetch(`${API_BASE}/chat/conversations/${state.activeChatConversationId}/messages`, {
method: 'POST',
body: JSON.stringify({ body })
});
await fetchAndRenderChatMessages();
await fetchAndRenderConversations();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to send sticker' : 'ไม่สามารถส่งสติกเกอร์ได้', 'error');
}
}
// Chat Image Upload handler
async function handleChatImageUpload(file) {
if (!file || !state.activeChatConversationId) return;
const input = document.getElementById('chat-message-input');
const imageBtn = document.getElementById('btn-chat-image');
imageBtn.disabled = true;
input.disabled = true;
const formData = new FormData();
formData.append('image', file);
try {
const res = await authFetch(`${API_BASE}/chat/upload`, {
method: 'POST',
body: formData
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || 'Upload failed');
}
const data = await res.json();
const body = `[image:${data.filePath}]`;
await authFetch(`${API_BASE}/chat/conversations/${state.activeChatConversationId}/messages`, {
method: 'POST',
body: JSON.stringify({ body })
});
await fetchAndRenderChatMessages();
await fetchAndRenderConversations();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to upload image' : 'อัปโหลดรูปภาพล้มเหลว: ' + err.message, 'error');
} finally {
imageBtn.disabled = false;
input.disabled = false;
document.getElementById('chat-image-input').value = '';
}
}
// Emoji and Sticker Popover Initialization
function initEmojiPicker() {
const emojis = [
'😀','😃','😄','😁','😆','😅','😂','🤣','😊','😇','🙂','🙃','😉','😌','😍','🥰','😘','😗','😙','😚','😋','😛','😝','😜','🤪','🤨','🧐','🤓','😎','🥸','🤩','🥳','😏','😒','😞','😔','😟','😕','🙁','☹️','😣','😖','😫','😩','🥺','😢','😭','😤','😠','😡','🤬','🤯','😳','🥵','🥶','😱','😨','😰','😥','😓','🤗','🤔','🫣','🤭','🫢','🫡','🤫','🫠','✍️','👍','👎','👊','✊','🤛','🤜','👏','🙌','👐','🤲','🤝','🙏','⚡','🔥','⭐','🎉','❤️','💔'
];
const stickers = [
{ name: 'love', char: '❤️' },
{ name: 'haha', char: '😂' },
{ name: 'cry', char: '😭' },
{ name: 'thumbsup', char: '👍' },
{ name: 'party', char: '🎉' },
{ name: 'clap', char: '👏' },
{ name: 'angry', char: '😡' },
{ name: 'wow', char: '😮' },
{ name: 'ok', char: '👌' },
{ name: 'wink', char: '😉' },
{ name: 'pray', char: '🙏' },
{ name: 'sweat', char: '😅' }
];
const emojiPane = document.getElementById('emoji-pane-emojis');
const stickerPane = document.getElementById('emoji-pane-stickers');
emojiPane.innerHTML = '';
emojis.forEach(e => {
const span = document.createElement('span');
span.className = 'emoji-item';
span.textContent = e;
span.addEventListener('click', () => {
const input = document.getElementById('chat-message-input');
input.value += e;
input.focus();
});
emojiPane.appendChild(span);
});
stickerPane.innerHTML = '';
stickers.forEach(s => {
const span = document.createElement('span');
span.className = 'sticker-item';
span.textContent = s.char;
span.title = s.name;
span.addEventListener('click', async () => {
document.getElementById('chat-emoji-popover').style.display = 'none';
await sendChatSticker(s.name);
});
stickerPane.appendChild(span);
});
// Tab switching
const tabBtns = document.querySelectorAll('.emoji-tab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => {
b.classList.remove('active');
b.style.borderBottom = 'none';
});
btn.classList.add('active');
btn.style.borderBottom = '2px solid var(--primary-color)';
const tab = btn.getAttribute('data-tab');
if (tab === 'emojis') {
emojiPane.style.display = 'grid';
stickerPane.style.display = 'none';
} else {
emojiPane.style.display = 'none';
stickerPane.style.display = 'grid';
}
});
});
}
async function openNewConversationModal() {
document.getElementById('new-conversation-search').value = '';
// Reset group chat creation states
state.groupChatMode = false;
state.groupChatSelectedUsers = [];
document.getElementById('group-chat-name').value = '';
document.getElementById('group-chat-name').style.display = 'none';
document.getElementById('btn-submit-group-chat').style.display = 'none';
const toggleBtn = document.getElementById('btn-toggle-group-mode');
toggleBtn.textContent = state.currentLanguage === 'en' ? 'Create Group' : 'สร้างกลุ่มแชท';
toggleBtn.className = 'btn btn-outline btn-sm';
try {
const res = await authFetch(`${API_BASE}/users/directory`);
state.chatUserDirectory = await res.json();
renderNewConversationUserList(state.chatUserDirectory);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load user list' : 'ไม่สามารถโหลดรายชื่อผู้ใช้ได้', 'error');
}
document.getElementById('modal-new-conversation').classList.add('active');
}
function closeNewConversationModal() {
document.getElementById('modal-new-conversation').classList.remove('active');
}
function renderNewConversationUserList(users) {
const container = document.getElementById('new-conversation-user-list');
container.innerHTML = '';
if (users.length === 0) {
container.innerHTML = `<div style="padding:12px; text-align:center; color:var(--text-muted); font-size:0.85rem;">${state.currentLanguage === 'en' ? 'No users found' : 'ไม่พบผู้ใช้'}</div>`;
return;
}
users.forEach(u => {
const item = document.createElement('div');
item.style.cssText = 'padding:10px 12px; cursor:pointer; border-radius:var(--radius-sm); display:flex; justify-content:space-between; align-items:center;';
item.onmouseenter = () => item.style.background = 'var(--bg-hover)';
item.onmouseleave = () => item.style.background = 'transparent';
const isOnline = u.is_online === 1;
const statusDot = `<span style="width:8px; height:8px; border-radius:50%; background-color:${isOnline ? '#10b981' : '#d1d5db'}; display:inline-block; margin-right:6px;" title="${isOnline ? 'Online' : 'Offline'}"></span>`;
if (state.groupChatMode) {
const isChecked = state.groupChatSelectedUsers.includes(u.id);
item.innerHTML = `
<div style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" class="group-user-chk" data-user-id="${u.id}" ${isChecked ? 'checked' : ''} style="cursor:pointer;">
${statusDot}
<b style="font-size:0.9rem;">${escapeHtml(u.username)}</b>
</div>
<span style="font-size:0.75rem; color:var(--text-muted);">${escapeHtml(translateRole(u.role))}</span>
`;
item.addEventListener('click', (e) => {
const chk = item.querySelector('.group-user-chk');
if (e.target !== chk) {
chk.checked = !chk.checked;
}
toggleGroupUserSelection(u.id, chk.checked);
});
} else {
item.innerHTML = `
<div style="display:flex; align-items:center;">
${statusDot}
<b style="font-size:0.9rem;">${escapeHtml(u.username)}</b>
</div>
<span style="font-size:0.75rem; color:var(--text-muted);">${escapeHtml(translateRole(u.role))}</span>
`;
item.addEventListener('click', () => handleStartConversation(u.id, u.username));
}
container.appendChild(item);
});
}
function toggleGroupUserSelection(userId, isSelected) {
if (isSelected) {
if (!state.groupChatSelectedUsers.includes(userId)) {
state.groupChatSelectedUsers.push(userId);
}
} else {
state.groupChatSelectedUsers = state.groupChatSelectedUsers.filter(id => id !== userId);
}
}
function filterNewConversationUserList() {
const query = document.getElementById('new-conversation-search').value.toLowerCase().trim();
if (!query) {
renderNewConversationUserList(state.chatUserDirectory);
return;
}
const filtered = state.chatUserDirectory.filter(u => u.username.toLowerCase().includes(query));
renderNewConversationUserList(filtered);
}
async function handleStartConversation(targetUserId, targetUsername) {
try {
const res = await authFetch(`${API_BASE}/chat/conversations`, {
method: 'POST',
body: JSON.stringify({ userId: targetUserId })
});
const data = await res.json();
closeNewConversationModal();
await fetchAndRenderConversations();
await openConversationThread(data.id, targetUsername);
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to start conversation' : 'ไม่สามารถเริ่มการสนทนาได้', 'error');
}
}
function toggleGroupChatMode() {
state.groupChatMode = !state.groupChatMode;
state.groupChatSelectedUsers = [];
const groupNameInput = document.getElementById('group-chat-name');
const submitBtn = document.getElementById('btn-submit-group-chat');
const toggleBtn = document.getElementById('btn-toggle-group-mode');
groupNameInput.value = '';
if (state.groupChatMode) {
groupNameInput.style.display = 'block';
submitBtn.style.display = 'flex';
toggleBtn.textContent = state.currentLanguage === 'en' ? 'Cancel' : 'ยกเลิกสร้างกลุ่ม';
toggleBtn.className = 'btn btn-danger-outline btn-sm';
} else {
groupNameInput.style.display = 'none';
submitBtn.style.display = 'none';
toggleBtn.textContent = state.currentLanguage === 'en' ? 'Create Group' : 'สร้างกลุ่มแชท';
toggleBtn.className = 'btn btn-outline btn-sm';
}
renderNewConversationUserList(state.chatUserDirectory);
}
async function handleSubmitGroupChat() {
const groupNameInput = document.getElementById('group-chat-name');
const groupName = groupNameInput.value.trim();
if (!groupName) {
showToast(state.currentLanguage === 'en' ? 'Please enter a group name' : 'กรุณาป้อนชื่อกลุ่มแชท', 'error');
groupNameInput.focus();
return;
}
if (state.groupChatSelectedUsers.length === 0) {
showToast(state.currentLanguage === 'en' ? 'Please select at least one participant' : 'กรุณาเลือกสมาชิกในกลุ่มอย่างน้อย 1 คน', 'error');
return;
}
try {
const res = await authFetch(`${API_BASE}/chat/conversations/group`, {
method: 'POST',
body: JSON.stringify({
name: groupName,
userIds: state.groupChatSelectedUsers
})
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || 'Failed to create group');
}
const data = await res.json();
closeNewConversationModal();
await fetchAndRenderConversations();
await openConversationThread(data.id, data.name);
showToast(state.currentLanguage === 'en' ? 'Group created' : 'สร้างกลุ่มแชทสำเร็จ');
} catch (err) {
showToast(err.message, 'error');
}
}
async function openGroupMembersModal() {
const conversationId = state.activeChatConversationId;
if (!conversationId) return;
try {
const res = await authFetch(`${API_BASE}/chat/conversations/${conversationId}/participants`);
state.groupParticipants = await res.json();
renderGroupParticipants();
// Show/hide creator controls
const isCreator = state.currentUser && state.activeChatCreatorId === state.currentUser.id;
const creatorControls = document.getElementById('group-creator-controls');
if (isCreator) {
creatorControls.style.display = 'block';
await populateInviteUserDropdown();
} else {
creatorControls.style.display = 'none';
}
document.getElementById('modal-group-members').classList.add('active');
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to load group members' : 'ไม่สามารถโหลดรายชื่อสมาชิกกลุ่มได้', 'error');
}
}
function closeGroupMembersModal() {
document.getElementById('modal-group-members').classList.remove('active');
}
function renderGroupParticipants() {
const container = document.getElementById('group-members-list');
container.innerHTML = '';
const isCreator = state.currentUser && state.activeChatCreatorId === state.currentUser.id;
state.groupParticipants.forEach(member => {
const item = document.createElement('div');
item.style.cssText = 'padding:8px 12px; display:flex; justify-content:space-between; align-items:center; border-radius:var(--radius-sm); border-bottom:1px solid var(--border-color);';
const isOnline = member.is_online === 1;
const statusDot = `<span style="width:8px; height:8px; border-radius:50%; background-color:${isOnline ? '#10b981' : '#d1d5db'}; display:inline-block; margin-right:6px;" title="${isOnline ? 'Online' : 'Offline'}"></span>`;
const isCurrentMemberCreator = state.activeChatCreatorId === member.id;
const badge = isCurrentMemberCreator ? ' <span style="background:var(--primary-color); color:white; font-size:0.65rem; padding:1px 4px; border-radius:3px; font-weight:600; margin-left:4px;">Creator</span>' : '';
item.innerHTML = `
<div style="display:flex; align-items:center;">
${statusDot}
<b style="font-size:0.85rem; color:var(--text-main);">${escapeHtml(member.username)}</b>
${badge}
</div>
`;
// Render Kick button if current user is creator, and member is not himself
if (isCreator && member.id !== state.currentUser.id) {
const kickBtn = document.createElement('button');
kickBtn.type = 'button';
kickBtn.className = 'btn btn-danger-outline btn-xs';
kickBtn.style.cssText = 'padding: 2px 6px; font-size: 0.7rem; border-radius: 4px; height: auto; font-weight:600; margin-left:8px;';
kickBtn.textContent = state.currentLanguage === 'en' ? 'Kick' : 'กีดกัน';
kickBtn.addEventListener('click', () => handleKickMember(member.id, member.username));
item.appendChild(kickBtn);
}
container.appendChild(item);
});
}
async function populateInviteUserDropdown() {
const select = document.getElementById('select-invite-user');
select.innerHTML = '';
try {
const res = await authFetch(`${API_BASE}/users/directory`);
const allUsers = await res.json();
// Filter out users who are already in the group
const existingUserIds = state.groupParticipants.map(p => p.id);
const inviteable = allUsers.filter(u => !existingUserIds.includes(u.id));
if (inviteable.length === 0) {
select.innerHTML = `<option value="">${state.currentLanguage === 'en' ? 'No users available to invite' : 'ไม่มีรายชื่อที่จะเชิญ'}</option>`;
document.getElementById('btn-submit-invite').disabled = true;
return;
}
document.getElementById('btn-submit-invite').disabled = false;
inviteable.forEach(u => {
const opt = document.createElement('option');
opt.value = u.id;
opt.textContent = `${u.username} (${translateRole(u.role)})`;
select.appendChild(opt);
});
} catch (err) {
console.error("Failed to load invite directory:", err);
}
}
async function handleInviteMember() {
const select = document.getElementById('select-invite-user');
const targetUserId = select.value;
if (!targetUserId) return;
const conversationId = state.activeChatConversationId;
try {
const res = await authFetch(`${API_BASE}/chat/conversations/${conversationId}/participants`, {
method: 'POST',
body: JSON.stringify({ targetUserId })
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || 'Failed to invite user');
}
showToast(state.currentLanguage === 'en' ? 'User invited' : 'เชิญผู้เข้ากลุ่มเรียบร้อย');
// Refresh participants
const freshRes = await authFetch(`${API_BASE}/chat/conversations/${conversationId}/participants`);
state.groupParticipants = await freshRes.json();
renderGroupParticipants();
await populateInviteUserDropdown();
} catch (err) {
showToast(err.message, 'error');
}
}
async function handleKickMember(targetUserId, username) {
const ok = confirm(state.currentLanguage === 'en' ? `Are you sure you want to kick ${username} from the group?` : `คุณแน่ใจหรือไม่ที่จะกีดกัน ${username} ออกจากกลุ่ม?`);
if (!ok) return;
const conversationId = state.activeChatConversationId;
try {
const res = await authFetch(`${API_BASE}/chat/conversations/${conversationId}/participants/${targetUserId}`, {
method: 'DELETE'
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || 'Failed to kick user');
}
showToast(state.currentLanguage === 'en' ? 'User kicked' : 'กีดกันผู้ใช้ออกจากกลุ่มแล้ว');
// Refresh participants
const freshRes = await authFetch(`${API_BASE}/chat/conversations/${conversationId}/participants`);
state.groupParticipants = await freshRes.json();
renderGroupParticipants();
// Refresh dropdown
const isCreator = state.currentUser && state.activeChatCreatorId === state.currentUser.id;
if (isCreator) {
await populateInviteUserDropdown();
}
} catch (err) {
showToast(err.message, 'error');
}
}
async function startVoiceRecording() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
showToast(state.currentLanguage === 'en' ? 'Microphone is not supported in this browser' : 'เบราว์เซอร์นี้ไม่รองรับการบันทึกเสียง', 'error');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
state.audioChunks = [];
state.mediaRecorder = new MediaRecorder(stream);
state.mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
state.audioChunks.push(e.data);
}
};
state.mediaRecorder.onstop = async () => {
// Stop all tracks on the stream
stream.getTracks().forEach(track => track.stop());
if (state.voiceRecordingShouldSend) {
const audioBlob = new Blob(state.audioChunks, { type: 'audio/webm' });
await uploadAndSendAudioBlob(audioBlob);
}
};
// Request data periodically (not just on stop) so the final moment of
// audio isn't lost to MediaRecorder's end-of-recording flush timing.
state.mediaRecorder.start(250);
state.recordingStartTime = Date.now();
state.voiceRecordingShouldSend = false;
// Toggle UI
document.getElementById('chat-message-input').style.display = 'none';
document.getElementById('btn-chat-submit').style.display = 'none';
document.getElementById('chat-recording-panel').style.display = 'flex';
document.getElementById('btn-chat-mic').classList.add('recording-active');
// Start timer
clearInterval(state.recordingTimerInterval);
document.getElementById('chat-recording-timer').textContent = '00:00';
state.recordingTimerInterval = setInterval(() => {
const seconds = Math.floor((Date.now() - state.recordingStartTime) / 1000);
const mins = String(Math.floor(seconds / 60)).padStart(2, '0');
const secs = String(seconds % 60).padStart(2, '0');
document.getElementById('chat-recording-timer').textContent = `${mins}:${secs}`;
}, 1000);
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to access microphone' : 'ไม่สามารถเข้าถึงไมโครโฟนได้', 'error');
}
}
function stopVoiceRecording(shouldSend) {
if (!state.mediaRecorder || state.mediaRecorder.state === 'inactive') return;
state.voiceRecordingShouldSend = shouldSend;
// Request the in-flight buffer first, then wait a beat before stopping so
// the last spoken moment has time to reach the recorder before it flushes.
const recorder = state.mediaRecorder;
recorder.requestData();
setTimeout(() => {
if (recorder.state !== 'inactive') recorder.stop();
}, 300);
// Reset timer
clearInterval(state.recordingTimerInterval);
state.recordingTimerInterval = null;
// Restore UI
document.getElementById('chat-message-input').style.display = 'block';
document.getElementById('btn-chat-submit').style.display = 'flex';
document.getElementById('chat-recording-panel').style.display = 'none';
document.getElementById('btn-chat-mic').classList.remove('recording-active');
}
async function uploadAndSendAudioBlob(blob) {
const conversationId = state.activeChatConversationId;
if (!conversationId) return;
const formData = new FormData();
formData.append('audio', blob, 'voice_clip.webm');
try {
// Upload audio file
const uploadRes = await authFetch(`${API_BASE}/chat/upload-audio`, {
method: 'POST',
body: formData
});
if (!uploadRes.ok) {
const errData = await uploadRes.json();
throw new Error(errData.error || 'Failed to upload audio');
}
const uploadData = await uploadRes.json();
const filePath = uploadData.filePath;
// Send chat message with the audio reference format: [audio:uploads/chat/...]
const sendRes = await authFetch(`${API_BASE}/chat/conversations/${conversationId}/messages`, {
method: 'POST',
body: JSON.stringify({
body: `[audio:${filePath}]`
})
});
if (!sendRes.ok) {
throw new Error('Failed to send voice message');
}
await fetchAndRenderChatMessages();
} catch (err) {
console.error(err);
showToast(err.message, 'error');
}
}
function openAddUserModal() {
document.getElementById('form-add-user').reset();
document.getElementById('modal-add-user').classList.add('active');
}
function closeAddUserModal() {
document.getElementById('modal-add-user').classList.remove('active');
}
async function handleNewUserSubmit(e) {
e.preventDefault();
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const userRole = document.getElementById('new-role').value;
try {
const res = await authFetch(`${API_BASE}/users`, {
method: 'POST',
body: JSON.stringify({ username, password, userRole })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || (state.currentLanguage === 'en' ? 'Username already exists or invalid data' : 'มีผู้ใช้นี้อยู่แล้วในระบบ หรือข้อมูลไม่ถูกต้อง'));
}
showToast(state.currentLanguage === 'en' ? 'User account created successfully' : 'สร้างบัญชีผู้ใช้งานใหม่เรียบร้อยแล้ว');
closeAddUserModal();
await fetchAdminUsers();
} catch (err) {
showToast(err.message, 'error');
}
}
async function handleRoleChange(userId, newRole) {
try {
await authFetch(`${API_BASE}/users/${userId}/role`, {
method: 'PUT',
body: JSON.stringify({ userRole: newRole })
});
showToast(state.currentLanguage === 'en' ? 'Role updated successfully' : 'อัปเดตบทบาทผู้ใช้สำเร็จ');
await fetchAdminUsers();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to update user role' : 'ไม่สามารถอัปเดตบทบาทผู้ใช้ได้', 'error');
}
}
async function handleDeleteUser(userId) {
if (!confirm(state.currentLanguage === 'en' ? 'Are you sure you want to delete this user?' : 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ใช้งานรายนี้?')) return;
try {
await authFetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' });
showToast(state.currentLanguage === 'en' ? 'User deleted successfully' : 'ลบผู้ใช้งานเรียบร้อยแล้ว');
await fetchAdminUsers();
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to delete user account' : 'ไม่สามารถลบผู้ใช้งานได้', 'error');
}
}
async function fetchProjectPermissions(projectId) {
if (!projectId) return;
try {
// Fetch mapped supervisor user IDs
const resPerms = await authFetch(`${API_BASE}/project-permissions/${projectId}`);
const allowedUserIds = await resPerms.json();
// Fetch users: admins can fetch all users, others fetch from directory
const role = state.currentUser ? state.currentUser.role : 'user';
const endpoint = (role === 'admin' || role === 'superadmin') ? `${API_BASE}/users` : `${API_BASE}/users/directory`;
const resUsers = await authFetch(endpoint);
const allUsers = await resUsers.json();
const container = document.getElementById('admin-supervisors-list');
container.innerHTML = '';
const supervisors = allUsers
.filter(u => u.role !== 'superadmin' && u.role !== 'excusive' && u.role !== 'admin')
.sort((a, b) => (a.username || '').localeCompare(b.username || '', undefined, { sensitivity: 'base' }));
if (supervisors.length === 0) {
container.innerHTML = `<p style="text-align:center; color:var(--text-muted);">${state.currentLanguage === 'en' ? 'No users to assign permissions to' : 'ไม่มีผู้ใช้งานที่สามารถมอบหมายสิทธิ์ได้'}</p>`;
return;
}
// Get the active project details to see who the creator is
const project = state.projects.find(p => p.id === parseInt(projectId));
const creatorId = project ? project.created_by : null;
supervisors.forEach(sup => {
const isCreator = sup.id === creatorId;
const isChecked = isCreator || allowedUserIds.includes(sup.id);
const disabledAttr = isCreator ? 'disabled checked' : '';
const label = document.createElement('label');
label.innerHTML = `
<input type="checkbox" class="supervisor-permission-checkbox" data-user-id="${sup.id}" ${disabledAttr}>
<span>${escapeHtml(sup.username)} (${translateRole(sup.role)}) ${isCreator ? (state.currentLanguage === 'en' ? '(Creator)' : '(ผู้สร้างโครงการ)') : ''}</span>
`;
if (!isCreator) {
// Bind check toggle listener
label.querySelector('input').addEventListener('change', async (e) => {
await toggleSupervisorProjectPermission(projectId, sup.id, e.target.checked);
});
}
container.appendChild(label);
// Set checked state
if (isChecked) {
label.querySelector('input').checked = true;
}
});
} catch (err) {
console.error(err);
showToast(state.currentLanguage === 'en' ? 'Failed to load project permissions' : 'ไม่สามารถโหลดสิทธิ์การเข้าถึงโครงการได้', 'error');
}
}
async function toggleSupervisorProjectPermission(projectId, supervisorId, grantAccess) {
try {
// Fetch current mapped supervisor IDs
const resPerms = await authFetch(`${API_BASE}/project-permissions/${projectId}`);
let allowedUserIds = await resPerms.json();
if (grantAccess) {
if (!allowedUserIds.includes(supervisorId)) {
allowedUserIds.push(supervisorId);
}
} else {
allowedUserIds = allowedUserIds.filter(id => id !== supervisorId);
}
// Send request to overwrite mappings
await authFetch(`${API_BASE}/project-permissions/${projectId}`, {
method: 'POST',
body: JSON.stringify({ supervisorIds: allowedUserIds })
});
showToast(state.currentLanguage === 'en' ? 'Permissions updated' : 'อัปเดตสิทธิ์ผู้ควบคุมเรียบร้อยแล้ว');
} catch (err) {
showToast(state.currentLanguage === 'en' ? 'Failed to update permission mapping' : 'ไม่สามารถอัปเดตการแชร์สิทธิ์ได้', 'error');
}
}
// ==========================================
// Revisions & Comparison Module
// ==========================================
function openRevisionsModal() {
if (!state.activeProjectId) return;
document.getElementById('modal-revisions').classList.add('active');
loadRevisionsList();
}
function closeRevisionsModal() {
document.getElementById('modal-revisions').classList.remove('active');
}
async function loadRevisionsList() {
const listContainer = document.getElementById('revisions-list');
listContainer.innerHTML = `<div class="text-center text-muted" style="padding: 20px;">${state.currentLanguage === 'en' ? 'Loading...' : 'กำลังโหลด...'}</div>`;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/revisions`);
const revisions = await res.json();
if (revisions.length === 0) {
listContainer.innerHTML = `<div class="text-center text-muted" style="padding: 20px;">${state.currentLanguage === 'en' ? 'No revisions found' : 'ไม่พบประวัติการแก้ไข'}</div>`;
return;
}
listContainer.innerHTML = '';
// Populate dropdown selectors
const selectA = document.getElementById('compare-rev-a');
const selectB = document.getElementById('compare-rev-b');
selectA.innerHTML = '';
selectB.innerHTML = '';
revisions.forEach((rev, index) => {
// Render list item
const item = document.createElement('div');
item.className = 'revision-card';
item.innerHTML = `
<div class="revision-card-header">
<span class="revision-card-num">Rev ${rev.revision_number}</span>
<span class="revision-card-time">${parseDbDate(rev.created_at).toLocaleString()}</span>
</div>
<div class="revision-card-author"><i class="fa-solid fa-user-pen"></i> By: ${escapeHtml(rev.username)}</div>
<div class="revision-card-summary">${escapeHtml(rev.change_summary || '')}</div>
`;
item.addEventListener('click', () => {
document.querySelectorAll('.revision-card').forEach(i => {
i.classList.remove('active');
});
item.classList.add('active');
// Auto-select in compare dropdowns
selectB.value = rev.revision_number;
if (index + 1 < revisions.length) {
selectA.value = revisions[index + 1].revision_number;
} else {
selectA.value = rev.revision_number;
}
compareRevisions();
});
listContainer.appendChild(item);
// Add options to dropdowns
const optA = document.createElement('option');
optA.value = rev.revision_number;
optA.textContent = `Rev ${rev.revision_number} (${rev.username})`;
selectA.appendChild(optA);
const optB = document.createElement('option');
optB.value = rev.revision_number;
optB.textContent = `Rev ${rev.revision_number} (${rev.username})`;
selectB.appendChild(optB.cloneNode(true));
});
// Default select: B = latest, A = second latest
if (revisions.length > 0) {
// Add active class to first card
const firstCard = listContainer.querySelector('.revision-card');
if (firstCard) firstCard.classList.add('active');
selectB.value = revisions[0].revision_number;
if (revisions.length > 1) {
selectA.value = revisions[1].revision_number;
} else {
selectA.value = revisions[0].revision_number;
}
// Trigger automatic first comparison
compareRevisions();
}
} catch (err) {
console.error(err);
listContainer.innerHTML = `<div class="text-center text-danger" style="padding: 20px;">Failed to load revisions</div>`;
}
}
// Helper to format detailed changes with rich HTML badges
function formatDiffDetails(details, type) {
if (!details) return '';
const parts = details.split(', ');
return parts.map(part => {
if (part.includes(' -> ')) {
const arrowIndex = part.indexOf(' -> ');
const leftSide = part.substring(0, arrowIndex);
const rightSide = part.substring(arrowIndex + 4);
const colonIndex = leftSide.indexOf(': ');
let label = '';
let oldValue = leftSide;
if (colonIndex !== -1) {
label = leftSide.substring(0, colonIndex + 2);
oldValue = leftSide.substring(colonIndex + 2);
}
let badgeOld = `<span class="diff-old">${escapeHtml(oldValue)}</span>`;
let badgeNew = `<span class="diff-new">${escapeHtml(rightSide)}</span>`;
return `<div style="margin-bottom: 6px;"><b>${escapeHtml(label)}</b> ${badgeOld} <i class="fa-solid fa-arrow-right-long" style="margin: 0 6px; color: var(--text-muted); font-size: 0.8rem;"></i> ${badgeNew}</div>`;
} else if (part.startsWith('Added (') && part.endsWith(')')) {
const content = part.substring(7, part.length - 1);
const innerParts = content.split(', ');
const badges = innerParts.map(ip => {
const cIndex = ip.indexOf(': ');
if (cIndex !== -1) {
const label = ip.substring(0, cIndex + 2);
const val = ip.substring(cIndex + 2);
return `<div style="margin-bottom: 2px;"><b>${escapeHtml(label)}</b> <span class="diff-tag-added">${escapeHtml(val)}</span></div>`;
}
return `<div style="margin-bottom: 2px;">${escapeHtml(ip)}</div>`;
}).join('');
return `<div style="display: flex; flex-direction: column; gap: 4px; padding: 4px 0;">${badges}</div>`;
} else if (part.endsWith(' -> Removed')) {
const leftVal = part.substring(0, part.length - 11);
return `<div><b class="diff-removed-label">${escapeHtml(leftVal)}</b> <span class="badge bg-danger-light text-danger" style="margin-left: 6px; font-size: 0.75rem;">Removed</span></div>`;
}
return `<div>${escapeHtml(part)}</div>`;
}).join('');
}
async function compareRevisions() {
const revA = document.getElementById('compare-rev-a').value;
const revB = document.getElementById('compare-rev-b').value;
const tbody = document.getElementById('revisions-compare-tbody');
if (!revA || !revB) return;
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted" style="padding: 30px 10px;">${state.currentLanguage === 'en' ? 'Comparing...' : 'กำลังเปรียบเทียบ...'}</td></tr>`;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/revisions/${revA}/compare/${revB}`);
const data = await res.json();
if (!data.changes || data.changes.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-success" style="padding: 30px 10px; font-weight:600;"><i class="fa-solid fa-circle-check"></i> ${state.currentLanguage === 'en' ? 'No differences found between these two versions' : 'ไม่มีรายการเปลี่ยนแปลงระหว่างสองเวอร์ชันนี้'}</td></tr>`;
return;
}
tbody.innerHTML = '';
data.changes.forEach(chg => {
const tr = document.createElement('tr');
let badgeClass = 'bg-warning-light text-warning';
let badgeText = state.currentLanguage === 'en' ? 'Modified' : 'แก้ไข';
if (chg.type === 'added') {
badgeClass = 'bg-success-light text-success';
badgeText = state.currentLanguage === 'en' ? 'Added' : 'เพิ่ม';
} else if (chg.type === 'removed') {
badgeClass = 'bg-danger-light text-danger';
badgeText = state.currentLanguage === 'en' ? 'Deleted' : 'ลบ';
}
tr.innerHTML = `
<td><b>${escapeHtml(chg.section_name)}</b></td>
<td>${escapeHtml(chg.description)}</td>
<td class="text-center">
<span class="badge ${badgeClass}" style="padding:4px 8px; border-radius:12px; font-size:0.75rem; font-weight:600;">${badgeText}</span>
</td>
<td style="font-size:0.85rem; line-height:1.4;">${formatDiffDetails(chg.details, chg.type)}</td>
`;
tbody.appendChild(tr);
});
} catch (err) {
console.error(err);
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-danger" style="padding: 30px 10px;">Failed to compare revisions</td></tr>`;
}
}
// ==========================================
// Project Progress Helper Functions (NEW)
// ==========================================
function buildProgressSystemDropdown() {
const select = document.getElementById('progress-system-select');
if (!select) return;
const currentValue = select.value;
select.innerHTML = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = 'ทั่วไป';
defaultOpt.textContent = state.currentLanguage === 'en' ? 'General (ทั่วไป)' : 'ทั่วไป (General)';
select.appendChild(defaultOpt);
const systemsSet = new Set();
// Add catalog systems
if (state.systems && Array.isArray(state.systems)) {
state.systems.forEach(s => {
if (s) systemsSet.add(s.trim());
});
}
// Add existing project progress systems
if (state.projectProgressList && Array.isArray(state.projectProgressList)) {
state.projectProgressList.forEach(item => {
if (item.system_name) {
systemsSet.add(item.system_name.trim());
}
});
}
systemsSet.forEach(sys => {
if (sys && sys !== 'ทั่วไป') {
const opt = document.createElement('option');
opt.value = sys;
opt.textContent = sys;
select.appendChild(opt);
}
});
const customOpt = document.createElement('option');
customOpt.value = 'CUSTOM';
customOpt.textContent = state.currentLanguage === 'en' ? '+ Define custom system...' : '+ กำหนดระบบใหม่...';
select.appendChild(customOpt);
if (Array.from(select.options).some(opt => opt.value === currentValue)) {
select.value = currentValue;
} else {
select.value = 'ทั่วไป';
}
}
function initSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
document.querySelectorAll('.btn-voice-input').forEach(btn => btn.style.display = 'none');
return;
}
document.querySelectorAll('.btn-voice-input').forEach(btn => {
const targetId = btn.dataset.target;
const textarea = document.getElementById(targetId);
if (!textarea) return;
let recognition = null;
let isRecording = false;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (isRecording) {
recognition.stop();
return;
}
recognition = new SpeechRecognition();
recognition.lang = state.currentLanguage === 'en' ? 'en-US' : 'th-TH';
recognition.continuous = true;
recognition.interimResults = false;
recognition.onstart = () => {
isRecording = true;
btn.innerHTML = '<i class="fa-solid fa-microphone fa-beat-fade text-danger"></i>';
showToast(state.currentLanguage === 'en' ? 'Listening...' : 'กำลังฟังเสียง...', 'info');
};
recognition.onresult = (event) => {
const transcript = event.results[event.results.length - 1][0].transcript;
const currentText = textarea.value;
textarea.value = currentText ? currentText + ' ' + transcript : transcript;
textarea.dispatchEvent(new Event('input'));
};
recognition.onerror = (err) => {
console.error('Speech recognition error', err);
isRecording = false;
btn.innerHTML = '<i class="fa-solid fa-microphone"></i>';
showToast(state.currentLanguage === 'en' ? 'Speech input error' : 'การป้อนข้อมูลด้วยเสียงขัดข้อง', 'danger');
};
recognition.onend = () => {
isRecording = false;
btn.innerHTML = '<i class="fa-solid fa-microphone"></i>';
};
recognition.start();
});
});
}
function addTimestampWatermark(file, timestampStr) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
try {
const maxDim = 1280;
let w = img.naturalWidth || img.width;
let h = img.naturalHeight || img.height;
if (w > maxDim || h > maxDim) {
if (w > h) {
h = Math.round((h * maxDim) / w);
w = maxDim;
} else {
w = Math.round((w * maxDim) / h);
h = maxDim;
}
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
const fontSize = Math.max(16, Math.round(canvas.width * 0.025));
ctx.font = `bold ${fontSize}px sans-serif`;
const text = timestampStr;
const textWidth = ctx.measureText(text).width;
const padding = 10;
const fillWidth = textWidth + padding * 2;
const fillHeight = fontSize + padding * 2;
const x = canvas.width - fillWidth - 20;
const y = canvas.height - fillHeight - 20;
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(x, y, fillWidth, fillHeight);
ctx.fillStyle = '#ffffff';
ctx.textBaseline = 'middle';
ctx.fillText(text, x + padding, y + fillHeight / 2);
canvas.toBlob((blob) => {
if (!blob) {
resolve(file);
return;
}
const newFile = new File([blob], file.name || 'watermarked.jpg', { type: 'image/jpeg' });
resolve(newFile);
}, 'image/jpeg', 0.85);
} catch (err) {
console.error('Watermarking failed, using original image:', err);
resolve(file);
}
};
img.onerror = () => resolve(file);
};
reader.onerror = () => resolve(file);
});
}
async function processAndSetProgressImage(file) {
if (!file) return;
setCurrentProgressTimestamp();
const tsInputVal = document.getElementById('progress-timestamp').value;
const tsDate = tsInputVal ? parseDbDate(tsInputVal) : new Date();
const tsStr = tsDate.toLocaleString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH');
showToast(state.currentLanguage === 'en' ? 'Processing image...' : 'กำลังประมวลผลรูปภาพ...', 'info');
try {
const watermarkedFile = await addTimestampWatermark(file, tsStr);
state.progressSelectedFile = watermarkedFile;
displayProgressImagePreview(watermarkedFile);
} catch (err) {
console.error(err);
state.progressSelectedFile = file;
displayProgressImagePreview(file);
}
}
async function triggerPrintProgressReport() {
if (!state.activeProjectId || !state.activeProjectSummary) return;
const container = document.getElementById('print-progress-report-container');
if (!container) return;
document.body.classList.remove('print-mode-quotation', 'print-mode-bom', 'print-mode-free-issue');
document.body.classList.add('print-mode-progress');
container.style.display = 'block';
const grouped = {};
const list = state.projectProgressList || [];
list.forEach(item => {
const sys = item.system_name || (state.currentLanguage === 'en' ? 'General' : 'ทั่วไป');
if (!grouped[sys]) {
grouped[sys] = [];
}
grouped[sys].push(item);
});
const systems = Object.keys(grouped).sort();
const projectTitle = state.activeProjectSummary.name;
const clientName = state.activeProjectSummary.client_name || '-';
const printDate = new Date().toLocaleDateString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH');
let html = `
<div class="print-progress-header">
<h1 class="print-progress-title">${state.currentLanguage === 'en' ? 'Project Progress Report' : 'รายงานความคืบหน้าโครงการ'}</h1>
<div class="print-progress-meta">
<div><b>${state.currentLanguage === 'en' ? 'Project' : 'โครงการ'}:</b> ${escapeHtml(projectTitle)}</div>
<div><b>${state.currentLanguage === 'en' ? 'Client' : 'ลูกค้า'}:</b> ${escapeHtml(clientName)}</div>
<div><b>${state.currentLanguage === 'en' ? 'Date' : 'วันที่พิมพ์'}:</b> ${printDate}</div>
</div>
</div>
`;
if (systems.length === 0) {
html += `<p style="text-align:center; padding: 40px; color:#64748b;">${state.currentLanguage === 'en' ? 'No progress updates found.' : 'ไม่พบข้อมูลความคืบหน้าของโครงการ'}</p>`;
} else {
systems.forEach(sys => {
html += `
<div class="print-progress-system-section">
<div class="print-progress-system-title">${escapeHtml(sys)}</div>
`;
const items = grouped[sys];
items.forEach(item => {
const updateDate = parseDbDate(item.timestamp).toLocaleString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH');
let imgHtml = '';
if (item.image_name) {
const imageUrl = withAuthToken(`${API_BASE}/projects/${state.activeProjectId}/progress/${item.id}/image?_ts=${Date.now()}`);
imgHtml = `
<div class="print-progress-card-image">
<img src="${imageUrl}" class="print-progress-card-img">
${item.image_title ? `<div class="print-progress-card-img-title">${escapeHtml(item.image_title)}</div>` : ''}
</div>
`;
}
const descHtml = `
<div class="print-progress-card-section">
<div class="print-progress-card-section-label">${state.currentLanguage === 'en' ? 'Description' : 'รายละเอียดความคืบหน้า'}</div>
<div class="print-progress-card-section-content">${escapeHtml(item.progress_desc || '')}</div>
</div>
`;
const issuesHtml = item.issues ? `
<div class="print-progress-card-section" style="margin-top:6px;">
<div class="print-progress-card-section-label" style="color:#ef4444;">${state.currentLanguage === 'en' ? 'Issues & Obstacles' : 'ปัญหาและอุปสรรค'}</div>
<div class="print-progress-card-section-content">${escapeHtml(item.issues)}</div>
</div>
` : '';
const solutionsHtml = item.solutions ? `
<div class="print-progress-card-section" style="margin-top:6px;">
<div class="print-progress-card-section-label" style="color:#10b981;">${state.currentLanguage === 'en' ? 'Action Plan / Solutions' : 'แนวทางแก้ไข'}</div>
<div class="print-progress-card-section-content">${escapeHtml(item.solutions)}</div>
</div>
` : '';
const suggestionsHtml = item.suggestions ? `
<div class="print-progress-card-section" style="margin-top:6px;">
<div class="print-progress-card-section-label" style="color:#3b82f6;">${state.currentLanguage === 'en' ? 'Suggestions' : 'ข้อเสนอแนะ'}</div>
<div class="print-progress-card-section-content">${escapeHtml(item.suggestions)}</div>
</div>
` : '';
html += `
<div class="print-progress-card">
<div class="print-progress-card-meta">
<span><b><i class="fa-solid fa-clock"></i> ${updateDate}</b></span>
<span><b>${state.currentLanguage === 'en' ? 'By' : 'โดย'}:</b> ${escapeHtml(item.creator_name || 'System')}</span>
</div>
<div class="print-progress-card-body">
${imgHtml}
<div class="print-progress-card-details">
${descHtml}
${issuesHtml}
${solutionsHtml}
${suggestionsHtml}
</div>
</div>
</div>
`;
});
html += `</div>`;
});
}
container.innerHTML = html;
window.addEventListener('afterprint', () => {
setTimeout(() => {
document.body.classList.remove('print-mode-progress');
container.style.display = 'none';
container.innerHTML = '';
}, 2000); // 2 seconds delay to allow mobile browsers to capture print preview
}, { once: true });
setTimeout(() => {
window.print();
}, 250);
}
// Auto-logout idle tracker logic
let idleTimer = null;
const IDLE_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
function resetIdleTimer() {
if (idleTimer) {
clearTimeout(idleTimer);
}
if (state.currentUser) {
idleTimer = setTimeout(async () => {
// Log logout action on backend
if (state.currentUser) {
const userId = state.currentUser.id;
const key = `modifiedProjects_${userId}`;
let modifiedProjects = [];
try {
modifiedProjects = JSON.parse(localStorage.getItem(key)) || [];
} catch (e) {
modifiedProjects = [];
}
try {
await fetch(`${API_BASE}/logout`, {
method: 'POST',
headers: {
...getHeaders()
},
body: JSON.stringify({ userId, modifiedProjects })
});
localStorage.removeItem(key);
} catch (err) {
console.error('Failed to report logout revisions:', err);
}
}
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
state.currentUser = null;
state.activeProjectId = null;
state.activeProject = null;
state.activeProjectSummary = null;
checkSession();
showToast(state.currentLanguage === 'en' ? 'Logged out due to inactivity' : 'ออกจากระบบอัตโนมัติเนื่องจากไม่มีการใช้งาน', 'error');
}, IDLE_TIMEOUT_MS);
}
}
function setupIdleTracker() {
const events = ['mousemove', 'mousedown', 'keypress', 'touchstart', 'scroll', 'click'];
events.forEach(evt => {
document.addEventListener(evt, resetIdleTimer, { passive: true });
});
resetIdleTimer();
}
// Super Admin System Panel Operations
function openSystemPanel() {
document.getElementById('modal-system-panel').classList.add('active');
fetchBackupVersions();
fetchBackupSchedule();
}
function closeSystemPanel() {
document.getElementById('modal-system-panel').classList.remove('active');
}
async function fetchBackupVersions() {
const tbody = document.getElementById('system-backups-tbody');
tbody.innerHTML = `<tr><td colspan="4" class="text-center">${state.currentLanguage === 'en' ? 'Loading backups...' : 'กำลังโหลดรายการสำรองข้อมูล...'}</td></tr>`;
try {
const res = await authFetch(`${API_BASE}/system/backups`);
if (!res.ok) throw new Error('Failed to fetch backups');
const backups = await res.json();
if (backups.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" class="text-center">${state.currentLanguage === 'en' ? 'No backup files found' : 'ไม่พบไฟล์สำรองข้อมูล'}</td></tr>`;
return;
}
tbody.innerHTML = '';
backups.forEach(b => {
const tr = document.createElement('tr');
const sizeKB = (b.size / 1024).toFixed(2);
const dateStr = parseDbDate(b.created_at).toLocaleString(state.currentLanguage === 'en' ? 'en-US' : 'th-TH');
const downloadUrl = `${API_BASE}/system/backups/${b.filename}/download?userId=${state.currentUser.id}&role=${state.currentUser.role}`;
tr.innerHTML = `
<td class="font-bold">
<a href="${downloadUrl}" download style="color:var(--primary-color); text-decoration:none;">
<i class="fa-solid fa-database"></i> ${escapeHtml(b.version || b.filename)}
</a>
</td>
<td>${sizeKB} KB</td>
<td>${dateStr}</td>
<td class="text-center">
<button class="btn btn-danger btn-sm btn-restore-backup" data-filename="${b.filename}" style="padding:4px 8px; font-size:0.75rem;">
<i class="fa-solid fa-rotate-left"></i> Restore
</button>
</td>
`;
tbody.appendChild(tr);
});
// Bind Restore listeners
tbody.querySelectorAll('.btn-restore-backup').forEach(btn => {
btn.addEventListener('click', async (e) => {
const filename = e.currentTarget.dataset.filename;
await restoreBackupVersion(filename);
});
});
} catch (err) {
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-danger">${err.message}</td></tr>`;
}
}
async function fetchBackupSchedule() {
try {
const res = await authFetch(`${API_BASE}/system/backups/schedule`);
if (!res.ok) throw new Error('Failed to fetch schedule config');
const config = await res.json();
document.getElementById('select-backup-schedule').value = config.schedule || 'disabled';
} catch (err) {
console.error(err);
}
}
async function saveBackupScheduleConfig() {
const schedule = document.getElementById('select-backup-schedule').value;
try {
const res = await authFetch(`${API_BASE}/system/backups/schedule`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedule })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to update schedule');
}
showToast(state.currentLanguage === 'en' ? 'Backup schedule updated successfully' : 'อัปเดตความถี่การสำรองข้อมูลเรียบร้อยแล้ว');
} catch (err) {
showToast(err.message, 'error');
}
}
async function triggerManualBackup() {
const btn = document.getElementById('btn-trigger-manual-backup');
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> ${state.currentLanguage === 'en' ? 'Backing up...' : 'กำลังสำรองข้อมูล...'}`;
try {
const res = await authFetch(`${API_BASE}/system/backups`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Backup failed');
}
showToast(t('toast_backup_created'));
await fetchBackupVersions();
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function triggerDbRepair() {
const btn = document.getElementById('btn-trigger-db-repair');
btn.disabled = true;
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> ${state.currentLanguage === 'en' ? 'Repairing...' : 'กำลังซ่อมแซม...'}`;
try {
const res = await authFetch(`${API_BASE}/system/repair`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Repair failed');
}
const data = await res.json();
const msg = state.currentLanguage === 'en'
? `Repair Complete: Status ${data.health}. ${data.message}`
: `ซ่อมแซมเสร็จสิ้น: สถานะสุขภาพข้อมูล ${data.health}. ${data.message}`;
showToast(msg);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function restoreBackupVersion(filename) {
const confirmMsg = state.currentLanguage === 'en'
? `CAUTION: Restoring will overwrite the current database with this snapshot. All active changes since this backup will be lost. Are you sure you want to restore "${filename}"?`
: `ข้อควรระวัง: การ Restore จะเขียนทับฐานข้อมูลปัจจุบันทั้งหมด ข้อมูลล่าสุดหลังจากวันที่ไฟล์นี้จะสูญหาย คุณยืนยันที่จะกู้คืนฐานข้อมูลจากไฟล์ "${filename}" ใช่หรือไม่?`;
if (!confirm(confirmMsg)) return;
try {
const res = await authFetch(`${API_BASE}/system/backups/${filename}/restore`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Restore failed');
}
showToast(t('toast_db_restored'));
closeSystemPanel();
await initApp();
} catch (err) {
showToast(err.message, 'error');
}
}
async function handleRestoreUpload(e) {
e.preventDefault();
const fileInput = document.getElementById('input-restore-file');
if (fileInput.files.length === 0) {
showToast(state.currentLanguage === 'en' ? 'Please select a .db file' : 'กรุณาเลือกไฟล์ .db', 'error');
return;
}
const confirmMsg = state.currentLanguage === 'en'
? `CAUTION: Uploading and restoring this file will completely overwrite the current database. Are you sure you want to proceed?`
: `ข้อควรระวัง: การอัปโหลดและกู้คืนไฟล์นี้จะเขียนทับฐานข้อมูลปัจจุบันทั้งหมด คุณยืนยันที่จะกู้คืนใช่หรือไม่?`;
if (!confirm(confirmMsg)) return;
const btn = document.getElementById('btn-submit-restore-file');
btn.disabled = true;
const formData = new FormData();
formData.append('db_file', fileInput.files[0]);
try {
const res = await fetch(`${API_BASE}/system/restore-file`, {
method: 'POST',
headers: {
...getAuthHeader()
},
body: formData
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Upload restore failed');
}
showToast(t('toast_db_restored'));
closeSystemPanel();
fileInput.value = '';
await initApp();
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
}
}
// ==========================================
// Project Sourcing, Procurement, and Stock Workflows
// ==========================================
function initPrPoStockFlow() {
// Modal Selectors
const modalProjectBoms = document.getElementById('modal-project-boms');
const modalProcurementPanel = document.getElementById('modal-procurement-panel');
const modalStockPanel = document.getElementById('modal-stock-panel');
const modalStockReportLoss = document.getElementById('modal-stock-report-loss');
// Open Project BOM & PR Request Dialog
const btnOpenProjectBoms = document.getElementById('btn-open-project-boms');
if (btnOpenProjectBoms) {
btnOpenProjectBoms.addEventListener('click', () => {
if (!state.activeProjectId) {
showToast('Please select a project first', 'error');
return;
}
document.getElementById('bom-project-title').textContent = `BOM & PR Requests: ${state.activeProjectSummary.name}`;
modalProjectBoms.classList.add('active');
switchBomTab('create-pr');
});
}
// Close Project BOM Modal
const btnCloseProjectBoms = document.getElementById('btn-close-project-boms');
if (btnCloseProjectBoms) {
btnCloseProjectBoms.addEventListener('click', () => {
modalProjectBoms.classList.remove('active');
});
}
// Tab Switchers in BOM Dialog
const btnTabCreatePr = document.getElementById('btn-tab-create-pr');
const btnTabViewPrs = document.getElementById('btn-tab-view-prs');
if (btnTabCreatePr) {
btnTabCreatePr.addEventListener('click', () => switchBomTab('create-pr'));
}
if (btnTabViewPrs) {
btnTabViewPrs.addEventListener('click', () => switchBomTab('view-prs'));
}
function switchBomTab(tab) {
if (tab === 'create-pr') {
btnTabCreatePr.className = 'btn btn-primary';
btnTabViewPrs.className = 'btn btn-outline';
document.getElementById('tab-content-create-pr').style.display = 'flex';
document.getElementById('tab-content-view-prs').style.display = 'none';
renderBomItems();
} else {
btnTabCreatePr.className = 'btn btn-outline';
btnTabViewPrs.className = 'btn btn-primary';
document.getElementById('tab-content-create-pr').style.display = 'none';
document.getElementById('tab-content-view-prs').style.display = 'block';
loadProjectPRs();
}
}
// Render BOM items inside PR creation tab
function renderBomItems() {
const tbody = document.getElementById('bom-items-tbody');
tbody.innerHTML = '';
const items = getActiveProjectItems();
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">ไม่มีสินค้าในระบบงานโครงการนี้</td></tr>';
return;
}
items.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="text-center"><input type="checkbox" class="bom-item-select" data-id="${item.id}" data-desc="${escapeHtml(item.description)}" data-brand="${escapeHtml(item.brand)}" data-model="${escapeHtml(item.model)}" data-qty="${item.qty}" data-unit="${escapeHtml(item.unit)}"></td>
<td>${escapeHtml(item.description)}</td>
<td>${escapeHtml(item.brand || '-')}</td>
<td>${escapeHtml(item.model || '-')}</td>
<td>
<select class="form-control bom-item-system-type" style="width: 100px; padding: 4px;">
<option value="Other">ทั่วไป</option>
<option value="EE">EE (ไฟฟ้า)</option>
<option value="ME">ME (เครื่องกล)</option>
</select>
</td>
<td class="text-center"><input type="number" class="form-control bom-item-qty" style="width: 80px; text-align: center; margin: 0 auto; padding: 4px;" value="${item.qty}"></td>
<td class="text-center">${escapeHtml(item.unit || 'ชิ้น')}</td>
`;
tbody.appendChild(tr);
});
}
function getActiveProjectItems() {
const items = [];
if (state.activeProjectSummary && state.activeProjectSummary.sections) {
state.activeProjectSummary.sections.forEach(sec => {
if (sec.items) {
sec.items.forEach(it => {
items.push({
id: it.id,
description: it.description || '',
brand: it.brand || '',
model: it.model || '',
unit: it.unit || '',
qty: it.qty || 0,
system_type: it.type || 'Other'
});
});
}
});
}
return items;
}
// Submit PR
const btnSubmitBomPr = document.getElementById('btn-submit-bom-pr');
if (btnSubmitBomPr) {
btnSubmitBomPr.addEventListener('click', async () => {
const targetDate = document.getElementById('pr-target-delivery-date').value;
if (!targetDate) {
showToast('กรุณาระบุวันที่ต้องการสินค้า', 'error');
return;
}
const selectedRows = document.querySelectorAll('.bom-item-select:checked');
if (selectedRows.length === 0) {
showToast('กรุณาเลือกสินค้าอย่างน้อย 1 รายการเพื่อสร้าง PR', 'error');
return;
}
const items = [];
selectedRows.forEach(row => {
const tr = row.closest('tr');
const qtyInput = tr.querySelector('.bom-item-qty');
const systemSelect = tr.querySelector('.bom-item-system-type');
items.push({
description: row.dataset.desc,
brand: row.dataset.brand,
model: row.dataset.model,
qty: parseFloat(qtyInput.value) || 0,
unit: row.dataset.unit,
system_type: systemSelect.value,
target_delivery_date: targetDate
});
});
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/prs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_delivery_date: targetDate, items })
});
if (!res.ok) throw new Error('Failed to submit PR request');
showToast('ส่งคำขอ PR เรียบร้อยแล้ว');
switchBomTab('view-prs');
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Load Project PRs list
async function loadProjectPRs() {
const tbody = document.getElementById('project-prs-tbody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center">กำลังโหลด...</td></tr>';
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/prs`);
const prs = await res.json();
tbody.innerHTML = '';
if (prs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">ไม่มีคำขอ PR ของโครงการนี้</td></tr>';
return;
}
prs.forEach(pr => {
const tr = document.createElement('tr');
const dateStr = parseDbDate(pr.created_at).toLocaleDateString('th-TH');
const targetStr = pr.target_delivery_date ? parseDbDate(pr.target_delivery_date).toLocaleDateString('th-TH') : '-';
const eeApproval = pr.ee_manager_approved === 1 ? '<span class="badge badge-success">EE Approved</span>' : (pr.ee_manager_approved === -1 ? '<span class="badge badge-danger">EE Rejected</span>' : '<span class="badge badge-warning">EE Pending</span>');
const meApproval = pr.me_manager_approved === 1 ? '<span class="badge badge-success">ME Approved</span>' : (pr.me_manager_approved === -1 ? '<span class="badge badge-danger">ME Rejected</span>' : '<span class="badge badge-warning">ME Pending</span>');
const execApproval = pr.executive_approved === 1 ? '<span class="badge badge-success">Approved</span>' : (pr.executive_approved === -1 ? '<span class="badge badge-danger">Rejected</span>' : '<span class="badge badge-warning">Pending</span>');
tr.innerHTML = `
<td class="text-center">PR-${pr.id}</td>
<td>${dateStr}</td>
<td>${targetStr}</td>
<td><span class="badge badge-info">${pr.status}</span></td>
<td><div style="display:flex; flex-direction:column; gap:4px;">${eeApproval} ${meApproval}</div></td>
<td>${execApproval}</td>
<td class="text-center"><button class="btn btn-outline btn-sm" onclick="viewPrDetailsDirectly(${pr.id})">ดูรายการ</button></td>
`;
tbody.appendChild(tr);
});
} catch (err) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center" style="color:var(--danger);">เกิดข้อผิดพลาดในการโหลด</td></tr>';
}
}
// Direct helper to view details inside BOM modal
window.viewPrDetailsDirectly = async function(prId) {
modalProjectBoms.classList.remove('active');
modalProcurementPanel.classList.add('active');
document.getElementById('btn-proc-tab-pr').click();
await loadProcurementPRs();
showPRDetails(prId);
};
// ==========================================
// Procurement & Pricing Flow
// ==========================================
const btnOpenProcurementPanel = document.getElementById('btn-open-procurement-panel');
if (btnOpenProcurementPanel) {
btnOpenProcurementPanel.addEventListener('click', () => {
modalProcurementPanel.classList.add('active');
document.getElementById('btn-proc-tab-pr').click();
});
}
const btnCloseProcurementPanel = document.getElementById('btn-close-procurement-panel');
if (btnCloseProcurementPanel) {
btnCloseProcurementPanel.addEventListener('click', () => {
modalProcurementPanel.classList.remove('active');
});
}
const btnProcTabPr = document.getElementById('btn-proc-tab-pr');
const btnProcTabPo = document.getElementById('btn-proc-tab-po');
if (btnProcTabPr) {
btnProcTabPr.addEventListener('click', () => {
btnProcTabPr.className = 'btn btn-primary btn-sm';
btnProcTabPo.className = 'btn btn-outline btn-sm';
document.getElementById('proc-prs-list-container').style.display = 'flex';
document.getElementById('proc-pos-list-container').style.display = 'none';
loadProcurementPRs();
});
}
if (btnProcTabPo) {
btnProcTabPo.addEventListener('click', () => {
btnProcTabPr.className = 'btn btn-outline btn-sm';
btnProcTabPo.className = 'btn btn-primary btn-sm';
document.getElementById('proc-prs-list-container').style.display = 'none';
document.getElementById('proc-pos-list-container').style.display = 'flex';
loadProcurementPOs();
});
}
// Load global PRs list
async function loadProcurementPRs() {
const container = document.getElementById('proc-prs-list-container');
container.innerHTML = '<div class="text-center">กำลังโหลด...</div>';
try {
const res = await authFetch(`${API_BASE}/prs`);
const prs = await res.json();
container.innerHTML = '';
if (prs.length === 0) {
container.innerHTML = '<div class="text-center" style="padding:20px;">ไม่มีรายการ PR</div>';
return;
}
prs.forEach(pr => {
const div = document.createElement('div');
div.style = 'background:#f1f5f9; padding:12px; border-radius:6px; cursor:pointer; border:1px solid #cbd5e1;';
div.innerHTML = `
<div style="font-weight:bold; display:flex; justify-content:space-between;">
<span>PR-${pr.id}</span>
<span class="badge badge-info">${pr.status}</span>
</div>
<div style="font-size:0.8rem; color:#475569; margin-top:4px;">โครงการ: ${escapeHtml(pr.project_name)}</div>
<div style="font-size:0.75rem; color:#64748b; margin-top:2px;">ผู้ขอ: ${escapeHtml(pr.creator_name)}</div>
`;
div.addEventListener('click', () => showPRDetails(pr.id));
container.appendChild(div);
});
} catch (err) {
container.innerHTML = '<div class="text-center" style="color:var(--danger);">เกิดข้อผิดพลาด</div>';
}
}
// Fetch and show single PR details
async function showPRDetails(prId) {
const pane = document.getElementById('procurement-details-pane');
pane.innerHTML = '<div class="text-center" style="padding:100px 0;">กำลังโหลด...</div>';
try {
const res = await authFetch(`${API_BASE}/prs/${prId}`);
const data = await res.json();
const pr = data.pr;
const items = data.items;
// Fetch suppliers list to select
const resSuppliers = await authFetch(`${API_BASE}/suppliers`);
const suppliers = await resSuppliers.json();
let itemsRows = items.map((item, idx) => {
const supplierOptions = suppliers.map(s => `
<option value="${s.id}" ${item.supplier_id === s.id ? 'selected' : ''}>${escapeHtml(s.name)}</option>
`).join('');
return `
<tr>
<td>${escapeHtml(item.description)}</td>
<td>${escapeHtml(item.brand || '-')}</td>
<td>${escapeHtml(item.model || '-')}</td>
<td class="text-center">${item.qty} ${escapeHtml(item.unit || 'ชิ้น')}</td>
<td><span class="badge badge-warning">${escapeHtml(item.system_type)}</span></td>
<td>
<input type="number" class="form-control item-sourced-price" data-id="${item.id}" style="width:100px; padding:4px;" value="${item.sourced_price || 0}">
</td>
<td>
<select class="form-control item-supplier-select" data-id="${item.id}" style="width:150px; padding:4px;">
<option value="">-- เลือกผู้ขาย --</option>
${supplierOptions}
</select>
</td>
</tr>
`;
}).join('');
const role = state.currentUser ? state.currentUser.role : 'user';
// Managers reviews approval status
const eeReviewMarkup = pr.ee_manager_approved === 1 ? '<span class="badge badge-success">EE Approved</span>' : (pr.ee_manager_approved === -1 ? '<span class="badge badge-danger">EE Rejected</span>' : '<span class="badge badge-warning">EE Pending</span>');
const meReviewMarkup = pr.me_manager_approved === 1 ? '<span class="badge badge-success">ME Approved</span>' : (pr.me_manager_approved === -1 ? '<span class="badge badge-danger">ME Rejected</span>' : '<span class="badge badge-warning">ME Pending</span>');
let actionButtons = '';
// 1. Procurement pricing submission
if (role === 'procurment' || role === 'admin' || role === 'superadmin') {
actionButtons += `<button class="btn btn-success" id="btn-proc-submit-pricing">อัปเดตราคาและขออนุมัติเทคนิค (Sourcing Complete)</button>`;
}
// 2. EE Manager review action
const hasEEItems = items.some(it => it.system_type === 'EE');
if (hasEEItems && (role === 'ee_manager' || role === 'admin' || role === 'superadmin') && pr.status === 'pending_manager_review') {
actionButtons += `
<div style="display:flex; gap:10px; align-items:center; background:#eff6ff; padding:12px; border-radius:6px; border:1px solid #bfdbfe;">
<span style="font-weight:bold;">EE Manager Review:</span>
<button class="btn btn-success btn-sm" onclick="submitPrReview(${pr.id}, true, 'EE')">อนุมัติระบบ EE</button>
<button class="btn btn-danger btn-sm" onclick="submitPrReview(${pr.id}, false, 'EE')">ปฏิเสธ</button>
</div>
`;
}
// 3. ME Manager review action
const hasMEItems = items.some(it => it.system_type === 'ME');
if (hasMEItems && (role === 'me_manager' || role === 'admin' || role === 'superadmin') && pr.status === 'pending_manager_review') {
actionButtons += `
<div style="display:flex; gap:10px; align-items:center; background:#eff6ff; padding:12px; border-radius:6px; border:1px solid #bfdbfe; margin-top:10px;">
<span style="font-weight:bold;">ME Manager Review:</span>
<button class="btn btn-success btn-sm" onclick="submitPrReview(${pr.id}, true, 'ME')">อนุมัติระบบ ME</button>
<button class="btn btn-danger btn-sm" onclick="submitPrReview(${pr.id}, false, 'ME')">ปฏิเสธ</button>
</div>
`;
}
// 4. Executive final approval
if ((role === 'excusive' || role === 'admin' || role === 'superadmin') && pr.status === 'pending_executive_approval') {
actionButtons += `
<div style="display:flex; gap:10px; align-items:center; background:#fef3c7; padding:12px; border-radius:6px; border:1px solid #fde68a;">
<span style="font-weight:bold; color:#b45309;"><i class="fa-solid fa-stamp"></i> Executive Final Approval:</span>
<button class="btn btn-success btn-sm" onclick="submitPrApproval(${pr.id}, true)">อนุมัติใบสั่งซื้อ (Convert to PO)</button>
<button class="btn btn-danger btn-sm" onclick="submitPrApproval(${pr.id}, false)">ปฏิเสธ</button>
</div>
`;
}
pane.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:8px;">
<h4>ใบขอจัดซื้อ PR-${pr.id} (โครงการ ID: ${pr.project_id})</h4>
<span class="badge badge-info" style="font-size:1rem;">สถานะ: ${pr.status}</span>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; background:#f8fafc; padding:12px; border-radius:6px; border:1px solid var(--border-color);">
<div><strong>วันที่ต้องการสินค้า:</strong> ${pr.target_delivery_date ? parseDbDate(pr.target_delivery_date).toLocaleDateString('th-TH') : '-'}</div>
<div><strong>ตรวจสอบเทคนิค EE:</strong> ${eeReviewMarkup}</div>
<div><strong>ตรวจสอบเทคนิค ME:</strong> ${meReviewMarkup}</div>
</div>
<table class="table">
<thead>
<tr>
<th>รายการ</th>
<th>ยี่ห้อ</th>
<th>รุ่น</th>
<th class="text-center">จำนวน</th>
<th>ระบบ</th>
<th>ราคาซื้อจริงที่จัดซื้อได้</th>
<th>ผู้จัดจำหน่าย (Supplier)</th>
</tr>
</thead>
<tbody>
${itemsRows}
</tbody>
</table>
<div style="display:flex; flex-direction:column; gap:10px; margin-top:20px; align-items:flex-end;">
${actionButtons}
</div>
`;
// Bind click for procurement pricing submission
const btnProcSubmitPricing = document.getElementById('btn-proc-submit-pricing');
if (btnProcSubmitPricing) {
btnProcSubmitPricing.addEventListener('click', async () => {
const priceInputs = pane.querySelectorAll('.item-sourced-price');
const supplierSelects = pane.querySelectorAll('.item-supplier-select');
const updatedItems = [];
for (let i = 0; i < priceInputs.length; i++) {
const id = priceInputs[i].dataset.id;
const price = parseFloat(priceInputs[i].value) || 0;
const supplierId = supplierSelects[i].value;
if (!supplierId) {
showToast('กรุณาระบุผู้จัดจำหน่ายให้ครบทุกรายการ', 'error');
return;
}
updatedItems.push({ id, sourced_price: price, supplier_id: supplierId });
}
try {
const res = await authFetch(`${API_BASE}/prs/${prId}/items`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: updatedItems })
});
if (!res.ok) throw new Error('Failed to update pricing');
showToast('อัปเดตราคาและส่งขออนุมัติเทคนิคแล้ว');
showPRDetails(prId);
} catch (err) {
showToast(err.message, 'error');
}
});
}
} catch (err) {
pane.innerHTML = `<div class="text-center" style="color:var(--danger); padding:100px 0;">เกิดข้อผิดพลาดในการโหลดรายละเอียด</div>`;
}
};
// Technical review API call helper
window.submitPrReview = async function(prId, approve, systemType) {
try {
const res = await authFetch(`${API_BASE}/prs/${prId}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approve, system_type: systemType })
});
if (!res.ok) throw new Error('Review submission failed');
showToast(approve ? 'อนุมัติเรียบร้อย' : 'ปฏิเสธคำขอจัดซื้อแล้ว');
showPRDetails(prId);
} catch (err) {
showToast(err.message, 'error');
}
};
// Executive final approval API call helper
window.submitPrApproval = async function(prId, approve) {
try {
const res = await authFetch(`${API_BASE}/prs/${prId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approve })
});
if (!res.ok) throw new Error('Approval submission failed');
showToast(approve ? 'อนุมัติคำขอจัดซื้อและออกเอกสารใบสั่งซื้อ PO แล้ว' : 'ปฏิเสธคำขอเรียบร้อยแล้ว');
showPRDetails(prId);
} catch (err) {
showToast(err.message, 'error');
}
};
// Load global POs list
async function loadProcurementPOs() {
const container = document.getElementById('proc-pos-list-container');
container.innerHTML = '<div class="text-center">กำลังโหลด...</div>';
try {
const res = await authFetch(`${API_BASE}/pos`);
const pos = await res.json();
container.innerHTML = '';
if (pos.length === 0) {
container.innerHTML = '<div class="text-center" style="padding:20px;">ไม่มีรายการสั่งซื้อ PO</div>';
return;
}
pos.forEach(po => {
const div = document.createElement('div');
div.style = 'background:#f1f5f9; padding:12px; border-radius:6px; cursor:pointer; border:1px solid #cbd5e1;';
div.innerHTML = `
<div style="font-weight:bold; display:flex; justify-content:space-between;">
<span>PO-${po.id}</span>
<span class="badge badge-info">${po.status}</span>
</div>
<div style="font-size:0.8rem; color:#475569; margin-top:4px;">โครงการ: ${escapeHtml(po.project_name)}</div>
<div style="font-size:0.75rem; color:#64748b; margin-top:2px;">ผู้จัดจำหน่าย: ${escapeHtml(po.supplier_name)}</div>
`;
div.addEventListener('click', () => showPODetails(po.id));
container.appendChild(div);
});
} catch (err) {
container.innerHTML = '<div class="text-center" style="color:var(--danger);">เกิดข้อผิดพลาด</div>';
}
}
// Show PO details
async function showPODetails(poId) {
const pane = document.getElementById('procurement-details-pane');
pane.innerHTML = '<div class="text-center" style="padding:100px 0;">กำลังโหลด...</div>';
try {
const res = await authFetch(`${API_BASE}/pos/${poId}`);
const data = await res.json();
const po = data.po;
const items = data.items;
let totalVal = 0;
let itemsRows = items.map((item, idx) => {
const itemTotal = item.qty * item.price;
totalVal += itemTotal;
return `
<tr>
<td>${escapeHtml(item.description)}</td>
<td>${escapeHtml(item.brand || '-')}</td>
<td>${escapeHtml(item.model || '-')}</td>
<td class="text-center">${item.qty} ${escapeHtml(item.unit || 'ชิ้น')}</td>
<td class="text-right">${formatCurrency(item.price)}</td>
<td class="text-right">${formatCurrency(itemTotal)}</td>
<td class="text-center">${item.received_qty} / ${item.qty}</td>
</tr>
`;
}).join('');
const role = state.currentUser ? state.currentUser.role : 'user';
let approveActionBtn = '';
if (po.status === 'pending_approval' && (role === 'excusive' || role === 'admin' || role === 'superadmin')) {
approveActionBtn = `
<div style="display:flex; gap:10px; align-items:center; background:#fef3c7; padding:12px; border-radius:6px; border:1px solid #fde68a;">
<span style="font-weight:bold; color:#b45309;"><i class="fa-solid fa-signature"></i> อนุมัติเอกสารใบสั่งซื้อ PO:</span>
<button class="btn btn-success btn-sm" onclick="submitPoApproval(${po.id}, true)">อนุมัติสั่งซื้อ (Approve PO)</button>
<button class="btn btn-danger btn-sm" onclick="submitPoApproval(${po.id}, false)">ปฏิเสธ (Reject PO)</button>
</div>
`;
}
pane.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-color); padding-bottom:8px;">
<h4>ใบสั่งซื้อ PO-${po.id} (โครงการ ID: ${po.project_id})</h4>
<span class="badge badge-info" style="font-size:1rem;">สถานะ: ${po.status}</span>
</div>
<div style="background:#f8fafc; padding:12px; border-radius:6px; border:1px solid var(--border-color); margin-bottom:16px;">
<div><strong>มูลค่ารวม:</strong> <span style="font-size:1.2rem; font-weight:bold; color:var(--primary-color);">${formatCurrency(totalVal)}</span></div>
</div>
<table class="table">
<thead>
<tr>
<th>รายการ</th>
<th>ยี่ห้อ</th>
<th>รุ่น</th>
<th class="text-center">จำนวนสั่ง</th>
<th class="text-right">ราคาต่อหน่วย</th>
<th class="text-right">ราคารวม</th>
<th class="text-center">จำนวนรับแล้ว</th>
</tr>
</thead>
<tbody>
${itemsRows}
</tbody>
</table>
<div style="display:flex; flex-direction:column; gap:10px; margin-top:20px; align-items:flex-end;">
${approveActionBtn}
</div>
`;
} catch (err) {
pane.innerHTML = `<div class="text-center" style="color:var(--danger); padding:100px 0;">เกิดข้อผิดพลาดในการโหลดรายละเอียด PO</div>`;
}
};
window.submitPoApproval = async function(poId, approve) {
try {
const res = await authFetch(`${API_BASE}/pos/${poId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approve })
});
if (!res.ok) throw new Error('PO Approval submission failed');
showToast(approve ? 'อนุมัติใบสั่งซื้อ PO เรียบร้อยแล้ว' : 'ปฏิเสธเอกสารสั่งซื้อแล้ว');
showPODetails(poId);
} catch (err) {
showToast(err.message, 'error');
}
};
// ==========================================
// Warehouse & Stock Management Flow
// ==========================================
const btnOpenStockPanel = document.getElementById('btn-open-stock-panel');
if (btnOpenStockPanel) {
btnOpenStockPanel.addEventListener('click', () => {
modalStockPanel.classList.add('active');
document.getElementById('btn-stock-tab-receive').click();
});
}
const btnCloseStockPanel = document.getElementById('btn-close-stock-panel');
if (btnCloseStockPanel) {
btnCloseStockPanel.addEventListener('click', () => {
modalStockPanel.classList.remove('active');
});
}
// Subtabs switcher
const tabs = ['receive', 'inventory', 'disburse', 'alerts'];
tabs.forEach(tab => {
const btn = document.getElementById(`btn-stock-tab-${tab}`);
if (btn) {
btn.addEventListener('click', () => {
tabs.forEach(t => {
const b = document.getElementById(`btn-stock-tab-${t}`);
const content = document.getElementById(`stock-content-${t}`);
if (b) b.className = t === tab ? 'btn btn-primary' : 'btn btn-outline';
if (content) content.style.display = t === tab ? (t === 'receive' || t === 'disburse' || t === 'alerts' ? 'flex' : 'block') : 'none';
});
// Load tab data
if (tab === 'receive') loadStockPOReceipts();
if (tab === 'inventory') loadStockInventory();
if (tab === 'disburse') loadStockDisbursements();
if (tab === 'alerts') loadStockAlerts();
});
}
});
// Receive goods POs list
async function loadStockPOReceipts() {
const list = document.getElementById('stock-approved-pos-list');
list.innerHTML = '<div>กำลังโหลด...</div>';
try {
const res = await authFetch(`${API_BASE}/pos`);
const pos = await res.json();
// Filter to approved / partially_received POs
const approved = pos.filter(po => po.status === 'approved' || po.status === 'partially_received');
list.innerHTML = '';
if (approved.length === 0) {
list.innerHTML = '<div class="text-center" style="color:#64748b;">ไม่มี PO ที่พร้อมรับสินค้า</div>';
return;
}
approved.forEach(po => {
const div = document.createElement('div');
div.style = 'background:#f1f5f9; padding:10px; border-radius:6px; cursor:pointer; border:1px solid #cbd5e1; font-size:0.85rem;';
div.innerHTML = `
<div style="font-weight:bold;">PO-${po.id}</div>
<div style="color:#475569;">โครงการ: ${escapeHtml(po.project_name)}</div>
<div style="color:#64748b; font-size:0.75rem;">ผู้ขาย: ${escapeHtml(po.supplier_name)}</div>
`;
div.addEventListener('click', () => showPOReceiptDetails(po.id));
list.appendChild(div);
});
} catch (err) {
list.innerHTML = '<div style="color:var(--danger);">เกิดข้อผิดพลาด</div>';
}
}
async function showPOReceiptDetails(poId) {
const pane = document.getElementById('stock-receive-po-details');
pane.innerHTML = '<div>กำลังโหลดรายละเอียด...</div>';
try {
const res = await authFetch(`${API_BASE}/pos/${poId}`);
const data = await res.json();
const po = data.po;
const items = data.items;
let itemsRows = items.map((item, idx) => {
const remaining = item.qty - item.received_qty;
return `
<tr class="stock-receipt-item-row" data-po-item-id="${item.id}">
<td>
<strong>${escapeHtml(item.description)}</strong>
<div style="font-size:0.75rem; color:#64748b;">ยี่ห้อ: ${escapeHtml(item.brand || '-')} / รุ่น: ${escapeHtml(item.model || '-')}</div>
</td>
<td class="text-center">${item.qty} ${escapeHtml(item.unit || 'ชิ้น')}</td>
<td class="text-center">${item.received_qty}</td>
<td class="text-center">
<input type="number" class="form-control receipt-qty" style="width:70px; text-align:center; padding:4px;" value="${remaining}" max="${remaining}" min="0">
</td>
<td>
<select class="form-control receipt-type" style="padding:4px;">
<option value="consumable">ใช้แล้วหมดไป</option>
<option value="tool_machinery">เครื่องมือ/เครื่องจักร</option>
</select>
</td>
<td>
<input type="text" class="form-control receipt-location" style="width:100px; padding:4px;" placeholder="ชั้นเก็บของ A1">
</td>
<td>
<input type="number" class="form-control receipt-min-qty" style="width:60px; text-align:center; padding:4px;" value="0">
</td>
<td class="text-center">
<input type="checkbox" class="receipt-defective">
</td>
</tr>
`;
}).join('');
pane.innerHTML = `
<div style="border-bottom:1px solid var(--border-color); padding-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
<h4>ตรวจรับสินค้าใบสั่งซื้อ PO-${po.id}</h4>
<button class="btn btn-primary" id="btn-submit-stock-receipt">บันทึกตรวจรับสินค้าเข้าคลัง</button>
</div>
<table class="table">
<thead>
<tr>
<th>รายการสินค้า</th>
<th class="text-center">จำนวนสั่ง</th>
<th class="text-center">รับแล้ว</th>
<th class="text-center">จำนวนตรวจรับครั้งนี้</th>
<th>ประเภทคลัง</th>
<th>สถานที่เก็บ</th>
<th>ระดับเตือนขั้นต่ำ</th>
<th class="text-center">เคลม/ชำรุด</th>
</tr>
</thead>
<tbody>
${itemsRows}
</tbody>
</table>
`;
// Bind click to submit receipt
document.getElementById('btn-submit-stock-receipt').addEventListener('click', async () => {
const rows = pane.querySelectorAll('.stock-receipt-item-row');
const receiptItems = [];
rows.forEach(row => {
const poItemId = parseInt(row.dataset.poItemId);
const qty = parseFloat(row.querySelector('.receipt-qty').value) || 0;
const type = row.querySelector('.receipt-type').value;
const location = row.querySelector('.receipt-location').value.trim();
const minQty = parseFloat(row.querySelector('.receipt-min-qty').value) || 0;
const isDefective = row.querySelector('.receipt-defective').checked;
if (qty > 0) {
receiptItems.push({
po_item_id: poItemId,
qty,
type,
location,
min_qty: minQty,
is_defective: isDefective
});
}
});
if (receiptItems.length === 0) {
showToast('กรุณาระบุจำนวนสินค้าที่ต้องการตรวจรับอย่างน้อย 1 รายการ', 'error');
return;
}
try {
const res = await authFetch(`${API_BASE}/pos/${poId}/receive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: receiptItems })
});
if (!res.ok) throw new Error('Failed to record stock receipt');
showToast('บันทึกตรวจรับสินค้าเข้าสต็อกเรียบร้อยแล้ว');
loadStockPOReceipts();
pane.innerHTML = '<div style="text-align: center; padding: 100px 0; color: #64748b;">ตรวจรับสำเร็จ เลือก PO ถัดไปเพื่อดำเนินการต่อ</div>';
} catch (err) {
showToast(err.message, 'error');
}
});
} catch (err) {
pane.innerHTML = '<div style="color:var(--danger);">เกิดข้อผิดพลาดในการโหลดรายละเอียด PO</div>';
}
}
// Load warehouse Inventory
async function loadStockInventory() {
const tbody = document.getElementById('stock-inventory-tbody');
tbody.innerHTML = '<tr><td colspan="9" class="text-center">กำลังโหลดคลังสินค้า...</td></tr>';
try {
const res = await authFetch(`${API_BASE}/stock`);
const stock = await res.json();
tbody.innerHTML = '';
if (stock.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">ไม่มีสินค้าอยู่ในคลังคลังขณะนี้</td></tr>';
return;
}
// Populating options for disburse dropdown
const disburseSelect = document.getElementById('disburse-select-item');
const returnSelect = document.getElementById('return-select-item');
const lossSelect = document.getElementById('loss-select-item');
if (disburseSelect) disburseSelect.innerHTML = '';
if (returnSelect) returnSelect.innerHTML = '';
if (lossSelect) lossSelect.innerHTML = '';
stock.forEach(item => {
const tr = document.createElement('tr');
const lastMove = item.last_movement_at ? parseDbDate(item.last_movement_at).toLocaleString('th-TH') : '-';
const safetyClass = item.qty < item.min_qty ? 'style="color:var(--danger); font-weight:bold;"' : '';
const typeText = item.type === 'consumable' ? 'ใช้แล้วหมดไป' : 'เครื่องมือ/เครื่องจักร';
tr.innerHTML = `
<td><strong>${escapeHtml(item.description)}</strong></td>
<td>${escapeHtml(item.brand || '-')}</td>
<td>${escapeHtml(item.model || '-')}</td>
<td>${escapeHtml(item.unit || 'ชิ้น')}</td>
<td class="text-center" ${safetyClass}>${item.qty}</td>
<td>${typeText}</td>
<td>${escapeHtml(item.location || '-')}</td>
<td class="text-center">${item.min_qty}</td>
<td style="font-size:0.75rem; color:#64748b;">${lastMove}</td>
`;
tbody.appendChild(tr);
// Add to selects
const opt = `<option value="${item.id}">${escapeHtml(item.description)} (${item.qty} ${escapeHtml(item.unit)})</option>`;
if (disburseSelect) disburseSelect.insertAdjacentHTML('beforeend', opt);
if (returnSelect) returnSelect.insertAdjacentHTML('beforeend', opt);
if (lossSelect) lossSelect.insertAdjacentHTML('beforeend', opt);
});
} catch (err) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center" style="color:var(--danger);">เกิดข้อผิดพลาดในการโหลดข้อมูล</td></tr>';
}
}
// Load Stock Disbursement tab (select dropdowns + pending approvals table)
async function loadStockDisbursements() {
const projSelect = document.getElementById('disburse-select-project');
const returnProjSelect = document.getElementById('return-select-project');
if (projSelect) projSelect.innerHTML = '';
if (returnProjSelect) returnProjSelect.innerHTML = '';
try {
// Populate projects
const resProjects = await authFetch(`${API_BASE}/projects`);
const projects = await resProjects.json();
projects.forEach(p => {
const opt = `<option value="${p.id}">${escapeHtml(p.name)}</option>`;
if (projSelect) projSelect.insertAdjacentHTML('beforeend', opt);
if (returnProjSelect) returnProjSelect.insertAdjacentHTML('beforeend', opt);
});
// Load Inventory list to select products
await loadStockInventory();
// Load Pending Disbursements approvals list
await loadPendingDisbursements();
} catch (err) {
console.error('Failed to load disbursements data:', err);
}
}
// Submit request to disburse items
const btnStockDisburseSubmit = document.getElementById('btn-stock-disburse-submit');
if (btnStockDisburseSubmit) {
btnStockDisburseSubmit.addEventListener('click', async () => {
const stockItemId = document.getElementById('disburse-select-item').value;
const projectId = document.getElementById('disburse-select-project').value;
const qty = parseFloat(document.getElementById('disburse-qty').value) || 0;
if (qty <= 0) {
showToast('กรุณาระบุจำนวนสินค้าที่ถูกต้อง', 'error');
return;
}
try {
const res = await authFetch(`${API_BASE}/stock/disburse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_item_id: stockItemId, qty, project_id: projectId })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to submit disbursement request');
}
showToast('ยื่นขอเบิกจ่ายสินค้าสำเร็จแล้ว รอผู้จัดการโครงการอนุมัติ');
document.getElementById('disburse-qty').value = '';
loadStockDisbursements();
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Submit returned items to stock
const btnStockReturnSubmit = document.getElementById('btn-stock-return-submit');
if (btnStockReturnSubmit) {
btnStockReturnSubmit.addEventListener('click', async () => {
const stockItemId = document.getElementById('return-select-item').value;
const projectId = document.getElementById('return-select-project').value;
const qty = parseFloat(document.getElementById('return-qty').value) || 0;
if (qty <= 0) {
showToast('กรุณาระบุจำนวนคืนที่ถูกต้อง', 'error');
return;
}
try {
const res = await authFetch(`${API_BASE}/stock/return`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_item_id: stockItemId, qty, project_id: projectId })
});
if (!res.ok) throw new Error('Return failed');
showToast('รับคืนสินค้าเข้าคลังเรียบร้อยแล้ว');
document.getElementById('return-qty').value = '';
loadStockDisbursements();
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Load pending disbursement requests to project
async function loadPendingDisbursements() {
const tbody = document.getElementById('stock-pending-disbursements-tbody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center">กำลังโหลดรายการขอเบิก...</td></tr>';
try {
const res = await authFetch(`${API_BASE}/stock/movements?status=pending_approval`);
const movements = await res.json();
tbody.innerHTML = '';
if (movements.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">ไม่มีรายการขออนุมัติเบิกจ่ายสินค้า</td></tr>';
return;
}
movements.forEach(m => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${escapeHtml(m.description)}</strong></td>
<td>${escapeHtml(m.project_name)}</td>
<td class="text-center">${m.qty} ${escapeHtml(m.unit || 'ชิ้น')}</td>
<td>${escapeHtml(m.requester_name || 'Stock Room')}</td>
<td><span class="badge badge-warning">รออนุมัติ</span></td>
<td class="text-center" style="display:flex; gap:6px; justify-content:center;">
<button class="btn btn-success btn-sm" onclick="approveDisbursement(${m.id}, true)">อนุมัติ</button>
<button class="btn btn-danger btn-sm" onclick="approveDisbursement(${m.id}, false)">ปฏิเสธ</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (err) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">โหลดรายการล้มเหลว</td></tr>';
}
}
window.approveDisbursement = async function(movementId, approve) {
try {
const res = await authFetch(`${API_BASE}/stock/approve-disbursement`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movement_id: movementId, approve })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to submit approval');
}
showToast(approve ? 'อนุมัติการเบิกจ่ายสินค้าและตัดสต็อกเรียบร้อย' : 'ปฏิเสธคำขอเบิกเรียบร้อย');
loadStockDisbursements();
} catch (err) {
showToast(err.message, 'error');
}
};
// Load Stock alerts (safety limits and dead stock)
async function loadStockAlerts() {
const safetyTbody = document.getElementById('stock-alert-safety-tbody');
const deadTbody = document.getElementById('stock-alert-dead-tbody');
safetyTbody.innerHTML = '<tr><td colspan="4" class="text-center">กำลังโหลด...</td></tr>';
deadTbody.innerHTML = '<tr><td colspan="4" class="text-center">กำลังโหลด...</td></tr>';
try {
const res = await authFetch(`${API_BASE}/stock/alerts`);
const data = await res.json();
// Safety Stock
safetyTbody.innerHTML = '';
if (data.safety.length === 0) {
safetyTbody.innerHTML = '<tr><td colspan="4" class="text-center" style="color:var(--success);">คลังสินค้าปกติ ไม่มีระดับต่ำกว่าเกณฑ์</td></tr>';
} else {
data.safety.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${escapeHtml(item.description)}</strong></td>
<td class="text-center" style="color:var(--danger); font-weight:bold;">${item.qty}</td>
<td class="text-center">${item.min_qty}</td>
<td>${escapeHtml(item.location || '-')}</td>
`;
safetyTbody.appendChild(tr);
});
}
// Dead Stock
deadTbody.innerHTML = '';
if (data.dead.length === 0) {
deadTbody.innerHTML = '<tr><td colspan="4" class="text-center" style="color:var(--success);">ไม่มีสินค้าค้างสต็อก</td></tr>';
} else {
data.dead.forEach(item => {
const tr = document.createElement('tr');
const text = item.alert_type === 'inactive_6m' ? 'ค้างเกิน 6 เดือน' : 'ค้างเกิน 3 เดือน';
const lastMove = item.last_movement_at ? parseDbDate(item.last_movement_at).toLocaleDateString('th-TH') : '-';
tr.innerHTML = `
<td><strong>${escapeHtml(item.description)}</strong></td>
<td class="text-center">${item.qty}</td>
<td style="color:var(--warning); font-weight:bold;">${text}</td>
<td>${lastMove}</td>
`;
deadTbody.appendChild(tr);
});
}
} catch (err) {
safetyTbody.innerHTML = '<tr><td colspan="4" class="text-center">เกิดข้อผิดพลาด</td></tr>';
deadTbody.innerHTML = '<tr><td colspan="4" class="text-center">เกิดข้อผิดพลาด</td></tr>';
}
}
// Damage & Loss Dialog Control
const btnReportDamageLost = document.getElementById('btn-stock-report-damage-lost');
if (btnReportDamageLost) {
btnReportDamageLost.addEventListener('click', () => {
modalStockReportLoss.classList.add('active');
});
}
const btnCloseStockLossModal = document.getElementById('btn-close-stock-loss-modal');
if (btnCloseStockLossModal) {
btnCloseStockLossModal.addEventListener('click', () => {
modalStockReportLoss.classList.remove('active');
});
}
const btnCancelStockLoss = document.getElementById('btn-cancel-stock-loss');
if (btnCancelStockLoss) {
btnCancelStockLoss.addEventListener('click', () => {
modalStockReportLoss.classList.remove('active');
});
}
const btnSubmitStockLoss = document.getElementById('btn-submit-stock-loss');
if (btnSubmitStockLoss) {
btnSubmitStockLoss.addEventListener('click', async () => {
const stockItemId = document.getElementById('loss-select-item').value;
const type = document.getElementById('loss-report-type').value;
const qty = parseFloat(document.getElementById('loss-qty').value) || 0;
if (qty <= 0) {
showToast('กรุณาระบุจำนวนสินค้าที่ถูกต้อง', 'error');
return;
}
try {
const res = await authFetch(`${API_BASE}/stock/report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_item_id: stockItemId, qty, type })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to submit loss report');
}
showToast(`บันทึกรายงานแจ้ง${type === 'damage' ? 'ชำรุด' : 'สูญหาย'}เรียบร้อยแล้ว`);
modalStockReportLoss.classList.remove('active');
loadStockInventory();
} catch (err) {
showToast(err.message, 'error');
}
});
}
}
// Automatically load new flows on app load
initPrPoStockFlow();
function renderComparisonMode() {
const tbody = document.getElementById('compare-mode-tbody');
if (!tbody) return;
if (!state.activeProjectSummary || !state.activeProjectSummary.sections) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align: center;" data-i18n="compare_no_data">ไม่มีข้อมูลสำหรับแสดงผล</td></tr>`;
return;
}
const sections = state.activeProjectSummary.sections;
let html = '';
// Calculate totals for comparison footer / summary row
let totalEstCost = 0;
let totalProdCost = 0;
let totalEstSale = 0;
let totalProdSale = 0;
sections.forEach(sec => {
const estCost = sec.cost_total || 0;
const prodCost = sec.production_cost_total || 0;
const costDiff = prodCost - estCost;
const estSale = sec.price_total || 0;
const prodSale = sec.production_price_total || 0;
const saleDiff = prodSale - estSale;
const estProfit = sec.profit || 0;
const prodProfit = sec.production_profit || 0;
const estMargin = sec.profit_margin_percent || 0;
const prodMargin = sec.production_profit_margin_percent || 0;
totalEstCost += estCost;
totalProdCost += prodCost;
totalEstSale += estSale;
totalProdSale += prodSale;
const costDiffClass = costDiff > 0 ? 'text-danger font-bold' : (costDiff < 0 ? 'text-success font-bold' : '');
const saleDiffClass = saleDiff > 0 ? 'text-success font-bold' : (saleDiff < 0 ? 'text-danger font-bold' : '');
const prodProfitClass = prodProfit >= 0 ? 'text-success font-bold' : 'text-danger font-bold';
const estProfitClass = estProfit >= 0 ? 'text-success font-bold' : 'text-danger font-bold';
html += `
<tr>
<td class="font-bold">${escapeHtml(sec.name)}</td>
<td class="text-right">${formatCurrency(estCost)}</td>
<td class="text-right">${formatCurrency(prodCost)}</td>
<td class="text-right ${costDiffClass}">${costDiff > 0 ? '+' : ''}${formatCurrency(costDiff)}</td>
<td class="text-right">${formatCurrency(estSale)}</td>
<td class="text-right">${formatCurrency(prodSale)}</td>
<td class="text-right ${saleDiffClass}">${saleDiff > 0 ? '+' : ''}${formatCurrency(saleDiff)}</td>
<td class="text-right ${estProfitClass}">${formatCurrency(estProfit)} (${estMargin.toFixed(2)}%)</td>
<td class="text-right ${prodProfitClass}">${formatCurrency(prodProfit)} (${prodMargin.toFixed(2)}%)</td>
</tr>
`;
});
// Add a summary row at the end
const overallCostDiff = totalProdCost - totalEstCost;
const overallSaleDiff = totalProdSale - totalEstSale;
const overallEstProfit = totalEstSale - totalEstCost;
const overallProdProfit = totalProdSale - totalProdCost;
const overallEstMargin = totalEstCost > 0 ? (overallEstProfit / totalEstCost) * 100 : 0;
const overallProdMargin = totalProdCost > 0 ? (overallProdProfit / totalProdCost) * 100 : 0;
const overallCostDiffClass = overallCostDiff > 0 ? 'text-danger font-bold' : (overallCostDiff < 0 ? 'text-success font-bold' : '');
const overallSaleDiffClass = overallSaleDiff > 0 ? 'text-success font-bold' : (overallSaleDiff < 0 ? 'text-danger font-bold' : '');
const overallEstProfitClass = overallEstProfit >= 0 ? 'text-success font-bold' : 'text-danger font-bold';
const overallProdProfitClass = overallProdProfit >= 0 ? 'text-success font-bold' : 'text-danger font-bold';
html += `
<tr style="background-color: #f8fafc; border-top: 2px solid var(--border-color); font-weight: bold;">
<td data-i18n="compare_total_sum">รวมทั้งสิ้น (Total Summary)</td>
<td class="text-right">${formatCurrency(totalEstCost)}</td>
<td class="text-right">${formatCurrency(totalProdCost)}</td>
<td class="text-right ${overallCostDiffClass}">${overallCostDiff > 0 ? '+' : ''}${formatCurrency(overallCostDiff)}</td>
<td class="text-right">${formatCurrency(totalEstSale)}</td>
<td class="text-right">${formatCurrency(totalProdSale)}</td>
<td class="text-right ${overallSaleDiffClass}">${overallSaleDiff > 0 ? '+' : ''}${formatCurrency(overallSaleDiff)}</td>
<td class="text-right ${overallEstProfitClass}">${formatCurrency(overallEstProfit)} (${overallEstMargin.toFixed(2)}%)</td>
<td class="text-right ${overallProdProfitClass}">${formatCurrency(overallProdProfit)} (${overallProdMargin.toFixed(2)}%)</td>
</tr>
`;
tbody.innerHTML = html;
}
async function renderSupplierComparisonMode() {
const tbody = document.getElementById('supplier-compare-mode-tbody');
if (!tbody) return;
tbody.innerHTML = `<tr><td colspan="9" style="text-align: center;"><i class="fa-solid fa-spinner fa-spin"></i> กำลังโหลดข้อมูล...</td></tr>`;
try {
const res = await authFetch(`${API_BASE}/projects/${state.activeProjectId}/items`);
if (!res.ok) {
throw new Error('Failed to fetch project items for comparison');
}
const items = await res.json();
if (items.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align: center;" data-i18n="compare_no_data">ไม่มีข้อมูลสำหรับแสดงผล</td></tr>`;
return;
}
let html = '';
let totalBidMat = 0;
let totalSupplierMat = 0;
items.forEach(item => {
const qty = parseFloat(item.qty) || 0;
const factor = 1 - (parseFloat(item.discount) || 0) / 100;
const bidUnit = parseFloat(item.price_material) || 0;
const bidTotal = bidUnit * qty * factor;
const supplierUnit = parseFloat(item.supplier_price) || 0;
const supplierTotal = supplierUnit * qty;
const diff = bidTotal - supplierTotal;
const marginPercent = supplierTotal > 0 ? (diff / supplierTotal) * 100 : 0;
totalBidMat += bidTotal;
totalSupplierMat += supplierTotal;
html += `
<tr style="transition: background-color 0.2s; cursor: default;" onmouseover="this.style.backgroundColor='#f8fafc'" onmouseout="this.style.backgroundColor='transparent'">
<td style="padding: 14px 16px; vertical-align: middle;">
<span style="background-color: #f1f5f9; color: #475569; padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; display: inline-block;">
${escapeHtml(item.section_name)}
</span>
</td>
<td style="padding: 14px 16px; vertical-align: middle;">
<div>
<div style="font-weight: 600; color: var(--text-main); font-size: 0.9rem;">${escapeHtml(item.description)}</div>
${item.brand ? `<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; display: flex; align-items: center; gap: 6px;">
<span style="background: #e0f2fe; color: #0369a1; padding: 1px 6px; border-radius: 3px; font-weight: 500;">${escapeHtml(item.brand)}</span>
${item.model ? `<span style="font-family: monospace; color: #64748b;">Model: ${escapeHtml(item.model)}</span>` : ''}
</div>` : ''}
</div>
</td>
<td class="text-right" style="padding: 14px 16px; vertical-align: middle; font-weight: 600;">
${qty} <small style="color: var(--text-muted); font-weight: normal;">${escapeHtml(item.unit || 'ea')}</small>
</td>
<td class="text-right" style="padding: 14px 16px; vertical-align: middle; font-weight: 500; color: #64748b;">${formatCurrency(bidUnit)}</td>
<td class="text-right" style="padding: 14px 16px; vertical-align: middle; font-weight: 600; color: #0f172a;">${formatCurrency(bidTotal)}</td>
<td class="text-right" style="padding: 14px 16px; vertical-align: middle; font-weight: 500; color: #64748b;">${formatCurrency(supplierUnit)}</td>
<td class="text-right" style="padding: 14px 16px; vertical-align: middle; font-weight: 600; color: #1e293b;">${formatCurrency(supplierTotal)}</td>
<td class="text-right" style="padding: 14px 16px; vertical-align: middle;">
<span style="display: inline-block; padding: 4px 10px; border-radius: 9999px; font-size: 0.8rem; font-weight: 600; ${diff >= 0 ? 'background-color: #ecfdf5; color: #047857;' : 'background-color: #fef2f2; color: #b91c1c;'}">
${diff >= 0 ? '+' : ''}${formatCurrency(diff)}
</span>
</td>
<td class="text-right" style="padding: 14px 16px; vertical-align: middle;">
<span style="display: inline-block; padding: 4px 10px; border-radius: 9999px; font-size: 0.8rem; font-weight: 600; ${diff >= 0 ? 'background-color: #ecfdf5; color: #047857;' : 'background-color: #fef2f2; color: #b91c1c;'}">
${marginPercent.toFixed(2)}%
</span>
</td>
</tr>
`;
});
// Add summary row
const totalDiff = totalBidMat - totalSupplierMat;
const totalMarginPercent = totalSupplierMat > 0 ? (totalDiff / totalSupplierMat) * 100 : 0;
// Update cards content
const totalBidEl = document.getElementById('supplier-compare-total-bid');
const totalSupplierEl = document.getElementById('supplier-compare-total-supplier');
const totalProfitEl = document.getElementById('supplier-compare-total-profit');
const profitCard = document.getElementById('supplier-compare-profit-card');
const profitIconWrapper = document.getElementById('supplier-compare-profit-icon-wrapper');
const profitIcon = document.getElementById('supplier-compare-profit-icon');
if (totalBidEl) totalBidEl.innerText = formatCurrency(totalBidMat) + ' ฿';
if (totalSupplierEl) totalSupplierEl.innerText = formatCurrency(totalSupplierMat) + ' ฿';
if (totalProfitEl) {
totalProfitEl.innerText = `${totalDiff >= 0 ? '+' : ''}${formatCurrency(totalDiff)} ฿ (${totalMarginPercent.toFixed(2)}%)`;
}
// Color coding the profit card based on performance
if (profitCard && profitIconWrapper && profitIcon) {
if (totalDiff >= 0) {
profitCard.style.background = 'linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%)';
profitCard.style.borderColor = '#99f6e4';
profitIconWrapper.style.backgroundColor = '#0d9488';
profitIcon.className = 'fa-solid fa-chart-line';
} else {
profitCard.style.background = 'linear-gradient(135deg, #fff5f5 0%, #fed7d7 100%)';
profitCard.style.borderColor = '#feb2b2';
profitIconWrapper.style.backgroundColor = '#e53e3e';
profitIcon.className = 'fa-solid fa-chart-line';
}
}
html += `
<tr style="background-color: #f8fafc; border-top: 2px solid var(--border-color); font-weight: 700; font-size: 0.95rem;">
<td colspan="2" style="padding: 16px; color: var(--text-main);">รวมทั้งสิ้น (Total Summary)</td>
<td class="text-right" style="padding: 16px;">-</td>
<td class="text-right" style="padding: 16px;">-</td>
<td class="text-right" style="padding: 16px; color: #0f172a; font-size: 1.05rem;">${formatCurrency(totalBidMat)}</td>
<td class="text-right" style="padding: 16px;">-</td>
<td class="text-right" style="padding: 16px; color: #1e293b; font-size: 1.05rem;">${formatCurrency(totalSupplierMat)}</td>
<td class="text-right" style="padding: 16px;">
<span style="display: inline-block; padding: 6px 12px; border-radius: 9999px; font-size: 0.85rem; font-weight: 700; ${totalDiff >= 0 ? 'background-color: #ecfdf5; color: #047857;' : 'background-color: #fef2f2; color: #b91c1c;'}">
${totalDiff >= 0 ? '+' : ''}${formatCurrency(totalDiff)}
</span>
</td>
<td class="text-right" style="padding: 16px;">
<span style="display: inline-block; padding: 6px 12px; border-radius: 9999px; font-size: 0.85rem; font-weight: 700; ${totalDiff >= 0 ? 'background-color: #ecfdf5; color: #047857;' : 'background-color: #fef2f2; color: #b91c1c;'}">
${totalMarginPercent.toFixed(2)}%
</span>
</td>
</tr>
`;
tbody.innerHTML = html;
} catch (err) {
console.error(err);
tbody.innerHTML = `<tr><td colspan="9" style="text-align: center; color: var(--danger);">เกิดข้อผิดพลาดในการโหลดข้อมูล</td></tr>`;
}
}
function showGlobalSpinner(text = null) {
const el = document.getElementById('global-loading-spinner');
if (el) {
const textEl = document.getElementById('global-spinner-text');
if (textEl) {
textEl.textContent = text || (state.currentLanguage === 'en' ? 'Processing...' : 'กำลังประมวลผล...');
}
el.style.display = 'flex';
}
}
function hideGlobalSpinner() {
const el = document.getElementById('global-loading-spinner');
if (el) {
el.style.display = 'none';
}
}
window.showGlobalSpinner = showGlobalSpinner;
window.hideGlobalSpinner = hideGlobalSpinner;
// ==========================================
// Mobile Bottom Navigation & Responsive Tables Autolabelling
// ==========================================
document.addEventListener('DOMContentLoaded', () => {
// 1. Mobile Bottom Navigation Event Bindings
const mobileTabs = {
'mobile-tab-project': 'btn-project-selector',
'mobile-tab-catalog': 'btn-open-catalog-manager',
'mobile-tab-supplier': 'btn-open-supplier-manager',
'mobile-tab-template': 'btn-open-template-manager'
};
Object.entries(mobileTabs).forEach(([tabId, triggerId]) => {
const tabBtn = document.getElementById(tabId);
if (tabBtn) {
tabBtn.addEventListener('click', (e) => {
e.preventDefault();
// Remove active class from all tabs
document.querySelectorAll('.mobile-bottom-tab-bar .tab-item').forEach(btn => btn.classList.remove('active'));
// Add active class to clicked tab
tabBtn.classList.add('active');
const triggerBtn = document.getElementById(triggerId);
if (triggerBtn) {
triggerBtn.click();
}
});
}
});
// 2. Active Tab State Synchronization based on Modal state
const modals = [
{ id: 'modal-catalog-manager', tabId: 'mobile-tab-catalog' },
{ id: 'modal-supplier-manager', tabId: 'mobile-tab-supplier' },
{ id: 'modal-template-manager', tabId: 'mobile-tab-template' }
];
const observer = new MutationObserver(() => {
let activeTab = 'mobile-tab-project'; // fallback to project selector
for (const modal of modals) {
const el = document.getElementById(modal.id);
if (el && el.classList.contains('active')) {
activeTab = modal.tabId;
break;
}
}
document.querySelectorAll('.mobile-bottom-tab-bar .tab-item').forEach(btn => {
if (btn.id === activeTab) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
});
modals.forEach(m => {
const el = document.getElementById(m.id);
if (el) {
observer.observe(el, { attributes: true, attributeFilter: ['class'] });
}
});
});
// 3. Automatic Table Data Label Injector for Stacked Responsive Tables
const tableObserver = new MutationObserver((mutations) => {
let shouldUpdate = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeName === 'TABLE' || (node.querySelector && node.querySelector('table')) || node.nodeName === 'TR' || node.nodeName === 'TBODY') {
shouldUpdate = true;
break;
}
}
}
if (shouldUpdate) break;
}
if (shouldUpdate) {
document.querySelectorAll('table').forEach(table => {
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
if (headers.length === 0) return;
table.querySelectorAll('tbody tr').forEach(tr => {
tr.querySelectorAll('td').forEach((td, index) => {
const headerText = headers[index];
if (headerText && !td.getAttribute('data-label')) {
td.setAttribute('data-label', headerText);
}
});
});
});
}
});
tableObserver.observe(document.body, { childList: true, subtree: true });