Files
ONE-OS/ONE-OS小程序/提车应收款-审批.jsx

1132 lines
43 KiB
JavaScript
Raw Permalink 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.
// 【重要】必须使用 const Component 作为组件变量名
// ONE-OS 小程序 - 提车应收款审批办理(参照 web 端提车应收款-查看,适配移动端)
const { useState, useMemo, useCallback, useRef, useEffect } = React;
const moment = window.moment || window.dayjs;
const COLOR_PRIMARY = '#16D1A1';
const COLOR_PRIMARY_DEEP = '#00BFA5';
const COLOR_PRIMARY_SOFT = 'rgba(22, 209, 161, 0.12)';
const COLOR_ACCENT = '#F97316';
const COLOR_ACCENT_DEEP = '#EA580C';
const COLOR_ACCENT_SOFT = 'rgba(249, 115, 22, 0.12)';
const COLOR_TEXT = '#1D2129';
const COLOR_TEXT_SEC = '#4E5969';
const COLOR_MUTED = '#86909C';
const COLOR_LINE = '#E5E6EB';
const COLOR_BG = '#FFFFFF';
const COLOR_PAGE = '#F2F3F5';
const COLOR_SUCCESS = '#00B42A';
const COLOR_DANGER = '#F53F3F';
const COLOR_WARN = '#FF7D00';
const FONT_FAMILY =
'-apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", STHeiti, sans-serif';
const resolveAntdBundle = () => {
const raw = window.antd;
if (!raw) return {};
if (raw.default && typeof raw.default === 'object') return { ...raw, ...raw.default };
return raw;
};
const antd = resolveAntdBundle();
const message = antd.message || { info: () => {}, success: () => {}, warning: () => {} };
const Drawer = antd.Drawer;
const Modal = antd.Modal;
const formatMoney = (val, withSymbol = true) => {
const n = parseFloat(val);
if (Number.isNaN(n)) return val || '—';
const s = n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return withSymbol ? `¥${s}` : s;
};
const formatYuan = (val) => {
const n = parseFloat(val);
if (Number.isNaN(n)) return '—';
return `${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`;
};
/** 根据审批任务构建详情 mock与 web 端字段对齐) */
const buildPickupDetail = (task) => {
const isShanghai = task?.bizNo === 'TC-2026-0312';
const projectInfo = isShanghai
? {
collectCode: 'HT-ZL-2025-088TC0001',
contractCode: 'HT-ZL-2025-088',
contractType: '正式合同',
projectName: '上海氢能城际物流项目',
customerName: '上海迅杰物流有限公司',
paymentMethod: '预付',
paymentCycle: '6个月',
contractStart: '2025-03-01',
contractEnd: '2026-02-28',
businessDept: '业务2部',
businessPerson: '李晓彤',
}
: {
collectCode: 'HT-ZL-2025-001TC0001',
contractCode: 'HT-ZL-2025-001',
contractType: '正式合同',
projectName: '嘉兴氢能示范项目',
customerName: '嘉兴某某物流有限公司',
paymentMethod: '预付',
paymentCycle: '6个月',
contractStart: '2025-01-15',
contractEnd: '2026-01-14',
businessDept: '业务1部',
businessPerson: '张经理',
};
const vehicles = isShanghai
? [
{
key: 'v1', index: 1, brand: '东风', model: 'DFH1180', plateNo: '沪A88123',
receivableRent: 62000, actualRent: '61200.00', rentRemark: '首期六期一次性付清',
receivableDeposit: 12000, receivableService: 880, actualService: '860.00',
discountAmount: '200.00', discountRemark: '首月优惠',
serviceItems: [
{ name: '代处理费用', receivable: 280, actual: '280.00', discount: '0.00', remark: '' },
{ name: '保险上浮', receivable: 600, actual: '580.00', discount: '20.00', remark: '客户协商' },
],
},
{
key: 'v2', index: 2, brand: '福田', model: 'BJ1180', plateNo: '沪A88234',
receivableRent: 58000, actualRent: '58000.00', rentRemark: '',
receivableDeposit: 10000, receivableService: 500, actualService: '500.00',
discountAmount: '0.00', discountRemark: '',
serviceItems: [{ name: '保养费用', receivable: 500, actual: '500.00', discount: '0.00', remark: '含首保' }],
},
{
key: 'v3', index: 3, brand: '重汽', model: 'ZZ1187', plateNo: '沪A88345',
receivableRent: 64000, actualRent: '63600.00', rentRemark: '按合同约定',
receivableDeposit: 12000, receivableService: 720, actualService: '700.00',
discountAmount: '150.00', discountRemark: '批量提车减免',
serviceItems: [
{ name: '代处理费用', receivable: 220, actual: '220.00', discount: '0.00', remark: '' },
{ name: '上牌服务', receivable: 500, actual: '480.00', discount: '20.00', remark: '已含上牌' },
],
},
]
: [
{
key: 'v1', index: 1, brand: '东风', model: 'DFH1180', plateNo: '浙A12345',
receivableRent: 30000, actualRent: '29800.00', rentRemark: '首期六期一次性付清',
receivableDeposit: 10000, receivableService: 700, actualService: '680.00',
discountAmount: '200.00', discountRemark: '首月优惠',
serviceItems: [
{ name: '代处理费用', receivable: 200, actual: '200.00', discount: '0.00', remark: '' },
{ name: '保险上浮', receivable: 500, actual: '480.00', discount: '20.00', remark: '客户协商' },
],
},
{
key: 'v2', index: 2, brand: '福田', model: 'BJ1180', plateNo: '浙A23456',
receivableRent: 27000, actualRent: '27000.00', rentRemark: '',
receivableDeposit: 8000, receivableService: 300, actualService: '300.00',
discountAmount: '0.00', discountRemark: '',
serviceItems: [{ name: '保养费用', receivable: 300, actual: '300.00', discount: '0.00', remark: '含首保' }],
},
];
const hasHydrogenPrepay = true;
const hydrogen = isShanghai
? { receivable: '4200.00', actual: '4100.00', discount: '100.00', discountRemark: '预付款批量减免' }
: { receivable: '3580.00', actual: '3500.00', discount: '80.00', discountRemark: '预付款批量减免' };
const approvalSteps = isShanghai
? [
{ title: '业务部主管', department: '业务2部', status: '已通过', person: '李晓彤', approveTime: '2026-05-30 10:30' },
{ title: '财务部', department: '财务部', status: '待审批', person: '张明辉', approveTime: '—' },
]
: [
{ title: '业务部主管', department: '业务1部', status: '已通过', person: '张经理', approveTime: '2026-02-28 09:30' },
{ title: '财务部', department: '财务部', status: '已通过', person: '李财务', approveTime: '2026-02-28 10:15' },
{ title: '事业部主管', department: '事业部', status: '已通过', person: '王总', approveTime: '2026-03-01 11:20' },
];
return {
projectInfo,
vehicles,
hasHydrogenPrepay,
hydrogen,
approvalSteps,
invoiceMethod: '先开票后付款',
invoiceRemark: isShanghai
? '增值税专用发票税率13%,开票项目:*现代服务*车辆租赁费;备注:上海氢能城际物流项目-提车首付款'
: '增值税专用发票税率13%,开票项目:*现代服务*车辆租赁费;备注:嘉兴氢能示范项目-提车首付款',
task: task || { bizNo: 'TC-2026-0312', status: '审批中' },
};
};
const calcTotals = (vehicles, hasHydrogenPrepay, hydrogen) => {
let receivableRent = 0;
let actualRent = 0;
let receivableDeposit = 0;
let receivableService = 0;
let actualService = 0;
let discountTotal = 0;
vehicles.forEach((v) => {
receivableRent += Number(v.receivableRent) || 0;
actualRent += parseFloat(v.actualRent) || 0;
receivableDeposit += Number(v.receivableDeposit) || 0;
receivableService += Number(v.receivableService) || 0;
actualService += parseFloat(v.actualService) || 0;
discountTotal += parseFloat(v.discountAmount) || 0;
});
const hydrogenReceivable = hasHydrogenPrepay ? (parseFloat(hydrogen.receivable) || 0) : 0;
const hydrogenActual = hasHydrogenPrepay ? (parseFloat(hydrogen.actual) || 0) : 0;
const receivableTotal = (
receivableRent + receivableDeposit + receivableService + hydrogenReceivable
).toFixed(2);
const actualTotal = (
actualRent + receivableDeposit + actualService - discountTotal + hydrogenActual
).toFixed(2);
return {
receivableRent: receivableRent.toFixed(2),
actualRent: actualRent.toFixed(2),
receivableDeposit: receivableDeposit.toFixed(2),
receivableService: receivableService.toFixed(2),
actualService: actualService.toFixed(2),
discountTotal: discountTotal.toFixed(2),
receivableTotal,
actualTotal,
};
};
const PAGE_STYLE = `
.tc-mini-root {
min-height: 100dvh;
background: linear-gradient(165deg, #e8ebef 0%, ${COLOR_PAGE} 40%);
display: flex;
justify-content: center;
padding: 16px 12px 32px;
box-sizing: border-box;
font-family: ${FONT_FAMILY};
-webkit-font-smoothing: antialiased;
}
.tc-phone {
width: 100%;
max-width: 390px;
min-height: 844px;
background: ${COLOR_PAGE};
border-radius: 28px;
overflow: hidden;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(15, 23, 42, 0.05);
display: flex;
flex-direction: column;
position: relative;
}
.tc-chrome { flex-shrink: 0; background: ${COLOR_BG}; }
.tc-status-bar {
height: 44px;
padding: 14px 24px 0;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
}
.tc-status-time { font-size: 15px; font-weight: 600; color: ${COLOR_TEXT}; }
.tc-mp-navbar {
height: 48px;
display: flex;
align-items: center;
padding: 0 8px 0 4px;
border-bottom: 1px solid rgba(0,0,0,.05);
position: relative;
background: ${COLOR_BG};
}
.tc-mp-back {
width: 40px;
height: 40px;
border: none;
background: transparent;
color: ${COLOR_TEXT};
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tc-mp-back:active { background: rgba(0,0,0,.05); }
.tc-mp-navbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 700;
color: ${COLOR_TEXT};
max-width: 56%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tc-scroll {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding-bottom: 88px;
}
.tc-hero {
margin: 12px 14px 0;
padding: 18px 16px 16px;
border-radius: 16px;
background: linear-gradient(135deg, ${COLOR_ACCENT} 0%, ${COLOR_ACCENT_DEEP} 100%);
color: #fff;
box-shadow: 0 10px 28px rgba(249, 115, 22, 0.35);
}
.tc-hero-label { font-size: 13px; opacity: 0.92; margin-bottom: 6px; }
.tc-hero-amount {
font-size: 36px;
font-weight: 800;
line-height: 1.1;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.tc-hero-sub {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255,255,255,.22);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tc-hero-compare { font-size: 12px; opacity: 0.9; line-height: 1.45; }
.tc-hero-detail-btn {
flex-shrink: 0;
min-height: 32px;
padding: 0 12px;
border: 1px solid rgba(255,255,255,.45);
border-radius: 999px;
background: rgba(255,255,255,.14);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.tc-hero-detail-btn:active { background: rgba(255,255,255,.24); }
.tc-hero-meta {
display: flex;
gap: 16px;
margin-top: 10px;
font-size: 12px;
opacity: 0.88;
}
.tc-section {
margin: 12px 14px 0;
background: ${COLOR_BG};
border-radius: 14px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
border: 1px solid rgba(0,0,0,.04);
}
.tc-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid ${COLOR_LINE};
}
.tc-section-title { font-size: 15px; font-weight: 700; color: ${COLOR_TEXT}; }
.tc-section-badge {
font-size: 11px;
font-weight: 600;
color: ${COLOR_ACCENT};
background: ${COLOR_ACCENT_SOFT};
padding: 2px 8px;
border-radius: 999px;
}
.tc-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 14px;
padding: 12px 14px 14px;
}
.tc-kv-item { min-width: 0; }
.tc-kv-label { font-size: 11px; color: ${COLOR_MUTED}; margin-bottom: 3px; }
.tc-kv-value {
font-size: 13px;
color: ${COLOR_TEXT};
font-weight: 500;
line-height: 1.4;
word-break: break-all;
}
.tc-kv-value--full { grid-column: 1 / -1; }
.tc-vehicle-card {
margin: 0 14px 10px;
padding: 12px 14px;
background: ${COLOR_PAGE};
border-radius: 12px;
border: 1px solid ${COLOR_LINE};
}
.tc-vehicle-card:last-child { margin-bottom: 14px; }
.tc-vehicle-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.tc-vehicle-plate {
font-size: 15px;
font-weight: 700;
color: ${COLOR_TEXT};
font-variant-numeric: tabular-nums;
}
.tc-vehicle-model { font-size: 12px; color: ${COLOR_MUTED}; margin-top: 2px; }
.tc-vehicle-idx {
font-size: 11px;
font-weight: 700;
color: ${COLOR_ACCENT};
background: ${COLOR_ACCENT_SOFT};
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
.tc-vehicle-amount-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
background: ${COLOR_BG};
border-radius: 10px;
margin-bottom: 8px;
}
.tc-vehicle-amount-label { font-size: 12px; color: ${COLOR_MUTED}; }
.tc-vehicle-amount-val {
font-size: 18px;
font-weight: 800;
color: ${COLOR_ACCENT};
font-variant-numeric: tabular-nums;
}
.tc-vehicle-kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
}
.tc-vehicle-kv-item { font-size: 12px; color: ${COLOR_TEXT_SEC}; }
.tc-vehicle-kv-item span { color: ${COLOR_MUTED}; }
.tc-service-toggle {
width: 100%;
margin-top: 8px;
min-height: 32px;
border: none;
background: transparent;
color: ${COLOR_PRIMARY_DEEP};
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-align: left;
padding: 0;
}
.tc-service-list {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed ${COLOR_LINE};
}
.tc-service-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 8px;
font-size: 12px;
padding: 6px 0;
border-bottom: 1px solid rgba(0,0,0,.04);
}
.tc-service-row:last-child { border-bottom: none; }
.tc-service-name { color: ${COLOR_TEXT}; font-weight: 500; }
.tc-service-amt { color: ${COLOR_ACCENT}; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
.tc-service-sub { grid-column: 1 / -1; color: ${COLOR_MUTED}; font-size: 11px; }
.tc-timeline { padding: 4px 14px 14px; }
.tc-step {
display: flex;
gap: 12px;
position: relative;
padding-bottom: 16px;
}
.tc-step:last-child { padding-bottom: 0; }
.tc-step:not(:last-child)::before {
content: '';
position: absolute;
left: 9px;
top: 22px;
bottom: 0;
width: 2px;
background: ${COLOR_LINE};
}
.tc-step-dot {
width: 20px;
height: 20px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
margin-top: 1px;
}
.tc-step-dot.done { background: ${COLOR_SUCCESS}; color: #fff; }
.tc-step-dot.wait { background: ${COLOR_ACCENT_SOFT}; color: ${COLOR_ACCENT}; border: 2px solid ${COLOR_ACCENT}; box-sizing: border-box; }
.tc-step-body { flex: 1; min-width: 0; }
.tc-step-title { font-size: 14px; font-weight: 600; color: ${COLOR_TEXT}; }
.tc-step-meta { font-size: 12px; color: ${COLOR_MUTED}; margin-top: 4px; line-height: 1.5; }
.tc-action-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
gap: 8px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(255,255,255,.96);
border-top: 1px solid ${COLOR_LINE};
backdrop-filter: blur(8px);
}
.tc-action-bar--view { justify-content: flex-end; }
.tc-btn {
flex: 1;
min-height: 44px;
border-radius: 12px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
touch-action: manipulation;
border: none;
}
.tc-btn-comment { flex: 0 0 auto; min-width: 64px; background: ${COLOR_BG}; color: ${COLOR_TEXT_SEC}; border: 1px solid ${COLOR_LINE}; }
.tc-btn-terminate { background: ${COLOR_PAGE}; color: ${COLOR_WARN}; border: 1px solid rgba(255, 125, 0, 0.3); }
.tc-btn-reject {
background: ${COLOR_PAGE};
color: ${COLOR_DANGER};
border: 1px solid rgba(245, 63, 63, 0.25);
}
.tc-btn-approve {
background: linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%);
color: #fff;
box-shadow: 0 4px 14px rgba(0, 191, 165, 0.3);
}
.tc-btn:active { opacity: 0.92; transform: scale(0.98); }
.tc-approval-form { display: flex; flex-direction: column; gap: 16px; padding: 4px 0 8px; }
.tc-form-field { display: flex; flex-direction: column; gap: 8px; }
.tc-form-label { font-size: 14px; color: ${COLOR_TEXT}; font-weight: 500; }
.tc-form-label-required::before { content: '*'; color: ${COLOR_DANGER}; margin-right: 4px; }
.tc-notify-group { display: flex; flex-wrap: wrap; gap: 16px; }
.tc-notify-item { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; color: ${COLOR_TEXT_SEC}; cursor: pointer; min-height: 32px; }
.tc-notify-item input { width: 16px; height: 16px; accent-color: ${COLOR_PRIMARY}; }
.tc-notify-item.disabled { opacity: 0.55; cursor: not-allowed; }
.tc-upload-btn {
display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px;
border: 1px dashed ${COLOR_LINE}; border-radius: 8px; background: ${COLOR_PAGE};
color: ${COLOR_TEXT_SEC}; font-size: 13px; cursor: pointer;
}
.tc-upload-hint { font-size: 12px; color: ${COLOR_MUTED}; line-height: 1.55; margin-top: 4px; }
.tc-file-list { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.tc-file-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; background: ${COLOR_PAGE}; border-radius: 8px; font-size: 13px; color: ${COLOR_TEXT}; }
.tc-file-remove { border: none; background: transparent; color: ${COLOR_MUTED}; font-size: 12px; cursor: pointer; padding: 4px; }
.tc-select-person {
min-height: 44px; padding: 0 14px; border: 1px solid ${COLOR_LINE}; border-radius: 10px;
background: ${COLOR_BG}; color: ${COLOR_MUTED}; font-size: 14px; text-align: left; cursor: pointer; width: 100%;
}
.tc-select-person.has-value { color: ${COLOR_TEXT}; }
.tc-cc-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.tc-cc-chip {
display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 999px;
background: ${COLOR_PRIMARY_SOFT}; color: ${COLOR_PRIMARY_DEEP}; font-size: 12px; font-weight: 600;
}
.tc-cc-chip button { border: none; background: transparent; color: inherit; cursor: pointer; padding: 0 2px; font-size: 14px; line-height: 1; }
.tc-textarea {
width: 100%; min-height: 96px; padding: 10px 12px; border: 1px solid ${COLOR_LINE}; border-radius: 10px;
font-size: 14px; color: ${COLOR_TEXT}; resize: vertical; box-sizing: border-box; font-family: inherit; outline: none;
}
.tc-textarea:focus { border-color: rgba(22, 209, 161, 0.55); box-shadow: 0 0 0 3px rgba(22, 209, 161, 0.12); }
.tc-textarea-counter { text-align: right; font-size: 12px; color: ${COLOR_MUTED}; margin-top: 4px; }
.tc-drawer-foot { display: flex; gap: 10px; padding-top: 12px; margin-top: 4px; border-top: 1px solid ${COLOR_LINE}; }
.tc-drawer-foot-btn { flex: 1; min-height: 44px; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; border: none; }
.tc-drawer-foot-cancel { background: ${COLOR_BG}; color: ${COLOR_TEXT_SEC}; border: 1px solid ${COLOR_LINE}; }
.tc-drawer-foot-confirm { background: #1677ff; color: #fff; }
.tc-person-list { display: flex; flex-direction: column; gap: 8px; }
.tc-person-item {
min-height: 48px; padding: 0 14px; border: 1px solid ${COLOR_LINE}; border-radius: 10px;
background: ${COLOR_BG}; font-size: 14px; color: ${COLOR_TEXT}; text-align: left; cursor: pointer;
}
.tc-person-item.selected { border-color: ${COLOR_PRIMARY}; background: ${COLOR_PRIMARY_SOFT}; color: ${COLOR_PRIMARY_DEEP}; font-weight: 600; }
.tc-drawer-rows { display: flex; flex-direction: column; gap: 0; }
.tc-drawer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid ${COLOR_LINE};
font-size: 14px;
}
.tc-drawer-row:last-child { border-bottom: none; }
.tc-drawer-row-label { color: ${COLOR_TEXT_SEC}; }
.tc-drawer-row-val { font-weight: 700; color: ${COLOR_TEXT}; font-variant-numeric: tabular-nums; }
.tc-drawer-row-val.highlight { color: ${COLOR_ACCENT}; font-size: 16px; }
.tc-drawer-total {
margin-top: 8px;
padding-top: 12px;
border-top: 2px solid ${COLOR_LINE};
display: flex;
justify-content: space-between;
font-size: 15px;
font-weight: 700;
}
.tc-drawer-total span:last-child { color: ${COLOR_ACCENT}; font-size: 18px; font-variant-numeric: tabular-nums; }
`;
const IconBack = () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
);
const InfoRow = ({ label, value, full }) => (
<div className={`tc-kv-item${full ? ' tc-kv-value--full' : ''}`}>
<div className="tc-kv-label">{label}</div>
<div className="tc-kv-value">{value || '—'}</div>
</div>
);
const VehicleCard = ({ vehicle }) => {
const [serviceOpen, setServiceOpen] = useState(false);
const vehicleActual = (
parseFloat(vehicle.actualRent || 0)
+ parseFloat(vehicle.receivableDeposit || 0)
+ parseFloat(vehicle.actualService || 0)
- parseFloat(vehicle.discountAmount || 0)
).toFixed(2);
return (
<div className="tc-vehicle-card">
<div className="tc-vehicle-head">
<div>
<div className="tc-vehicle-plate">{vehicle.plateNo || '待上牌'}</div>
<div className="tc-vehicle-model">{vehicle.brand} · {vehicle.model}</div>
</div>
<span className="tc-vehicle-idx">#{vehicle.index}</span>
</div>
<div className="tc-vehicle-amount-row">
<span className="tc-vehicle-amount-label">本车实收合计</span>
<span className="tc-vehicle-amount-val">{formatMoney(vehicleActual)}</span>
</div>
<div className="tc-vehicle-kv">
<div className="tc-vehicle-kv-item"><span>实收月租金 </span>{formatYuan(vehicle.actualRent)}</div>
<div className="tc-vehicle-kv-item"><span>应收保证金 </span>{formatYuan(vehicle.receivableDeposit)}</div>
<div className="tc-vehicle-kv-item"><span>实收服务费 </span>{formatYuan(vehicle.actualService)}</div>
<div className="tc-vehicle-kv-item"><span>减免金额 </span>{formatYuan(vehicle.discountAmount)}</div>
</div>
{vehicle.rentRemark ? (
<div style={{ fontSize: 12, color: COLOR_MUTED, marginTop: 8 }}>租金备注{vehicle.rentRemark}</div>
) : null}
{(vehicle.serviceItems || []).length > 0 && (
<>
<button type="button" className="tc-service-toggle" onClick={() => setServiceOpen((o) => !o)}>
{serviceOpen ? '收起' : '查看'}服务费明细{(vehicle.serviceItems || []).length}
</button>
{serviceOpen && (
<div className="tc-service-list">
{(vehicle.serviceItems || []).map((s, i) => (
<div className="tc-service-row" key={i}>
<span className="tc-service-name">{s.name}</span>
<span className="tc-service-amt">{formatMoney(s.actual)}</span>
<span className="tc-service-sub">
应收 {formatYuan(s.receivable)} · 减免 {formatYuan(s.discount)}{s.remark ? ` · ${s.remark}` : ''}
</span>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
const AmountBreakdownDrawer = ({ open, onClose, title, rows, totalLabel, totalValue, highlightTotal }) => (
Drawer ? (
<Drawer title={title} placement="bottom" height="auto" open={open} onClose={onClose} styles={{ body: { padding: '8px 20px 24px' } }}>
<div className="tc-drawer-rows">
{rows.map((r) => (
<div className="tc-drawer-row" key={r.label}>
<span className="tc-drawer-row-label">{r.label}</span>
<span className={`tc-drawer-row-val${r.highlight ? ' highlight' : ''}`}>{r.value}</span>
</div>
))}
<div className="tc-drawer-total">
<span>{totalLabel}</span>
<span>{totalValue}</span>
</div>
</div>
</Drawer>
) : null
);
const APPROVAL_DECISION_META = {
approve: { title: '审批通过', success: '审批已通过(原型)' },
reject: { title: '审批驳回', success: '已驳回(原型)' },
terminate: { title: '审批终止', success: '已终止(原型)' },
};
const MOCK_CC_CANDIDATES = ['李晓彤', '陈高伟', '王法务', '财务-赵敏'];
const UPLOAD_ACCEPT = '.png,.jpg,.jpeg,.doc,.docx,.xlsx,.xls,.ppt,.pdf';
const UPLOAD_HINT = '请上传不超过 20MB 的 png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf 格式文件';
const emptyDecisionForm = () => ({
notifyEmail: false,
notifySms: false,
ccUsers: [],
opinion: '',
attachments: [],
});
const ApprovalDecisionDrawer = ({ open, actionType, onClose, onConfirm }) => {
const meta = APPROVAL_DECISION_META[actionType] || APPROVAL_DECISION_META.approve;
const [form, setForm] = useState(emptyDecisionForm);
const [ccPickerOpen, setCcPickerOpen] = useState(false);
const fileInputRef = useRef(null);
useEffect(() => {
if (open) {
setForm(emptyDecisionForm());
setCcPickerOpen(false);
}
}, [open, actionType]);
const handleDrawerClose = () => {
if (ccPickerOpen) {
setCcPickerOpen(false);
return;
}
onClose();
};
const toggleCcUser = (name) => {
setForm((p) => ({
...p,
ccUsers: p.ccUsers.includes(name) ? p.ccUsers.filter((n) => n !== name) : [...p.ccUsers, name],
}));
};
const handleUpload = (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setForm((p) => ({
...p,
attachments: [
...p.attachments,
...files.map((f) => ({ id: `${f.name}-${Date.now()}`, name: f.name, size: f.size })),
],
}));
e.target.value = '';
};
const handleConfirm = () => {
onConfirm?.({ actionType, ...form, notifyInternal: true });
onClose();
};
if (!Drawer) return null;
return (
<Drawer
title={ccPickerOpen ? '选择抄送人' : meta.title}
placement="bottom"
height={ccPickerOpen ? 360 : 'auto'}
open={open}
onClose={handleDrawerClose}
destroyOnClose
zIndex={1000}
styles={{ body: { padding: '8px 16px 24px', maxHeight: ccPickerOpen ? undefined : '78vh', overflowY: 'auto' } }}
>
{ccPickerOpen ? (
<>
<div className="tc-person-list">
{MOCK_CC_CANDIDATES.map((name) => (
<button key={name} type="button" className={`tc-person-item${form.ccUsers.includes(name) ? ' selected' : ''}`} onClick={() => toggleCcUser(name)}>{name}</button>
))}
</div>
<div className="tc-drawer-foot">
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-cancel" onClick={() => setCcPickerOpen(false)}>返回</button>
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-confirm" onClick={() => setCcPickerOpen(false)}>确定</button>
</div>
</>
) : (
<>
<div className="tc-approval-form">
<div className="tc-form-field">
<div className="tc-form-label">通知方式</div>
<div className="tc-notify-group">
<label className="tc-notify-item disabled">
<input type="checkbox" checked disabled readOnly />站内信
</label>
<label className="tc-notify-item">
<input type="checkbox" checked={form.notifyEmail} onChange={(e) => setForm((p) => ({ ...p, notifyEmail: e.target.checked }))} />
邮件
</label>
<label className="tc-notify-item">
<input type="checkbox" checked={form.notifySms} onChange={(e) => setForm((p) => ({ ...p, notifySms: e.target.checked }))} />
短信
</label>
</div>
</div>
<div className="tc-form-field">
<div className="tc-form-label">附件上传</div>
<button type="button" className="tc-upload-btn" onClick={() => fileInputRef.current?.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
上传
</button>
<input ref={fileInputRef} type="file" accept={UPLOAD_ACCEPT} multiple style={{ display: 'none' }} onChange={handleUpload} />
<div className="tc-upload-hint">{UPLOAD_HINT}</div>
{form.attachments.length > 0 && (
<div className="tc-file-list">
{form.attachments.map((f) => (
<div className="tc-file-item" key={f.id}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<button type="button" className="tc-file-remove" onClick={() => setForm((p) => ({ ...p, attachments: p.attachments.filter((a) => a.id !== f.id) }))}>删除</button>
</div>
))}
</div>
)}
</div>
<div className="tc-form-field">
<div className="tc-form-label">抄送人</div>
<button type="button" className={`tc-select-person${form.ccUsers.length ? ' has-value' : ''}`} onClick={() => setCcPickerOpen(true)}>
{form.ccUsers.length ? `已选 ${form.ccUsers.length}` : '选择人员'}
</button>
{form.ccUsers.length > 0 && (
<div className="tc-cc-chips">
{form.ccUsers.map((name) => (
<span className="tc-cc-chip" key={name}>{name}<button type="button" onClick={() => toggleCcUser(name)}>×</button></span>
))}
</div>
)}
</div>
<div className="tc-form-field">
<div className="tc-form-label">审批意见</div>
<textarea className="tc-textarea" placeholder="请输入" value={form.opinion} onChange={(e) => setForm((p) => ({ ...p, opinion: e.target.value }))} maxLength={500} />
</div>
</div>
<div className="tc-drawer-foot">
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-cancel" onClick={onClose}>取消</button>
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-confirm" onClick={handleConfirm}>确认</button>
</div>
</>
)}
</Drawer>
);
};
const ApprovalCommentDrawer = ({ open, onClose, onConfirm }) => {
const [content, setContent] = useState('');
useEffect(() => { if (open) setContent(''); }, [open]);
const handleConfirm = () => {
if (!content.trim()) { message.warning('请输入评论内容'); return; }
onConfirm?.({ content: content.trim() });
onClose();
};
if (!Drawer) return null;
return (
<Drawer title="添加评论" placement="bottom" height="auto" open={open} onClose={onClose} destroyOnClose zIndex={1000} styles={{ body: { padding: '8px 16px 24px' } }}>
<div className="tc-form-field">
<div className="tc-form-label tc-form-label-required">评论内容</div>
<textarea className="tc-textarea" placeholder="请输入评论内容" value={content} onChange={(e) => setContent(e.target.value.slice(0, 500))} maxLength={500} />
<div className="tc-textarea-counter">{content.length} / 500</div>
</div>
<div className="tc-drawer-foot">
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-cancel" onClick={onClose}>取消</button>
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-confirm" onClick={handleConfirm}>确认</button>
</div>
</Drawer>
);
};
const ApprovalActionBar = ({ mode, onComment, onTerminate, onReject, onApprove }) => {
if (mode === 'approve') {
return (
<div className="tc-action-bar">
<button type="button" className="tc-btn tc-btn-comment" onClick={onComment}>评论</button>
<button type="button" className="tc-btn tc-btn-terminate" onClick={onTerminate}>终止</button>
<button type="button" className="tc-btn tc-btn-reject" onClick={onReject}>驳回</button>
<button type="button" className="tc-btn tc-btn-approve" onClick={onApprove}>通过</button>
</div>
);
}
return (
<div className="tc-action-bar tc-action-bar--view">
<button type="button" className="tc-btn tc-btn-comment" style={{ flex: '0 0 88px' }} onClick={onComment}>评论</button>
</div>
);
};
/** 可被审批中心内嵌调用的详情视图 */
const PickupReceivableApproveView = ({ task, mode = 'approve', onBack }) => {
const detail = useMemo(() => buildPickupDetail(task), [task]);
const { projectInfo, vehicles, hasHydrogenPrepay, hydrogen, approvalSteps, invoiceMethod, invoiceRemark } = detail;
const totals = useMemo(
() => calcTotals(vehicles, hasHydrogenPrepay, hydrogen),
[vehicles, hasHydrogenPrepay, hydrogen]
);
const [actualDrawerOpen, setActualDrawerOpen] = useState(false);
const [receivableDrawerOpen, setReceivableDrawerOpen] = useState(false);
const [decisionOpen, setDecisionOpen] = useState(false);
const [decisionType, setDecisionType] = useState('approve');
const [commentOpen, setCommentOpen] = useState(false);
const showActions = mode === 'approve' || mode === 'view';
const displayActualTotal = task?.actualAmount || totals.actualTotal;
const actualBreakdownRows = useMemo(() => {
const rows = [
{ label: '总计实收车辆月租金', value: formatYuan(totals.actualRent) },
{ label: '总计应收车辆保证金', value: formatYuan(totals.receivableDeposit) },
{ label: '总计实收服务费', value: formatYuan(totals.actualService) },
{ label: '总计减免金额', value: `- ${formatYuan(totals.discountTotal)}` },
];
if (hasHydrogenPrepay) {
rows.push({ label: '氢费预充值实收金额', value: formatYuan(hydrogen.actual), highlight: true });
}
return rows;
}, [totals, hasHydrogenPrepay, hydrogen.actual]);
const receivableBreakdownRows = useMemo(() => {
const rows = [
{ label: '总计应收车辆月租金', value: formatYuan(totals.receivableRent) },
{ label: '总计应收车辆保证金', value: formatYuan(totals.receivableDeposit) },
{ label: '总计应收服务费', value: formatYuan(totals.receivableService) },
];
if (hasHydrogenPrepay) {
rows.push({ label: '氢费预充值应收金额', value: formatYuan(hydrogen.receivable) });
}
return rows;
}, [totals, hasHydrogenPrepay, hydrogen.receivable]);
const openDecision = (type) => {
setDecisionType(type);
setDecisionOpen(true);
};
const handleDecisionConfirm = (payload) => {
const meta = APPROVAL_DECISION_META[payload.actionType];
if (payload.actionType === 'reject' || payload.actionType === 'terminate') {
message.warning(meta?.success || '操作成功(原型)');
} else {
message.success(meta?.success || '操作成功(原型)');
}
if (mode === 'approve') onBack?.();
};
const handleCommentConfirm = () => {
message.success('评论已添加(原型)');
};
const diff = (parseFloat(totals.receivableTotal) - parseFloat(displayActualTotal)).toFixed(2);
return (
<>
<div className="tc-scroll">
<div className="tc-hero">
<div className="tc-hero-label">实收款总额</div>
<div className="tc-hero-amount">{formatMoney(displayActualTotal)}</div>
<div className="tc-hero-meta">
<span>{projectInfo.customerName}</span>
<span>{vehicles.length} 台车</span>
</div>
<div className="tc-hero-sub">
<div className="tc-hero-compare">
应收款总额 {formatMoney(totals.receivableTotal)}
<br />
较应收减免 {formatMoney(diff)}
</div>
<button type="button" className="tc-hero-detail-btn" onClick={() => setActualDrawerOpen(true)}>
实收明细
</button>
</div>
</div>
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">项目信息</span>
</div>
<div className="tc-kv-grid">
<InfoRow label="提车收款单编码" value={projectInfo.collectCode} full />
<InfoRow label="合同编码" value={projectInfo.contractCode} />
<InfoRow label="合同类型" value={projectInfo.contractType} />
<InfoRow label="项目名称" value={projectInfo.projectName} full />
<InfoRow label="客户企业名称" value={projectInfo.customerName} full />
<InfoRow label="付款方式" value={projectInfo.paymentMethod} />
<InfoRow label="付款周期" value={projectInfo.paymentCycle} />
<InfoRow label="合同生效" value={projectInfo.contractStart} />
<InfoRow label="合同结束" value={projectInfo.contractEnd} />
<InfoRow label="业务部门" value={projectInfo.businessDept} />
<InfoRow label="业务负责人" value={projectInfo.businessPerson} />
</div>
</div>
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">提车应收款 · 车辆明细</span>
<span className="tc-section-badge">{vehicles.length} </span>
</div>
{vehicles.map((v) => (
<VehicleCard key={v.key} vehicle={v} />
))}
</div>
{hasHydrogenPrepay && (
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">氢费预付款</span>
</div>
<div className="tc-kv-grid">
<InfoRow label="应收金额" value={formatYuan(hydrogen.receivable)} />
<InfoRow label="实收金额" value={formatYuan(hydrogen.actual)} />
<InfoRow label="减免金额" value={formatYuan(hydrogen.discount)} />
<InfoRow label="减免备注" value={hydrogen.discountRemark} full />
</div>
</div>
)}
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">开票信息</span>
</div>
<div className="tc-kv-grid">
<InfoRow label="开票方式" value={invoiceMethod} />
<InfoRow label="开票备注" value={invoiceRemark} full />
</div>
</div>
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">审批情况</span>
</div>
<div className="tc-timeline">
{approvalSteps.map((step, idx) => (
<div className="tc-step" key={idx}>
<div className={`tc-step-dot ${step.status === '已通过' ? 'done' : 'wait'}`}>
{step.status === '已通过' ? '✓' : ''}
</div>
<div className="tc-step-body">
<div className="tc-step-title">{step.department || step.title}</div>
<div className="tc-step-meta">
状态{step.status}<br />
审批人{step.person}<br />
时间{step.approveTime}
</div>
</div>
</div>
))}
</div>
</div>
<div style={{ height: showActions ? 16 : 24 }} />
</div>
{showActions && !decisionOpen && !commentOpen && (
<ApprovalActionBar
mode={mode}
onComment={() => setCommentOpen(true)}
onTerminate={() => openDecision('terminate')}
onReject={() => openDecision('reject')}
onApprove={() => openDecision('approve')}
/>
)}
<ApprovalDecisionDrawer
open={decisionOpen}
actionType={decisionType}
onClose={() => setDecisionOpen(false)}
onConfirm={handleDecisionConfirm}
/>
<ApprovalCommentDrawer
open={commentOpen}
onClose={() => setCommentOpen(false)}
onConfirm={handleCommentConfirm}
/>
<AmountBreakdownDrawer
open={actualDrawerOpen}
onClose={() => setActualDrawerOpen(false)}
title="实收款明细"
rows={actualBreakdownRows}
totalLabel="实收款总额"
totalValue={formatMoney(displayActualTotal)}
highlightTotal
/>
<AmountBreakdownDrawer
open={receivableDrawerOpen}
onClose={() => setReceivableDrawerOpen(false)}
title="应收款明细"
rows={receivableBreakdownRows}
totalLabel="应收款总额"
totalValue={formatMoney(totals.receivableTotal)}
/>
</>
);
};
const MiniProgramChrome = ({ title, showBack, onBack }) => {
const time = moment ? moment().format('HH:mm') : '9:41';
return (
<div className="tc-chrome">
<div className="tc-status-bar">
<span className="tc-status-time">{time}</span>
</div>
<div className="tc-mp-navbar">
{showBack ? (
<button type="button" className="tc-mp-back" onClick={onBack} aria-label="返回">
<IconBack />
</button>
) : (
<span style={{ width: 40, flexShrink: 0 }} />
)}
<span className="tc-mp-navbar-title">{title}</span>
</div>
</div>
);
};
const DEFAULT_TASK = {
id: 'ap-2',
flowType: '提车应收款',
bizNo: 'TC-2026-0312',
customerName: '上海迅杰物流有限公司',
projectName: '上海氢能城际物流项目',
vehicleCount: 3,
actualAmount: '186800.00',
status: '审批中',
};
const Component = function PickupReceivableApprovePage() {
const task = window.__pickupApproveTask || DEFAULT_TASK;
const mode = window.__pickupApproveMode || 'approve';
const handleBack = () => {
if (window.__pickupApproveBack) window.__pickupApproveBack();
else message.info('返回审批中心(原型)');
};
return (
<div className="tc-mini-root">
<style>{PAGE_STYLE}</style>
<div className="tc-phone">
<MiniProgramChrome title="提车应收款审批" showBack onBack={handleBack} />
<PickupReceivableApproveView task={task} mode={mode} onBack={handleBack} />
</div>
</div>
);
};
if (typeof window !== 'undefined') {
window.Component = Component;
window.PickupReceivableApproveView = PickupReceivableApproveView;
window.buildPickupDetail = buildPickupDetail;
window.__tcPickupStyles = PAGE_STYLE;
}
export default Component;