forked from wangziqi/gongxue-base
P2-14: Deposit installment tracking + refund approval flow
- Add DepositInstallment entity (id, depositId, amount, dueDate, paidDate, status, createdAt) - Add installments OneToMany relation to Deposit entity with cascade+eager - Add refund approval fields: refundStatus, refundRequestedAt, refundApprovedBy, refundApprovedAt - Add installment DTOs (CreateInstallmentDto, UpdateInstallmentDto) - Add refund approval DTOs (ApproveRefundDto, CreateDepositWithInstallmentsDto) - Service: add/create/update/delete installments, requestRefund, approveRefund, findPendingRefunds - Controller: GET deposits/:id, GET pending-refunds, POST :id/installments, PUT installments/:id, DELETE installments/:id, POST :id/request-refund, PUT :id/approve-refund - Frontend: detail modal with installment list, refund request button, pending refunds tab with approve actions
This commit is contained in:
@@ -12,8 +12,11 @@ import {
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Tabs,
|
||||
List,
|
||||
Card,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
@@ -25,16 +28,34 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
||||
deducted: { text: '已全扣', color: 'red' },
|
||||
};
|
||||
|
||||
const refundStatusMap: Record<string, { text: string; color: string }> = {
|
||||
pending: { text: '待班主任审批', color: 'orange' },
|
||||
head_teacher_approved: { text: '待财务审批', color: 'blue' },
|
||||
finance_approved: { text: '已退款', color: 'green' },
|
||||
refunded: { text: '已退款', color: 'green' },
|
||||
};
|
||||
|
||||
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 [pendingRefunds, setPendingRefunds] = useState<any[]>([]);
|
||||
const [pendingLoading, setPendingLoading] = useState(false);
|
||||
const [createForm] = Form.useForm();
|
||||
const [refundForm] = Form.useForm();
|
||||
const [installmentForm] = Form.useForm();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
@@ -48,6 +69,17 @@ const DepositsPage: React.FC = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchPendingRefunds = async () => {
|
||||
setPendingLoading(true);
|
||||
try {
|
||||
const res = await api.get('/deposits/pending-refunds');
|
||||
setPendingRefunds(res || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setPendingLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
@@ -99,6 +131,67 @@ const DepositsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestRefund = async (record: any) => {
|
||||
try {
|
||||
await api.post(`/deposits/${record.id}/request-refund`);
|
||||
message.success('退款申请已提交');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveRefund = async (record: any) => {
|
||||
try {
|
||||
await api.put(`/deposits/${record.id}/approve-refund`);
|
||||
message.success('审批通过');
|
||||
fetchPendingRefunds();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
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 = [
|
||||
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '押金金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
@@ -108,6 +201,12 @@ const DepositsPage: React.FC = () => {
|
||||
dataIndex: 'status',
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '退款审批',
|
||||
dataIndex: 'refundStatus',
|
||||
render: (s: string) =>
|
||||
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: '退还金额',
|
||||
dataIndex: 'refundAmount',
|
||||
@@ -123,21 +222,39 @@ const DepositsPage: React.FC = () => {
|
||||
{ title: '备注', dataIndex: 'notes', render: (v: any) => v || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
width: 240,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
{record.status === 'paid' && (
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setRefundModal(record);
|
||||
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
|
||||
}}
|
||||
>
|
||||
退还
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="deposit:view"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setDetailModal(record);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</PermissionButton>
|
||||
{record.status === 'paid' && !record.refundStatus && (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setRefundModal(record);
|
||||
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
|
||||
}}
|
||||
>
|
||||
退还
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
onClick={() => handleRequestRefund(record)}
|
||||
>
|
||||
申请退款
|
||||
</PermissionButton>
|
||||
</>
|
||||
)}
|
||||
<PermissionButton permission="deposit:delete">
|
||||
<Popconfirm
|
||||
@@ -160,63 +277,125 @@ const DepositsPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const pendingColumns = [
|
||||
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '押金金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '缴纳日期', dataIndex: 'paidDate' },
|
||||
{
|
||||
title: '审批状态',
|
||||
dataIndex: 'refundStatus',
|
||||
render: (s: string) => <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '申请时间',
|
||||
dataIndex: 'refundRequestedAt',
|
||||
render: (v: any) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: any, record: any) => (
|
||||
<PermissionButton
|
||||
permission="deposit:approve"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleApproveRefund(record)}
|
||||
>
|
||||
审批通过
|
||||
</PermissionButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => {
|
||||
setActiveTab(key);
|
||||
if (key === 'pending') fetchPendingRefunds();
|
||||
}}
|
||||
>
|
||||
<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: 1000 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
items={[
|
||||
{
|
||||
key: 'all',
|
||||
label: '押金列表',
|
||||
children: (
|
||||
<>
|
||||
<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: 1300 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: '待审批退款',
|
||||
children: (
|
||||
<Table
|
||||
columns={pendingColumns}
|
||||
dataSource={pendingRefunds}
|
||||
rowKey="id"
|
||||
loading={pendingLoading}
|
||||
scroll={{ x: 800 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
title="收取押金"
|
||||
open={createModal}
|
||||
@@ -254,6 +433,7 @@ const DepositsPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Refund Modal */}
|
||||
<Modal
|
||||
title={`退还押金 - ${refundModal?.student?.name}`}
|
||||
open={!!refundModal}
|
||||
@@ -284,6 +464,115 @@ const DepositsPage: React.FC = () => {
|
||||
</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.refundStatus && (
|
||||
<p>
|
||||
<strong>退款审批:</strong>{' '}
|
||||
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
|
||||
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
|
||||
</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>
|
||||
),
|
||||
<PermissionButton key="del" permission="deposit:delete">
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
onConfirm={() => handleDeleteInstallment(item.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</PermissionButton>,
|
||||
].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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user