ERP/frontend/js/components/shipment-orders.js

406 lines
19 KiB
JavaScript
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.

// 仓库管理 —— 发货单
window.ShipmentOrders = (() => {
let currentUser = null;
let itemIndex = 0;
const isSuperadmin = () => currentUser && currentUser.role === 'superadmin';
const isAdmin = () => currentUser && currentUser.role === 'admin';
const statusLabel = s => ({ pending: '待确认', approved: '已确认', rejected: '已驳回' }[s] || s);
const statusColor = s => ({ pending: '#f59e0b', approved: '#10b981', rejected: '#ef4444' }[s] || '#94a3b8');
const render = async () => {
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
currentUser = res.ok ? await res.json() : null;
} catch (e) { currentUser = null; }
return `
<div style="padding:20px;max-width:1100px;margin:0 auto">
<div id="shipment-content"></div>
</div>
<style>
.sh-table{width:100%;border-collapse:collapse;font-size:13px}
.sh-table th,.sh-table td{padding:10px 14px;text-align:left;border-bottom:1px solid var(--border)}
.sh-table th{background:var(--bg);font-weight:600;color:var(--text-secondary)}
.sh-table tr:hover td{background:var(--bg)}
.sh-badge{display:inline-block;border-radius:99px;padding:2px 10px;font-size:11px;font-weight:600}
.sh-btn{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500}
.sh-btn-primary{background:var(--primary,#3b82f6);color:#fff}
.sh-btn-success{background:#10b981;color:#fff}
.sh-btn-danger{background:#ef4444;color:#fff}
.sh-btn-secondary{background:var(--bg);color:var(--text);border:1px solid var(--border)}
.sh-input{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font-size:13px;box-sizing:border-box}
select.sh-input{height:36px;padding-top:0;padding-bottom:0;appearance:auto}
.sh-card{background:var(--surface);border-radius:12px;border:1px solid var(--border);overflow:hidden}
</style>
`;
};
const init = async () => {
stopPolling();
if (!currentUser) {
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
currentUser = res.ok ? await res.json() : null;
} catch (e) { currentUser = null; }
}
const el = document.getElementById('shipment-content');
if (!el) return;
if (isSuperadmin()) { await renderSuperadminView(el); startPolling(); }
else if (isAdmin()) { await renderAdminView(el); startPolling(); }
else el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">无权限</div>';
};
let _modelList = [];
const loadModels = async () => {
const res = await fetch('/api/warehouse/shipment-models', { credentials: 'include' });
const data = res.ok ? await res.json() : { list: [] };
_modelList = data.list || [];
};
const refreshModelDatalist = () => {
const dl = document.getElementById('sh-model-datalist');
if (!dl) return;
dl.innerHTML = _modelList.map(m => `<option value="${m.model}">`).join('');
};
const refreshModelManageList = () => {
const box = document.getElementById('sh-model-list');
if (!box) return;
box.innerHTML = _modelList.length === 0
? '<span style="color:var(--text-secondary);font-size:12px">暂无型号</span>'
: _modelList.map(m => `
<span style="display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:12px">
${m.model}
<button onclick="ShipmentOrders.deleteModel(${m.id})" style="background:none;border:none;cursor:pointer;color:#ef4444;font-size:13px;line-height:1;padding:0 2px" title="删除">×</button>
</span>`).join('');
};
const addModel = async () => {
const input = document.getElementById('sh-new-model');
if (!input) return;
const model = input.value.trim();
if (!model) return;
const res = await fetch('/api/warehouse/shipment-models', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model })
});
const data = await res.json();
if (res.ok && data.ok) {
_modelList.push({ id: data.id, model: data.model });
_modelList.sort((a, b) => a.model.localeCompare(b.model));
input.value = '';
refreshModelManageList();
refreshModelDatalist();
} else {
alert(data.error || '添加失败');
}
};
const deleteModel = async (id) => {
if (!confirm('确定删除此型号?')) return;
const res = await fetch(`/api/warehouse/shipment-models/${id}`, { method: 'DELETE', credentials: 'include' });
if (res.ok) {
_modelList = _modelList.filter(m => m.id !== id);
refreshModelManageList();
refreshModelDatalist();
}
};
// ── 管理员视图:上传发货数量 ─────────────────────────────────
const renderAdminView = async (el) => {
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
const [ordersRes] = await Promise.all([
fetch('/api/warehouse/shipment-orders', { credentials: 'include' }),
loadModels()
]);
const data = ordersRes.ok ? await ordersRes.json() : { list: [] };
const list = data.list || [];
itemIndex = 0;
el.innerHTML = `
<datalist id="sh-model-datalist">${_modelList.map(m => `<option value="${m.model}">`).join('')}</datalist>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<span style="font-size:15px;font-weight:600">发货单</span>
</div>
<!-- 新增发货单 -->
<div class="sh-card" style="padding:20px;margin-bottom:24px">
<div style="font-size:14px;font-weight:600;margin-bottom:16px;color:var(--text)">新增发货数量上传</div>
<div id="sh-items-container"></div>
<button class="sh-btn sh-btn-secondary" style="margin-top:4px" onclick="ShipmentOrders.addItem()"> 添加产品</button>
<div style="margin-top:16px">
<label style="font-size:12px;color:var(--text-secondary)">备注(可选)</label>
<textarea id="sh-note" class="sh-input" rows="2" placeholder="备注..." style="margin-top:4px;resize:vertical"></textarea>
</div>
<div style="margin-top:16px;display:flex;justify-content:flex-end">
<button class="sh-btn sh-btn-primary" onclick="ShipmentOrders.submit()">提交</button>
</div>
</div>
<!-- 历史记录 -->
<div style="font-size:14px;font-weight:600;margin-bottom:12px">我的提交记录</div>
<div class="sh-card">
<table class="sh-table">
<thead><tr>
<th>单号</th><th>产品明细</th><th>备注</th><th>提交时间</th><th>状态</th><th>驳回原因</th><th>确认人</th>
</tr></thead>
<tbody id="sh-admin-tbody">
${buildAdminRows(list)}
</tbody>
</table>
</div>
`;
addItem();
};
const buildAdminRows = (list) => {
if (list.length === 0) return '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:30px">暂无记录</td></tr>';
return list.map(r => `
<tr>
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
<td style="font-size:12px">${(r.items || []).map(i => `${i.model} × ${i.qty}`).join('<br>')}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.note || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').slice(0,16)}</td>
<td><span class="sh-badge" style="background:${statusColor(r.status)}22;color:${statusColor(r.status)}">${statusLabel(r.status)}</span></td>
<td style="font-size:12px;color:#ef4444">${r.reject_reason || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.confirmed_by || '—'}</td>
</tr>`).join('');
};
const adminSilentRefresh = async () => {
const tbody = document.getElementById('sh-admin-tbody');
if (!tbody) return;
const res = await fetch('/api/warehouse/shipment-orders', { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
tbody.innerHTML = buildAdminRows(data.list || []);
};
const addItem = () => {
const container = document.getElementById('sh-items-container');
if (!container) return;
const idx = itemIndex++;
const row = document.createElement('div');
row.id = `sh-item-${idx}`;
row.style.cssText = 'display:flex;gap:10px;align-items:flex-end;margin-bottom:10px;flex-wrap:wrap';
row.innerHTML = `
<div style="flex:2 1 160px">
<label style="font-size:12px;color:var(--text-secondary)">产品型号 *</label>
<select class="sh-input sh-model">
<option value="">请选择产品型号</option>
${_modelList.map(m => `<option value="${m.model}">${m.model}</option>`).join('')}
</select>
</div>
<div style="flex:1 1 100px">
<label style="font-size:12px;color:var(--text-secondary)">发货数量 *</label>
<input type="number" class="sh-input sh-qty" placeholder="数量" min="1" />
</div>
<button class="sh-btn sh-btn-danger" style="flex-shrink:0;margin-bottom:2px" onclick="ShipmentOrders.removeItem(${idx})">删除</button>
`;
container.appendChild(row);
};
const removeItem = (idx) => {
const row = document.getElementById(`sh-item-${idx}`);
if (row) row.remove();
const container = document.getElementById('sh-items-container');
if (container && container.children.length === 0) addItem();
};
const submit = async () => {
const rows = document.querySelectorAll('#sh-items-container > div');
const items = [];
for (const row of rows) {
const model = row.querySelector('.sh-model').value.trim();
const qty = parseInt(row.querySelector('.sh-qty').value);
if (!model || !qty || qty <= 0) {
alert('请填写所有产品型号和发货数量数量须大于0');
return;
}
items.push({ model, qty });
}
if (items.length === 0) { alert('请至少添加一个产品'); return; }
const note = document.getElementById('sh-note')?.value.trim() || '';
const res = await fetch('/api/warehouse/shipment-orders', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, note })
});
const data = await res.json();
if (res.ok && data.ok) {
alert(`提交成功,单号:${data.order_no}`);
await renderAdminView(document.getElementById('shipment-content'));
} else {
alert(data.error || '提交失败');
}
};
// 生成表格行 HTML供首次渲染和静默刷新复用
const buildTableRows = (list) => {
if (list.length === 0) return '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:30px">暂无记录</td></tr>';
return list.map(r => `
<tr>
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
<td>${r.created_by}</td>
<td style="font-size:12px">${(r.items || []).map(i => `${i.model} \u00d7 ${i.qty}`).join('<br>')}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.note || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').slice(0,16)}</td>
<td><span class="sh-badge" style="background:${statusColor(r.status)}22;color:${statusColor(r.status)}">${statusLabel(r.status)}</span></td>
<td style="font-size:12px;color:#ef4444">${r.reject_reason || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.confirmed_by || '—'}</td>
<td style="white-space:nowrap">
${r.status === 'pending'
? `<button class="sh-btn sh-btn-success" style="margin-right:6px" onclick="ShipmentOrders.confirm('${r.order_no}','approve')">确认</button>
<button class="sh-btn sh-btn-danger" onclick="ShipmentOrders.showRejectModal('${r.order_no}')">驳回</button>`
: '—'}
</td>
</tr>`).join('');
};
const buildModelManageHtml = () => `
<div class="sh-card" style="padding:16px 20px;margin-bottom:20px">
<div style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--text)">\u4ea7\u54c1\u578b\u53f7\u7ba1\u7406</div>
<div style="display:flex;gap:8px;margin-bottom:10px">
<input id="sh-new-model" class="sh-input" placeholder="\u8f93\u5165\u65b0\u578b\u53f7..." style="flex:1;max-width:260px"
onkeydown="if(event.key==='Enter')ShipmentOrders.addModel()" />
<button class="sh-btn sh-btn-primary" onclick="ShipmentOrders.addModel()">\u6dfb\u52a0</button>
</div>
<div id="sh-model-list" style="display:flex;flex-wrap:wrap;gap:6px">${
_modelList.length === 0
? '<span style="color:var(--text-secondary);font-size:12px">\u6682\u65e0\u578b\u53f7</span>'
: _modelList.map(m => `
<span style="display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:12px">
${m.model}
<button onclick="ShipmentOrders.deleteModel(${m.id})" style="background:none;border:none;cursor:pointer;color:#ef4444;font-size:13px;line-height:1;padding:0 2px" title="\u5220\u9664">\u00d7</button>
</span>`).join('')
}</div>
</div>`;
// ── 超级管理员视图:首次渲染骨架 ────────────────────────────
const renderSuperadminView = async (el) => {
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
const [res] = await Promise.all([
fetch('/api/warehouse/shipment-orders', { credentials: 'include' }),
loadModels()
]);
const data = res.ok ? await res.json() : { list: [] };
const list = data.list || [];
const pending = list.filter(r => r.status === 'pending').length;
el.innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
<span style="font-size:15px;font-weight:600">发货单审批</span>
<span id="sh-pending-badge" style="${pending > 0 ? '' : 'display:none;'}background:#f59e0b;color:#fff;border-radius:99px;padding:2px 8px;font-size:11px;font-weight:700">${pending} 待确认</span>
</div>
${buildModelManageHtml()}
<div class="sh-card">
<table class="sh-table">
<thead><tr>
<th>单号</th><th>申请人</th><th>产品明细</th><th>备注</th><th>提交时间</th><th>状态</th><th>驳回原因</th><th>确认人</th><th>操作</th>
</tr></thead>
<tbody id="sh-tbody">${buildTableRows(list)}</tbody>
</table>
</div>
<!-- 驳回弹窗 -->
<div id="sh-reject-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;align-items:center;justify-content:center">
<div style="background:var(--surface);border-radius:12px;padding:24px;width:420px;max-width:90vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
<div style="font-size:15px;font-weight:600;margin-bottom:16px">驳回原因</div>
<textarea id="sh-reject-reason" class="sh-input" rows="3" placeholder="请填写驳回原因..." style="resize:vertical"></textarea>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px">
<button class="sh-btn sh-btn-secondary" onclick="ShipmentOrders.closeRejectModal()">取消</button>
<button class="sh-btn sh-btn-danger" onclick="ShipmentOrders.submitReject()">确认驳回</button>
</div>
</div>
</div>
`;
};
// ── 静默刷新(仅更新 tbody 和徽标,不重建整个页面)──────────
const silentRefresh = async () => {
const tbody = document.getElementById('sh-tbody');
const badge = document.getElementById('sh-pending-badge');
if (!tbody) return;
const res = await fetch('/api/warehouse/shipment-orders', { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const list = data.list || [];
const pending = list.filter(r => r.status === 'pending').length;
tbody.innerHTML = buildTableRows(list);
if (badge) {
badge.textContent = `${pending} 待确认`;
badge.style.display = pending > 0 ? '' : 'none';
}
};
let _rejectOrderNo = null;
let _pollTimer = null;
const startPolling = () => {
stopPolling();
_pollTimer = setInterval(async () => {
const el = document.getElementById('shipment-content');
if (!el) { stopPolling(); return; }
if (isSuperadmin()) {
const modal = document.getElementById('sh-reject-modal');
const modalOpen = modal && modal.style.display === 'flex';
if (!modalOpen) await silentRefresh();
} else if (isAdmin()) {
await adminSilentRefresh();
}
}, 5000);
};
const stopPolling = () => {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
};
const showRejectModal = (orderNo) => {
_rejectOrderNo = orderNo;
const modal = document.getElementById('sh-reject-modal');
if (modal) { modal.style.display = 'flex'; document.getElementById('sh-reject-reason').value = ''; }
};
const closeRejectModal = () => {
const modal = document.getElementById('sh-reject-modal');
if (modal) modal.style.display = 'none';
_rejectOrderNo = null;
};
const submitReject = async () => {
const reason = document.getElementById('sh-reject-reason')?.value.trim();
if (!reason) { alert('请填写驳回原因'); return; }
await confirm(_rejectOrderNo, 'reject', reason);
closeRejectModal();
};
const confirm = async (orderNo, action, rejectReason = '') => {
const res = await fetch(`/api/warehouse/shipment-orders/${orderNo}/confirm`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, reject_reason: rejectReason })
});
const data = await res.json();
if (res.ok && data.ok) {
await silentRefresh();
} else {
alert(data.error || '操作失败');
}
};
return { render, init, addItem, removeItem, submit, confirm, showRejectModal, closeRejectModal, submitReject, stopPolling, addModel, deleteModel };
})();
Router.register('/warehouse/shipment', async () => {
const html = await window.ShipmentOrders.render();
setTimeout(() => window.ShipmentOrders.init(), 0);
return html;
});
Router.onBeforeEach(() => { window.ShipmentOrders.stopPolling(); });