feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -1,89 +1,41 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useCallback, useMemo, useState } from 'react';
import {
Table,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Select,
Space,
Tag,
Popconfirm,
Card,
Empty,
} from 'antd';
import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons';
import { PlusOutlined, 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);
import { usePermission } from '../../hooks/usePermission';
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import {
depositStudentLookupsSchema,
depositsSchema,
eligibleStudentsSchema,
} from '../../api/schemas';
import {
DepositModals,
roomTypeOptions,
suggestedDepositByRoomType,
} from './DepositModals';
import type { DepositRecord, EligibleStudent } from './DepositModals';
import { DepositTable } from './DepositTable';
const DepositsPage: React.FC = () => {
const [data, setData] = useState<DepositRecord[]>([]);
const [students, setStudents] = useState<DepositStudentLookup[]>([]);
const [eligibleStudents, setEligibleStudents] = useState<EligibleStudent[]>([]);
const { hasPermission } = usePermission();
const canPurgeDeposit = hasPermission('deposit:purge');
const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [eligibleLoading, setEligibleLoading] = useState(false);
const [selectionTouched, setSelectionTouched] = useState(false);
const [eligibleRoomType, setEligibleRoomType] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
const [createModal, setCreateModal] = useState(false);
const [batchModal, setBatchModal] = useState(false);
const [refundModal, setRefundModal] = useState<DepositRecord | null>(null);
@@ -99,47 +51,115 @@ const DepositsPage: React.FC = () => {
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 {
data: fetchResult = { data: [], students: [] },
isLoading,
isFetching,
} = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({
queryKey: ['deposits'],
queryFn: async () => {
try {
const [d, s] = await Promise.all([
api.get<DepositRecord[]>('/deposits'),
api.get<DepositStudentLookup[]>('/deposits/student-lookups'),
]);
return {
data: validateResponse<DepositRecord[]>(depositsSchema, d),
students: validateResponse<DepositStudentLookup[]>(depositStudentLookupsSchema, s),
};
} catch (e: any) {
message.error(e?.message || '加载失败');
return { data: [], students: [] };
}
},
});
const data = fetchResult.data;
const students = fetchResult.students;
const loading = isLoading || isFetching;
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);
}
}, []);
const invalidateDeposits: QueryKey[] = [['deposits'], ['deposits', 'eligible']];
const createMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/deposits', payload),
{ invalidate: invalidateDeposits },
);
const batchCreateMutation = useApiMutation(
async (payload: Record<string, unknown>) => api.post('/deposits/batch', payload),
{ invalidate: invalidateDeposits },
);
const refundMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
api.put(`/deposits/${id}/refund`, payload),
{ invalidate: invalidateDeposits },
);
const addInstallmentMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
api.post(`/deposits/${id}/installments`, payload),
{ invalidate: [['deposits']] },
);
const payInstallmentMutation = useApiMutation(
async (installmentId: number) => api.post(`/deposits/installments/${installmentId}/pay`),
{ invalidate: [['deposits']] },
);
const saveInstallmentCellMutation = useApiMutation(
async ({
installmentId,
field,
value,
}: {
installmentId: number;
field: 'status' | 'paidDate';
value: unknown;
}) => api.put(`/deposits/installments/${installmentId}`, { [field]: value }),
{ invalidate: [['deposits']] },
);
const deleteInstallmentMutation = useApiMutation(
async (installmentId: number) => api.delete(`/deposits/installments/${installmentId}`),
{ invalidate: [['deposits']] },
);
const archiveMutation = useApiMutation(
async (id: number) => api.delete(`/deposits/${id}`),
{ invalidate: invalidateDeposits },
);
const purgeMutation = useApiMutation(
async (id: number) => api.delete(`/deposits/${id}/permanent`),
{ invalidate: invalidateDeposits },
);
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data: eligibleStudents = [],
isFetching: eligibleFetching,
} = useQuery<EligibleStudent[]>({
queryKey: ['deposits', 'eligible', eligibleRoomType],
queryFn: async () => {
const params: Record<string, string> = {};
if (eligibleRoomType) params.roomType = eligibleRoomType;
return validateResponse<EligibleStudent[]>(
eligibleStudentsSchema,
await api.get('/deposits/eligible', { params }),
);
},
});
const eligibleLoading = eligibleFetching;
const effectiveSelectedEligibleIds = selectionTouched
? selectedEligibleStudentIds
: eligibleStudents.map((item) => item.studentId);
const fetchEligibleStudents = useCallback(
(roomType?: string) => {
setEligibleRoomType(roomType);
queryClient.invalidateQueries({ queryKey: ['deposits', 'eligible'] });
},
[queryClient],
);
useEffect(() => {
fetchEligibleStudents(filterRoomType);
}, [fetchEligibleStudents, filterRoomType]);
const changeFilterRoomType = (value: string | undefined) => {
setFilterRoomType(value);
setSelectionTouched(false);
fetchEligibleStudents(value);
};
const depositByStudentId = useMemo(() => {
const map = new Map<number, DepositRecord>();
data.forEach((item) => map.set(item.studentId, item));
for (const item of data) map.set(item.studentId, item);
return map;
}, [data]);
@@ -176,7 +196,6 @@ const DepositsPage: React.FC = () => {
};
});
}
return data.filter((d) => {
if (searchText) {
const s = searchText.toLowerCase();
@@ -192,10 +211,11 @@ const DepositsPage: React.FC = () => {
const openBatchModal = (roomType = filterRoomType || '四人间') => {
const amount = suggestedDepositByRoomType[roomType] ?? 100;
setBatchRoomType(roomType);
setSelectionTouched(false);
fetchEligibleStudents(roomType);
batchForm.resetFields();
batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() });
setBatchModal(true);
fetchEligibleStudents(roomType);
};
const handleBatchRoomTypeChange = (roomType: string) => {
@@ -207,55 +227,38 @@ const DepositsPage: React.FC = () => {
};
const handleCreate = async () => {
setSaving(true);
try {
const values = await createForm.validateFields();
await api.post('/deposits', {
await createMutation.mutateAsync({
studentId: values.studentId,
amount: values.amount,
paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes,
});
message.success('押金金额已增加');
message.success('押金收取成功');
setCreateModal(false);
createForm.resetFields();
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
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,
await batchCreateMutation.mutateAsync({
studentIds: effectiveSelectedEligibleIds,
amount: values.amount,
paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes,
roomType: values.roomType,
});
message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`);
message.success('批量收取成功');
setBatchModal(false);
batchForm.resetFields();
await fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
setSelectionTouched(false);
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
@@ -264,19 +267,18 @@ const DepositsPage: React.FC = () => {
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,
await refundMutation.mutateAsync({
id: refundModal.id,
payload: {
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 || '操作失败');
}
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -286,31 +288,27 @@ const DepositsPage: React.FC = () => {
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'),
await addInstallmentMutation.mutateAsync({
id: installmentModal,
payload: {
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 || '操作失败');
}
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePayInstallment = async (installmentId: number) => {
try {
await api.put(`/deposits/installments/${installmentId}`, {
paidDate: dayjs().format('YYYY-MM-DD'),
status: 'paid',
});
await payInstallmentMutation.mutateAsync(installmentId);
message.success('分期已标记为已缴');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
@@ -319,117 +317,27 @@ const DepositsPage: React.FC = () => {
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);
try {
await saveInstallmentCellMutation.mutateAsync({ installmentId, field, value });
message.success('分期记录已保存');
if (detailModal) {
const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);
setDetailModal(refreshed);
}
} catch {
// 错误提示由 useApiMutation 统一处理
}
await fetchData();
};
const handleDeleteInstallment = async (installmentId: number) => {
try {
await api.delete(`/deposits/installments/${installmentId}`);
await deleteInstallmentMutation.mutateAsync(installmentId);
message.success('分期已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
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: '学生',
@@ -475,7 +383,7 @@ const DepositsPage: React.FC = () => {
allowClear
style={{ width: 130 }}
value={filterRoomType}
onChange={(v) => setFilterRoomType(v)}
onChange={changeFilterRoomType}
options={roomTypeOptions}
/>
<Select
@@ -514,301 +422,55 @@ const DepositsPage: React.FC = () => {
</PermissionButton>
</Space>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
<DepositTable
data={filteredData}
loading={loading || (!!filterRoomType && eligibleLoading)}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
canPurgeDeposit={canPurgeDeposit}
refundForm={refundForm}
onDetail={(record) => setDetailModal(record)}
onRefund={(record) => setRefundModal(record)}
onArchive={(id) => archiveMutation.mutateAsync(id)}
onPurge={(id) => purgeMutation.mutateAsync(id)}
/>
<DepositModals
batchModal={batchModal}
createModal={createModal}
refundModal={refundModal}
detailModal={detailModal}
installmentModal={installmentModal}
batchForm={batchForm}
createForm={createForm}
refundForm={refundForm}
installmentForm={installmentForm}
saving={saving}
batchRoomType={batchRoomType}
effectiveSelectedEligibleIds={effectiveSelectedEligibleIds}
eligibleStudents={eligibleStudents}
eligibleLoading={eligibleLoading}
eligibleColumns={eligibleColumns}
studentOptions={studentOptions}
onBatchRoomTypeChange={handleBatchRoomTypeChange}
onBatchCreate={handleBatchCreate}
onCreate={handleCreate}
onRefund={handleRefund}
onAddInstallment={handleAddInstallment}
onPayInstallment={handlePayInstallment}
onSaveInstallmentCell={saveInstallmentCell}
onDeleteInstallment={handleDeleteInstallment}
onCloseBatch={() => setBatchModal(false)}
onCloseCreate={() => setCreateModal(false)}
onCloseRefund={() => setRefundModal(null)}
onCloseDetail={() => setDetailModal(null)}
onCloseInstallment={() => setInstallmentModal(null)}
onOpenInstallment={(id) => {
setInstallmentModal(id);
installmentForm.resetFields();
}}
onSelectEligible={(ids) => {
setSelectedEligibleStudentIds(ids);
setSelectionTouched(true);
}}
/>
{/* 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>
);
};