ERP/frontend/js/components/customer-order.js

729 lines
28 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 客户订单管理
(() => {
Router.register('/plan-mgmt/customer-order', async () => {
// 先返回 HTML
const html = `
<style>
/* 完全重新设计页面布局 */
.view:has(#customer-order-page) {
padding: 0 !important;
display: flex;
flex-direction: column;
height: 100vh;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
#customer-order-page {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
}
#customer-order-page .page-header {
padding: 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#customer-order-page .content-area {
flex: 1;
padding: 20px;
overflow: hidden;
display: flex;
flex-direction: column;
}
#customer-order-page .table-wrapper {
flex: 1;
overflow: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
#customer-order-page table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
#customer-order-page thead {
background: var(--border);
position: sticky;
top: 0;
z-index: 10;
}
#customer-order-page th {
padding: 8px 12px;
text-align: left;
font-weight: 600;
color: var(--text);
border-bottom: 2px solid var(--border);
font-size: 14px;
}
#customer-order-page td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--text);
font-size: 14px;
}
#customer-order-page tbody tr:last-child td {
border-bottom: none;
}
#customer-order-page tbody tr:hover {
background: var(--hover);
}
#customer-order-page .text-center {
text-align: center;
color: var(--text-2);
}
#customer-order-page .filtered-row {
display: none !important;
}
</style>
<div id="customer-order-page">
<div class="page-header">
<h1 style="margin: 0; font-size: 24px;">客户订单管理</h1>
<div class="page-actions" style="margin-top: 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
<button id="add-order-btn" class="btn btn-primary">新增订单</button>
<button id="refresh-undelivered-btn" class="btn btn-secondary" title="刷新未交订单数据">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
</svg>
刷新未交订单
</button>
<div style="display: flex; align-items: center; gap: 8px; margin-left: auto;">
<label style="font-size: 14px; color: var(--text-2);">物料筛选:</label>
<input
type="text"
id="material-filter"
placeholder="输入物料名称筛选..."
class="input"
style="width: 200px; padding: 6px 12px; font-size: 14px;"
/>
<button id="clear-filter-btn" class="btn btn-secondary" style="padding: 6px 12px;" title="清除筛选">✕</button>
</div>
</div>
</div>
<div class="content-area">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>下单时间</th>
<th>订单编号</th>
<th>客户名称</th>
<th>物料</th>
<th>订单数量</th>
<th>已发货</th>
<th style="color: #f59e0b; font-weight: 700;">未交订单</th>
<th>单价</th>
<th>操作</th>
</tr>
</thead>
<tbody id="order-list">
<tr>
<td colspan="9" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 新增订单弹窗 -->
<div id="order-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 900px; max-height: 90vh; overflow-y: auto;">
<div class="modal-header">
<h2 id="modal-title">新增订单</h2>
<button class="modal-close" onclick="window.CustomerOrder.closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="order-form">
<!-- 订单基本信息 -->
<div style="background: var(--surface); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border);">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: var(--text); font-weight: 600;">订单基本信息</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px;">
<div class="field" style="margin: 0;">
<label>下单时间 <span style="color: var(--danger);">*</span></label>
<input type="date" id="order-date" class="input" required />
</div>
<div class="field" style="margin: 0;">
<label>订单编号 <span style="color: var(--danger);">*</span></label>
<input type="text" id="order-no" class="input" placeholder="如CGDD001695" required />
</div>
<div class="field" style="margin: 0;">
<label>客户名称 <span style="color: var(--danger);">*</span></label>
<input type="text" id="customer-name" class="input" placeholder="如:易泰勒" required />
</div>
</div>
</div>
<!-- 物料列表 -->
<div style="background: var(--surface); padding: 20px; border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0; font-size: 16px; color: var(--text); font-weight: 600;">物料清单</h3>
<button type="button" class="btn btn-primary" onclick="window.CustomerOrder.addMaterialRow()">
添加物料
</button>
</div>
<div id="materials-container">
<!-- 物料行将动态添加到这里 -->
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="window.CustomerOrder.closeModal()">取消</button>
<button class="btn btn-primary" onclick="window.CustomerOrder.saveOrder()">保存订单</button>
</div>
</div>
</div>
`;
// DOM 渲染后初始化
setTimeout(() => {
const addBtn = document.getElementById('add-order-btn');
if (addBtn) {
addBtn.addEventListener('click', () => {
openModal();
});
}
const refreshBtn = document.getElementById('refresh-undelivered-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
refreshBtn.disabled = true;
refreshBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px; animation: spin 1s linear infinite;"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/></svg>刷新中...';
await loadOrders();
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/></svg>刷新未交订单';
API.toast('未交订单数据已更新', 'success');
});
}
// 物料筛选功能
const filterInput = document.getElementById('material-filter');
const clearFilterBtn = document.getElementById('clear-filter-btn');
if (filterInput) {
filterInput.addEventListener('input', (e) => {
filterOrders(e.target.value);
});
}
if (clearFilterBtn) {
clearFilterBtn.addEventListener('click', () => {
if (filterInput) {
filterInput.value = '';
filterOrders('');
}
});
}
loadOrders();
}, 100);
return html;
});
async function loadOrders() {
try {
console.log('开始加载订单列表...');
const res = await fetch('/api/customer-orders');
console.log('API响应状态:', res.status);
const data = await res.json();
console.log('订单数据:', data);
const tbody = document.getElementById('order-list');
if (!tbody) {
console.error('找不到 order-list 元素');
return;
}
if (!data.list || data.list.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">暂无数据</td></tr>';
return;
}
tbody.innerHTML = data.list.map(order => {
const undeliveredQty = order.undelivered_qty || 0;
const shippedQty = order.shipped_qty || 0;
const isManual = order.manual_undelivered_qty !== null && order.manual_undelivered_qty !== undefined;
const undeliveredStyle = undeliveredQty > 0 ? 'color: #f59e0b; font-weight: 700;' : 'color: #10b981;';
const manualIcon = isManual ? '<span title="手动设置" style="cursor: help; margin-left: 4px;">✏️</span>' : '';
return `
<tr data-material="${(order.material || '').replace(/"/g, '&quot;')}" data-order-id="${order.id}">
<td>${order.order_date || '—'}</td>
<td>${order.order_no || '—'}</td>
<td>${order.customer_name || '—'}</td>
<td style="white-space: pre-line; word-break: break-word;">${order.material || '—'}</td>
<td>${order.quantity || 0}</td>
<td style="color: #3b82f6;">${shippedQty}</td>
<td style="${undeliveredStyle}"
onclick="window.CustomerOrder.editUndelivered(${order.id}, ${undeliveredQty}, ${order.auto_undelivered_qty || 0})"
title="点击编辑未交订单数量(自动计算: ${order.auto_undelivered_qty || 0}"
style="cursor: pointer; ${undeliveredStyle}">
${undeliveredQty}${manualIcon}
</td>
<td>${order.unit_price || 0}</td>
<td>
<button class="btn-text" onclick="window.CustomerOrder.editOrder(${order.id})">编辑</button>
<button class="btn-text btn-danger" onclick="window.CustomerOrder.deleteOrder(${order.id})">删除</button>
</td>
</tr>
`}).join('');
// 应用当前筛选(如果有)
const filterInput = document.getElementById('material-filter');
if (filterInput && filterInput.value) {
filterOrders(filterInput.value);
}
console.log('订单列表加载完成');
} catch (err) {
console.error('加载订单失败:', err);
const tbody = document.getElementById('order-list');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center" style="color: red;">加载失败,请刷新重试</td></tr>';
}
API.toast('加载订单失败', 'error');
}
}
function filterOrders(keyword) {
const tbody = document.getElementById('order-list');
if (!tbody) return;
const rows = tbody.querySelectorAll('tr');
const filterText = keyword.toLowerCase().trim();
let visibleCount = 0;
rows.forEach(row => {
// 跳过空数据行
if (row.querySelector('.text-center')) {
return;
}
const material = (row.getAttribute('data-material') || '').toLowerCase();
if (!filterText || material.includes(filterText)) {
row.classList.remove('filtered-row');
visibleCount++;
} else {
row.classList.add('filtered-row');
}
});
// 如果没有匹配结果,显示提示
if (visibleCount === 0 && filterText) {
const existingMsg = tbody.querySelector('.no-filter-results');
if (!existingMsg) {
const msgRow = document.createElement('tr');
msgRow.className = 'no-filter-results';
msgRow.innerHTML = '<td colspan="9" class="text-center" style="color: var(--text-2);">没有匹配的物料</td>';
tbody.appendChild(msgRow);
}
} else {
const msgRow = tbody.querySelector('.no-filter-results');
if (msgRow) {
msgRow.remove();
}
}
}
let materialRowIndex = 0;
let productList = []; // 缓存BOM产品列表
async function loadProductList() {
try {
const res = await fetch('/api/bom/products');
const data = await res.json();
productList = (data.list || []).map(p => p.product_name).filter(Boolean);
// 同时从已有订单中提取物料名称(去重)
const orderRes = await fetch('/api/customer-orders');
const orderData = await orderRes.json();
if (orderData.list) {
for (const o of orderData.list) {
if (o.material && !productList.includes(o.material)) {
productList.push(o.material);
}
}
}
} catch (e) {
console.error('加载产品列表失败:', e);
}
}
function buildMaterialOptions() {
return productList.map(name => `<option value="${name}">`).join('');
}
function addMaterialRow(material = '', quantity = '', unitPrice = '') {
const container = document.getElementById('materials-container');
const index = materialRowIndex++;
const datalistId = `material-options-${index}`;
const row = document.createElement('div');
row.className = 'material-row';
row.id = `material-row-${index}`;
row.style.cssText = 'display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 12px; margin-bottom: 12px; padding: 16px; background: var(--surface-2); border-radius: 8px; border: 1px solid var(--border);';
row.innerHTML = `
<div class="field" style="margin: 0;">
<label>物料名称 <span style="color: var(--danger);">*</span></label>
<input type="text" class="input material-name" list="${datalistId}" placeholder="输入或选择物料名称" value="${material}" required autocomplete="off" />
<datalist id="${datalistId}">${buildMaterialOptions()}</datalist>
</div>
<div class="field" style="margin: 0;">
<label>数量 <span style="color: var(--danger);">*</span></label>
<input type="number" class="input material-quantity" placeholder="数量" value="${quantity}" min="1" required />
</div>
<div class="field" style="margin: 0;">
<label>单价 <span style="color: var(--danger);">*</span></label>
<input type="number" class="input material-price" placeholder="单价" value="${unitPrice}" step="0.01" min="0" required />
</div>
<div style="display: flex; align-items: flex-end; padding-bottom: 2px;">
<button type="button" class="btn btn-danger" onclick="window.CustomerOrder.removeMaterialRow(${index})" style="white-space: nowrap;">
🗑️ 删除
</button>
</div>
`;
container.appendChild(row);
}
function removeMaterialRow(index) {
const row = document.getElementById(`material-row-${index}`);
if (row) {
row.remove();
}
// 如果没有物料行了,至少保留一行
const container = document.getElementById('materials-container');
if (container.children.length === 0) {
addMaterialRow();
}
}
let currentEditId = null;
async function openModal(order = null) {
// 加载产品列表用于下拉选择
await loadProductList();
const modal = document.getElementById('order-modal');
const title = document.getElementById('modal-title');
// 清空物料列表
const container = document.getElementById('materials-container');
container.innerHTML = '';
materialRowIndex = 0;
if (order) {
// 编辑模式
title.textContent = '编辑订单';
currentEditId = order.id;
document.getElementById('order-date').value = order.order_date || '';
document.getElementById('order-no').value = order.order_no || '';
document.getElementById('customer-name').value = order.customer_name || '';
// 添加物料信息
addMaterialRow(order.material || '', order.quantity || '', order.unit_price || '');
} else {
// 新增模式
title.textContent = '新增订单';
currentEditId = null;
document.getElementById('order-form').reset();
// 设置默认日期为今天
const today = new Date().toISOString().split('T')[0];
document.getElementById('order-date').value = today;
// 添加一行空物料
addMaterialRow();
}
modal.style.display = 'flex';
}
function closeModal() {
const modal = document.getElementById('order-modal');
modal.style.display = 'none';
document.getElementById('order-form').reset();
// 清空物料列表
const container = document.getElementById('materials-container');
if (container) {
container.innerHTML = '';
}
materialRowIndex = 0;
}
async function saveOrder() {
const orderDate = document.getElementById('order-date').value.trim();
const orderNo = document.getElementById('order-no').value.trim();
const customerName = document.getElementById('customer-name').value.trim();
if (!orderDate || !orderNo || !customerName) {
API.toast('请填写订单基本信息', 'error');
return;
}
// 收集所有物料信息
const materials = [];
const materialRows = document.querySelectorAll('.material-row');
if (materialRows.length === 0) {
API.toast('请至少添加一个物料', 'error');
return;
}
for (const row of materialRows) {
const materialName = row.querySelector('.material-name').value.trim();
const quantity = parseInt(row.querySelector('.material-quantity').value);
const unitPrice = parseFloat(row.querySelector('.material-price').value);
if (!materialName || !quantity || isNaN(unitPrice)) {
API.toast('请填写所有物料信息', 'error');
return;
}
if (quantity <= 0) {
API.toast('物料数量必须大于0', 'error');
return;
}
if (unitPrice < 0) {
API.toast('单价不能为负数', 'error');
return;
}
materials.push({
material: materialName,
quantity: quantity,
unit_price: unitPrice
});
}
// 保存前确认金额
let totalAmount = 0;
const detailLines = materials.map(m => {
const amount = m.quantity * m.unit_price;
totalAmount += amount;
return `<tr><td style="padding:6px 12px;">${m.material}</td><td style="padding:6px 12px;text-align:right;">${m.quantity}</td><td style="padding:6px 12px;text-align:right;">${m.unit_price}</td><td style="padding:6px 12px;text-align:right;font-weight:600;">${amount.toFixed(2)}</td></tr>`;
}).join('');
const confirmed = await new Promise(resolve => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:10000;';
overlay.innerHTML = `
<div style="background:var(--surface,#fff);border-radius:12px;padding:24px;max-width:600px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2);">
<h3 style="margin:0 0 16px 0;font-size:18px;">确认订单金额</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:16px;">
<thead><tr style="background:var(--border,#eee);">
<th style="padding:8px 12px;text-align:left;">物料</th>
<th style="padding:8px 12px;text-align:right;">数量</th>
<th style="padding:8px 12px;text-align:right;">单价</th>
<th style="padding:8px 12px;text-align:right;">金额</th>
</tr></thead>
<tbody>${detailLines}</tbody>
<tfoot><tr style="border-top:2px solid var(--border,#ccc);">
<td colspan="3" style="padding:8px 12px;font-weight:700;font-size:16px;">合计</td>
<td style="padding:8px 12px;text-align:right;font-weight:700;font-size:16px;color:var(--primary,#1989FA);">${totalAmount.toFixed(2)}</td>
</tr></tfoot>
</table>
<div style="display:flex;justify-content:flex-end;gap:12px;">
<button id="confirm-cancel" class="btn btn-secondary" style="padding:8px 24px;">取消</button>
<button id="confirm-ok" class="btn btn-primary" style="padding:8px 24px;">确认保存</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#confirm-ok').onclick = () => { document.body.removeChild(overlay); resolve(true); };
overlay.querySelector('#confirm-cancel').onclick = () => { document.body.removeChild(overlay); resolve(false); };
});
if (!confirmed) return;
try {
if (currentEditId) {
// 编辑模式:只能编辑单条记录
if (materials.length !== 1) {
API.toast('编辑模式下只能有一个物料', 'error');
return;
}
const mat = materials[0];
const res = await fetch(`/api/customer-orders/${currentEditId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_date: orderDate,
order_no: orderNo,
customer_name: customerName,
material: mat.material,
quantity: mat.quantity,
unit_price: mat.unit_price
})
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast('更新成功', 'success');
closeModal();
await loadOrders();
} else {
API.toast(data.error || '更新失败', 'error');
}
} else {
// 新增模式:为每个物料创建一条订单记录
let successCount = 0;
for (const mat of materials) {
const res = await fetch('/api/customer-orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_date: orderDate,
order_no: orderNo,
customer_name: customerName,
material: mat.material,
quantity: mat.quantity,
unit_price: mat.unit_price
})
});
const data = await res.json();
if (res.ok && data.ok) {
successCount++;
} else {
console.error('保存物料失败:', mat.material, data.error);
}
}
if (successCount === materials.length) {
API.toast(`订单保存成功,共 ${successCount} 个物料`, 'success');
closeModal();
await loadOrders();
} else if (successCount > 0) {
API.toast(`部分保存成功(${successCount}/${materials.length}`, 'warning');
closeModal();
await loadOrders();
} else {
API.toast('保存失败', 'error');
}
}
} catch (err) {
console.error('保存订单失败:', err);
API.toast('保存失败', 'error');
}
}
async function deleteOrder(id) {
if (!confirm('确定要删除这条订单吗?')) {
return;
}
try {
const res = await fetch(`/api/customer-orders/${id}`, {
method: 'DELETE'
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast('删除成功', 'success');
await loadOrders();
} else {
API.toast(data.error || '删除失败', 'error');
}
} catch (err) {
console.error('删除订单失败:', err);
API.toast('删除失败', 'error');
}
}
async function editUndelivered(orderId, currentQty, autoQty) {
const newQty = prompt(
`编辑未交订单数量\n\n当前值: ${currentQty}\n自动计算值: ${autoQty}\n\n请输入新的未交订单数量(留空恢复自动计算):`,
currentQty
);
// 用户取消
if (newQty === null) {
return;
}
// 用户输入空值,恢复自动计算
const finalQty = newQty.trim() === '' ? null : newQty.trim();
try {
const res = await fetch(`/api/customer-orders/${orderId}/undelivered`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ undelivered_qty: finalQty })
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast(finalQty === null ? '已恢复自动计算' : '未交订单数量已更新', 'success');
await loadOrders();
} else {
API.toast(data.error || '更新失败', 'error');
}
} catch (err) {
console.error('更新未交订单数量失败:', err);
API.toast('更新失败', 'error');
}
}
async function editOrder(id) {
try {
const res = await fetch('/api/customer-orders');
const data = await res.json();
const order = data.list.find(o => o.id === id);
if (!order) {
API.toast('订单不存在', 'error');
return;
}
openModal(order);
} catch (err) {
console.error('加载订单失败:', err);
API.toast('加载失败', 'error');
}
}
// 暴露给全局
window.CustomerOrder = {
openModal,
closeModal,
saveOrder,
editOrder,
deleteOrder,
editUndelivered,
addMaterialRow,
removeMaterialRow
};
})();