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

379 lines
11 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, { useEffect, useState, useMemo } from 'react';
import {
Table,
Button,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Space,
message,
Tag,
Popconfirm,
Upload,
Tooltip,
} from 'antd';
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const ClassroomRentalsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [classrooms, setClassrooms] = useState<any[]>([]);
const [tenants, setTenants] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
const [searchText, setSearchText] = useState('');
const filteredData = useMemo(() => {
if (!searchText) return data;
const s = searchText.toLowerCase();
return data.filter((r: any) => {
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
const matchTenant = r.tenant?.name?.toLowerCase().includes(s);
return matchClassroom || matchTenant;
});
}, [data, searchText]);
const fetchData = async () => {
setLoading(true);
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
const res: any = await api.get('/classroom-rentals', { params });
setData(res);
} catch (e) {
console.error(e);
}
setLoading(false);
};
const fetchMeta = async () => {
try {
const [cr, tn]: any = await Promise.all([api.get('/classrooms'), api.get('/tenants')]);
setClassrooms(cr);
setTenants(tn);
} catch (e) {
console.error(e);
}
};
useEffect(() => {
fetchMeta();
}, []);
useEffect(() => {
fetchData();
}, [filterMonth]);
const handleSave = async () => {
const values = await form.validateFields();
const payload = {
classroomId: values.classroomId,
tenantId: values.tenantId,
startDate: values.dateRange[0].format('YYYY-MM-DD'),
endDate: values.dateRange[1].format('YYYY-MM-DD'),
dailyRate: values.dailyRate,
totalAmount: values.totalAmount,
notes: values.notes,
};
try {
if (editing) {
await api.put(`/classroom-rentals/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/classroom-rentals', payload);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) {
if (e?.conflicts?.length) {
const list = e.conflicts
.map((c: any) => `${c.tenantName}(${c.startDate}~${c.endDate})`)
.join('、');
message.error(`时间段冲突:${list}`);
} else {
message.error(e?.message || '操作失败');
}
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}`);
message.success('已删除');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const handleDownloadContract = (id: number, filename?: string) => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/classroom-rentals/${id}/contract`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
if (!res.ok) throw new Error('下载失败');
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `contract-${id}.pdf`;
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败(可能文件已丢失)'));
};
const handleDeleteContract = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}/contract`);
message.success('合同已删除');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (record: any) => {
setEditing(record);
form.setFieldsValue({
classroomId: record.classroomId,
tenantId: record.tenantId,
dateRange: [dayjs(record.startDate), dayjs(record.endDate)],
dailyRate: record.dailyRate ? Number(record.dailyRate) : undefined,
totalAmount: record.totalAmount ? Number(record.totalAmount) : undefined,
notes: record.notes,
});
setModalOpen(true);
};
const columns = [
{
title: '教室',
dataIndex: 'classroom',
render: (c: any) =>
c ? (
<span>
{c.building ? `${c.building} · ` : ''}
{c.name}
</span>
) : (
'-'
),
},
{
title: '租赁方',
dataIndex: 'tenant',
render: (t: any) =>
t ? (
<Tag color={t.color} style={{ background: t.color, color: '#fff', borderColor: t.color }}>
{t.name}
</Tag>
) : (
'-'
),
},
{ title: '开始日期', dataIndex: 'startDate' },
{ title: '结束日期', dataIndex: 'endDate' },
{
title: '时长',
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
},
},
{ title: '日租金', dataIndex: 'dailyRate', render: (v: any) => (v ? `¥${v}` : '-') },
{ title: '总额', dataIndex: 'totalAmount', render: (v: any) => (v ? `¥${v}` : '-') },
{
title: '合同',
dataIndex: 'contractPath',
render: (v: string, r: any) =>
v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}
>
</Button>
</Tooltip>
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
) : (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/classroom-rentals/${r.id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('合同已上传');
onSuccess?.({});
fetchData();
} catch (e: any) {
message.error(e?.message || '上传失败');
onError?.(e);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
</Button>
</Upload>
),
},
{
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="rental:edit" size="small" onClick={() => openEdit(record)}>
</PermissionButton>
<Popconfirm
title="确定删除该租赁订单?合同文件将一并删除。"
onConfirm={() => handleDelete(record.id)}
>
<PermissionButton permission="rental:delete" size="small" danger>
</PermissionButton>
</Popconfirm>
</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('');
}}
/>
<DatePicker
picker="month"
placeholder="按月筛选"
value={filterMonth}
onChange={setFilterMonth}
allowClear
format="YYYY-MM"
/>
</Space>
<PermissionButton
permission="rental:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
scroll={{ x: 1200 }}
/>
<Modal
title={editing ? '编辑租赁' : '新增租赁'}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
okText="保存"
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择教室"
options={classrooms.map((c) => ({
value: c.id,
label: `${c.building ? c.building + ' · ' : ''}${c.name}${c.roomType}`,
}))}
/>
</Form.Item>
<Form.Item name="tenantId" label="租赁方" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择租赁方"
options={tenants.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
<Form.Item name="dateRange" label="租赁起止日期" rules={[{ required: true }]}>
<DatePicker.RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="dailyRate" label="日租金(可选)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="totalAmount" label="合同总额(可选)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ClassroomRentalsPage;