485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
|
import {
|
|
Table,
|
|
Modal,
|
|
Form,
|
|
Select,
|
|
DatePicker,
|
|
InputNumber,
|
|
Input,
|
|
Space,
|
|
Tag,
|
|
Popconfirm,
|
|
List,
|
|
Card,
|
|
Empty,
|
|
} from 'antd';
|
|
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import api from '../../api';
|
|
import PermissionButton from '../../components/PermissionButton';
|
|
import { message } from '../../ui/app-message';
|
|
import { buildDepositStudentOption } from './deposit-student-option';
|
|
|
|
const statusMap: Record<string, { text: string; color: string }> = {
|
|
paid: { text: '已缴', color: 'green' },
|
|
refunded: { text: '已全退', color: 'blue' },
|
|
partial_refund: { text: '部分退还', color: 'orange' },
|
|
deducted: { text: '已全扣', color: 'red' },
|
|
};
|
|
|
|
const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
|
pending: { text: '待缴', color: 'orange' },
|
|
paid: { text: '已缴', color: 'green' },
|
|
};
|
|
|
|
|
|
const DepositsPage: React.FC = () => {
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [students, setStudents] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [createModal, setCreateModal] = useState(false);
|
|
const [refundModal, setRefundModal] = useState<any>(null);
|
|
const [detailModal, setDetailModal] = useState<any>(null);
|
|
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
|
|
const [createForm] = Form.useForm();
|
|
const [refundForm] = Form.useForm();
|
|
const [installmentForm] = Form.useForm();
|
|
const [searchText, setSearchText] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [d, s]: any[] = await Promise.all([
|
|
api.get('/deposits'),
|
|
api.get('/deposits/student-lookups'),
|
|
]);
|
|
setData(d);
|
|
setStudents(s);
|
|
} catch (e: any) {
|
|
message.error(e?.message || '加载失败,请稍后重试');
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const filteredData = useMemo(() => {
|
|
return data.filter((d: any) => {
|
|
if (searchText) {
|
|
const s = searchText.toLowerCase();
|
|
if (!d.student?.name?.toLowerCase().includes(s)) return false;
|
|
}
|
|
if (filterStatus && d.status !== filterStatus) return false;
|
|
return true;
|
|
});
|
|
}, [data, searchText, filterStatus]);
|
|
|
|
const studentOptions = useMemo(
|
|
() =>
|
|
students
|
|
.filter((s: any) => s.status === 'active')
|
|
.map(buildDepositStudentOption),
|
|
[students],
|
|
);
|
|
|
|
const handleCreate = async () => {
|
|
setSaving(true);
|
|
const values = await createForm.validateFields();
|
|
try {
|
|
await api.post('/deposits', {
|
|
studentId: values.studentId,
|
|
amount: values.amount,
|
|
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
|
notes: values.notes,
|
|
});
|
|
message.success('押金记录已创建');
|
|
setCreateModal(false);
|
|
createForm.resetFields();
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '操作失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleRefund = async () => {
|
|
setSaving(true);
|
|
const values = await refundForm.validateFields();
|
|
try {
|
|
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('退还操作完成');
|
|
setRefundModal(null);
|
|
refundForm.resetFields();
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '操作失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleAddInstallment = async () => {
|
|
if (installmentModal == null) return;
|
|
const values = await installmentForm.validateFields();
|
|
try {
|
|
await api.post(`/deposits/${installmentModal}/installments`, {
|
|
amount: values.amount,
|
|
dueDate: values.dueDate.format('YYYY-MM-DD'),
|
|
});
|
|
message.success('分期已添加');
|
|
setInstallmentModal(null);
|
|
installmentForm.resetFields();
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '操作失败');
|
|
}
|
|
};
|
|
|
|
const handlePayInstallment = async (installmentId: number) => {
|
|
try {
|
|
await api.put(`/deposits/installments/${installmentId}`, {
|
|
paidDate: dayjs().format('YYYY-MM-DD'),
|
|
status: 'paid',
|
|
});
|
|
message.success('分期已标记为已缴');
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '操作失败');
|
|
}
|
|
};
|
|
|
|
const handleDeleteInstallment = async (installmentId: number) => {
|
|
try {
|
|
await api.delete(`/deposits/installments/${installmentId}`);
|
|
message.success('分期已删除');
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '操作失败');
|
|
}
|
|
};
|
|
|
|
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: '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 || '-' },
|
|
{
|
|
title: '操作',
|
|
width: 240,
|
|
render: (_: any, record: any) => (
|
|
<Space>
|
|
<PermissionButton
|
|
permission="deposit:view"
|
|
size="small"
|
|
onClick={() => {
|
|
setDetailModal(record);
|
|
}}
|
|
>
|
|
详情
|
|
</PermissionButton>
|
|
{record.status === 'paid' && (
|
|
<>
|
|
<PermissionButton
|
|
permission="deposit:refund"
|
|
size="small"
|
|
type="primary"
|
|
onClick={() => {
|
|
setRefundModal(record);
|
|
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
|
|
}}
|
|
>
|
|
退还
|
|
</PermissionButton>
|
|
</>
|
|
)}
|
|
<Popconfirm
|
|
title="确定删除?"
|
|
onConfirm={async () => {
|
|
try {
|
|
await api.delete(`/deposits/${record.id}`);
|
|
message.success('删除成功');
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '删除失败');
|
|
}
|
|
}}
|
|
>
|
|
<PermissionButton
|
|
permission="deposit:delete"
|
|
size="small"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
>
|
|
删除
|
|
</PermissionButton>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
], [fetchData]);
|
|
|
|
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
style={{
|
|
marginBottom: 16,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
flexWrap: 'wrap',
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<Space wrap>
|
|
<Input.Search
|
|
placeholder="搜索学生姓名"
|
|
allowClear
|
|
style={{ width: 180 }}
|
|
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: 'paid', label: '已缴' },
|
|
{ value: 'refunded', label: '已全退' },
|
|
{ value: 'partial_refund', label: '部分退还' },
|
|
{ value: 'deducted', label: '已全扣' },
|
|
]}
|
|
/>
|
|
</Space>
|
|
<PermissionButton
|
|
permission="deposit:create"
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => {
|
|
createForm.resetFields();
|
|
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
|
setCreateModal(true);
|
|
}}
|
|
>
|
|
收取押金
|
|
</PermissionButton>
|
|
</div>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={filteredData}
|
|
rowKey="id"
|
|
loading={loading}
|
|
scroll={{ x: 1200 }}
|
|
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
|
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
|
/>
|
|
|
|
{/* Create Modal */}
|
|
<Modal
|
|
title="收取押金"
|
|
open={createModal}
|
|
onOk={handleCreate}
|
|
onCancel={() => setCreateModal(false)}
|
|
okText="确认"
|
|
confirmLoading={saving}
|
|
>
|
|
<Form form={createForm} layout="vertical">
|
|
<Form.Item
|
|
name="studentId"
|
|
label="学生"
|
|
rules={[{ required: true, message: '请选择学生' }]}
|
|
>
|
|
<Select
|
|
showSearch
|
|
optionFilterProp="label"
|
|
placeholder="搜索并选择学生"
|
|
options={studentOptions}
|
|
/>
|
|
</Form.Item>
|
|
<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>
|
|
<Form.Item name="notes" label="备注">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Refund Modal */}
|
|
<Modal
|
|
title={`退还押金 - ${refundModal?.student?.name}`}
|
|
open={!!refundModal}
|
|
onOk={handleRefund}
|
|
onCancel={() => setRefundModal(null)}
|
|
okText="确认退还"
|
|
confirmLoading={saving}
|
|
>
|
|
<Form form={refundForm} layout="vertical">
|
|
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
|
押金金额: <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>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Detail Modal */}
|
|
<Modal
|
|
title={`押金详情 - ${detailModal?.student?.name}`}
|
|
open={!!detailModal}
|
|
onCancel={() => setDetailModal(null)}
|
|
footer={null}
|
|
width={640}
|
|
>
|
|
{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>{' '}
|
|
<Tag color={statusMap[detailModal.status]?.color}>
|
|
{statusMap[detailModal.status]?.text || detailModal.status}
|
|
</Tag>
|
|
</p>
|
|
{detailModal.notes && <p><strong>备注:</strong> {detailModal.notes}</p>}
|
|
</Card>
|
|
|
|
{/* Installments Section */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
<h4 style={{ margin: 0 }}>分期记录</h4>
|
|
<PermissionButton
|
|
permission="deposit:edit"
|
|
size="small"
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => {
|
|
setInstallmentModal(detailModal.id);
|
|
installmentForm.resetFields();
|
|
}}
|
|
>
|
|
添加分期
|
|
</PermissionButton>
|
|
</div>
|
|
{detailModal.installments?.length > 0 ? (
|
|
<List
|
|
dataSource={detailModal.installments}
|
|
renderItem={(item: any) => (
|
|
<List.Item
|
|
actions={[
|
|
item.status === 'pending' && (
|
|
<PermissionButton
|
|
key="pay"
|
|
permission="deposit:edit"
|
|
size="small"
|
|
type="primary"
|
|
icon={<DollarOutlined />}
|
|
onClick={() => handlePayInstallment(item.id)}
|
|
>
|
|
标记已缴
|
|
</PermissionButton>
|
|
),
|
|
<Popconfirm
|
|
title="确定删除?"
|
|
onConfirm={() => handleDeleteInstallment(item.id)}
|
|
>
|
|
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<DeleteOutlined />}>
|
|
删除
|
|
</PermissionButton>
|
|
</Popconfirm>
|
|
].filter(Boolean)}
|
|
>
|
|
<List.Item.Meta
|
|
title={`¥${Number(item.amount).toFixed(2)}`}
|
|
description={`到期: ${item.dueDate}${item.paidDate ? ` | 缴纳: ${item.paidDate}` : ''}`}
|
|
/>
|
|
<Tag color={installmentStatusMap[item.status]?.color}>
|
|
{installmentStatusMap[item.status]?.text || item.status}
|
|
</Tag>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
) : (
|
|
<p style={{ color: '#999' }}>暂无分期记录</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Add Installment Modal */}
|
|
<Modal
|
|
title="添加分期"
|
|
open={installmentModal != null}
|
|
onOk={handleAddInstallment}
|
|
onCancel={() => setInstallmentModal(null)}
|
|
okText="确认"
|
|
>
|
|
<Form form={installmentForm} layout="vertical">
|
|
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
|
|
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
|
|
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DepositsPage;
|