ERP/frontend/js/components/warehouse.js
2026-05-14 16:55:55 +08:00

438 lines
22 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.Warehouse = (() => {
let currentTab = 'borrow';
let currentUser = null;
let _approvedBorrows = [];
const statusLabel = (s) => {
const map = { pending: '待确认', approved: '已批准', rejected: '已拒绝', received: '已同意', not_received: '未收到', partial: '部分收到', returned_back: '已退回' };
return map[s] || s;
};
const statusColor = (s) => {
const map = { pending: '#f59e0b', approved: '#10b981', rejected: '#ef4444', received: '#10b981', not_received: '#ef4444', partial: '#f59e0b', returned_back: '#6b7280' };
return map[s] || '#94a3b8';
};
const isSuperadmin = () => currentUser && currentUser.role === 'superadmin';
const isAdmin = () => currentUser && currentUser.role === 'admin';
// ── 主渲染 ──────────────────────────────────
const render = async () => {
return `
<div style="padding:20px;max-width:1200px;margin:0 auto">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
<div style="display:flex;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:4px">
${tabBtn('borrow', '借出单', currentTab)}
${tabBtn('return', '借出还入单', currentTab)}
</div>
</div>
<div id="warehouse-content"></div>
</div>
<style>
.wh-tab-btn { padding:8px 18px;border:none;border-radius:7px;cursor:pointer;font-size:13px;font-weight:500;transition:all .15s;background:transparent;color:var(--text-secondary); }
.wh-tab-btn.active { background:var(--primary,#3b82f6);color:#fff; }
.wh-table { width:100%;border-collapse:collapse;font-size:13px; }
.wh-table th,.wh-table td { padding:11px 14px;text-align:left;border-bottom:1px solid var(--border); }
.wh-table th { background:var(--bg);font-weight:600;color:var(--text-secondary); }
.wh-table tr:hover td { background:var(--bg); }
.wh-btn { padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500; }
.wh-btn-primary { background:var(--primary,#3b82f6);color:#fff; }
.wh-btn-success { background:#10b981;color:#fff; }
.wh-btn-danger { background:#ef4444;color:#fff; }
.wh-btn-ghost { background:var(--bg);color:var(--text);border:1px solid var(--border); }
.wh-modal-overlay { position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:2000; }
.wh-modal { background:var(--surface);border-radius:16px;padding:28px;width:90%;max-width:480px;box-shadow:0 20px 60px rgba(0,0,0,.25); }
.wh-form-row { margin-bottom:14px; }
.wh-form-row label { display:block;font-size:12px;font-weight:600;color:var(--text-secondary);margin-bottom:5px; }
.wh-form-row input,.wh-form-row textarea,.wh-form-row select { width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--text);font-size:13px;box-sizing:border-box; }
.wh-form-row textarea { height:80px;resize:vertical; }
.wh-status-badge { display:inline-block;padding:3px 9px;border-radius:99px;font-size:11px;font-weight:600; }
</style>
`;
};
const tabBtn = (key, label, cur) =>
`<button class="wh-tab-btn${cur === key ? ' active' : ''}" onclick="Warehouse.switchTab('${key}')">${label}</button>`;
const renderUnreturnedCard = async () => {
try {
const res = await fetch('/api/warehouse/unreturned-summary', { credentials: 'include' });
const data = res.ok ? await res.json() : { summary: [], total_unreturned: 0 };
const summary = data.summary || [];
if (summary.length === 0) return '';
const rows = summary.map(s =>
`<span style="display:inline-flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:4px 12px;font-size:12px">
<span style="font-weight:600">${s.model}</span>
<span style="color:#ef4444;font-weight:700">未还 ${s.unreturned}</span>
<span style="color:var(--text-secondary)">(借 ${s.borrowed} / 还 ${s.returned})</span>
</span>`
).join('');
return `<div style="background:#fff7ed;border:1px solid #fed7aa;border-radius:10px;padding:12px 16px;margin-bottom:16px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<span style="font-size:13px;font-weight:700;color:#ea580c;margin-right:4px">⚠️ 未还汇总</span>
${rows}
</div>`;
} catch (e) { return ''; }
};
const init = async () => {
renderTab();
};
const switchTab = (tab) => {
currentTab = tab;
document.querySelectorAll('.wh-tab-btn').forEach(b => {
b.classList.toggle('active', b.textContent.trim() === (tab === 'borrow' ? '借出单' : '借出还入单'));
});
renderTab();
};
// ── 借出单列表 ──────────────────────────────
const renderTab = async () => {
const el = document.getElementById('warehouse-content');
if (!el) return;
if (currentTab === 'borrow') await renderBorrowTab(el);
else await renderReturnTab(el);
};
const renderBorrowTab = async (el) => {
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
const [res, retRes] = await Promise.all([
fetch('/api/warehouse/borrow-orders', { credentials: 'include' }),
fetch('/api/warehouse/return-orders', { credentials: 'include' })
]);
const data = res.ok ? await res.json() : { list: [] };
const retData = retRes.ok ? await retRes.json() : { list: [] };
const list = data.list || [];
// 按borrow_order_no聚合已同意的还入数量
const receivedMap = {};
(retData.list || []).forEach(r => {
if (r.status === 'received') {
receivedMap[r.borrow_order_no] = (receivedMap[r.borrow_order_no] || 0) + (r.received_qty != null ? r.received_qty : r.qty);
}
});
const pendingCount = list.filter(r => r.status === 'pending').length;
el.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:15px;font-weight:600">借出单列表</span>
${pendingCount > 0 ? `<span style="background:#f59e0b;color:#fff;border-radius:99px;padding:2px 8px;font-size:11px;font-weight:700">${pendingCount} 待确认</span>` : ''}
</div>
${isSuperadmin() ? `<button class="wh-btn wh-btn-primary" onclick="Warehouse.showCreateBorrowModal()">+ 新建借出单</button>` : ''}
</div>
<div style="background:var(--surface);border-radius:12px;overflow:hidden;border:1px solid var(--border)">
<table class="wh-table">
<thead><tr>
<th>单号</th><th>型号</th><th>数量</th><th>未还数</th><th>借出理由</th>
<th>申请人</th><th>申请时间</th><th>状态</th><th>拒绝原因</th><th>确认人</th>
${isAdmin() ? '<th>操作</th>' : ''}
</tr></thead>
<tbody>
${list.length === 0 ? `<tr><td colspan="10" style="text-align:center;color:var(--text-secondary);padding:30px">暂无数据</td></tr>` : ''}
${list.map(r => {
const received = receivedMap[r.order_no] || 0;
const unreturned = r.status === 'approved' ? Math.max(r.qty - received, 0) : null;
return `
<tr>
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
<td>${r.model}</td>
<td>${r.qty}</td>
<td style="font-weight:700;color:${unreturned > 0 ? '#ef4444' : (unreturned === 0 ? '#10b981' : 'var(--text-secondary)')}">${unreturned == null ? '—' : unreturned}</td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.reason}">${r.reason}</td>
<td>${r.created_by}</td>
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').replace('T',' ').slice(0, 16)}</td>
<td><span class="wh-status-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>
${isAdmin() ? `<td style="white-space:nowrap">
${r.status === 'pending' && currentUser.role === 'admin'
? `<button class="wh-btn wh-btn-success" style="margin-right:6px" onclick="Warehouse.confirmBorrow('${r.order_no}','approve')">批准</button>
<button class="wh-btn wh-btn-danger" onclick="Warehouse.showRejectModal('borrow','${r.order_no}')">拒绝</button>`
: '—'}
</td>` : ''}
</tr>
`;}).join('')}
</tbody>
</table>
</div>
`;
};
// ── 还入单列表 ──────────────────────────────
const renderReturnTab = async (el) => {
el.innerHTML = '<div style="color:var(--text-secondary);padding:20px">加载中...</div>';
const [retRes, borrowRes] = await Promise.all([
fetch('/api/warehouse/return-orders', { credentials: 'include' }),
fetch('/api/warehouse/borrow-orders?status=approved', { credentials: 'include' })
]);
const retData = retRes.ok ? await retRes.json() : { list: [] };
const borrowData = borrowRes.ok ? await borrowRes.json() : { list: [] };
const list = retData.list || [];
_approvedBorrows = borrowData.list || [];
const pendingCount = list.filter(r => r.status === 'pending').length;
el.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:15px;font-weight:600">借出还入单列表</span>
${pendingCount > 0 ? `<span style="background:#f59e0b;color:#fff;border-radius:99px;padding:2px 8px;font-size:11px;font-weight:700">${pendingCount} 待确认</span>` : ''}
</div>
${isSuperadmin() ? `<button class="wh-btn wh-btn-primary" onclick="Warehouse.showCreateReturnModal()">+ 新建还入单</button>` : ''}
</div>
<div style="background:var(--surface);border-radius:12px;overflow:hidden;border:1px solid var(--border)">
<table class="wh-table">
<thead><tr>
<th>还入单号</th><th>对应借出单</th><th>型号</th><th>还入数</th>
<th>申请人</th><th>申请时间</th><th>状态</th><th>备注</th><th>确认人</th>
${isAdmin() ? '<th>操作</th>' : ''}
</tr></thead>
<tbody>
${list.length === 0 ? `<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:30px">暂无数据</td></tr>` : ''}
${list.map(r => `
<tr>
<td style="font-family:monospace;font-size:12px">${r.order_no}</td>
<td style="font-family:monospace;font-size:12px">${r.borrow_order_no}</td>
<td>${r.model}</td>
<td>${r.qty}</td>
<td>${r.created_by}</td>
<td style="font-size:12px;color:var(--text-secondary)">${(r.created_at || '').replace('T',' ').slice(0, 16)}</td>
<td><span class="wh-status-badge" style="background:${statusColor(r.status)}22;color:${statusColor(r.status)}">${statusLabel(r.status)}</span></td>
<td style="font-size:12px;color:var(--text-secondary)">${r.reject_reason || '—'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${r.confirmed_by || '—'}</td>
${isAdmin() ? `<td style="white-space:nowrap">
${r.status === 'pending' && currentUser.role === 'admin'
? `<button class="wh-btn wh-btn-success" style="margin-right:6px" onclick="Warehouse.confirmReturn('${r.order_no}','approve')">同意</button>
<button class="wh-btn wh-btn-danger" onclick="Warehouse.showNotReceivedModal('${r.order_no}',${r.qty})">退回</button>`
: '—'}
</td>` : ''}
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
};
// ── 新建借出单弹窗 ──────────────────────────
const showCreateBorrowModal = () => {
const html = `
<div class="wh-modal-overlay" id="wh-modal">
<div class="wh-modal">
<div style="font-size:16px;font-weight:700;margin-bottom:20px">新建借出单</div>
<div class="wh-form-row"><label>借出型号 *</label><input id="wh-b-model" placeholder="请输入型号"></div>
<div class="wh-form-row"><label>数量 *</label><input id="wh-b-qty" type="number" min="1" placeholder="请输入数量"></div>
<div class="wh-form-row"><label>借出理由 *</label><textarea id="wh-b-reason" placeholder="请说明借出原因"></textarea></div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
<button class="wh-btn wh-btn-ghost" onclick="Warehouse.closeModal()">取消</button>
<button class="wh-btn wh-btn-primary" onclick="Warehouse.submitBorrow()">提交</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
};
const submitBorrow = async () => {
const model = document.getElementById('wh-b-model')?.value.trim();
const qty = document.getElementById('wh-b-qty')?.value;
const reason = document.getElementById('wh-b-reason')?.value.trim();
if (!model || !qty || !reason) { API.toast('请填写所有必填项'); return; }
const res = await fetch('/api/warehouse/borrow-orders', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, qty: parseInt(qty), reason })
});
const data = await res.json();
if (data.ok) {
API.toast(`借出单 ${data.order_no} 已提交,等待仓管确认`);
closeModal();
renderTab();
} else {
API.toast(data.error || '提交失败');
}
};
// ── 新建还入单弹窗 ──────────────────────────
const showCreateReturnModal = () => {
const options = (_approvedBorrows || []).map(b =>
`<option value="${b.order_no}" data-model="${b.model}">${b.order_no}${b.model} × ${b.qty}</option>`
).join('');
const html = `
<div class="wh-modal-overlay" id="wh-modal">
<div class="wh-modal">
<div style="font-size:16px;font-weight:700;margin-bottom:20px">新建借出还入单</div>
<div class="wh-form-row">
<label>对应借出单 *</label>
<select id="wh-r-borrow" onchange="Warehouse.onBorrowSelect(this)">
<option value="">-- 请选择已批准的借出单 --</option>
${options || '<option disabled>暂无已批准的借出单</option>'}
</select>
</div>
<div class="wh-form-row"><label>还入型号 *</label><input id="wh-r-model" placeholder="自动填充或手动输入"></div>
<div class="wh-form-row"><label>还入数量 *</label><input id="wh-r-qty" type="number" min="1" placeholder="请输入数量"></div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
<button class="wh-btn wh-btn-ghost" onclick="Warehouse.closeModal()">取消</button>
<button class="wh-btn wh-btn-primary" onclick="Warehouse.submitReturn()">提交</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
};
const onBorrowSelect = (sel) => {
const opt = sel.options[sel.selectedIndex];
const model = opt.getAttribute('data-model') || '';
const modelEl = document.getElementById('wh-r-model');
if (modelEl) modelEl.value = model;
};
const submitReturn = async () => {
const borrow_order_no = document.getElementById('wh-r-borrow')?.value;
const model = document.getElementById('wh-r-model')?.value.trim();
const qty = document.getElementById('wh-r-qty')?.value;
if (!borrow_order_no || !model || !qty) { API.toast('请填写所有必填项'); return; }
const res = await fetch('/api/warehouse/return-orders', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ borrow_order_no, model, qty: parseInt(qty) })
});
const data = await res.json();
if (data.ok) {
API.toast(`还入单 ${data.order_no} 已提交,等待仓管确认`);
closeModal();
renderTab();
} else {
API.toast(data.error || '提交失败');
}
};
// ── 拒绝弹窗 ────────────────────────────────
const showRejectModal = (type, orderNo) => {
const html = `
<div class="wh-modal-overlay" id="wh-modal">
<div class="wh-modal">
<div style="font-size:16px;font-weight:700;margin-bottom:20px;color:#ef4444">拒绝单据</div>
<div style="font-size:13px;color:var(--text-secondary);margin-bottom:16px">单号:${orderNo}</div>
<div class="wh-form-row"><label>拒绝原因 *</label><textarea id="wh-reject-reason" placeholder="请填写拒绝原因"></textarea></div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
<button class="wh-btn wh-btn-ghost" onclick="Warehouse.closeModal()">取消</button>
<button class="wh-btn wh-btn-danger" onclick="Warehouse.doReject('${type}','${orderNo}')">确认拒绝</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
};
const doReject = async (type, orderNo) => {
const reject_reason = document.getElementById('wh-reject-reason')?.value.trim();
if (!reject_reason) { API.toast('请填写拒绝原因'); return; }
const url = type === 'borrow'
? `/api/warehouse/borrow-orders/${orderNo}/confirm`
: `/api/warehouse/return-orders/${orderNo}/confirm`;
const res = await fetch(url, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reject', reject_reason })
});
const data = await res.json();
if (data.ok) { API.toast('已拒绝'); closeModal(); renderTab(); }
else API.toast(data.error || '操作失败');
};
// ── 批准操作 ────────────────────────────────
const confirmBorrow = async (orderNo, action) => {
const res = await fetch(`/api/warehouse/borrow-orders/${orderNo}/confirm`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
const data = await res.json();
if (data.ok) { API.toast('操作成功'); renderTab(); }
else API.toast(data.error || '操作失败');
};
const confirmReturn = async (orderNo, action) => {
const res = await fetch(`/api/warehouse/return-orders/${orderNo}/confirm`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
const data = await res.json();
if (data.ok) { API.toast('操作成功'); renderTab(); }
else API.toast(data.error || '操作失败');
};
const showNotReceivedModal = (orderNo, totalQty) => {
const html = `
<div class="wh-modal-overlay" id="wh-modal">
<div class="wh-modal">
<div style="font-size:16px;font-weight:700;margin-bottom:20px">退回还入单</div>
<div class="wh-form-row">
<label>还入单号</label>
<input value="${orderNo}" disabled style="background:var(--bg);color:var(--text-secondary)">
</div>
<div class="wh-form-row">
<label>还入申请数量</label>
<input value="${totalQty}" disabled style="background:var(--bg);color:var(--text-secondary)">
</div>
<div class="wh-form-row">
<label>退回备注(可选)</label>
<textarea id="wh-not-recv-note" placeholder="说明退回原因"></textarea>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
<button class="wh-btn wh-btn-ghost" onclick="Warehouse.closeModal()">取消</button>
<button class="wh-btn wh-btn-danger" onclick="Warehouse.submitNotReceived('${orderNo}',${totalQty})">确认退回</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
};
const submitNotReceived = async (orderNo, totalQty) => {
const note = document.getElementById('wh-not-recv-note')?.value || '';
const res = await fetch(`/api/warehouse/return-orders/${orderNo}/confirm`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reject', note })
});
const data = await res.json();
if (data.ok) { closeModal(); API.toast('已退回'); renderTab(); }
else API.toast(data.error || '操作失败');
};
const closeModal = () => {
document.getElementById('wh-modal')?.remove();
};
const renderPage = async (tab) => {
currentTab = tab;
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
currentUser = res.ok ? await res.json() : null;
} catch (e) { currentUser = null; }
return render();
};
return { render, renderPage, init, switchTab, showCreateBorrowModal, submitBorrow,
showCreateReturnModal, onBorrowSelect, submitReturn,
showRejectModal, doReject, confirmBorrow, confirmReturn,
showNotReceivedModal, submitNotReceived, closeModal };
})();
Router.register('/warehouse/borrow', async () => {
const html = await window.Warehouse.renderPage('borrow');
setTimeout(() => { window.Warehouse.init(); }, 0);
return html;
});
Router.register('/warehouse/return', async () => {
const html = await window.Warehouse.renderPage('return');
setTimeout(() => { window.Warehouse.init(); }, 0);
return html;
});