Files
gongxue-base/apps/admin/src/pages/Deposits/index.tsx

817 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Table,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Space,
Tag,
Popconfirm,
Card,
Empty,
} from 'antd';
import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '有余额', color: 'green' },
refunded: { text: '已全退', color: 'blue' },
depleted: { text: '已扣完', color: 'red' },
};
const installmentStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待缴', color: 'orange' },
paid: { text: '已缴', color: 'green' },
};
const roomTypeOptions = [
{ value: '单人间', label: '单人间' },
{ value: '四人间', label: '四人间' },
];
const suggestedDepositByRoomType: Record<string, number> = {
单人间: 200,
四人间: 100,
};
interface DepositRecord {
id: number;
studentId: number;
amount: number;
status: string;
paidDate: string;
refundDate?: string | null;
notes?: string | null;
installments?: Array<{
id: number;
amount: number;
dueDate: string;
paidDate?: string | null;
status: string;
}>;
student?: DepositStudentLookup;
}
interface EligibleStudent {
studentId: number;
studentName: string;
studentNo?: string | null;
roomId: number;
roomNumber: string;
building?: string | null;
roomType?: string | null;
capacity: number;
depositAmount: number;
}
const isFormValidationError = (error: unknown) =>
typeof error === 'object' &&
error !== null &&
Array.isArray((error as { errorFields?: unknown }).errorFields);
const DepositsPage: React.FC = () => {
const [data, setData] = useState<DepositRecord[]>([]);
const [students, setStudents] = useState<DepositStudentLookup[]>([]);
const [eligibleStudents, setEligibleStudents] = useState<EligibleStudent[]>([]);
const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [eligibleLoading, setEligibleLoading] = useState(false);
const [createModal, setCreateModal] = useState(false);
const [batchModal, setBatchModal] = useState(false);
const [refundModal, setRefundModal] = useState<DepositRecord | null>(null);
const [detailModal, setDetailModal] = useState<DepositRecord | null>(null);
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
const [createForm] = Form.useForm();
const [batchForm] = Form.useForm();
const [refundForm] = Form.useForm();
const [installmentForm] = Form.useForm();
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterRoomType, setFilterRoomType] = useState<string | undefined>(undefined);
const [batchRoomType, setBatchRoomType] = useState<string>('四人间');
const [saving, setSaving] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [d, s] = await Promise.all([
api.get<DepositRecord[]>('/deposits'),
api.get<DepositStudentLookup[]>('/deposits/student-lookups'),
]);
setData(d);
setStudents(s);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
} finally {
setLoading(false);
}
}, []);
const fetchEligibleStudents = useCallback(async (roomType?: string) => {
setEligibleLoading(true);
try {
const params = roomType ? `?roomType=${encodeURIComponent(roomType)}` : '';
const rows = await api.get<EligibleStudent[]>(`/deposits/eligible-students${params}`);
setEligibleStudents(rows);
setSelectedEligibleStudentIds(rows.map((item) => item.studentId));
} catch (e: any) {
message.error(e?.message || '加载在住人员失败');
} finally {
setEligibleLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
fetchEligibleStudents(filterRoomType);
}, [fetchEligibleStudents, filterRoomType]);
const depositByStudentId = useMemo(() => {
const map = new Map<number, DepositRecord>();
data.forEach((item) => map.set(item.studentId, item));
return map;
}, [data]);
const filteredData = useMemo(() => {
if (filterRoomType) {
const s = searchText.trim().toLowerCase();
return eligibleStudents
.filter(
(item) =>
!s ||
item.studentName.toLowerCase().includes(s) ||
item.studentNo?.toLowerCase().includes(s),
)
.map((item) => {
const deposit = depositByStudentId.get(item.studentId);
return {
id: deposit?.id ?? `eligible-${item.studentId}`,
studentId: item.studentId,
amount: deposit?.amount ?? item.depositAmount ?? 0,
status: deposit?.status ?? 'unpaid',
paidDate: deposit?.paidDate ?? '',
refundDate: deposit?.refundDate,
notes: deposit?.notes,
installments: deposit?.installments ?? [],
student: {
id: item.studentId,
name: item.studentName,
studentNo: item.studentNo,
roomType: item.roomType,
},
roomNumber: item.roomNumber,
building: item.building,
roomType: item.roomType,
};
});
}
return data.filter((d) => {
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, depositByStudentId, eligibleStudents, filterRoomType, filterStatus, searchText]);
const studentOptions = useMemo(() => buildDepositStudentOptions(students), [students]);
const openBatchModal = (roomType = filterRoomType || '四人间') => {
const amount = suggestedDepositByRoomType[roomType] ?? 100;
setBatchRoomType(roomType);
batchForm.resetFields();
batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() });
setBatchModal(true);
fetchEligibleStudents(roomType);
};
const handleBatchRoomTypeChange = (roomType: string) => {
setBatchRoomType(roomType);
batchForm.setFieldsValue({
amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100,
});
fetchEligibleStudents(roomType);
};
const handleCreate = async () => {
setSaving(true);
try {
const values = await createForm.validateFields();
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();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
};
const handleBatchCreate = async () => {
if (selectedEligibleStudentIds.length === 0) {
message.warning('请选择至少一名学生');
return;
}
setSaving(true);
try {
const values = await batchForm.validateFields();
await api.post('/deposits/batch', {
studentIds: selectedEligibleStudentIds,
amount: values.amount,
paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes,
roomType: values.roomType,
});
message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`);
setBatchModal(false);
batchForm.resetFields();
await fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
};
const handleRefund = async () => {
if (!refundModal) return;
setSaving(true);
try {
const values = await refundForm.validateFields();
await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'),
notes: values.notes,
});
message.success('退还操作完成');
setRefundModal(null);
refundForm.resetFields();
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
};
const handleAddInstallment = async () => {
if (installmentModal == null) return;
try {
const values = await installmentForm.validateFields();
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) {
if (!isFormValidationError(e)) {
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 saveInstallmentCell = async (
installmentId: number,
field: 'status' | 'paidDate',
value: unknown,
) => {
await api.put(`/deposits/installments/${installmentId}`, { [field]: value });
message.success('分期记录已保存');
if (detailModal) {
const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);
setDetailModal(refreshed);
}
await fetchData();
};
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: (_: unknown, r: any) => r.student?.name || '-' },
{
title: '当前可用押金',
dataIndex: 'amount',
width: 130,
render: (v: number) => `¥${Number(v || 0).toFixed(2)}`,
},
{
title: '房间',
width: 120,
render: (_: unknown, r: any) =>
r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-',
},
{ title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' },
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
render: (s: string) =>
s === 'unpaid' ? (
<Tag color="default"></Tag>
) : (
<Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>
),
},
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
{
title: '操作',
width: 240,
render: (_: unknown, record: any) => {
const hasDeposit = typeof record.id === 'number';
return (
<Space>
{hasDeposit && (
<PermissionButton
permission="deposit:view"
size="small"
onClick={() => {
setDetailModal(record);
}}
>
</PermissionButton>
)}
{record.status === 'paid' && hasDeposit && (
<PermissionButton
permission="deposit:refund"
size="small"
type="primary"
onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs() });
}}
>
退
</PermissionButton>
)}
{hasDeposit && (
<Popconfirm
title="确定归档?"
onConfirm={async () => {
try {
await api.delete(`/deposits/${record.id}`);
message.success('归档成功');
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
message.error(e?.message || '归档失败');
}
}}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
);
},
},
],
[fetchData, fetchEligibleStudents, filterRoomType, refundForm],
);
const eligibleColumns = [
{
title: '学生',
render: (_: unknown, r: EligibleStudent) =>
`${r.studentName} (${r.studentNo || `#${r.studentId}`})`,
},
{
title: '房间',
render: (_: unknown, r: EligibleStudent) =>
`${r.building ? `${r.building}-` : ''}${r.roomNumber}`,
},
{ title: '房型', dataIndex: 'roomType' },
{
title: '当前押金',
dataIndex: 'depositAmount',
render: (v: number) => `¥${Number(v || 0).toFixed(2)}`,
},
];
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) => {
setSearchText(e.target.value);
}}
/>
<Select
placeholder="房型筛选"
allowClear
style={{ width: 130 }}
value={filterRoomType}
onChange={(v) => setFilterRoomType(v)}
options={roomTypeOptions}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
disabled={!!filterRoomType}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '有余额' },
{ value: 'refunded', label: '已全退' },
{ value: 'depleted', label: '已扣完' },
]}
/>
</Space>
<Space wrap>
<PermissionButton
permission="deposit:create"
icon={<TeamOutlined />}
onClick={() => openBatchModal()}
>
</PermissionButton>
<PermissionButton
permission="deposit:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</Space>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading || (!!filterRoomType && eligibleLoading)}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
{/* Batch Create Modal */}
<Modal
title="按房型批量收取押金"
open={batchModal}
onOk={handleBatchCreate}
onCancel={() => setBatchModal(false)}
okText="确认批量收取"
confirmLoading={saving}
okButtonProps={{ disabled: selectedEligibleStudentIds.length === 0 }}
width={760}
>
<Form form={batchForm} layout="vertical">
<Space style={{ width: '100%' }} align="start" wrap>
<Form.Item
name="roomType"
label="房型"
rules={[{ required: true, message: '请选择房型' }]}
>
<Select
style={{ width: 140 }}
options={roomTypeOptions}
onChange={handleBatchRoomTypeChange}
/>
</Form.Item>
<Form.Item
name="amount"
label="每人收取金额(元)"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: 180 }} />
</Form.Item>
<Form.Item
name="paidDate"
label="收取日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker style={{ width: 180 }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
</Space>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder={`${batchRoomType}押金`} />
</Form.Item>
</Form>
<div style={{ marginBottom: 8 }}>
<strong>{selectedEligibleStudentIds.length}</strong> / {eligibleStudents.length}
{suggestedDepositByRoomType[batchRoomType] && (
<span style={{ color: '#999', marginLeft: 8 }}>
¥{suggestedDepositByRoomType[batchRoomType]}
</span>
)}
</div>
<Table
size="small"
columns={eligibleColumns}
dataSource={eligibleStudents}
rowKey="studentId"
loading={eligibleLoading}
locale={{ emptyText: <Empty description="暂无符合条件的在住人员" /> }}
pagination={{ pageSize: 6, showSizeChanger: false }}
rowSelection={{
selectedRowKeys: selectedEligibleStudentIds,
onChange: (keys) => setSelectedEligibleStudentIds(keys as number[]),
}}
/>
</Modal>
{/* 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.01} 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="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 && detailModal.installments.length > 0 ? (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={detailModal.installments}
columns={[
{
title: '金额',
dataIndex: 'amount',
render: (value: number) => `¥${Number(value).toFixed(2)}`,
},
{ title: '到期日', dataIndex: 'dueDate' },
{
title: '实付日',
dataIndex: 'paidDate',
render: (value: string, item: any) => (
<EditableCell
value={value}
editor="date"
permission="deposit:edit"
onSave={(next) => saveInstallmentCell(item.id, 'paidDate', next)}
>
{value || '-'}
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'status',
render: (value: string, item: any) => (
<EditableCell
value={value}
editor="select"
options={[
{ value: 'pending', label: '待缴' },
{ value: 'paid', label: '已缴' },
{ value: 'overdue', label: '逾期' },
]}
permission="deposit:edit"
onSave={(next) => saveInstallmentCell(item.id, 'status', next)}
>
<Tag color={installmentStatusMap[value]?.color}>
{installmentStatusMap[value]?.text || value}
</Tag>
</EditableCell>
),
},
{
title: '操作',
render: (_: unknown, item: any) => (
<Space>
{item.status === 'pending' && (
<PermissionButton
permission="deposit:edit"
size="small"
type="primary"
icon={<DollarOutlined />}
onClick={() => handlePayInstallment(item.id)}
>
</PermissionButton>
)}
<Popconfirm
title="确定归档?"
onConfirm={() => handleDeleteInstallment(item.id)}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
},
]}
/>
) : (
<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.01} 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;