719 lines
24 KiB
TypeScript
719 lines
24 KiB
TypeScript
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||
import {
|
||
Table,
|
||
Modal,
|
||
Form,
|
||
DatePicker,
|
||
Space,
|
||
Tag,
|
||
Descriptions,
|
||
Popconfirm,
|
||
Input,
|
||
Select,
|
||
Tooltip,
|
||
Spin,
|
||
Empty,
|
||
} from 'antd';
|
||
import {
|
||
FileTextOutlined,
|
||
DeleteOutlined,
|
||
DownloadOutlined,
|
||
FilePdfOutlined,
|
||
} from '@ant-design/icons';
|
||
import dayjs from 'dayjs';
|
||
import api from '../../api';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
import { downloadBlob } from '../../utils/download';
|
||
import { message } from '../../ui/app-message';
|
||
|
||
const statusMap: Record<string, { text: string; color: string }> = {
|
||
draft: { text: '草稿', color: 'default' },
|
||
paid: { text: '已支付', color: 'green' },
|
||
};
|
||
|
||
const typeMap: Record<string, string> = {
|
||
water: '水费',
|
||
electricity: '电费',
|
||
cleaning: '保洁费',
|
||
rent: '租金',
|
||
damage: '损坏赔偿',
|
||
penalty: '罚款',
|
||
other: '其他',
|
||
};
|
||
|
||
const escapeHtml = (value: unknown) =>
|
||
String(value ?? '')
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
|
||
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;
|
||
|
||
const buildBillPrintHtml = (bill: any) => {
|
||
const studentName = bill.student?.name || '-';
|
||
const status = statusMap[bill.status]?.text || bill.status || '-';
|
||
const generatedAt = bill.generatedAt
|
||
? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm')
|
||
: dayjs().format('YYYY-MM-DD HH:mm');
|
||
const hasDeposit = Number(bill.availableDeposit || 0) > 0;
|
||
const depositDeducted = Number(bill.depositDeductedAmount || 0);
|
||
const items = bill.items || [];
|
||
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)}</title>
|
||
<style>
|
||
@page { size: A4; margin: 0; }
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
color: #000;
|
||
background: #f5f5f5;
|
||
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
|
||
-webkit-print-color-adjust: exact;
|
||
print-color-adjust: exact;
|
||
}
|
||
.page {
|
||
width: 210mm;
|
||
min-height: 297mm;
|
||
margin: 0 auto;
|
||
padding: 50px;
|
||
background: #fff;
|
||
}
|
||
h1 { margin: 0; text-align: center; font-size: 20px; line-height: 1.35; font-weight: 700; }
|
||
.generated-time { margin-top: 8px; text-align: center; color: #666; font-size: 10px; }
|
||
.basic-info { margin-top: 24px; font-size: 12px; line-height: 1.8; }
|
||
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
|
||
.amount-summary { font-size: 12px; line-height: 1.75; }
|
||
.total { color: #007aff; font-size: 14px; font-weight: 700; }
|
||
.deposit { color: #52c41a; font-size: 11px; }
|
||
.deposit-deducted { color: #fa8c16; font-size: 11px; }
|
||
table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; }
|
||
th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; font-size: 9px; line-height: 1.45; text-align: left; vertical-align: top; word-break: break-word; }
|
||
th { color: #333; font-weight: 700; }
|
||
td.amount, th.amount { text-align: right; white-space: nowrap; }
|
||
.footer { margin-top: 34px; text-align: center; color: #999; font-size: 8px; }
|
||
.print-actions {
|
||
position: fixed;
|
||
right: 18px;
|
||
top: 18px;
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.print-actions button {
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
border: 1px solid #1f6feb;
|
||
border-radius: 4px;
|
||
background: #1f6feb;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
}
|
||
@media print {
|
||
body { background: #fff; }
|
||
.page { margin: 0; }
|
||
.print-actions { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="print-actions">
|
||
<button onclick="window.print()">打印 / 另存为 PDF</button>
|
||
</div>
|
||
<main class="page">
|
||
<h1>恭学教育基地水电费账单</h1>
|
||
<div class="generated-time">生成时间: ${escapeHtml(generatedAt)}</div>
|
||
|
||
<div class="basic-info">
|
||
<div>学生姓名: ${escapeHtml(studentName)}</div>
|
||
<div>计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}</div>
|
||
<div>账单状态: ${escapeHtml(status)}</div>
|
||
</div>
|
||
|
||
<section>
|
||
<div class="section-title">费用汇总</div>
|
||
<div class="amount-summary">
|
||
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
|
||
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
|
||
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
|
||
${
|
||
hasDeposit || depositDeducted > 0
|
||
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
|
||
${depositDeducted > 0 ? `<div class="deposit-deducted">已扣押金: -${escapeHtml(money(depositDeducted))}</div>` : ''}`
|
||
: ''
|
||
}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<div class="section-title">费用明细</div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 22%;">费用类型</th>
|
||
<th>说明</th>
|
||
<th style="width: 11%;">天数</th>
|
||
<th style="width: 12%;">总人天</th>
|
||
<th class="amount" style="width: 16%;">金额(元)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${
|
||
items.length
|
||
? items
|
||
.map(
|
||
(item: any) => `<tr>
|
||
<td>${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')}</td>
|
||
<td>${escapeHtml(item.description || '-')}</td>
|
||
<td>${escapeHtml(item.days || 0)}</td>
|
||
<td>${escapeHtml(item.totalRoomDays || 0)}</td>
|
||
<td class="amount">${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}</td>
|
||
</tr>`,
|
||
)
|
||
.join('')
|
||
: '<tr><td colspan="5" style="text-align:center; color:#999;">暂无费用明细</td></tr>'
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<div class="footer">
|
||
本账单由恭学教育基地管理系统自动生成
|
||
</div>
|
||
</main>
|
||
<script>
|
||
window.addEventListener('load', () => {
|
||
setTimeout(() => window.print(), 250);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
};
|
||
|
||
const BillsPage: React.FC = () => {
|
||
const [bills, setBills] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [generateModal, setGenerateModal] = useState(false);
|
||
const [detailModal, setDetailModal] = useState<any>(null);
|
||
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||
const [searchText, setSearchText] = useState('');
|
||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||
const [filterExpenseType, setFilterExpenseType] = useState<string | undefined>(undefined);
|
||
const [generateForm] = Form.useForm();
|
||
const [saving, setSaving] = useState(false);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [batchLoading, setBatchLoading] = useState(false);
|
||
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: Record<string, string | undefined> = {};
|
||
if (filterStatus) params.status = filterStatus;
|
||
if (filterExpenseType) params.expenseType = filterExpenseType;
|
||
const res = await api.get('/bills', { params }) as unknown[];
|
||
setBills(res);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载失败,请稍后重试');
|
||
}
|
||
setLoading(false);
|
||
}, [filterStatus, filterExpenseType]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
const filteredBills = useMemo(() => {
|
||
return bills.filter((b: any) => {
|
||
if (searchText) {
|
||
const s = searchText.toLowerCase();
|
||
const matchName = b.student?.name?.toLowerCase().includes(s);
|
||
const matchPeriod = `${b.periodStart} ~ ${b.periodEnd}`.includes(s);
|
||
if (!matchName && !matchPeriod) return false;
|
||
}
|
||
if (filterStatus && b.status !== filterStatus) return false;
|
||
return true;
|
||
});
|
||
}, [bills, searchText, filterStatus]);
|
||
|
||
const selectedBillRows = useMemo(
|
||
() => bills.filter((bill: any) => selectedRows.includes(bill.id)),
|
||
[bills, selectedRows],
|
||
);
|
||
const canBatchConfirm =
|
||
selectedBillRows.length > 0
|
||
&& selectedBillRows.every((bill: any) => bill.status === 'draft' && bill.depositSufficient);
|
||
const canBatchDelete =
|
||
selectedBillRows.length > 0 && selectedBillRows.every((bill: any) => bill.status !== 'paid');
|
||
|
||
const handleGenerate = async () => {
|
||
if (saving) return;
|
||
|
||
try {
|
||
const values = await generateForm.validateFields();
|
||
setSaving(true);
|
||
const res: any = await api.post('/bills/generate', {
|
||
billingMonth: values.billingMonth.format('YYYY-MM'),
|
||
});
|
||
message.success(res.message || '生成成功');
|
||
setGenerateModal(false);
|
||
generateForm.resetFields();
|
||
void fetchData();
|
||
} catch (e: any) {
|
||
// Ant Design 的表单校验失败会 reject;字段本身已展示错误,无需再弹“生成失败”。
|
||
if (!e?.errorFields) {
|
||
message.error(e?.message || '生成失败');
|
||
}
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const showDetail = async (id: number) => {
|
||
setDetailLoading(true);
|
||
try {
|
||
const res = await api.get(`/bills/${id}`);
|
||
setDetailModal(res);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载失败,请稍后重试');
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
};
|
||
|
||
const updateStatus = async (id: number, status: string) => {
|
||
try {
|
||
await api.put(`/bills/${id}/status`, { status });
|
||
message.success('账单已确认支付,押金已自动扣除');
|
||
fetchData();
|
||
if (detailModal?.id === id) {
|
||
void showDetail(id);
|
||
}
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败');
|
||
}
|
||
};
|
||
|
||
const batchUpdateStatus = async (status: string) => {
|
||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
||
if (batchLoading) return;
|
||
setBatchLoading(true);
|
||
try {
|
||
await api.put('/bills/batch/status', { ids: selectedRows, status });
|
||
message.success(`已确认支付 ${selectedRows.length} 条账单,并自动扣除押金`);
|
||
setSelectedRows([]);
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败');
|
||
} finally {
|
||
setBatchLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await api.delete(`/bills/${id}`);
|
||
message.success('账单已删除');
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '删除失败');
|
||
}
|
||
};
|
||
|
||
const batchDelete = async () => {
|
||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
||
if (batchLoading) return;
|
||
setBatchLoading(true);
|
||
try {
|
||
await api.post('/bills/batch/delete', { ids: selectedRows });
|
||
message.success(`已删除 ${selectedRows.length} 条账单`);
|
||
setSelectedRows([]);
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败');
|
||
} finally {
|
||
setBatchLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleExportExcel = () => {
|
||
downloadBlob('/bills/export/excel', `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`).then(
|
||
() => message.success('Excel 导出成功'),
|
||
() => message.error('导出失败'),
|
||
);
|
||
};
|
||
|
||
const handleExportPdf = useCallback(async (billId: number) => {
|
||
const printWindow = window.open('', '_blank');
|
||
if (!printWindow) {
|
||
message.error('无法打开打印窗口,请允许浏览器弹窗后重试');
|
||
return;
|
||
}
|
||
|
||
printWindow.document.write('<!doctype html><title>账单加载中</title><body>账单加载中...</body>');
|
||
try {
|
||
const bill = await api.get(`/bills/${billId}`);
|
||
printWindow.document.open();
|
||
printWindow.document.write(buildBillPrintHtml(bill));
|
||
printWindow.document.close();
|
||
} catch (e: any) {
|
||
printWindow.close();
|
||
message.error(e?.message || '账单数据加载失败');
|
||
}
|
||
}, []);
|
||
|
||
const columns = useMemo(() => [
|
||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||
{ title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
|
||
{
|
||
title: '分摊费用',
|
||
dataIndex: 'sharedAmount',
|
||
width: 120,
|
||
align: 'right' as const,
|
||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '个人费用',
|
||
dataIndex: 'personalAmount',
|
||
width: 120,
|
||
align: 'right' as const,
|
||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '总计',
|
||
dataIndex: 'totalAmount',
|
||
width: 100,
|
||
align: 'right' as const,
|
||
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||
},
|
||
{
|
||
title: '可用押金',
|
||
dataIndex: 'availableDeposit',
|
||
width: 120,
|
||
render: (v: number) =>
|
||
v > 0 ? (
|
||
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
|
||
) : (
|
||
<span style={{ color: '#999' }}>-</span>
|
||
),
|
||
},
|
||
{
|
||
title: '已扣押金',
|
||
dataIndex: 'depositDeductedAmount',
|
||
width: 120,
|
||
render: (v: number) =>
|
||
Number(v || 0) > 0 ? (
|
||
<span style={{ color: '#fa8c16' }}>¥{Number(v).toFixed(2)}</span>
|
||
) : (
|
||
<span style={{ color: '#999' }}>-</span>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 90,
|
||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag>,
|
||
},
|
||
{
|
||
title: '生成时间',
|
||
dataIndex: 'generatedAt',
|
||
width: 160,
|
||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 320,
|
||
render: (_: any, record: any) => (
|
||
<Space>
|
||
<PermissionButton
|
||
permission="bill:view"
|
||
size="small"
|
||
type="link"
|
||
onClick={() => showDetail(record.id)}
|
||
>
|
||
详情
|
||
</PermissionButton>
|
||
{record.status === 'draft' && (
|
||
<Tooltip
|
||
title={
|
||
record.depositSufficient
|
||
? '确认后将自动从该学生押金余额中扣除账单金额'
|
||
: '押金不足,请先到押金管理收取押金'
|
||
}
|
||
>
|
||
<PermissionButton
|
||
permission="bill:confirm"
|
||
size="small"
|
||
type="primary"
|
||
disabled={!record.depositSufficient}
|
||
onClick={() => updateStatus(record.id, 'paid')}
|
||
>
|
||
确认支付
|
||
</PermissionButton>
|
||
</Tooltip>
|
||
)}
|
||
<PermissionButton
|
||
permission="bill:export-pdf"
|
||
size="small"
|
||
icon={<FilePdfOutlined />}
|
||
onClick={() => handleExportPdf(record.id)}
|
||
>
|
||
PDF
|
||
</PermissionButton>
|
||
<Popconfirm
|
||
title="确定删除此账单?"
|
||
onConfirm={() => handleDelete(record.id)}
|
||
okText="删除"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton
|
||
permission="bill:delete"
|
||
size="small"
|
||
danger
|
||
disabled={record.status === 'paid'}
|
||
icon={<DeleteOutlined />}
|
||
>
|
||
删除
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
], [showDetail, updateStatus, handleDelete, handleExportPdf]);
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
style={{
|
||
marginBottom: 16,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<Space wrap>
|
||
<Input.Search
|
||
placeholder="搜索学生姓名或账单周期"
|
||
allowClear
|
||
style={{ width: 220 }}
|
||
onSearch={(v) => setSearchText(v)}
|
||
onChange={(e) => {
|
||
if (!e.target.value) setSearchText('');
|
||
}}
|
||
/>
|
||
<Select
|
||
placeholder="状态筛选"
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
value={filterStatus}
|
||
onChange={(v) => setFilterStatus(v)}
|
||
options={[
|
||
{ value: 'draft', label: '草稿' },
|
||
{ value: 'paid', label: '已支付' },
|
||
]}
|
||
/>
|
||
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
|
||
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
|
||
<PermissionButton
|
||
permission="bill:confirm"
|
||
type="primary"
|
||
onClick={() => batchUpdateStatus('paid')}
|
||
disabled={!canBatchConfirm}
|
||
>
|
||
批量确认支付
|
||
</PermissionButton>
|
||
<Popconfirm
|
||
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
|
||
onConfirm={batchDelete}
|
||
okText="删除"
|
||
cancelText="取消"
|
||
disabled={!canBatchDelete}
|
||
>
|
||
<PermissionButton
|
||
permission="bill:delete"
|
||
danger
|
||
disabled={!canBatchDelete}
|
||
icon={<DeleteOutlined />}
|
||
>
|
||
批量删除
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
</Space>
|
||
<Space>
|
||
<PermissionButton
|
||
permission="bill:generate"
|
||
type="primary"
|
||
icon={<FileTextOutlined />}
|
||
onClick={() => {
|
||
generateForm.resetFields();
|
||
setGenerateModal(true);
|
||
}}
|
||
>
|
||
生成账单
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="bill:export-excel"
|
||
icon={<DownloadOutlined />}
|
||
onClick={handleExportExcel}
|
||
>
|
||
导出Excel
|
||
</PermissionButton>
|
||
</Space>
|
||
</div>
|
||
<Table
|
||
scroll={{ x: 1400 }}
|
||
columns={columns}
|
||
dataSource={filteredBills}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={{
|
||
defaultPageSize: 15,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [15, 30, 50, 100],
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
}}
|
||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||
rowSelection={{
|
||
selectedRowKeys: selectedRows,
|
||
onChange: (keys) => setSelectedRows(keys as number[]),
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
title="生成账单"
|
||
open={generateModal}
|
||
onOk={handleGenerate}
|
||
onCancel={() => setGenerateModal(false)}
|
||
okText="生成"
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={generateForm} layout="vertical">
|
||
<Form.Item
|
||
name="billingMonth"
|
||
label="账单月份"
|
||
rules={[{ required: true, message: '请选择账单月份' }]}
|
||
extra="只能选择已结束月份,每个月只能生成一次账单"
|
||
>
|
||
<DatePicker
|
||
style={{ width: '100%' }}
|
||
picker="month"
|
||
placeholder="选择月份"
|
||
format="YYYY-MM"
|
||
disabledDate={(current) =>
|
||
!!current && !current.endOf('month').isBefore(dayjs(), 'day')
|
||
}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="账单详情"
|
||
open={!!detailModal}
|
||
onCancel={() => setDetailModal(null)}
|
||
footer={null}
|
||
width={800}
|
||
>
|
||
{detailModal && (
|
||
<Spin spinning={detailLoading}>
|
||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={statusMap[detailModal.status]?.color}>
|
||
{statusMap[detailModal.status]?.text}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="账单周期">
|
||
{detailModal.periodStart} ~ {detailModal.periodEnd}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="生成时间">
|
||
{dayjs(detailModal.generatedAt).format('YYYY-MM-DD HH:mm')}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="分摊费用">
|
||
¥{Number(detailModal.sharedAmount).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="个人费用">
|
||
¥{Number(detailModal.personalAmount).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="合计" span={2}>
|
||
<strong style={{ fontSize: 18, color: '#007AFF' }}>
|
||
¥{Number(detailModal.totalAmount).toFixed(2)}
|
||
</strong>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
{(Number(detailModal.availableDeposit || 0) > 0
|
||
|| Number(detailModal.depositDeductedAmount || 0) > 0
|
||
|| detailModal.status === 'draft') && (
|
||
<div
|
||
style={{
|
||
marginBottom: 16,
|
||
padding: 12,
|
||
background: detailModal.depositSufficient || detailModal.status === 'paid' ? '#f6ffed' : '#fff2f0',
|
||
border: `1px solid ${detailModal.depositSufficient || detailModal.status === 'paid' ? '#b7eb8f' : '#ffccc7'}`,
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
|
||
押金联动(确认支付时实时扣除,不在生成账单时冻结)
|
||
</div>
|
||
<Space size={24} wrap>
|
||
<span>
|
||
当前可用押金:
|
||
<strong style={{ color: '#52c41a' }}>
|
||
¥{Number(detailModal.availableDeposit).toFixed(2)}
|
||
</strong>
|
||
</span>
|
||
<span>
|
||
已扣押金:
|
||
<strong style={{ color: '#fa8c16' }}>
|
||
-¥{Number(detailModal.depositDeductedAmount || 0).toFixed(2)}
|
||
</strong>
|
||
</span>
|
||
</Space>
|
||
</div>
|
||
)}
|
||
<h4>费用明细</h4>
|
||
<Table
|
||
scroll={{ x: 700 }}
|
||
dataSource={detailModal.items || []}
|
||
rowKey="id"
|
||
pagination={false}
|
||
size="small"
|
||
columns={[
|
||
{ title: '类型', dataIndex: 'expenseType', render: (v: string) => typeMap[v] || v },
|
||
{ title: '说明', dataIndex: 'description' },
|
||
{
|
||
title: '计费天数',
|
||
dataIndex: 'days',
|
||
render: (v: number) => (v > 0 ? `${v}天` : '-'),
|
||
},
|
||
{
|
||
title: '宿舍总人天',
|
||
dataIndex: 'totalRoomDays',
|
||
render: (v: number) => (v > 0 ? `${v}天` : '-'),
|
||
},
|
||
{
|
||
title: '宿舍总费用',
|
||
dataIndex: 'roomTotalAmount',
|
||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '应分摊',
|
||
dataIndex: 'studentAmount',
|
||
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||
},
|
||
]}
|
||
/>
|
||
</Spin>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default BillsPage;
|