- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
192 lines
8.3 KiB
TypeScript
192 lines
8.3 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { Table, Button, Modal, Form, Select, DatePicker, InputNumber, Input, Space, message, Tag, Popconfirm } from 'antd';
|
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import api from '../../api';
|
|
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 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 [createForm] = Form.useForm();
|
|
const [refundForm] = Form.useForm();
|
|
const [searchText, setSearchText] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
|
|
|
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) { console.error(e); }
|
|
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 handleCreate = async () => {
|
|
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 || '操作失败'); }
|
|
};
|
|
|
|
const handleRefund = async () => {
|
|
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 || '操作失败'); }
|
|
};
|
|
|
|
const columns = [
|
|
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
|
|
{ title: '押金金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
|
{ title: '缴纳日期', dataIndex: 'paidDate' },
|
|
{
|
|
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', render: (v: any) => v || '-' },
|
|
{ title: '退还日期', dataIndex: 'refundDate', render: (v: any) => v || '-' },
|
|
{ title: '备注', dataIndex: 'notes', render: (v: any) => v || '-' },
|
|
{
|
|
title: '操作', width: 160,
|
|
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:delete">
|
|
<Popconfirm title="确定删除?" onConfirm={async () => {
|
|
try { await api.delete(`/deposits/${record.id}`); message.success('删除成功'); fetchData(); } catch (e: any) { message.error(e?.message || '删除失败'); }
|
|
}}>
|
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
|
</Popconfirm>
|
|
</PermissionButton>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
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} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} />
|
|
|
|
<Modal title="收取押金" open={createModal} onOk={handleCreate} onCancel={() => setCreateModal(false)} okText="确认">
|
|
<Form form={createForm} layout="vertical">
|
|
<Form.Item name="studentId" label="学生" rules={[{ required: true, message: '请选择学生' }]}>
|
|
<Select showSearch optionFilterProp="label" placeholder="搜索并选择学生"
|
|
options={students.filter((s: any) => s.status === 'active').map((s: any) => ({ value: s.id, label: `${s.name} (${s.idNumber || s.phone || ''})` }))} />
|
|
</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>
|
|
|
|
<Modal title={`退还押金 - ${refundModal?.student?.name}`} open={!!refundModal} onOk={handleRefund} onCancel={() => setRefundModal(null)} okText="确认退还">
|
|
<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>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DepositsPage;
|