Files
gongxue-base/apps/admin/src/pages/Deposits/index.tsx
wangziqi 97c2ceccde fix: re-apply UX fixes on Bills, Expenses, Deposits
- Add Empty component to antd imports
- Replace console.error with message.error in catch blocks
- Add batchLoading state + guard on batch operations
- Add locale emptyText to Tables
2026-07-09 19:57:10 +08:00

662 lines
22 KiB
TypeScript

import React, { useEffect, useState, useMemo } from 'react';
import {
Table,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Space,
message,
Tag,
Popconfirm,
Tabs,
List,
Card,
Empty,
} from 'antd';
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
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 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' },
};
interface PendingRefund {
id: number;
student?: { name?: string };
amount?: number;
paidDate?: string;
refundStatus?: string;
refundRequestedAt?: string;
}
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<PendingRefund[]>([]);
const [pendingLoading, setPendingLoading] = useState(false);
const [rejectModal, setRejectModal] = useState<any>(null);
const [rejectReason, setRejectReason] = useState('');
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 [saving, setSaving] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const [d, s]: any[] = await Promise.all([api.get('/deposits'), api.get('/students')]);
setData(d);
setStudents(s);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
};
const fetchPendingRefunds = async () => {
setPendingLoading(true);
try {
const res = await api.get<PendingRefund[]>('/deposits/pending-refunds');
setPendingRefunds(res || []);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setPendingLoading(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((s: any) => ({
value: s.id,
label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`,
})),
[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 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 handleRejectRefund = async () => {
if (!rejectModal) return;
setSaving(true);
try {
await api.put(`/deposits/${rejectModal.id}/reject-refund`, { reason: rejectReason || '未说明原因' });
message.success('已驳回退款申请');
setRejectModal(null);
setRejectReason('');
fetchPendingRefunds();
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: 'refundStatus',
render: (s: string) =>
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[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' && !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>
</>
)}
<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>
),
},
], [handleRequestRefund, fetchData]);
const pendingColumns = [
{ 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: '审批状态', width: 120,
dataIndex: 'refundStatus',
render: (s: string) => <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag>,
},
{
title: '申请时间', width: 160,
dataIndex: 'refundRequestedAt',
render: (v: any) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作', width: 200,
render: (_: unknown, record: PendingRefund) => (
<Space>
<PermissionButton
permission="deposit:approve"
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => handleApproveRefund(record)}
>
</PermissionButton>
<PermissionButton
permission="deposit:approve"
size="small"
danger
onClick={() => {
setRejectModal(record);
setRejectReason('');
}}
>
</PermissionButton>
</Space>
),
},
];
return (
<div>
<Tabs
activeKey={activeTab}
onChange={(key) => {
setActiveTab(key);
if (key === 'pending') fetchPendingRefunds();
}}
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: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
</>
),
},
{
key: 'pending',
label: '待审批退款',
children: (
<Table
columns={pendingColumns}
dataSource={pendingRefunds}
rowKey="id"
loading={pendingLoading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
),
},
]}
/>
{/* 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.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>
),
<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>
{/* Reject Refund Modal */}
<Modal
title={`驳回退款申请 - ${rejectModal?.student?.name || ''}`}
open={!!rejectModal}
onOk={handleRejectRefund}
onCancel={() => { setRejectModal(null); setRejectReason(''); }}
okText="确认驳回"
okButtonProps={{ danger: true }}
confirmLoading={saving}
>
<Input.TextArea
aria-label="驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}
/>
</Modal>
</div>
);
};
export default DepositsPage;