feat: link deposits to bill payment

This commit is contained in:
2026-07-14 16:38:38 +08:00
parent eac336a54a
commit 598b4e8acd
12 changed files with 272 additions and 218 deletions

View File

@@ -26,11 +26,8 @@ import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker;
const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' },
confirmed: { text: '已确认', color: 'blue' },
paid: { text: '已支付', color: 'green' },
};
@@ -61,6 +58,7 @@ const buildBillPrintHtml = (bill: any) => {
? 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>
@@ -93,8 +91,7 @@ const buildBillPrintHtml = (bill: any) => {
.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-applied { color: #fa8c16; font-size: 11px; }
.after-deposit { color: #ff3b30; font-size: 14px; font-weight: 700; }
.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; }
@@ -144,10 +141,9 @@ const buildBillPrintHtml = (bill: any) => {
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
${
hasDeposit
hasDeposit || depositDeducted > 0
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
<div class="deposit-applied">押金抵扣: -${escapeHtml(money(bill.depositApplied))}</div>
<div class="after-deposit">抵扣后应付: ${escapeHtml(money(bill.amountAfterDeposit ?? bill.totalAmount))}</div>`
${depositDeducted > 0 ? `<div class="deposit-deducted">已扣押金: -${escapeHtml(money(depositDeducted))}</div>` : ''}`
: ''
}
</div>
@@ -243,6 +239,16 @@ const BillsPage: React.FC = () => {
});
}, [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;
@@ -250,8 +256,7 @@ const BillsPage: React.FC = () => {
const values = await generateForm.validateFields();
setSaving(true);
const res: any = await api.post('/bills/generate', {
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
billingMonth: values.billingMonth.format('YYYY-MM'),
});
message.success(res.message || '生成成功');
setGenerateModal(false);
@@ -282,10 +287,10 @@ const BillsPage: React.FC = () => {
const updateStatus = async (id: number, status: string) => {
try {
await api.put(`/bills/${id}/status`, { status });
message.success('状态更新成功');
message.success('账单已确认支付,押金已自动扣除');
fetchData();
if (detailModal?.id === id) {
setDetailModal({ ...detailModal, status });
void showDetail(id);
}
} catch (e: any) {
message.error(e?.message || '操作失败');
@@ -298,7 +303,7 @@ const BillsPage: React.FC = () => {
setBatchLoading(true);
try {
await api.put('/bills/batch/status', { ids: selectedRows, status });
message.success(`批量更新 ${selectedRows.length} 条账单`);
message.success(`确认支付 ${selectedRows.length} 条账单,并自动扣除押金`);
setSelectedRows([]);
fetchData();
} catch (e: any) {
@@ -396,20 +401,15 @@ const BillsPage: React.FC = () => {
),
},
{
title: '抵扣后应付',
dataIndex: 'amountAfterDeposit',
width: 130,
render: (v: number, r: any) => {
const has = Number(r.availableDeposit || 0) > 0;
if (!has) return <span style={{ color: '#999' }}>-</span>;
const after = Number(v ?? r.totalAmount).toFixed(2);
const applied = Number(r.depositApplied || 0).toFixed(2);
return (
<Tooltip title={`已抵扣押金 ¥${applied}`}>
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
</Tooltip>
);
},
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: '状态',
@@ -437,23 +437,23 @@ const BillsPage: React.FC = () => {
</PermissionButton>
{record.status === 'draft' && (
<PermissionButton
permission="bill:confirm"
size="small"
onClick={() => updateStatus(record.id, 'confirmed')}
<Tooltip
title={
record.depositSufficient
? '确认后将自动从该学生押金余额中扣除账单金额'
: '押金不足,请先到押金管理收取押金'
}
>
</PermissionButton>
)}
{record.status === 'confirmed' && (
<PermissionButton
permission="bill:confirm"
size="small"
type="primary"
onClick={() => updateStatus(record.id, 'paid')}
>
</PermissionButton>
<PermissionButton
permission="bill:confirm"
size="small"
type="primary"
disabled={!record.depositSufficient}
onClick={() => updateStatus(record.id, 'paid')}
>
</PermissionButton>
</Tooltip>
)}
<PermissionButton
permission="bill:export-pdf"
@@ -469,7 +469,13 @@ const BillsPage: React.FC = () => {
okText="删除"
cancelText="取消"
>
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}>
<PermissionButton
permission="bill:delete"
size="small"
danger
disabled={record.status === 'paid'}
icon={<DeleteOutlined />}
>
</PermissionButton>
</Popconfirm>
@@ -507,38 +513,30 @@ const BillsPage: React.FC = () => {
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'draft', label: '草稿' },
{ value: 'confirmed', 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"
onClick={() => batchUpdateStatus('confirmed')}
disabled={selectedRows.length === 0}
>
</PermissionButton>
<PermissionButton
permission="bill:confirm"
type="primary"
onClick={() => batchUpdateStatus('paid')}
disabled={selectedRows.length === 0}
disabled={!canBatchConfirm}
>
</PermissionButton>
<Popconfirm
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchDelete}
okText="删除"
cancelText="取消"
disabled={selectedRows.length === 0}
disabled={!canBatchDelete}
>
<PermissionButton
permission="bill:delete"
danger
disabled={selectedRows.length === 0}
disabled={!canBatchDelete}
icon={<DeleteOutlined />}
>
@@ -595,15 +593,19 @@ const BillsPage: React.FC = () => {
>
<Form form={generateForm} layout="vertical">
<Form.Item
name="period"
label="账单周期"
rules={[{ required: true, message: '请选择账单周期' }]}
extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用"
name="billingMonth"
label="账单月份"
rules={[{ required: true, message: '请选择账单月份' }]}
extra="只能选择已结束月份,每个月只能生成一次账单"
>
<RangePicker
<DatePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
picker="month"
placeholder="选择月份"
format="YYYY-MM"
disabledDate={(current) =>
!!current && !current.endOf('month').isBefore(dayjs(), 'day')
}
/>
</Form.Item>
</Form>
@@ -643,18 +645,20 @@ const BillsPage: React.FC = () => {
</strong>
</Descriptions.Item>
</Descriptions>
{Number(detailModal.availableDeposit || 0) > 0 && (
{(Number(detailModal.availableDeposit || 0) > 0
|| Number(detailModal.depositDeductedAmount || 0) > 0
|| detailModal.status === 'draft') && (
<div
style={{
marginBottom: 16,
padding: 12,
background: '#f6ffed',
border: '1px solid #b7eb8f',
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>
@@ -664,16 +668,9 @@ const BillsPage: React.FC = () => {
</strong>
</span>
<span>
<strong style={{ color: '#fa8c16' }}>
-¥{Number(detailModal.depositApplied || 0).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa541c', fontSize: 16 }}>
¥
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
-¥{Number(detailModal.depositDeductedAmount || 0).toFixed(2)}
</strong>
</span>
</Space>

View File

@@ -22,10 +22,9 @@ import { message } from '../../ui/app-message';
import { buildDepositStudentOptions } from './deposit-student-option';
const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
paid: { text: '有余额', color: 'green' },
refunded: { text: '已全退', color: 'blue' },
partial_refund: { text: '部分退还', color: 'orange' },
deducted: { text: '已全扣', color: 'red' },
depleted: { text: '已扣完', color: 'red' },
};
const installmentStatusMap: Record<string, { text: string; color: string }> = {
@@ -100,7 +99,7 @@ const DepositsPage: React.FC = () => {
paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes,
});
message.success('押金记录已创建');
message.success('押金金额已增加');
setCreateModal(false);
createForm.resetFields();
fetchData();
@@ -119,8 +118,6 @@ const DepositsPage: React.FC = () => {
const values = await refundForm.validateFields();
await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'),
deductionAmount: values.deductionAmount || 0,
deductionReason: values.deductionReason,
notes: values.notes,
});
message.success('退还操作完成');
@@ -180,24 +177,13 @@ const DepositsPage: React.FC = () => {
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
{ title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120 },
{
title: '状态',
dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '退还金额',
dataIndex: 'refundAmount',
render: (v: any) => (v != null ? `¥${Number(v).toFixed(2)}` : '-'),
},
{
title: '扣除金额',
dataIndex: 'deductionAmount',
render: (v: any) => (v > 0 ? `¥${Number(v).toFixed(2)}` : '-'),
},
{ title: '扣除原因', dataIndex: 'deductionReason', width: 120, render: (v: any) => v || '-' },
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' },
{
@@ -222,7 +208,7 @@ const DepositsPage: React.FC = () => {
type="primary"
onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
refundForm.setFieldsValue({ refundDate: dayjs() });
}}
>
退
@@ -285,10 +271,9 @@ const DepositsPage: React.FC = () => {
value={filterStatus}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '已缴' },
{ value: 'paid', label: '有余额' },
{ value: 'refunded', label: '已全退' },
{ value: 'partial_refund', label: '部分退还' },
{ value: 'deducted', label: '已全扣' },
{ value: 'depleted', label: '已扣完' },
]}
/>
</Space>
@@ -342,11 +327,11 @@ const DepositsPage: React.FC = () => {
options={studentOptions}
/>
</Form.Item>
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}>
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="paidDate" label="缴纳日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择缴纳日期" format="YYYY-MM-DD" />
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
@@ -365,22 +350,11 @@ const DepositsPage: React.FC = () => {
>
<Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
</div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="deductionAmount" label="扣除金额(元)" extra="如无扣除填0">
<InputNumber
min={0}
max={Number(refundModal?.amount || 500)}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="deductionReason" label="扣除原因">
<Input placeholder="如:房间损坏赔偿" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
@@ -398,8 +372,8 @@ const DepositsPage: React.FC = () => {
{detailModal && (
<div>
<Card size="small" style={{ marginBottom: 16 }}>
<p><strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
<p><strong>:</strong> {detailModal.paidDate}</p>
<p><strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
<p><strong>:</strong> {detailModal.paidDate}</p>
<p>
<strong>:</strong>{' '}
<Tag color={statusMap[detailModal.status]?.color}>