Công nợ
Đã thanh toán đủ
0 đ
Hệ thống nội bộ. Quản trị viên và khách hàng có thể đăng nhập bằng SĐT + PIN hoặc Google.
Đã thanh toán đủ
0 đ
`); } } async function prepareInvoiceVietQr(amount, targetBatchDesc) { const block = document.getElementById('invoiceVietQrBlock'); const image = document.getElementById('invoiceVietQrImage'); const caption = document.getElementById('invoiceVietQrCaption'); const notice = document.getElementById('invoiceFullyPaidNotice'); const bank = state.settings || {}; if (notice) notice.classList.add('hidden'); if (!block || !image) return null; if (!(amount > 0)) { block.classList.add('hidden'); if (notice) { const detail = document.getElementById('invoiceFullyPaidNoticeDetail'); if (detail) { detail.textContent = amount < 0 ? `Khách hàng hiện đang dư tiền cọc: ${fmtVnd(Math.abs(amount))} (sẽ tự động cấn trừ vào các đơn tiếp theo).` : 'Khách hàng đã thanh toán đầy đủ 100% công nợ (Số dư: 0 đ).'; } notice.classList.remove('hidden'); } return null; } if (!bank.account_number) { block.classList.add('hidden'); return null; } try { const data = await loadVietQrData(amount); image.crossOrigin = 'anonymous'; image.src = data.data_url; caption.textContent = `${fmtVnd(data.amount)} • ND: ${data.ref_code_display || data.transfer_note} • ${data.bank_name} • STK: ${data.account_number}`; block.classList.remove('hidden'); if (typeof image.decode === 'function') await image.decode().catch(() => {}); return data; } catch (error) { console.warn('prepareInvoiceVietQr error:', error); block.classList.add('hidden'); return null; } } function applySelectedBank(bank, customName) { const codeInput = document.getElementById('bankInputCode'); const binInput = document.getElementById('bankInputBin'); const nameInput = document.getElementById('bankInputName'); const manualContainer = document.getElementById('bankManualNameContainer'); const card = document.getElementById('bankSelectedCard'); const badge = document.getElementById('bankSelectedBadge'); const title = document.getElementById('bankSelectedTitle'); const sub = document.getElementById('bankSelectedSub'); if (bank) { if (codeInput) codeInput.value = bank.code; if (binInput) binInput.value = bank.bin || ''; if (nameInput) nameInput.value = bank.name; if (manualContainer) manualContainer.classList.add('hidden'); if (card) { card.classList.remove('hidden'); if (badge) badge.textContent = bank.shortName || bank.code; if (title) title.textContent = bank.name; if (sub) sub.textContent = `Mã BIN: ${bank.bin || '—'} • Chuẩn VietQR NAPAS (${bank.code})`; } } else if (customName !== undefined && customName !== null) { if (codeInput) codeInput.value = ''; if (binInput) binInput.value = ''; if (nameInput) nameInput.value = customName || ''; if (card) card.classList.add('hidden'); if (manualContainer) { if (customName !== '') manualContainer.classList.remove('hidden'); else manualContainer.classList.toggle('hidden', !document.getElementById('bankSelectPreset')?.value); } } else { if (codeInput) codeInput.value = ''; if (binInput) binInput.value = ''; if (nameInput) nameInput.value = ''; if (card) card.classList.add('hidden'); if (manualContainer) manualContainer.classList.add('hidden'); } } function filterBankOptions(query) { const select = document.getElementById('bankSelectPreset'); const clearBtn = document.getElementById('bankSearchClearBtn'); if (!select) return; const q = toAsciiPaymentText(query || '', 50).toLowerCase().trim(); if (clearBtn) clearBtn.classList.toggle('hidden', !q); const optgroups = select.querySelectorAll('optgroup'); let firstMatch = null; optgroups.forEach(group => { let groupHasMatch = false; const options = group.querySelectorAll('option'); options.forEach(opt => { if (!q) { opt.hidden = false; groupHasMatch = true; } else { const text = toAsciiPaymentText(opt.textContent + ' ' + (opt.dataset.keywords || '') + ' ' + (opt.dataset.bin || '') + ' ' + (opt.dataset.code || ''), 100).toLowerCase(); const match = text.includes(q); opt.hidden = !match; if (match) { groupHasMatch = true; if (!firstMatch) firstMatch = opt; } } }); group.hidden = !groupHasMatch; }); if (q && firstMatch && select.value !== firstMatch.value) { if (firstMatch.value.toLowerCase() === q || (firstMatch.dataset.bin && firstMatch.dataset.bin === q)) { select.value = firstMatch.value; onBankPresetChange(firstMatch.value); } } } function clearBankSearch() { const input = document.getElementById('bankSearchInput'); if (input) input.value = ''; filterBankOptions(''); } function configureBankInfo() { if (!state.currentUser || state.currentUser.role !== 'admin') return; const current = state.settings || {}; clearBankSearch(); const numberInput = document.getElementById('bankInputNumber'); const holderInput = document.getElementById('bankInputHolder'); const noteInput = document.getElementById('bankInputNote'); const previewBox = document.getElementById('bankConfigQrPreview'); if (numberInput) numberInput.value = String(current.account_number || '').replace(/[\s\.-]/g, ''); if (holderInput) holderInput.value = current.account_holder || ''; if (noteInput) noteInput.value = current.transfer_note || ''; if (previewBox) previewBox.classList.add('hidden'); const selectPreset = document.getElementById('bankSelectPreset'); let matchedBank = null; if (current.bank_bin) { matchedBank = BANK_DIRECTORY.find(b => b.bin === String(current.bank_bin)); } if (!matchedBank && current.bank_code) { matchedBank = BANK_DIRECTORY.find(b => b.code.toLowerCase() === String(current.bank_code).toLowerCase()); } if (!matchedBank && current.bank_name) { const rawName = String(current.bank_name).toLowerCase(); matchedBank = BANK_DIRECTORY.find(b => rawName.includes(b.code.toLowerCase()) || rawName.includes(b.shortName.toLowerCase()) || (b.bin && rawName.includes(b.bin)) ); if (!matchedBank) { const bankId = getClientVietQrBankId(current.bank_name); matchedBank = BANK_DIRECTORY.find(b => b.code.toLowerCase() === String(bankId).toLowerCase() || b.bin === String(bankId)); } } if (selectPreset) { if (matchedBank) { selectPreset.value = matchedBank.code; applySelectedBank(matchedBank); } else if (current.bank_name) { selectPreset.value = 'OTHER'; applySelectedBank(null, current.bank_name); } else { selectPreset.value = ''; applySelectedBank(null, ''); } } const modal = document.getElementById('bankConfigModal'); if (modal) modal.classList.remove('hidden'); } function closeBankConfigModal() { const modal = document.getElementById('bankConfigModal'); if (modal) modal.classList.add('hidden'); } function onBankPresetChange(val) { if (!val) { applySelectedBank(null, ''); return; } if (val === 'OTHER') { const nameInput = document.getElementById('bankInputName'); applySelectedBank(null, nameInput ? nameInput.value : ''); const manualContainer = document.getElementById('bankManualNameContainer'); if (manualContainer) manualContainer.classList.remove('hidden'); if (nameInput) nameInput.focus(); return; } const bank = BANK_DIRECTORY.find(b => b.code === val); if (bank) { applySelectedBank(bank); } } function testBankConfigQr() { const code = document.getElementById('bankInputCode')?.value.trim(); const bin = document.getElementById('bankInputBin')?.value.trim(); const name = document.getElementById('bankInputName')?.value.trim(); const number = document.getElementById('bankInputNumber')?.value.replace(/[^0-9]/g, ''); const holder = document.getElementById('bankInputHolder')?.value.trim(); const note = document.getElementById('bankInputNote')?.value.trim() || 'TT test'; const previewBox = document.getElementById('bankConfigQrPreview'); const previewImg = document.getElementById('bankConfigQrPreviewImg'); const caption = document.getElementById('bankConfigQrPreviewCaption'); if ((!code && !bin && !name) || !number || !holder) { alert('Vui lòng chọn Ngân hàng, nhập Số tài khoản và Tên chủ tài khoản trước khi thử tạo mã.'); return; } const bankIdentifier = bin || code || name; const qrUrl = buildDirectVietQrUrl(bankIdentifier, number, holder, 50000, note); if (previewImg) { previewImg.crossOrigin = 'anonymous'; previewImg.src = qrUrl; } const bankTitle = name || (code ? `${code} (${bin || ''})` : 'Ngân hàng'); if (caption) caption.textContent = `${bankTitle} • STK: ${number} • ${holder.toUpperCase()} (Test 50.000 đ)`; if (previewBox) previewBox.classList.remove('hidden'); } async function saveBankConfig(e) { if (e) e.preventDefault(); const code = document.getElementById('bankInputCode')?.value.trim(); const bin = document.getElementById('bankInputBin')?.value.trim(); let name = document.getElementById('bankInputName')?.value.trim(); const number = document.getElementById('bankInputNumber')?.value.replace(/[^0-9]/g, ''); const holder = document.getElementById('bankInputHolder')?.value.trim().toUpperCase(); const note = document.getElementById('bankInputNote')?.value.trim(); const btn = document.getElementById('btnSaveBankConfig'); if (!code && !bin && !name) { return alert('Vui lòng chọn ngân hàng từ danh sách.'); } if (number && !/^\d{6,20}$/.test(number)) { return alert('Số tài khoản phải gồm từ 6 đến 20 chữ số.'); } if (number && (!name || !holder)) { return alert('Vui lòng nhập đủ tên ngân hàng và chủ tài khoản.'); } if (btn) { btn.disabled = true; btn.innerHTML = ' Đang lưu...'; } try { const res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bank_name: name, bank_code: code, bank_bin: bin, account_number: number, account_holder: holder, transfer_note: note }) }); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Không thể lưu cấu hình'); state.settings = data.settings || {}; state.vietQrCache.clear(); renderOverviewDashboard(); closeBankConfigModal(); alert('Đã lưu và chuẩn hóa thông tin ngân hàng thành công!'); } catch (error) { alert(error.message || 'Không thể lưu cấu hình chuyển khoản.'); } finally { if (btn) { btn.disabled = false; btn.innerHTML = ' Lưu Cấu Hình'; } } } function renderDashboard() { const visibleOrders = getVisibleOrders(); const batchOrders = visibleOrders.filter(o => !isCancelledOrder(o)); const batchPayments = getVisiblePayments(); const settlement = currentSettlementTotals(); // Totals const totalCny = batchOrders.reduce((sum, o) => sum + (parseFloat(o.total_cny) || 0), 0); const totalWeight = batchOrders.reduce((sum, o) => sum + (parseFloat(o.weight_kg) || 0), 0); const totalVnd = batchOrders.reduce((sum, o) => sum + (parseFloat(o.total_vnd) || 0), 0); const totalPaid = settlement.allocated; // Goods VND vs Shipping VND let goodsVnd = 0; let shippingVnd = 0; batchOrders.forEach(o => { const cny = parseFloat(o.total_cny) || 0; const kg = parseFloat(o.weight_kg) || 0; goodsVnd += cny * (o.applied_cny_rate || 4208.2); shippingVnd += kg * (o.applied_shipping_rate || 34000); }); const remainingVnd = settlement.remaining; const kpiBatchTotal = document.getElementById('kpiBatchTotalVnd'); if (kpiBatchTotal) kpiBatchTotal.textContent = fmtVnd(Math.round(goodsVnd + shippingVnd)); // Desktop KPI UI const kpiCny = document.getElementById('kpiTotalCny'); if (kpiCny) kpiCny.textContent = fmtCny(totalCny); const kpiCount = document.getElementById('kpiOrderCount'); if (kpiCount) kpiCount.textContent = `${batchOrders.length} mặt hàng`; const kpiWeight = document.getElementById('kpiTotalWeight'); if (kpiWeight) kpiWeight.textContent = fmtKg(totalWeight); const kpiGoods = document.getElementById('kpiGoodsVnd'); if (kpiGoods) kpiGoods.textContent = fmtVnd(Math.round(goodsVnd)); const kpiShip = document.getElementById('kpiShippingVnd'); if (kpiShip) kpiShip.textContent = fmtVnd(Math.round(shippingVnd)); const kpiPaid = document.getElementById('kpiPaidVnd'); if (kpiPaid) kpiPaid.textContent = fmtVnd(totalPaid); const kpiPaidCnt = document.getElementById('kpiPaidCount'); if (kpiPaidCnt) kpiPaidCnt.textContent = `${batchPayments.filter(isApprovedPayment).length} lần đã duyệt`; const kpiRem = document.getElementById('kpiRemainingVnd'); applyProvisionalLabel(kpiRem, document.getElementById('kpiRemainingProvisional'), remainingVnd, settlement.provisional); // Mobile Compact KPI UI const mobCny = document.getElementById('mobKpiCny'); const mobWeight = document.getElementById('mobKpiWeight'); const mobRem = document.getElementById('mobKpiRemaining'); const mobGoods = document.getElementById('mobKpiGoodsVnd'); const mobShip = document.getElementById('mobKpiShippingVnd'); const mobPaid = document.getElementById('mobKpiPaidVnd'); const mobRemDet = document.getElementById('mobKpiRemainingDetail'); const mobCount = document.getElementById('mobOrderCount'); if (mobCny) mobCny.textContent = fmtCny(totalCny); if (mobWeight) mobWeight.textContent = fmtKg(totalWeight); if (mobRem) { if (remainingVnd > 0) { mobRem.textContent = 'Nợ: -' + fmtVnd(remainingVnd) + (Math.round(settlement.provisional) !== Math.round(remainingVnd) ? ' (tạm tính -' + fmtVnd(Math.round(settlement.provisional)) + ')' : ''); mobRem.style.color = '#f87171'; } else if (remainingVnd < 0) { mobRem.textContent = 'Dư: +' + fmtVnd(Math.abs(remainingVnd)) + (Math.round(settlement.provisional) !== Math.round(remainingVnd) ? ' (tạm tính +' + fmtVnd(Math.abs(Math.round(settlement.provisional))) + ')' : ''); mobRem.style.color = '#4ade80'; } else { mobRem.textContent = 'Đã đủ (0 đ)'; mobRem.style.color = '#cbbba3'; } } if (mobGoods) mobGoods.textContent = fmtVnd(Math.round(goodsVnd)); if (mobShip) mobShip.textContent = fmtVnd(Math.round(shippingVnd)); if (mobPaid) mobPaid.textContent = fmtVnd(totalPaid); if (mobRemDet) mobRemDet.textContent = remainingVnd > 0 ? ('-' + fmtVnd(remainingVnd)) : (remainingVnd < 0 ? ('+' + fmtVnd(Math.abs(remainingVnd))) : '0 đ'); if (mobCount) mobCount.textContent = batchOrders.length; renderOrders(visibleOrders); } // --- RENDER ORDERS (OPTIMIZED VIEW-AWARE RENDERING) --- function renderOrders(orders) { const searchInput = document.getElementById('searchInput'); const mobSearchInput = document.getElementById('mobileSearchInput'); const search = ((searchInput && searchInput.value) || (mobSearchInput && mobSearchInput.value) || '').toLowerCase(); const statusFilter = document.getElementById('statusFilter'); const mobStatusFilter = document.getElementById('mobileStatusFilter'); const filter = (statusFilter && statusFilter.value) || (mobStatusFilter && mobStatusFilter.value) || 'ALL'; const filtered = sortOrdersForDisplay(orders.filter(o => { const matchSearch = (o.product_name || '').toLowerCase().includes(search) || (o.customer_note || '').toLowerCase().includes(search) || (o.internal_note || '').toLowerCase().includes(search); const matchFilter = filter === 'ALL' || (o.status || '').includes(filter); return matchSearch && matchFilter; })); const countEl = document.getElementById('tableFilteredCount'); if (countEl) countEl.textContent = `${filtered.length} / ${orders.length} đơn`; const mobCountEl = document.getElementById('mobOrderCount'); if (mobCountEl) mobCountEl.textContent = filtered.length; state._lastFilteredOrders = filtered; if (state.viewMode === 'cards') { renderCards(filtered); state._tableNeedsRender = true; state._cardsNeedsRender = false; } else { renderTable(filtered); state._cardsNeedsRender = true; state._tableNeedsRender = false; } } // --- RENDER ORDERS TABLE (DESKTOP VIEW - LARGE FULL-FIT IMAGES) --- function renderTable(filtered) { const tbody = document.getElementById('ordersTableBody'); if (!tbody) return; const isAdmin = state.currentUser ? state.currentUser.role === 'admin' : true; // Dynamically toggle columns based on Role const thCust = document.getElementById('thCustomerNote'); const thIntern = document.getElementById('thInternalNote'); const thAct = document.getElementById('thActions'); if (thCust) thCust.classList.remove('hidden'); if (thIntern) { if (isAdmin) thIntern.classList.remove('hidden'); else thIntern.classList.add('hidden'); } if (thAct) { if (isAdmin) thAct.classList.remove('hidden'); else thAct.classList.add('hidden'); } const colSpan = isAdmin ? 12 : 10; if (filtered.length === 0) { tbody.innerHTML = `
`; return; } tbody.innerHTML = filtered.map((o, idx) => { const isChecked = state.selectedOrderIds.has(o.id); const imageUrls = getImageUrls(o); const imageUrl = imageUrls[0] || ''; const hasImage = !!imageUrl; const statusColor = o.status === 'Đã về' ? 'bg-emerald-100 text-emerald-800' : o.status === 'Đang đặt' ? 'bg-amber-100 text-amber-800' : o.status === 'Đã giao' ? 'bg-blue-100 text-blue-800' : 'bg-rose-100 text-rose-800'; return `
`; }).join(''); if (typeof hydrateProtectedMediaImages === 'function') hydrateProtectedMediaImages(tbody); } // --- RENDER MOBILE CARDS FEED (CLEAN & TOUCH-FRIENDLY FOR SMARTPHONE) --- function renderCards(filtered) { const container = document.getElementById('ordersCardsBody'); if (!container) return; const isAdmin = state.currentUser ? state.currentUser.role === 'admin' : true; if (filtered.length === 0) { container.innerHTML = '
';
return;
}
container.innerHTML = filtered.map((o, idx) => {
const isChecked = state.selectedOrderIds.has(o.id);
const imageUrls = getImageUrls(o);
const imageUrl = imageUrls[0] || '';
const hasImage = !!imageUrl;
const batch = (state.batches || []).find(item => item.id === o.batch_id);
const st = String(o.status || 'Đang đặt');
const stClass = /^đã về$/i.test(st) ? 'in' : /hủy|không mua/i.test(st) ? 'bad' : 'wait';
const stLabel = /^đã về$/i.test(st) ? 'Đã về' : /hủy|không mua/i.test(st) ? 'Hủy' : 'Đang đặt';
return `
' : ''}
${imageUrls.length > 1 ? `${imageUrls.length}` : ''}
`; } else if (slot.error) { statusBadge = `
`; } else { statusBadge = ` Sẵn sàng `; } return `
`; }).join(''); } } function removeFormImageAt(index, e) { if (e) e.stopPropagation(); if (!state.formImageSlots || !state.formImageSlots[index]) return; const slot = state.formImageSlots[index]; if (slot.blobUrl) { try { URL.revokeObjectURL(slot.blobUrl); } catch (err) {} } state.formImageSlots.splice(index, 1); renderFormImages(); } function retryFormImageAt(index, e) { if (e) e.stopPropagation(); if (!state.formImageSlots || !state.formImageSlots[index]) return; const slot = state.formImageSlots[index]; if (!slot.file) return; slot.uploading = true; slot.error = null; renderFormImages(); uploadSingleFormImage(slot); } function removeFormImage(e) { if (e) e.stopPropagation(); setFormImages([]); // Reset file inputs const fileInput = document.getElementById('formFileInput'); const cameraInput = document.getElementById('mobileCameraInput'); const galleryInput = document.getElementById('mobileGalleryInput'); if (fileInput) fileInput.value = ''; if (cameraInput) cameraInput.value = ''; if (galleryInput) galleryInput.value = ''; } // --- LIGHTBOX (IMAGE ZOOM) --- function openLightbox(urls, title, startIndex = 0) { const safeUrls = parseImageUrls(urls); if (!safeUrls.length) return; state.lightboxUrls = safeUrls; state.lightboxIndex = Math.max(0, Math.min(startIndex, safeUrls.length - 1)); state.lightboxTitle = title || ''; renderLightboxImage(); document.getElementById('lightboxModal').classList.remove('hidden'); } function renderLightboxImage() { const total = state.lightboxUrls.length; const image = document.getElementById('lightboxImage'); const counter = document.getElementById('lightboxCounter'); const prev = document.getElementById('lightboxPrev'); const next = document.getElementById('lightboxNext'); if (image) setMediaImageSource(image, state.lightboxUrls[state.lightboxIndex] || '', true); document.getElementById('lightboxCaption').textContent = state.lightboxTitle; if (counter) { counter.textContent = `${state.lightboxIndex + 1} / ${total}`; counter.classList.toggle('hidden', total < 2); } [prev, next].forEach(button => { if (!button) return; button.classList.toggle('hidden', total < 2); button.classList.toggle('flex', total >= 2); }); } function moveLightbox(step) { const total = state.lightboxUrls.length; if (total < 2) return; state.lightboxIndex = (state.lightboxIndex + step + total) % total; renderLightboxImage(); } function openOrderLightboxById(id) { const order = (state.orders || []).find(item => item.id === id); if (order) openLightbox(getImageUrls(order), productDisplayName(order)); } function openRequestLightboxById(id) { const request = (state.orderRequests || []).find(item => item.id === id); if (request) openLightbox(getImageUrls(request), productDisplayName(request)); } function closeLightbox() { document.getElementById('lightboxModal').classList.add('hidden'); } // --- CUSTOMER EXPORT (XUẤT ĐƠN CHO KHÁCH HÀNG - HỖ TRỢ TẤT CẢ CÁC ĐỢT HOẶC TỪNG ĐỢT) --- let currentExportBatchId = 'ALL'; function openExportModal(targetBatchId) { currentExportBatchId = targetBatchId !== undefined ? targetBatchId : (state.currentBatchId || 'ALL'); renderExportModal(); document.getElementById('exportModal').classList.remove('hidden'); } function closeExportModal() { document.getElementById('exportModal').classList.add('hidden'); } function switchExportBatch(batchId) { currentExportBatchId = batchId; renderExportModal(); } function renderExportModal() { const isAll = currentExportBatchId === 'ALL'; const customerUser = (state.users || []).find(u => u.id === state.selectedCustomerId) || (state.currentUser && state.currentUser.role === 'customer' ? state.currentUser : null); const defaultCustomerName = customerUser ? customerUser.name : 'Anh Tuấn'; // Populate batch selector dropdown in modal header const batchSelect = document.getElementById('invoiceExportBatchSelect'); if (batchSelect) { let opts = ``; (state.batches || []).forEach(b => { opts += ``; }); batchSelect.innerHTML = opts; } // Filter orders and payments let relevantOrders = []; let relevantPayments = []; let customerName = defaultCustomerName; if (isAll) { relevantOrders = getVisibleOrders().filter(o => !isCancelledOrder(o)); relevantPayments = getVisiblePayments(); if (relevantOrders.length && relevantOrders[0].customer_name) { customerName = relevantOrders[0].customer_name; } } else { const batch = (state.batches || []).find(b => b.id === currentExportBatchId) || { name: 'Đợt hàng', customer_name: defaultCustomerName }; relevantOrders = (state.orders || []).filter(o => o.batch_id === currentExportBatchId && !isCancelledOrder(o)); relevantPayments = (state.payments || []).filter(p => p.batch_id === currentExportBatchId); customerName = batch.customer_name || defaultCustomerName; } // Header meta document.getElementById('invoiceCustomerName').textContent = customerName.toUpperCase(); document.getElementById('invoiceDate').textContent = `Ngày lập: ${new Date().toLocaleDateString('vi-VN')}`; const activeRule = getActiveRateRule(); document.getElementById('invoiceRatesInfo').textContent = `Tỷ giá: ${(activeRule.cny_rate).toLocaleString('vi-VN')} đ/Tệ | Cước: ${(activeRule.shipping_rate_per_kg).toLocaleString('vi-VN')} đ/kg`; const breakdownContainer = document.getElementById('invoiceBatchBreakdownContainer'); const tbody = document.getElementById('invoiceTableBody'); const paymentsList = document.getElementById('invoicePaymentsList'); // Compute breakdown per batch const batchMap = new Map(); (state.batches || []).forEach(b => { batchMap.set(b.id, { batch: b, orders: [], payments: [] }); }); relevantOrders.forEach(o => { if (!batchMap.has(o.batch_id)) { batchMap.set(o.batch_id, { batch: { id: o.batch_id, name: o.batch_id }, orders: [], payments: [] }); } batchMap.get(o.batch_id).orders.push(o); }); relevantPayments.forEach(p => { if (!batchMap.has(p.batch_id)) { batchMap.set(p.batch_id, { batch: { id: p.batch_id, name: p.batch_id }, orders: [], payments: [] }); } batchMap.get(p.batch_id).payments.push(p); }); // Calculate totals let totalCny = 0; let totalWeight = 0; let totalGoodsVnd = 0; let totalShippingVnd = 0; let totalVnd = 0; let totalPaid = 0; const batchStats = []; batchMap.forEach(({ batch, orders, payments }) => { if (!orders.length && !payments.length) return; const bCny = orders.reduce((sum, o) => sum + (Number(o.total_cny) || 0), 0); const bWeight = orders.reduce((sum, o) => sum + (Number(o.weight_kg) || 0), 0); let bGoodsVnd = 0; let bShippingVnd = 0; orders.forEach(o => { bGoodsVnd += (Number(o.total_cny) || 0) * (Number(o.applied_cny_rate) || activeRule.cny_rate || 4208.2); bShippingVnd += (Number(o.weight_kg) || 0) * (Number(o.applied_shipping_rate) || activeRule.shipping_rate_per_kg || 34000); }); const live = (state.batches || []).find(item => item.id === batch.id) || {}; const bTotalVnd = Number(live.due_vnd != null ? live.due_vnd : (orders.reduce((sum, o) => sum + (Number(o.total_vnd) || 0), 0) || Math.round(bGoodsVnd + bShippingVnd))); const bPaidVnd = Number(live.allocated_vnd != null ? live.allocated_vnd : payments.filter(isApprovedPayment).reduce((sum, p) => sum + (Number(p.amount_vnd) || 0), 0)); const bDiff = Number(live.remaining_vnd != null ? live.remaining_vnd : bTotalVnd - bPaidVnd); const bProvisional = Number(live.provisional_remaining_vnd != null ? live.provisional_remaining_vnd : bDiff); totalCny += bCny; totalWeight += bWeight; totalGoodsVnd += bGoodsVnd; totalShippingVnd += bShippingVnd; totalVnd += bTotalVnd; totalPaid += bPaidVnd; batchStats.push({ batch, orders, payments, cny: bCny, weight: bWeight, goodsVnd: bGoodsVnd, shippingVnd: bShippingVnd, totalVnd: bTotalVnd, paidVnd: bPaidVnd, diff: bDiff, provisional: bProvisional, closed: isClosedBatch(live) }); }); const finalDue = batchStats.reduce((sum, st) => sum + (Number(st.diff) || 0), 0); const finalProvisional = batchStats.reduce((sum, st) => sum + (Number(st.provisional) || 0), 0); state.currentInvoiceDue = finalProvisional; if (isAll) { document.getElementById('invoiceBatchName').textContent = `Tất cả các đợt (${batchStats.length} đợt)`; document.getElementById('invoiceModalTitle').textContent = `Bảng Kê & Đối Soát Tổng Hợp - ${customerName}`; // Render Batch Reconciliation Table if (breakdownContainer) { breakdownContainer.classList.remove('hidden'); breakdownContainer.innerHTML = `
| # | Tên Đợt Hàng | Số Món | Tổng Cân | Tiền Hàng (đ) | Cước VC (đ) | Tổng Tiền Đợt | Đã Cọc/TT | Thừa / Thiếu |
|---|---|---|---|---|---|---|---|---|
| ${i + 1} | ${escapeHtml(st.batch.name)} | ${st.orders.length} | ${fmtKg(st.weight)} | ${fmtVnd(Math.round(st.goodsVnd))} | ${fmtVnd(Math.round(st.shippingVnd))} | ${fmtVnd(st.totalVnd)} | ${fmtVnd(st.paidVnd)} | ${statusHtml} |
| TỔNG CỘNG TẤT CẢ: | ${relevantOrders.length} món | ${fmtKg(totalWeight)} | ${fmtVnd(Math.round(totalGoodsVnd))} | ${fmtVnd(Math.round(totalShippingVnd))} | ${fmtVnd(totalVnd)} | ${fmtVnd(totalPaid)} | ${finalDue > 0 ? `Thiếu ${fmtVnd(finalDue)}` : (finalDue < 0 ? `Thừa ${fmtVnd(Math.abs(finalDue))}` : 'Đã khớp (0 đ)')} | |
`; } // Render grouped items table let orderIndex = 1; tbody.innerHTML = batchStats.map(st => { if (!st.orders.length) return ''; const rows = st.orders.map(o => { const imageUrl = safeMediaUrl(o.image_url); return `
`; }).join(''); return `
${rows} `; }).join(''); } else { // Single batch mode const currentBatch = (state.batches || []).find(b => b.id === currentExportBatchId) || { name: 'Đợt hàng' }; document.getElementById('invoiceBatchName').textContent = currentBatch.name; document.getElementById('invoiceModalTitle').textContent = `Hóa Đơn & Bảng Kê - ${currentBatch.name}`; if (breakdownContainer) { breakdownContainer.classList.add('hidden'); breakdownContainer.innerHTML = ''; } tbody.innerHTML = relevantOrders.map((o, idx) => { const imageUrl = safeMediaUrl(o.image_url); return `
`; }).join(''); } // Render Payments List if (relevantPayments.length === 0) { paymentsList.innerHTML = '
'; } else { paymentsList.innerHTML = relevantPayments.map(p => { const b = (state.batches || []).find(item => item.id === p.batch_id); const batchTag = isAll && b ? ` [${escapeHtml(b.name)}]` : ''; const st = isPendingPayment(p) ? 'Tạm tính' : isApprovedPayment(p) ? 'Đã duyệt' : 'Từ chối'; return `
`; }).join(''); } // Summaries document.getElementById('invoiceTotalPaid').textContent = fmtVnd(totalPaid); document.getElementById('invoiceSummaryCny').textContent = fmtCny(totalCny); document.getElementById('invoiceSummaryGoodsVnd').textContent = fmtVnd(Math.round(totalGoodsVnd)); document.getElementById('invoiceSummaryWeight').textContent = fmtKg(totalWeight); document.getElementById('invoiceSummaryShippingVnd').textContent = fmtVnd(Math.round(totalShippingVnd)); document.getElementById('invoiceSummaryGrandTotal').textContent = fmtVnd(totalVnd); document.getElementById('invoiceSummaryDeductedPaid').textContent = `- ${fmtVnd(totalPaid)}`; const finalDueLabel = document.getElementById('invoiceSummaryFinalDueLabel'); const finalDueEl = document.getElementById('invoiceSummaryFinalDue'); if (finalDue > 0) { if (finalDueLabel) finalDueLabel.textContent = 'CÒN LẠI PHẢI THANH TOÁN:'; if (finalDueEl) { finalDueEl.className = 'font-black text-lg bg-rose-100 text-rose-700 px-2.5 py-0.5 rounded border border-rose-300'; finalDueEl.textContent = fmtVnd(finalDue) + (Math.round(finalProvisional) !== Math.round(finalDue) ? ' (TẠM TÍNH ' + fmtVnd(finalProvisional) + ')' : ''); } } else if (finalDue < 0) { if (finalDueLabel) finalDueLabel.textContent = 'KHÁCH ĐANG DƯ TIỀN CỌC:'; if (finalDueEl) { finalDueEl.className = 'font-black text-lg bg-emerald-100 text-emerald-700 px-2.5 py-0.5 rounded border border-emerald-300'; finalDueEl.textContent = `Thừa ${fmtVnd(Math.abs(finalDue))}`; } } else { if (finalDueLabel) finalDueLabel.textContent = 'TRẠNG THÁI CÔNG NỢ:'; if (finalDueEl) { finalDueEl.className = 'font-black text-lg bg-slate-100 text-slate-700 px-2.5 py-0.5 rounded border border-slate-300'; finalDueEl.textContent = 'Đã khớp (0 đ)'; } } // Bank Info & VietQR const bank = state.settings || {}; const invoiceBank = document.getElementById('invoiceBankInfo'); const hasBank = Boolean(bank.bank_name && bank.account_number && bank.account_holder); if (invoiceBank) { invoiceBank.classList.toggle('hidden', !hasBank); invoiceBank.textContent = hasBank ? `Thông tin chuyển khoản: ${bank.bank_name} • STK: ${bank.account_number} • ${bank.account_holder}${bank.transfer_note ? ' • ND: ' + bank.transfer_note : ''}` : ''; } const batchLabelForQr = isAll ? `tat ca don hang ${customerName}` : (currentBatchLabel()); state.invoiceVietQrPromise = prepareInvoiceVietQr(finalDue, batchLabelForQr); } // Download Long Image for Zalo async function downloadZaloImage() { const invoice = document.getElementById('customerInvoiceContainer'); const btn = document.getElementById('btnDownloadZalo'); btn.innerHTML = ' Đang dựng ảnh sắc nét...'; btn.disabled = true; try { if (state.invoiceVietQrPromise) await state.invoiceVietQrPromise; await new Promise(r => setTimeout(r, 120)); const canvas = await html2canvas(invoice, { scale: 2, useCORS: true, allowTaint: false, backgroundColor: '#ffffff' }); const link = document.createElement('a'); link.download = `Bang_Ke_Doi_Soat_${currentExportBatchId}_${Date.now()}.png`; link.href = canvas.toDataURL('image/png'); link.click(); } catch (err) { alert('Lỗi xuất ảnh: ' + err.message); } finally { btn.innerHTML = ' 📸 Xuất Ảnh Dài Zalo'; btn.disabled = false; } } // Quick Copy Text for Zalo Chat function copyZaloTextSummary() { const isAll = currentExportBatchId === 'ALL'; const customerUser = (state.users || []).find(u => u.id === state.selectedCustomerId) || (state.currentUser && state.currentUser.role === 'customer' ? state.currentUser : null); const defaultCustomerName = customerUser ? customerUser.name : 'Quý khách'; let relevantOrders = []; let relevantPayments = []; let customerName = defaultCustomerName; if (isAll) { relevantOrders = getVisibleOrders().filter(o => !isCancelledOrder(o)); relevantPayments = getVisiblePayments(); if (relevantOrders.length && relevantOrders[0].customer_name) customerName = relevantOrders[0].customer_name; } else { const batch = (state.batches || []).find(b => b.id === currentExportBatchId) || { name: 'Đợt hàng' }; relevantOrders = (state.orders || []).filter(o => o.batch_id === currentExportBatchId && !isCancelledOrder(o)); relevantPayments = (state.payments || []).filter(p => p.batch_id === currentExportBatchId); customerName = batch.customer_name || defaultCustomerName; } const bank = state.settings || {}; const bankLine = bank.account_number ? `\n🏦 Chuyển khoản: ${bank.bank_name}\n💳 STK: ${bank.account_number} - ${bank.account_holder}${bank.transfer_note ? `\n📝 Nội dung: ${bank.transfer_note}` : ''}` : ''; let text = ''; if (isAll) { const batchMap = new Map(); relevantOrders.forEach(o => { if (!batchMap.has(o.batch_id)) batchMap.set(o.batch_id, { orders: [], payments: [] }); batchMap.get(o.batch_id).orders.push(o); }); relevantPayments.forEach(p => { if (!batchMap.has(p.batch_id)) batchMap.set(p.batch_id, { orders: [], payments: [] }); batchMap.get(p.batch_id).payments.push(p); }); const lines = []; let totalAllVnd = 0; let totalAllPaid = 0; batchMap.forEach(({ orders, payments }, bId) => { const b = (state.batches || []).find(x => x.id === bId) || { name: bId }; const bVnd = orders.reduce((sum, o) => sum + (Number(o.total_vnd) || 0), 0); const bPaid = payments.reduce((sum, p) => sum + (Number(p.amount_vnd) || 0), 0); const bDiff = bVnd - bPaid; totalAllVnd += bVnd; totalAllPaid += bPaid; lines.push(`🔹 ${b.name} (${orders.length} món): Tổng ${fmtVnd(bVnd)} - Đã TT ${fmtVnd(bPaid)} ➔ ${bDiff > 0 ? 'Còn thiếu: ' + fmtVnd(bDiff) : (bDiff < 0 ? 'Khách thừa: ' + fmtVnd(Math.abs(bDiff)) : 'Đã khớp (0 đ)')}`); }); const finalDue = totalAllVnd - totalAllPaid; text = `📦 BẢNG TỔNG HỢP ĐỐI SOÁT CÔNG NỢ - ${customerName.toUpperCase()} 📅 Ngày lập: ${new Date().toLocaleDateString('vi-VN')} ============================= ${lines.join('\n')} ----------------------------- 💰 TỔNG TẤT CẢ CÁC ĐỢT: • Tổng chi phí hàng & cước: ${fmtVnd(totalAllVnd)} • Tổng đã thanh toán / cọc: ${fmtVnd(totalAllPaid)} ${finalDue > 0 ? `🔴 CÒN LẠI CẦN THANH TOÁN: ${fmtVnd(finalDue)}` : (finalDue < 0 ? `🟢 KHÁCH ĐANG DƯ TIỀN CỌC: ${fmtVnd(Math.abs(finalDue))} (cấn trừ đợt sau)` : '⚪ ĐÃ THANH TOÁN ĐỦ TOÀN BỘ (0 đ)')} ${bankLine} ============================= ${customerName} kiểm tra chi tiết theo ảnh bảng kê gửi kèm giúp em nhé ạ!`; } else { const currentBatch = (state.batches || []).find(b => b.id === currentExportBatchId) || { name: 'Đợt hàng' }; const totalCny = relevantOrders.reduce((sum, o) => sum + (parseFloat(o.total_cny) || 0), 0); const totalWeight = relevantOrders.reduce((sum, o) => sum + (parseFloat(o.weight_kg) || 0), 0); const totalVnd = relevantOrders.reduce((sum, o) => sum + (parseFloat(o.total_vnd) || 0), 0); const totalPaid = relevantPayments.reduce((sum, p) => sum + (parseFloat(p.amount_vnd) || 0), 0); const finalDue = totalVnd - totalPaid; text = `📦 THÔNG BÁO GIAO HÀNG & ĐỐI SOÁT - ${customerName.toUpperCase()} ============================= 🔹 Đợt hàng: ${currentBatch.name} 🔹 Số lượng: ${relevantOrders.length} món 🔹 Tổng tiền hàng Tệ: ${fmtCny(totalCny)} 🔹 Tổng cân nặng: ${fmtKg(totalWeight)} ----------------------------- 💰 Tổng tiền đơn đợt này: ${fmtVnd(totalVnd)} 💳 Đã nhận cọc: ${fmtVnd(totalPaid)} ${finalDue > 0 ? `🔴 CÒN LẠI CẦN THANH TOÁN: ${fmtVnd(finalDue)}` : (finalDue < 0 ? `🟢 KHÁCH ĐANG DƯ TIỀN CỌC: ${fmtVnd(Math.abs(finalDue))}` : '⚪ ĐÃ THANH TOÁN ĐỦ (0 đ)')} ${bankLine} ============================= ${customerName} kiểm tra chi tiết theo ảnh bảng kê đính kèm giúp em nhé ạ!`; } navigator.clipboard.writeText(text).then(() => { alert(`Đã sao chép nội dung tóm tắt! Bạn có thể dán ngay vào Zalo gửi cho ${customerName}.`); }); } // --- RATE RULES CONFIG MODAL --- function openRateModal() { document.getElementById('rateEffectiveDate').value = new Date().toISOString().slice(0, 10); document.getElementById('rateCny').value = '4208.2'; document.getElementById('rateShipping').value = '34000'; document.getElementById('rateNote').value = ''; renderRateHistoryTable(); document.getElementById('rateModal').classList.remove('hidden'); } function closeRateModal() { document.getElementById('rateModal').classList.add('hidden'); } function renderRateHistoryTable() { const tbody = document.getElementById('rateHistoryTableBody'); const sorted = [...state.rate_rules].sort((a, b) => b.effective_date.localeCompare(a.effective_date)); tbody.innerHTML = sorted.map(r => `
`).join(''); } async function saveRateRule(e) { e.preventDefault(); const payload = { effective_date: document.getElementById('rateEffectiveDate').value, cny_rate: document.getElementById('rateCny').value, shipping_rate_per_kg: document.getElementById('rateShipping').value, note: document.getElementById('rateNote').value.trim() }; try { const res = await fetch('/api/rate-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json(); if (data.success) { alert('Đã lưu biểu giá mới! Các đơn hàng đã được cập nhật tỷ giá theo ngày hiệu lực.'); closeRateModal(); await loadData(); } } catch (err) { alert('Lỗi kết nối'); } } // --- PAYMENTS MODAL --- function resetPaymentForm() { document.getElementById('paymentEditId').value = ''; document.getElementById('paymentDate').value = new Date().toISOString().slice(0, 10); document.getElementById('paymentAmount').value = ''; document.getElementById('paymentNote').value = ''; if (document.getElementById('paymentRefCode')) { document.getElementById('paymentRefCode').value = ''; document.getElementById('paymentRefCode').readOnly = false; } if (document.getElementById('paymentRefSearch')) document.getElementById('paymentRefSearch').value = ''; state.paymentImageUrls = []; renderPaymentBillPreviews(); const isAdmin = state.currentUser && state.currentUser.role === 'admin'; document.getElementById('paymentFormTitle').textContent = isAdmin ? 'Ghi nhận / khách báo đã CK' : 'Tôi đã chuyển khoản'; document.getElementById('paymentSubmitButton').textContent = isAdmin ? 'Lưu khoản cọc (đã duyệt)' : 'Gửi xác nhận đã CK'; } function populatePaymentBatchSelect(selectedId) { const select = document.getElementById('paymentBatchSelect'); if (!select) return; const open = getOpenBatches(); const preferred = selectedId || (state.currentBatchId !== 'ALL' ? state.currentBatchId : (open[0] && open[0].id) || ''); select.innerHTML = open.length ? open.map(b => ``).join('') : ''; } function openPaymentModal() { resetPaymentForm(); populatePaymentBatchSelect(); renderPaymentsTable(); document.getElementById('paymentModal').classList.remove('hidden'); } function closePaymentModal() { document.getElementById('paymentModal').classList.add('hidden'); } function renderPaymentBillPreviews() { const grid = document.getElementById('paymentBillPreview'); if (!grid) return; grid.innerHTML = (state.paymentImageUrls || []).map((url, index) => `
`).join(''); } function removePaymentBill(index) { state.paymentImageUrls.splice(index, 1); renderPaymentBillPreviews(); } async function handlePaymentBillSelect(e) { const files = Array.from((e.target && e.target.files) || []).slice(0, Math.max(0, 6 - state.paymentImageUrls.length)); if (!files.length) return; const validFiles = files.filter(f => f.type && f.type.startsWith('image/')); if (!validFiles.length) return; const uploadPromises = validFiles.map(async file => { try { const compressed = await compressImage(file, 1200, 1200, 0.78); const upRes = await fetch('/api/upload-image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_base64: compressed.base64, file_name: `bill_${Date.now()}.jpg` }) }); const upData = await upRes.json(); if (upRes.ok && upData.success) { state.paymentImageUrls.push(upData.url); renderPaymentBillPreviews(); } } catch (err) { console.error('Error uploading bill image:', err); } }); await Promise.allSettled(uploadPromises); if (e.target) e.target.value = ''; renderPaymentBillPreviews(); } function paymentStatusLabel(payment) { if (isPendingPayment(payment)) return { cls: 'pending', text: 'Chờ duyệt' }; if (isApprovedPayment(payment)) return { cls: 'approved', text: 'Đã duyệt' }; return { cls: 'rejected', text: 'Từ chối' }; } function openPaymentBill(id) { const payment = (state.payments || []).find(item => item.id === id); const urls = getImageUrls(payment); if (urls.length) openLightbox(urls, 'Bill chuyển khoản'); } function canEditPayment(payment) { const isAdmin = state.currentUser && state.currentUser.role === 'admin'; const batch = (state.batches || []).find(b => b.id === payment.batch_id); if (isClosedBatch(batch)) return false; if (isAdmin) return true; return isPendingPayment(payment) && (payment.created_by === state.currentUser.id || payment.customer_id === state.currentUser.id); } function renderPaymentsTable() { const tbody = document.getElementById('paymentsTableBody'); const list = getVisiblePayments().slice().sort((a, b) => String(b.payment_date || '').localeCompare(String(a.payment_date || ''))); const isAdmin = state.currentUser && state.currentUser.role === 'admin'; if (!list.length) { tbody.innerHTML = `
`; return; } tbody.innerHTML = list.map(p => { const st = paymentStatusLabel(p); const batch = (state.batches || []).find(item => item.id === p.batch_id); const bill = (p.image_urls && p.image_urls[0]) || p.image_url || ''; const edit = canEditPayment(p); return `
`; }).join(''); if (typeof hydrateProtectedMediaImages === 'function') hydrateProtectedMediaImages(tbody); } function editPayment(id) { const payment = (state.payments || []).find(item => item.id === id); if (!payment || !canEditPayment(payment)) return; document.getElementById('paymentEditId').value = payment.id; populatePaymentBatchSelect(payment.batch_id); document.getElementById('paymentDate').value = payment.payment_date || ''; document.getElementById('paymentAmount').value = payment.amount_vnd || ''; document.getElementById('paymentNote').value = payment.note || ''; if (document.getElementById('paymentRefCode')) { document.getElementById('paymentRefCode').value = formatPaymentRef(payment.ref_code); document.getElementById('paymentRefCode').readOnly = Boolean(payment.ref_code); } state.paymentImageUrls = getImageUrls(payment); renderPaymentBillPreviews(); document.getElementById('paymentFormTitle').textContent = 'Sửa khoản cọc'; document.getElementById('paymentSubmitButton').textContent = 'Lưu thay đổi'; } async function savePayment(e) { e.preventDefault(); const editId = (document.getElementById('paymentEditId').value || '').trim(); const batchId = document.getElementById('paymentBatchSelect').value; const isAdmin = state.currentUser && state.currentUser.role === 'admin'; const refCode = normalizePaymentRef(document.getElementById('paymentRefCode') ? document.getElementById('paymentRefCode').value : ''); if (!batchId) return alert('Không còn đợt đang mở để ghi cọc.'); if (!isAdmin && !state.paymentImageUrls.length && !refCode) return alert('Đính ảnh bill hoặc nhập mã CK trên VietQR.'); const payload = { batch_id: batchId, payment_date: document.getElementById('paymentDate').value, amount_vnd: document.getElementById('paymentAmount').value, note: document.getElementById('paymentNote').value.trim(), image_urls: [...state.paymentImageUrls], image_url: state.paymentImageUrls[0] || null, ref_code: refCode }; try { const res = await fetch(editId ? `/api/payments/${editId}` : '/api/payments', { method: editId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Không thể lưu khoản cọc'); resetPaymentForm(); populatePaymentBatchSelect(batchId); await loadData(); renderPaymentsTable(); showToast(isAdmin && !editId ? 'Đã ghi nhận cọc và tính vào công nợ.' : (editId ? 'Đã lưu thay đổi.' : 'Đã gửi xác nhận. Chờ admin duyệt.')); } catch (err) { alert(err.message || 'Lỗi kết nối'); } } async function lookupPaymentRef() { const typed = document.getElementById('paymentRefSearch').value; const code = normalizePaymentRef(typed); if (code.length < 4) return alert('Nhập mã CK trên sao kê, ví dụ 5678-K7Q2'); try { const res = await fetch('/api/payments/lookup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ref_code: typed }) }); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Không tìm thấy mã'); const payment = data.payment; if (!(state.payments || []).some(p => p.id === payment.id)) state.payments.unshift(payment); editPayment(payment.id); showToast('Đã tìm thấy ' + formatPaymentRef(payment.ref_code) + '. Kiểm tra số tiền rồi duyệt.'); } catch (err) { alert(err.message || 'Không tìm thấy mã CK'); } } async function approvePayment(id) { const payment = (state.payments || []).find(item => item.id === id); const formId = document.getElementById('paymentEditId').value; const formAmount = formId === id ? document.getElementById('paymentAmount').value : (payment && payment.amount_vnd); const amount = Number(formAmount); try { const res = await fetch(`/api/payments/${id}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount_vnd: amount > 0 ? amount : undefined }) }); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Không duyệt được'); await loadData(); renderPaymentsTable(); resetPaymentForm(); showToast('Đã duyệt cọc ' + formatPaymentRef(data.payment && data.payment.ref_code) + ', công nợ đã cập nhật.'); } catch (err) { alert(err.message || 'Lỗi kết nối'); } } async function rejectPayment(id) { const reason = prompt('Lý do từ chối khoản cọc:', 'Bill chưa rõ / sai số tiền'); if (reason === null) return; try { const res = await fetch(`/api/payments/${id}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reject_reason: reason }) }); const data = await res.json(); if (!res.ok || !data.success) throw new Error(data.error || 'Không từ chối được'); await loadData(); renderPaymentsTable(); showToast('Đã từ chối khoản cọc.'); } catch (err) { alert(err.message || 'Lỗi kết nối'); } } async function deletePayment(id) { if (!confirm('Xóa khoản chuyển khoản này?')) return; try { const res = await fetch(`/api/payments/${id}`, { method: 'DELETE' }); const data = await res.json(); if (data.success) { renderPaymentsTable(); await loadData(); } } catch (e) { alert('Lỗi kết nối'); } } // --- NEW BATCH MODAL --- async function openNewBatchModal() { const name = prompt('Nhập tên đợt hàng mới (Ví dụ: Đợt tháng 9):'); if (!name) return; const customer = state.selectedCustomerId !== 'ALL' ? (state.users || []).find(u => u.id === state.selectedCustomerId) : null; if (state.currentUser && state.currentUser.role === 'admin' && !customer) return alert('Vui lòng chọn một khách hàng cụ thể trước khi tạo đợt mới.'); try { const res = await fetch('/api/batches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, customer_id: customer ? customer.id : state.currentUser.id, customer_name: customer ? customer.name : state.currentUser.name, created_at: new Date().toISOString().slice(0, 10) }) }); const data = await res.json(); if (data.success) { state.currentBatchId = data.batch.id; await loadData(); } } catch (e) { alert('Lỗi kết nối'); } } // --- UTILITY --- function escapeHtml(text) { if (!text) return ''; return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function inlineJsArg(value) { return escapeHtml(JSON.stringify(String(value == null ? '' : value))); } function safeMediaUrl(value) { const url = String(value || '').trim(); if (/^https:\/\//i.test(url) || /^gdrive:\/\/[A-Za-z0-9_-]{10,}$/i.test(url) || /^data:image\/(?:jpeg|png|webp|gif);base64,/i.test(url)) return url; return ''; } const MEDIA_PLACEHOLDER_SRC = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; const protectedMediaCache = new Map(); let protectedMediaObserver = null; const MAX_CONCURRENT_IMAGE_FETCH = 3; let activeImageFetches_ = 0; const imageFetchQueue_ = []; const pendingImageTasks_ = new Map(); function isProtectedDriveMediaUrl(value) { const url = String(value || '').trim(); return /^gdrive:\/\//i.test(url) || /^https:\/\/drive\.google\.com\/(?:uc\?|file\/d\/|open\?)/i.test(url); } function mediaImageAttributes(value) { const url = safeMediaUrl(value); if (!url) return `src="${MEDIA_PLACEHOLDER_SRC}"`; if (!isProtectedDriveMediaUrl(url)) return `src="${escapeHtml(url)}"`; return `src="${MEDIA_PLACEHOLDER_SRC}" data-media-url="${escapeHtml(url)}" data-media-state="pending"`; } function loadProtectedMediaUrl(url, sessionGen) { if (protectedMediaCache.has(url)) { return protectedMediaCache.get(url); } const storageKey = 'media:' + url; try { if (typeof window !== 'undefined' && window.sessionStorage) { const cached = window.sessionStorage.getItem(storageKey); if (cached) { const resolved = Promise.resolve(cached); protectedMediaCache.set(url, resolved); return resolved; } } } catch (e) {} if (pendingImageTasks_.has(url)) { return pendingImageTasks_.get(url); } const task = (async () => { try { const response = await fetch('/api/image-data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); const data = await response.json(); if (!response.ok || !data.success || !data.data_url) { throw new Error(data.error || 'Không thể tải ảnh sản phẩm.'); } const dataUrl = data.data_url; // Only cache if current session generation is still active if (sessionGen === undefined || sessionGen === (state.sessionGeneration || 0)) { if (typeof dataUrl === 'string' && dataUrl.length <= 200000) { try { if (typeof window !== 'undefined' && window.sessionStorage) { window.sessionStorage.setItem(storageKey, dataUrl); } } catch (storageErr) {} } } return dataUrl; } finally { pendingImageTasks_.delete(url); } })(); pendingImageTasks_.set(url, task); protectedMediaCache.set(url, task); task.catch(() => { protectedMediaCache.delete(url); }); return task; } function processImageQueue_() { while (activeImageFetches_ < MAX_CONCURRENT_IMAGE_FETCH && imageFetchQueue_.length > 0) { const item = imageFetchQueue_.shift(); if (!item) break; const { url, sessionGen } = item; const targets = Array.isArray(item.images) ? item.images : (item.image ? [item.image] : []); const activeTargets = targets.filter(image => image && image.dataset.mediaUrl === url && image.dataset.mediaState !== 'loaded'); if (sessionGen !== (state.sessionGeneration || 0)) continue; if (!activeTargets.length) continue; activeImageFetches_++; activeTargets.forEach(image => { image.dataset.mediaState = 'loading'; }); loadProtectedMediaUrl(url, sessionGen) .then(src => { if (sessionGen !== (state.sessionGeneration || 0)) return; activeTargets.forEach(image => { if (image.dataset.mediaUrl !== url) return; image.src = src; image.dataset.mediaState = 'loaded'; }); }) .catch(() => { activeTargets.forEach(image => { if (image.dataset.mediaUrl === url) image.dataset.mediaState = 'error'; }); }) .finally(() => { activeImageFetches_--; processImageQueue_(); }); } } function enqueueImageLoad_(image, prioritize = false) { if (!image || image.dataset.mediaState === 'loading' || image.dataset.mediaState === 'loaded') return; const url = image.dataset.mediaUrl; if (!url) return; const currentGen = state.sessionGeneration || 0; const queuedIndex = imageFetchQueue_.findIndex(item => item.url === url && item.sessionGen === currentGen); if (queuedIndex >= 0) { const queueItem = imageFetchQueue_[queuedIndex]; queueItem.images = Array.isArray(queueItem.images) ? queueItem.images : (queueItem.image ? [queueItem.image] : []); if (!queueItem.images.includes(image)) queueItem.images.push(image); if (prioritize) { imageFetchQueue_.splice(queuedIndex, 1); imageFetchQueue_.unshift(queueItem); } processImageQueue_(); return; } const queueItem = { image, images: [image], url, sessionGen: currentGen }; if (prioritize) { imageFetchQueue_.unshift(queueItem); } else { imageFetchQueue_.push(queueItem); } processImageQueue_(); } function prioritizeImageLoad(url, image) { if (!url || !image) return; if (protectedMediaObserver) protectedMediaObserver.unobserve(image); const sessionGen = state.sessionGeneration || 0; const queuedIndex = imageFetchQueue_.findIndex(item => item.url === url && item.sessionGen === sessionGen); if (queuedIndex >= 0) { const [item] = imageFetchQueue_.splice(queuedIndex, 1); item.images = Array.isArray(item.images) ? item.images : (item.image ? [item.image] : []); if (!item.images.includes(image)) item.images.push(image); imageFetchQueue_.unshift(item); } else { image.dataset.mediaUrl = url; image.dataset.mediaState = 'pending'; enqueueImageLoad_(image, true); } processImageQueue_(); } function hydrateProtectedMediaImage(image) { if (!image) return; if (protectedMediaObserver) { protectedMediaObserver.observe(image); } else { enqueueImageLoad_(image, false); } } function hydrateProtectedMediaImages(root) { const scope = root && root.querySelectorAll ? root : document; scope.querySelectorAll('img[data-media-url]').forEach(hydrateProtectedMediaImage); if (root && root.matches && root.matches('img[data-media-url]')) hydrateProtectedMediaImage(root); } function initProtectedMediaImageLoader() { if (typeof IntersectionObserver !== 'undefined') { if (!protectedMediaObserver) { protectedMediaObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; protectedMediaObserver.unobserve(img); enqueueImageLoad_(img, false); } }); }, { rootMargin: '200px', threshold: 0.01 }); } } hydrateProtectedMediaImages(document); } function setMediaImageSource(image, value, prioritize = false) { if (!image) return; const url = safeMediaUrl(value); image.removeAttribute('data-media-url'); image.removeAttribute('data-media-state'); if (isProtectedDriveMediaUrl(url)) { image.src = MEDIA_PLACEHOLDER_SRC; image.dataset.mediaUrl = url; image.dataset.mediaState = 'pending'; if (prioritize) prioritizeImageLoad(url, image); else hydrateProtectedMediaImage(image); } else { image.src = url || MEDIA_PLACEHOLDER_SRC; } } function parseImageUrls(value) { let values = []; if (Array.isArray(value)) values = value; else { const text = String(value || '').trim(); if (!text) return []; if (text.startsWith('[')) { try { values = JSON.parse(text); } catch (e) { values = [text]; } } else values = [text]; } return [...new Set(values.map(safeMediaUrl).filter(Boolean))].slice(0, 10); } function getImageUrls(item) { const urls = parseImageUrls(item && item.image_urls); if (urls.length) return urls; return parseImageUrls(item && item.image_url); } function productDisplayName(item) { return String(item && item.product_name || '').trim() || 'Sản phẩm chưa đặt tên'; } function safeExternalUrl(value) { const url = String(value || '').trim(); return /^https?:\/\//i.test(url) ? url : ''; } function normalizeProductLink(value) { let url = String(value || '').trim(); if (!url) return ''; if (!/^https?:\/\//i.test(url) && /^[\w.-]+\.[a-z]{2,}([/:?#].*)?$/i.test(url)) url = 'https://' + url; return safeExternalUrl(url); } function openProductLinkFromInput(inputId) { const input = document.getElementById(inputId); const url = normalizeProductLink(input ? input.value : ''); if (!url) { showToast('Chưa có link hợp lệ để mở'); return; } if (input) input.value = url; window.open(url, '_blank', 'noopener,noreferrer'); } function isStoredMediaUrl(value) { const url = String(value || '').trim(); return /^https?:\/\//i.test(url) || /^gdrive:\/\//i.test(url); } function productLinkMarkup(link, extraClass) { const url = safeExternalUrl(link); if (!url) return ''; return `Mở link`; } // ========================================================================= // --- AUTHENTICATION & ROLE MANAGEMENT (SĐT + PIN 4-8 SỐ) --- // ========================================================================= function setLoginStatus(message) { const el = document.getElementById('loginStatusMsg'); if (!el) return; if (message) { el.innerHTML = `
`; el.classList.add('is-on'); el.classList.remove('hidden'); } else { el.textContent = ''; el.classList.remove('is-on'); el.classList.add('hidden'); } } function setLoginError(message) { const el = document.getElementById('loginErrorMsg'); if (!el) return; if (message) { el.innerHTML = `
`; el.classList.add('is-on'); el.classList.remove('hidden'); } else { el.textContent = ''; el.classList.remove('is-on'); el.classList.add('hidden'); } } function lockApp(statusMessage) { document.documentElement.classList.add('is-locked'); document.body.classList.add('is-locked'); const modal = document.getElementById('loginModal'); if (modal) { modal.classList.add('is-open'); modal.classList.remove('hidden'); } setLoginStatus(statusMessage || ''); } function unlockApp() { document.documentElement.classList.remove('is-locked'); document.body.classList.remove('is-locked'); const modal = document.getElementById('loginModal'); if (modal) { modal.classList.remove('is-open'); modal.classList.add('hidden'); } setLoginStatus(''); setLoginError(''); if (typeof clearLoginInputErrors === 'function') clearLoginInputErrors(); if (typeof setLoginSubmitting === 'function') setLoginSubmitting(false); } // --- SNAPSHOT & SESSION LIFECYCLE MANAGEMENT --- function hashTokenFingerprint(token) { let hash = 0; const str = String(token || ''); for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash |= 0; } return Math.abs(hash).toString(36) + '_' + (str.length > 8 ? str.slice(-8) : str); } function getSnapshotKey(userId, role, token) { return 'china:snapshot:v1:' + encodeURIComponent(userId) + ':' + encodeURIComponent(role) + ':' + hashTokenFingerprint(token); } function sanitizeForSnapshot(db) { if (!db || typeof db !== 'object') return {}; const cleanOrder = (o) => { const clone = { ...o }; delete clone.internal_note; // Confidential: never persist internal_note to client disk return clone; }; const cleanRequest = (r) => { const clone = { ...r }; delete clone.internal_note; return clone; }; return { batches: Array.isArray(db.batches) ? db.batches : [], rate_rules: Array.isArray(db.rate_rules) ? db.rate_rules : [], weight_batches: Array.isArray(db.weight_batches) ? db.weight_batches : [], orders: Array.isArray(db.orders) ? db.orders.map(cleanOrder) : [], payments: Array.isArray(db.payments) ? db.payments : [], settings: (db.settings && typeof db.settings === 'object') ? db.settings : {}, users: Array.isArray(db.users) ? db.users : [], order_requests: Array.isArray(db.order_requests) ? db.order_requests.map(cleanRequest) : (Array.isArray(db.orderRequests) ? db.orderRequests.map(cleanRequest) : []) }; } function saveLocalSnapshot(user, db) { if (!user || !user.id || !state.authToken) return; try { const key = getSnapshotKey(user.id, user.role, state.authToken); const sanitized = sanitizeForSnapshot(db); const snapshot = { schemaVersion: 1, userId: user.id, role: user.role, sessionFingerprint: hashTokenFingerprint(state.authToken), savedAt: Date.now(), expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours TTL data: sanitized }; const serialized = JSON.stringify(snapshot); if (serialized.length <= 2 * 1024 * 1024) { // Max 2MB storage safety cap // Evict older snapshots for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith('china:snapshot:v1:') && k !== key) { localStorage.removeItem(k); } } localStorage.setItem(key, serialized); } } catch (err) { console.warn('Could not save snapshot:', err); } } function loadLocalSnapshot(user, token) { if (!user || !user.id || !token) return null; try { const key = getSnapshotKey(user.id, user.role, token); const raw = localStorage.getItem(key); if (!raw) return null; const snapshot = JSON.parse(raw); if (!snapshot || snapshot.schemaVersion !== 1) return null; if (snapshot.userId !== user.id || snapshot.role !== user.role) return null; if (snapshot.sessionFingerprint !== hashTokenFingerprint(token)) return null; if (Date.now() > snapshot.expiresAt) { localStorage.removeItem(key); return null; } return snapshot; } catch (err) { return null; } } function showSnapshotBanner(savedAt, isOffline = false) { let banner = document.getElementById('snapshotBanner'); if (!banner) { banner = document.createElement('div'); banner.id = 'snapshotBanner'; banner.className = 'w-full px-4 py-2 text-xs font-semibold text-center z-40 transition-all'; const mainHeader = document.querySelector('header'); if (mainHeader && mainHeader.parentNode) { mainHeader.parentNode.insertBefore(banner, mainHeader.nextSibling); } else { document.body.prepend(banner); } } const timeStr = new Date(savedAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }); if (isOffline) { banner.className = 'w-full px-4 py-2 text-xs font-semibold text-center z-40 bg-rose-500 text-white shadow-sm flex items-center justify-center gap-2'; banner.innerHTML = `⚠️ Không có kết nối mạng · Hiển thị bản lưu lúc ${timeStr} `; } else { banner.className = 'w-full px-4 py-2 text-xs font-semibold text-center z-40 bg-amber-500/90 text-amber-950 backdrop-blur-xs shadow-sm flex items-center justify-center gap-2'; banner.innerHTML = `⚡ Hiển thị bản lưu lúc ${timeStr} · Đang đồng bộ máy chủ...`; } banner.classList.remove('hidden'); } function hideSnapshotBanner() { const banner = document.getElementById('snapshotBanner'); if (banner) banner.classList.add('hidden'); } function lockWriteOperations(locked) { state.isReadOnlyPreview = Boolean(locked); const writeButtons = [ document.getElementById('mobileQuickAddButton'), document.getElementById('overviewMergeButton') ]; writeButtons.forEach(btn => { if (btn) { btn.disabled = Boolean(locked); btn.classList.toggle('opacity-50', Boolean(locked)); btn.classList.toggle('pointer-events-none', Boolean(locked)); } }); } function purgeSessionData_() { state.sessionGeneration = (state.sessionGeneration || 0) + 1; localStorage.removeItem('china_app_user'); localStorage.removeItem('china_app_token'); state.currentUser = null; state.authToken = ''; state.orders = []; state.batches = []; state.payments = []; state.orderRequests = []; state.users = []; state.selectedOrderIds.clear(); hideSnapshotBanner(); lockWriteOperations(false); // Purge all snapshots const snapKeys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith('china:snapshot:v1:')) snapKeys.push(k); } snapKeys.forEach(k => localStorage.removeItem(k)); // Purge all sessionStorage media cache const mediaKeys = []; for (let i = 0; i < sessionStorage.length; i++) { const k = sessionStorage.key(i); if (k && k.startsWith('media:')) mediaKeys.push(k); } mediaKeys.forEach(k => sessionStorage.removeItem(k)); protectedMediaCache.clear(); imageFetchQueue_.length = 0; updateUserHeaderUI(); applyRoleUI(); openLoginModal(true); } function handleSessionExpired() { purgeSessionData_(); setLoginError('Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.'); } function handleLogout() { if (confirm('Bạn có chắc chắn muốn đăng xuất tài khoản?')) { purgeSessionData_(); } } async function retrySyncNow() { showToast('Đang kết nối lại máy chủ...'); await loadData(); } function applyDatabasePayload(db) { if (!db) return; state.batches = db.batches || []; state.rate_rules = db.rate_rules || []; state.weight_batches = db.weight_batches || []; state.orders = db.orders || []; state.payments = db.payments || []; state.settings = db.settings || {}; state.users = db.users || []; state.orderRequests = db.order_requests || db.orderRequests || []; if (!state.currentBatchId || state.currentBatchId === 'ALL' || !state.batches.some(b => b.id === state.currentBatchId)) { state.currentBatchId = pickNewestBatchId() || (state.batches[0] && state.batches[0].id) || 'ALL'; } updateTabBadges(); } async function checkAuthOnLoad() { const savedUserRaw = localStorage.getItem('china_app_user'); const savedToken = localStorage.getItem('china_app_token'); if (!savedUserRaw || !savedToken) { purgeSessionData_(); return false; } let savedUser = null; try { savedUser = JSON.parse(savedUserRaw); } catch(e) {} if (!savedUser || !savedUser.id) { purgeSessionData_(); return false; } state.authToken = savedToken; state.currentUser = savedUser; // 1. Instant Snapshot Bootstrap (< 50ms) const snapT0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); const snapshot = loadLocalSnapshot(savedUser, savedToken); let snapshotApplied = false; if (snapshot && snapshot.data) { applyDatabasePayload(snapshot.data); updateUserHeaderUI(); applyRoleUI(); renderCustomerSelect(); renderBatchSelect(); renderEffectiveRateBadge(); renderDashboard(); renderRequests(); showSnapshotBanner(snapshot.savedAt, false); lockWriteOperations(true); unlockApp(); snapshotApplied = true; const snapT1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); window.__snapshotInteractiveDuration = snapT1 - snapT0; } else { lockApp('Đang mở sổ hàng...'); } // 2. Parallel Network Verification (/auth/me and /database) try { const authHdr = {}; if (savedToken) authHdr['Authorization'] = 'Bearer ' + savedToken; if (savedUser && savedUser.id) authHdr['x-user-id'] = savedUser.id; const [meResult, dbResult] = await Promise.allSettled([ fetch('/api/auth/me', { headers: authHdr }), fetch('/api/database', { headers: authHdr }) ]); // Check auth/me result if (meResult.status === 'rejected' || !meResult.value.ok) { if (meResult.status === 'fulfilled' && meResult.value.status === 401) { handleSessionExpired(); return false; } if (snapshotApplied) { showSnapshotBanner(snapshot.savedAt, true); unlockApp(); return true; } throw new Error('Không thể kết nối máy chủ xác thực.'); } const meData = await meResult.value.json(); if (!meData.authenticated || !meData.user) { handleSessionExpired(); return false; } state.currentUser = meData.user; localStorage.setItem('china_app_user', JSON.stringify(meData.user)); updateUserHeaderUI(); applyRoleUI(); // Check database result if (dbResult.status === 'fulfilled' && dbResult.value.ok) { const dbData = await dbResult.value.json(); applyDatabasePayload(dbData); saveLocalSnapshot(state.currentUser, dbData); state._initialDataLoaded = true; hideSnapshotBanner(); lockWriteOperations(false); renderCustomerSelect(); renderBatchSelect(); renderEffectiveRateBadge(); renderDashboard(); renderRequests(); unlockApp(); return true; } else { if (snapshotApplied) { showSnapshotBanner(snapshot.savedAt, true); unlockApp(); return true; } throw new Error('Không thể tải dữ liệu từ máy chủ.'); } } catch (netErr) { if (snapshotApplied) { showSnapshotBanner(snapshot.savedAt, true); unlockApp(); return true; } purgeSessionData_(); setLoginError('Lỗi kết nối máy chủ. Vui lòng thử đăng nhập lại.'); unlockApp(); return false; } } function updateUserHeaderUI() { const nameEl = document.getElementById('userDisplayName'); const roleEl = document.getElementById('userRoleBadge'); const avatarEl = document.getElementById('userAvatarIcon'); const mobNameEl = document.getElementById('mobUserDisplayName'); const mobRoleEl = document.getElementById('mobUserRoleBadge'); if (state.currentUser) { const displayName = state.currentUser.name || state.currentUser.phone; if (nameEl) nameEl.textContent = displayName; if (mobNameEl) mobNameEl.textContent = displayName; if (roleEl) { if (state.currentUser.role === 'admin') { roleEl.textContent = '👑 Quản Trị Viên'; roleEl.className = 'text-[10px] text-amber-400 font-bold'; } else { roleEl.textContent = '👤 Khách Hàng'; roleEl.className = 'text-[10px] text-emerald-400 font-bold'; } } if (mobRoleEl) { mobRoleEl.textContent = state.currentUser.role === 'admin' ? '👑 Quản Trị Viên' : '👤 Khách Hàng'; mobRoleEl.className = state.currentUser.role === 'admin' ? 'text-[10px] text-amber-400 font-bold' : 'text-[10px] text-emerald-400 font-bold'; } if (avatarEl) { avatarEl.innerHTML = state.currentUser.role === 'admin' ? '' : ''; avatarEl.className = 'ava'; } } else { if (nameEl) nameEl.textContent = 'Chưa đăng nhập'; if (roleEl) roleEl.textContent = 'Bấm để đăng nhập'; if (mobNameEl) mobNameEl.textContent = 'Chưa đăng nhập'; if (mobRoleEl) mobRoleEl.textContent = 'Khách Hàng'; } } function applyRoleUI() { const isAdmin = state.currentUser && state.currentUser.role === 'admin'; // Elements only for Admin const adminElements = [ document.querySelector("button[onclick='openNewOrderModal()']"), document.querySelector("button[onclick='openRateModal()']"), document.querySelector("button[onclick='openNewBatchModal()']"), document.getElementById('btnAdminUsers'), document.getElementById('mobBtnAdminUsers'), document.getElementById('mobBtnBankConfig'), document.getElementById('formInternalNoteWrapper') ]; adminElements.forEach(el => { if (el) { if (isAdmin) el.classList.remove('hidden'); else el.classList.add('hidden'); } }); document.querySelectorAll('[data-admin-only], button[onclick="openNewOrderModal()"], button[onclick="openNewBatchModal()"]') .forEach(el => el.classList.toggle('hidden', !isAdmin)); updateWeightMergeBar(); updateMobileQuickAddVisibility(); // Populate customer filter for admin const custFilter = document.getElementById('requestCustomerFilter'); if (custFilter && isAdmin && state.users) { const customers = state.users.filter(u => u.role === 'customer'); custFilter.innerHTML = '' + customers.map(c => ``).join(''); } } function updateTabBadges() { const badgeOrders = document.getElementById('tabOrderBadge'); const badgeRequests = document.getElementById('tabRequestBadge'); const mobBadgeOrders = document.getElementById('mobTabOrderBadge'); const mobBadgeRequests = document.getElementById('mobTabRequestBadge'); if (badgeOrders) { const filteredOrders = getVisibleOrders(); badgeOrders.textContent = filteredOrders.length; if (mobBadgeOrders) mobBadgeOrders.textContent = filteredOrders.length; } if (badgeRequests) { const pendingReqs = state.orderRequests.filter(r => r.status === 'pending'); badgeRequests.textContent = pendingReqs.length; if (mobBadgeRequests) mobBadgeRequests.textContent = pendingReqs.length; } } // Open / Close Login Modal function openLoginModal(isMandatory = false) { lockApp(''); setLoginError(''); clearLoginInputErrors(); setLoginSubmitting(false); const phoneInput = document.getElementById('loginPhone'); const pinInput = document.getElementById('loginPin'); if (state.currentUser && phoneInput && !phoneInput.value) phoneInput.value = state.currentUser.phone || ''; if (pinInput && isMandatory) pinInput.value = ''; setTimeout(() => { const focusEl = (phoneInput && phoneInput.value) ? pinInput : phoneInput; if (focusEl) focusEl.focus(); }, 50); } function closeLoginModal() { if (!state.currentUser || !state.authToken) return; unlockApp(); } function togglePinVisibility(inputId, iconId) { const input = document.getElementById(inputId); const icon = document.getElementById(iconId); if (!input) return; if (input.type === 'password') { input.type = 'text'; if (icon) { if (icon.tagName === 'I') icon.className = 'fa-solid fa-eye-slash mr-1'; else icon.textContent = 'Ẩn'; } } else { input.type = 'password'; if (icon) { if (icon.tagName === 'I') icon.className = 'fa-solid fa-eye mr-1'; else icon.textContent = 'Hiện'; } } } function checkPhoneAccount() { const wrapper = document.getElementById('loginNameWrapper'); if (wrapper) wrapper.classList.add('hidden'); } function setLoginSubmitting(isSubmitting, message = 'Đang xác thực...') { const submitButton = document.getElementById('loginSubmitButton'); const phoneInput = document.getElementById('loginPhone'); const pinInput = document.getElementById('loginPin'); if (!submitButton) return; if (isSubmitting) { submitButton.disabled = true; submitButton.classList.add('is-loading'); submitButton.classList.remove('is-success'); submitButton.innerHTML = ` ${escapeHtml(message)} `; if (phoneInput) phoneInput.readOnly = true; if (pinInput) pinInput.readOnly = true; } else { submitButton.disabled = false; submitButton.classList.remove('is-loading', 'is-success'); submitButton.innerHTML = `Đăng nhập`; if (phoneInput) phoneInput.readOnly = false; if (pinInput) pinInput.readOnly = false; } } function setLoginSuccess(message = 'Đăng nhập thành công!') { const submitButton = document.getElementById('loginSubmitButton'); if (!submitButton) return; submitButton.classList.remove('is-loading'); submitButton.classList.add('is-success'); submitButton.innerHTML = ` ${escapeHtml(message)} `; } function triggerLoginShake(focusTarget = 'loginPin') { const card = document.querySelector('.login-gate-card'); if (card) { card.classList.remove('login-shake'); void card.offsetWidth; card.classList.add('login-shake'); } const phoneInput = document.getElementById('loginPhone'); const pinInput = document.getElementById('loginPin'); if (focusTarget === 'loginPhone' && phoneInput) { phoneInput.classList.add('input-error'); phoneInput.focus(); phoneInput.select(); } else if (pinInput) { pinInput.classList.add('input-error'); pinInput.focus(); pinInput.select(); } } function clearLoginInputErrors() { const phoneInput = document.getElementById('loginPhone'); const pinInput = document.getElementById('loginPin'); if (phoneInput) phoneInput.classList.remove('input-error'); if (pinInput) pinInput.classList.remove('input-error'); } async function handleLoginSubmit(e) { if (typeof window.submitChinaLogin === 'function') return window.submitChinaLogin(e); if (e && e.preventDefault) e.preventDefault(); clearLoginInputErrors(); const phone = (document.getElementById('loginPhone').value || '').trim(); const pin = (document.getElementById('loginPin').value || '').trim(); if (!phone) { setLoginError('Vui lòng nhập số điện thoại hoặc tài khoản'); triggerLoginShake('loginPhone'); return; } if (!pin) { setLoginError('Vui lòng nhập mã PIN (4 đến 8 chữ số)'); triggerLoginShake('loginPin'); return; } setLoginError(''); setLoginStatus('Đang kết nối máy chủ và xác thực tài khoản...'); setLoginSubmitting(true, 'Đang xác thực...'); try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, pin }) }); const data = await res.json(); if (!res.ok || !data.success) { setLoginStatus(''); setLoginSubmitting(false); const errMsg = data.error || 'Đăng nhập không thành công. Kiểm tra lại SĐT và PIN.'; setLoginError(errMsg); triggerLoginShake(errMsg.toLowerCase().includes('pin') ? 'loginPin' : 'loginPhone'); return; } setLoginStatus(''); setLoginSuccess('Đăng nhập thành công!'); state.currentUser = data.user; state.authToken = data.token; localStorage.setItem('china_app_user', JSON.stringify(data.user)); localStorage.setItem('china_app_token', data.token); setTimeout(async () => { closeLoginModal(); setLoginSubmitting(false); await loadData(); showToast('Xin chào ' + data.user.name); }, 350); } catch (err) { console.error('Login error:', err); setLoginStatus(''); setLoginSubmitting(false); setLoginError(err && err.message ? err.message : 'Không kết nối được máy chủ Google Apps Script. Bấm Đăng nhập để thử lại.'); triggerLoginShake('loginPin'); } } // handleLogout is handled by unified purgeSessionData_ // Change PIN modal handlers function openChangePinModal() { document.getElementById('changePinModal').classList.remove('hidden'); document.getElementById('changePinErrorMsg').classList.add('hidden'); document.getElementById('currentPin').value = ''; document.getElementById('newPin').value = ''; document.getElementById('confirmNewPin').value = ''; const notice = document.getElementById('mandatoryPinNotice'); if (notice) notice.classList.toggle('hidden', !(state.currentUser && state.currentUser.must_change_pin)); } function closeChangePinModal() { if (state.currentUser && state.currentUser.must_change_pin) return; document.getElementById('changePinModal').classList.add('hidden'); } async function handleChangePinSubmit(e) { e.preventDefault(); const oldPin = document.getElementById('currentPin').value.trim(); const newPin = document.getElementById('newPin').value.trim(); const confirmPin = document.getElementById('confirmNewPin').value.trim(); const errorEl = document.getElementById('changePinErrorMsg'); if (newPin !== confirmPin) { errorEl.textContent = 'Mã PIN mới và xác nhận mã PIN không khớp nhau!'; errorEl.classList.remove('hidden'); return; } if (!/^\d{4,8}$/.test(newPin)) { errorEl.textContent = 'Mã PIN phải từ 4 đến 8 chữ số!'; errorEl.classList.remove('hidden'); return; } try { const res = await fetch('/api/auth/change-pin', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.authToken }, body: JSON.stringify({ old_pin: oldPin, new_pin: newPin }) }); const data = await res.json(); if (res.ok) { alert('✅ ' + (data.message || 'Đổi mã PIN thành công!') + '\nVui lòng đăng nhập lại bằng PIN mới.'); if (state.currentUser) state.currentUser.must_change_pin = false; closeChangePinModal(); handleSessionExpired(); } else { errorEl.textContent = data.error || 'Đổi mã PIN thất bại'; errorEl.classList.remove('hidden'); } } catch (err) { errorEl.textContent = 'Không thể đổi mã PIN: ' + (err.message || 'Lỗi kết nối'); errorEl.classList.remove('hidden'); } } // ========================================================================= // --- ADMIN USER & DEFAULT PIN MANAGEMENT --- // ========================================================================= async function openAdminUsersModal() { document.getElementById('adminUsersModal').classList.remove('hidden'); resetAdminUserForm(); await loadAdminUsers(); } function closeAdminUsersModal() { document.getElementById('adminUsersModal').classList.add('hidden'); } function resetAdminUserForm() { document.getElementById('adminUserEditId').value = ''; document.getElementById('adminUserName').value = ''; document.getElementById('adminUserPhone').value = ''; document.getElementById('adminUserPin').value = ''; document.getElementById('adminUserPin').required = true; document.getElementById('adminUserFormTitle').innerHTML = ' Cấp Tài Khoản & Mật Khẩu (PIN) Mặc Định Mới'; } async function loadAdminUsers() { const tbody = document.getElementById('adminUsersTableBody'); if (!tbody) return; tbody.innerHTML = '
'; try { const res = await fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + state.authToken } }); if (!res.ok) { tbody.innerHTML = '
'; return; } const data = await res.json(); state.adminUsers = data.users || []; renderAdminUsersTable(); } catch (err) { tbody.innerHTML = '
'; } } function renderAdminUsersTable() { const tbody = document.getElementById('adminUsersTableBody'); if (!tbody) return; if (!state.adminUsers || state.adminUsers.length === 0) { tbody.innerHTML = '
'; return; } tbody.innerHTML = state.adminUsers.map(u => { const isCustomer = u.role === 'customer'; return `
`; }).join(''); if (typeof hydrateProtectedMediaImages === 'function') hydrateProtectedMediaImages(tbody); } function editAdminUser(id) { const u = (state.adminUsers || []).find(item => item.id === id); if (!u) return; document.getElementById('adminUserEditId').value = u.id; document.getElementById('adminUserName').value = u.name || ''; document.getElementById('adminUserPhone').value = u.phone || ''; document.getElementById('adminUserPin').value = ''; document.getElementById('adminUserPin').required = false; document.getElementById('adminUserFormTitle').innerHTML = ` Sửa Tài Khoản Khách: ${escapeHtml(u.name)}`; document.getElementById('adminUserName').focus(); } async function handleSaveAdminUser(e) { e.preventDefault(); const id = document.getElementById('adminUserEditId').value; const name = document.getElementById('adminUserName').value.trim(); const phone = document.getElementById('adminUserPhone').value.trim(); const pin = document.getElementById('adminUserPin').value.trim(); if (!/^0\d{9,10}$/.test(phone)) return alert('Số điện thoại phải gồm 10-11 chữ số và bắt đầu bằng 0'); if ((!id && !/^\d{4,8}$/.test(pin)) || (id && pin && !/^\d{4,8}$/.test(pin))) { return alert('Mã PIN phải gồm từ 4 đến 8 chữ số'); } try { const res = await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.authToken }, body: JSON.stringify({ id, name, phone, pin, role: 'customer' }) }); const data = await res.json(); if (res.ok) { alert(id ? 'Đã cập nhật tài khoản khách thành công.' : 'Đã cấp tài khoản khách thành công. Hãy gửi riêng SĐT và PIN vừa tạo cho khách.'); resetAdminUserForm(); await loadAdminUsers(); await loadData(); } else { alert('Lỗi: ' + (data.error || 'Không thể lưu tài khoản')); } } catch (err) { alert('Lỗi kết nối máy chủ'); } } async function resetAdminUserPin(id, name) { const newPin = prompt(`Nhập mã PIN mới (4-8 số) để cấp lại cho "${name}":`, ''); if (newPin === null) return; const cleanPin = newPin.trim(); if (!/^\d{4,8}$/.test(cleanPin)) { return alert('Mã PIN phải từ 4 đến 8 chữ số!'); } try { const res = await fetch(`/api/admin/users/${id}/reset-pin`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.authToken }, body: JSON.stringify({ pin: cleanPin }) }); const data = await res.json(); if (res.ok) { alert(`✅ ${data.message || 'Đã đặt lại mã PIN thành công!'}`); await loadAdminUsers(); } else { alert('Lỗi: ' + (data.error || 'Không thể reset PIN')); } } catch (err) { alert('Lỗi kết nối máy chủ'); } } async function deleteAdminUser(id, name) { if (!confirm(`Bạn có chắc chắn muốn xóa tài khoản khách "${name}"?`)) return; try { const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + state.authToken } }); const data = await res.json(); if (res.ok) { alert('✅ Đã xóa tài khoản thành công!'); await loadAdminUsers(); } else { alert('Lỗi: ' + (data.error || 'Không thể xóa')); } } catch (err) { alert('Lỗi kết nối máy chủ'); } } // ========================================================================= // --- ORDER REQUESTS (PHÂN HỆ YÊU CẦU MUA HÀNG) --- // ========================================================================= function renderRequests() { const container = document.getElementById('requestsContainer'); if (!container) return; const search = (document.getElementById('requestSearchInput')?.value || '').toLowerCase().trim(); const statusFilter = document.getElementById('requestStatusFilter')?.value || 'ALL'; const custFilter = document.getElementById('requestCustomerFilter')?.value || 'ALL'; const isAdmin = state.currentUser && state.currentUser.role === 'admin'; let list = state.orderRequests || []; // Filter for customer: Customer only sees their own requests if (!isAdmin && state.currentUser) { list = list.filter(r => r.customer_id === state.currentUser.id || r.customer_phone === state.currentUser.phone); } else if (isAdmin && custFilter !== 'ALL') { list = list.filter(r => r.customer_id === custFilter); } // Status filter if (statusFilter !== 'ALL') { list = list.filter(r => r.status === statusFilter); } // Search query if (search) { list = list.filter(r => (r.product_name && r.product_name.toLowerCase().includes(search)) || (r.customer_note && r.customer_note.toLowerCase().includes(search)) || (r.product_link && r.product_link.toLowerCase().includes(search)) || (r.customer_name && r.customer_name.toLowerCase().includes(search)) ); } const countBadge = document.getElementById('requestCountBadge'); if (countBadge) countBadge.textContent = `${list.length} yêu cầu`; if (list.length === 0) { container.innerHTML = `
${!isAdmin ? 'Bấm nút "Thêm Yêu Cầu Mới" ở trên để gửi ảnh và link sản phẩm bạn muốn đặt.' : 'Khách hàng chưa gửi yêu cầu mới nào hoặc không khớp với bộ lọc.'}
`; return; } container.innerHTML = list.map(req => { const isPending = req.status === 'pending'; const isOrdered = req.status === 'ordered'; const isCancelled = req.status === 'cancelled'; const imageUrls = getImageUrls(req); const imageUrl = imageUrls[0] || ''; const productUrl = safeExternalUrl(req.product_link); const statusBadge = isPending ? ' Chờ xử lý' : isOrdered ? ' Đã duyệt sang Đơn Hàng' : ' Hết hàng / Hủy'; return `
`; }).join(''); } // Request Modal handlers function openOrderRequestModal(requestId) { document.getElementById('orderRequestForm').reset(); document.getElementById('reqFormId').value = ''; state.reqImageBase64s = []; const title = document.getElementById('orderRequestModalTitle'); const submitLabel = document.getElementById('reqSubmitLabel'); const req = requestId ? (state.orderRequests || []).find(item => item.id === requestId) : null; if (req) { document.getElementById('reqFormId').value = req.id; document.getElementById('reqProductName').value = req.product_name || ''; document.getElementById('reqProductLink').value = req.product_link || ''; document.getElementById('reqQuantity').value = req.quantity || 1; document.getElementById('reqEstPrice').value = req.est_price_cny != null ? req.est_price_cny : ''; document.getElementById('reqCustomerNote').value = req.customer_note || ''; state.reqImageBase64s = getImageUrls(req); if (title) title.innerHTML = ' Sửa yêu cầu đặt hàng'; if (submitLabel) submitLabel.textContent = 'Lưu thay đổi'; } else { if (title) title.innerHTML = ' Gửi Yêu Cầu Mua Hàng Mới (Order)'; if (submitLabel) submitLabel.textContent = 'Gửi Yêu Cầu Cho Shop'; } renderReqImagePreviews(); document.getElementById('orderRequestModal').classList.remove('hidden'); } function editOrderRequest(id) { openOrderRequestModal(id); } function closeOrderRequestModal() { document.getElementById('orderRequestModal').classList.add('hidden'); } function setupReqPasteAndDrop() { const dropZone = document.getElementById('reqImageDropZone'); if (!dropZone) return; dropZone.addEventListener('paste', e => { const items = (e.clipboardData || e.originalEvent.clipboardData).items; const files = []; for (let item of items) { if (item.type.indexOf('image') !== -1) { const file = item.getAsFile(); if (file) files.push(file); } } if (files.length) processReqImageFiles(files); }); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('border-sky-500', 'bg-sky-50/50'); }); dropZone.addEventListener('dragleave', e => { e.preventDefault(); dropZone.classList.remove('border-sky-500', 'bg-sky-50/50'); }); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('border-sky-500', 'bg-sky-50/50'); if (e.dataTransfer.files && e.dataTransfer.files.length) processReqImageFiles(e.dataTransfer.files); }); } function handleReqImageSelect(e) { if (e.target.files && e.target.files.length) processReqImageFiles(e.target.files); } async function processReqImageFiles(fileList) { const available = Math.max(0, 10 - state.reqImageBase64s.length); const files = Array.from(fileList || []).slice(0, available); if (!files.length) return alert('Mỗi sản phẩm được gửi tối đa 10 ảnh.'); await Promise.all(files.map(file => processReqImageFile(file))); } async function processReqImageFile(file) { if (!file.type || !file.type.startsWith('image/')) { alert('Vui lòng chọn file hình ảnh (JPG, PNG, WEBP)'); return; } try { const compressed = await compressImage(file, 1200, 1200, 0.78); state.reqImageBase64s.push(compressed.base64); renderReqImagePreviews(); } catch (error) { alert('Không thể xử lý ảnh này. Vui lòng thử ảnh khác.'); } } function renderReqImagePreviews() { const wrapper = document.getElementById('reqImagePreviewWrapper'); const placeholder = document.getElementById('reqImagePlaceholder'); const grid = document.getElementById('reqImagePreviewGrid'); const count = document.getElementById('reqImageCount'); if (wrapper) wrapper.classList.toggle('hidden', !state.reqImageBase64s.length); if (placeholder) placeholder.classList.toggle('hidden', !!state.reqImageBase64s.length); if (count) count.textContent = state.reqImageBase64s.length; if (grid) grid.innerHTML = state.reqImageBase64s.map((url, index) => `
`).join(''); } function removeReqImage(index, e) { if (e) e.stopPropagation(); state.reqImageBase64s.splice(index, 1); renderReqImagePreviews(); } async function handleSendOrderRequest(e) { e.preventDefault(); const requestId = (document.getElementById('reqFormId').value || '').trim(); const productName = document.getElementById('reqProductName').value.trim(); const productLink = normalizeProductLink(document.getElementById('reqProductLink').value); const quantity = parseFloat(document.getElementById('reqQuantity').value) || 1; const estPrice = parseFloat(document.getElementById('reqEstPrice').value) || null; const customerNote = document.getElementById('reqCustomerNote').value.trim(); if (!state.reqImageBase64s.length) { alert('⚠️ Vui lòng chụp hoặc chọn ít nhất 1 ảnh sản phẩm để gửi shop kiểm tra!'); return; } try { // 1. Upload newly selected images in parallel, keep existing Drive URLs as-is const uploadTasks = state.reqImageBase64s.map(async (current, index) => { if (isStoredMediaUrl(current)) return current; const upRes = await fetch('/api/upload-image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_base64: current, file_name: `request_${Date.now()}_${index + 1}.jpg` }) }); const upData = await upRes.json(); if (!upRes.ok || !upData.success) throw new Error(upData.error || `Không thể tải ảnh thứ ${index + 1} lên Google Drive`); return upData.url; }); const imageUrls = await Promise.all(uploadTasks); // 2. Submit or update order request const payload = { product_name: productName, image_url: imageUrls[0], image_urls: imageUrls, product_link: productLink, quantity, est_price_cny: estPrice, customer_note: customerNote, customer_id: state.currentUser.id, customer_name: state.currentUser.name, customer_phone: state.currentUser.phone, batch_id: state.currentBatchId }; const res = await fetch(requestId ? `/api/order-requests/${requestId}` : '/api/order-requests', { method: requestId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.authToken }, body: JSON.stringify(payload) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || (requestId ? 'Không thể lưu yêu cầu' : 'Không thể gửi yêu cầu')); if (!state.orderRequests) state.orderRequests = []; if (requestId) { const index = state.orderRequests.findIndex(item => item.id === requestId); if (index >= 0) state.orderRequests[index] = data.request; else state.orderRequests.unshift(data.request); } else { state.orderRequests.unshift(data.request); } closeOrderRequestModal(); switchMainTab('requests'); updateTabBadges(); renderRequests(); alert(requestId ? 'Đã lưu thay đổi yêu cầu đặt hàng.' : '🎉 Đã gửi yêu cầu mua hàng thành công!\nChúng tôi sẽ liên hệ báo giá sớm nhất cho bạn.'); } catch (err) { console.error('Error submitting order request:', err); alert('Lỗi gửi yêu cầu: ' + (err.message || 'Vui lòng thử lại')); } } // Admin Convert Request to Order modal function openConvertModal(requestId) { const req = (state.orderRequests || []).find(r => r.id === requestId); if (!req) return; document.getElementById('convRequestId').value = req.id; document.getElementById('convProductName').textContent = productDisplayName(req); document.getElementById('convCustomerName').textContent = req.customer_name || 'Khách hàng'; document.getElementById('convCustomerPhone').textContent = req.customer_phone || ''; const requestImages = getImageUrls(req); setMediaImageSource(document.getElementById('convImagePreview'), requestImages[0] || ''); const convImageCount = document.getElementById('convImageCount'); if (convImageCount) { convImageCount.textContent = `${requestImages.length} ảnh`; convImageCount.classList.toggle('hidden', requestImages.length < 2); } const linkInput = document.getElementById('convProductLinkInput'); if (linkInput) linkInput.value = req.product_link || ''; const noteEl = document.getElementById('convCustomerNote'); if (req.customer_note) { noteEl.textContent = `[Khách note]: ${req.customer_note}`; noteEl.classList.remove('hidden'); } else { noteEl.classList.add('hidden'); } // Populate batch selector const batchSelect = document.getElementById('convBatchSelect'); const requestBatches = state.batches.filter(b => !isClosedBatch(b) && (b.customer_id === req.customer_id || (!b.customer_id && (!b.customer_name || b.customer_name === req.customer_name)))); batchSelect.innerHTML = requestBatches.map(b => ` `).join(''); // 2-way pricing defaults const qty = req.quantity || 1; const unitPrice = req.est_price_cny || 0; const totalCny = unitPrice ? Math.round(unitPrice * qty * 100) / 100 : ''; document.getElementById('convQuantity').value = qty; document.getElementById('convUnitPrice').value = unitPrice || ''; document.getElementById('convTotalCny').value = totalCny || ''; document.getElementById('convInternalNote').value = req.product_link ? `Link khách: ${req.product_link}` : ''; selectConvPricingMode('forward'); document.getElementById('convertRequestModal').classList.remove('hidden'); } function closeConvertModal() { document.getElementById('convertRequestModal').classList.add('hidden'); } function selectConvPricingMode(direction) { state.lastConvDirection = direction === 'reverse' ? 'reverse' : 'forward'; const unitButton = document.getElementById('convModeUnit'); const packageButton = document.getElementById('convModePackage'); if (unitButton) unitButton.className = state.lastConvDirection === 'forward' ? 'px-3 py-2 rounded-lg bg-amber-600 text-white font-bold border border-amber-600' : 'px-3 py-2 rounded-lg bg-white text-amber-800 font-bold border border-amber-300'; if (packageButton) packageButton.className = state.lastConvDirection === 'reverse' ? 'px-3 py-2 rounded-lg bg-amber-600 text-white font-bold border border-amber-600' : 'px-3 py-2 rounded-lg bg-white text-amber-800 font-bold border border-amber-300'; recalcConvPrice(state.lastConvDirection); } function recalcConvPrice(dir) { state.lastConvDirection = dir === 'reverse' ? 'reverse' : 'forward'; const unitEl = document.getElementById('convUnitPrice'); const qtyEl = document.getElementById('convQuantity'); const totalEl = document.getElementById('convTotalCny'); const unit = parseFloat(unitEl.value); const qty = parseFloat(qtyEl.value) || 1; const total = parseFloat(totalEl.value); if (dir === 'forward') { if (!isNaN(unit)) { totalEl.value = Math.round(unit * qty * 100) / 100; } } else if (dir === 'reverse') { if (!isNaN(total)) { unitEl.value = Math.round((total / qty) * 100) / 100; } } } async function handleConvertRequestSubmit(e) { e.preventDefault(); const requestId = document.getElementById('convRequestId').value; const batchId = document.getElementById('convBatchSelect').value; const unitPrice = parseFloat(document.getElementById('convUnitPrice').value) || null; const quantity = parseFloat(document.getElementById('convQuantity').value) || 1; const totalCny = parseFloat(document.getElementById('convTotalCny').value) || null; const productLink = normalizeProductLink(document.getElementById('convProductLinkInput') ? document.getElementById('convProductLinkInput').value : ''); const internalNote = document.getElementById('convInternalNote').value.trim(); const req = (state.orderRequests || []).find(r => r.id === requestId); if (!req) return; try { const res = await fetch('/api/order-requests/convert-to-order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.authToken }, body: JSON.stringify({ request_id: requestId, batch_id: batchId, unit_price_cny: unitPrice, quantity, total_cny: totalCny, product_link: productLink, internal_note: internalNote, product_name: req.product_name, _recalcDirection: state.lastConvDirection }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Không thể chuyển yêu cầu thành đơn hàng'); closeConvertModal(); if (data.order) { state.orders = state.orders || []; state.orders.unshift(data.order); } if (data.request && state.orderRequests) { const rIdx = state.orderRequests.findIndex(r => r.id === data.request.id); if (rIdx >= 0) state.orderRequests[rIdx] = data.request; } renderDashboard(); renderRequests(); updateTabBadges(); switchMainTab('orders'); if (state.currentUser) { saveLocalSnapshot(state.currentUser, { batches: state.batches, rate_rules: state.rate_rules, weight_batches: state.weight_batches, orders: state.orders, payments: state.payments, settings: state.settings, users: state.users, order_requests: state.orderRequests }); } showToast(`🎉 ${data.message || 'Đã duyệt yêu cầu và chuyển sang Đơn Hàng thành công!'}`); loadData().catch(e => console.warn('Background sync after convert:', e)); } catch (err) { console.error('Error converting request:', err); alert('Lỗi chuyển yêu cầu: ' + (err.message || 'Vui lòng thử lại')); } } async function rejectRequest(requestId) { const reason = prompt('Nhập lý do báo hết hàng / không mua được cho khách:', 'Hết hàng tại xưởng Trung Quốc'); if (reason === null) return; try { const res = await fetch(`/api/order-requests/${requestId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + state.authToken }, body: JSON.stringify({ status: 'cancelled', reject_reason: reason }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Không thể cập nhật yêu cầu'); await loadData(); } catch (e) { alert('Lỗi cập nhật yêu cầu: ' + (e.message || 'Vui lòng thử lại')); } } async function deleteRequest(requestId) { if (!confirm('Bạn có chắc chắn muốn xóa yêu cầu đặt hàng này?')) return; try { const res = await fetch(`/api/order-requests/${requestId}`, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + state.authToken } }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Không thể xóa yêu cầu'); await loadData(); } catch (e) { alert('Lỗi xóa yêu cầu: ' + (e.message || 'Vui lòng thử lại')); } }