fix permissions and teacher attendance workflows
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
@@ -21,6 +20,13 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface UnavailableDatesResponse {
|
||||
dates: string[];
|
||||
}
|
||||
export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
|
||||
`${classroomId}:${date.format('YYYY-MM')}`;
|
||||
|
||||
const ClassroomRentalsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
@@ -33,6 +39,11 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
|
||||
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
|
||||
const unavailableRequestVersion = useRef(0);
|
||||
const [unavailableDatesLoading, setUnavailableDatesLoading] = useState(false);
|
||||
const selectedClassroomId = Form.useWatch('classroomId', form);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchText) return data;
|
||||
@@ -74,8 +85,84 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
fetchData();
|
||||
}, [filterMonth]);
|
||||
|
||||
const resetUnavailableDates = () => {
|
||||
unavailableRequestVersion.current += 1;
|
||||
loadedUnavailableMonths.current.clear();
|
||||
setUnavailableDates(new Set());
|
||||
};
|
||||
|
||||
const loadUnavailableDates = useCallback(
|
||||
async (classroomId: number, date: Dayjs, excludeId?: number) => {
|
||||
const key = unavailableDatesCacheKey(classroomId, date);
|
||||
if (loadedUnavailableMonths.current.has(key)) return;
|
||||
loadedUnavailableMonths.current.add(key);
|
||||
const requestVersion = unavailableRequestVersion.current;
|
||||
|
||||
setUnavailableDatesLoading(true);
|
||||
try {
|
||||
const response = await api.get<UnavailableDatesResponse>(
|
||||
'/classroom-rentals/unavailable-dates',
|
||||
{
|
||||
params: {
|
||||
classroomId,
|
||||
year: date.year(),
|
||||
month: date.month() + 1,
|
||||
excludeId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (requestVersion !== unavailableRequestVersion.current) return;
|
||||
setUnavailableDates((current) => {
|
||||
const next = new Set(current);
|
||||
response.dates.forEach((item) => next.add(item));
|
||||
return next;
|
||||
});
|
||||
} catch (e: any) {
|
||||
loadedUnavailableMonths.current.delete(key);
|
||||
if (requestVersion === unavailableRequestVersion.current) {
|
||||
message.error(e?.message || '加载教室占用日期失败');
|
||||
}
|
||||
} finally {
|
||||
if (requestVersion === unavailableRequestVersion.current) {
|
||||
setUnavailableDatesLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClassroomChange = (classroomId: number) => {
|
||||
form.setFieldValue('dateRange', undefined);
|
||||
resetUnavailableDates();
|
||||
void loadUnavailableDates(classroomId, dayjs(), editing?.id);
|
||||
void loadUnavailableDates(classroomId, dayjs().add(1, 'month'), editing?.id);
|
||||
};
|
||||
|
||||
const handleCalendarChange = (date: Dayjs) => {
|
||||
const classroomId = form.getFieldValue('classroomId');
|
||||
if (classroomId) void loadUnavailableDates(classroomId, date, editing?.id);
|
||||
};
|
||||
|
||||
const isDateUnavailable = (date: Dayjs) => unavailableDates.has(date.format('YYYY-MM-DD'));
|
||||
|
||||
const rangeIncludesUnavailableDate = (range?: [Dayjs, Dayjs]) => {
|
||||
if (!range) return false;
|
||||
for (
|
||||
let date = range[0].startOf('day');
|
||||
!date.isAfter(range[1], 'day');
|
||||
date = date.add(1, 'day')
|
||||
) {
|
||||
if (isDateUnavailable(date)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
if (rangeIncludesUnavailableDate(values.dateRange)) {
|
||||
message.error('所选日期范围包含已排课或已租赁日期,请重新选择');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
classroomId: values.classroomId,
|
||||
@@ -124,10 +211,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
|
||||
const handleDownloadContract = async (id: number, filename?: string) => {
|
||||
try {
|
||||
await downloadBlob(
|
||||
`/classroom-rentals/${id}/contract`,
|
||||
filename || `contract-${id}.pdf`,
|
||||
);
|
||||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||||
} catch {
|
||||
message.error('下载失败(可能文件已丢失)');
|
||||
}
|
||||
@@ -145,6 +229,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
|
||||
const openEdit = (record: any) => {
|
||||
setEditing(record);
|
||||
resetUnavailableDates();
|
||||
form.setFieldsValue({
|
||||
classroomId: record.classroomId,
|
||||
tenantId: record.tenantId,
|
||||
@@ -154,115 +239,145 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
notes: record.notes,
|
||||
});
|
||||
setModalOpen(true);
|
||||
void loadUnavailableDates(record.classroomId, dayjs(record.startDate), record.id);
|
||||
void loadUnavailableDates(
|
||||
record.classroomId,
|
||||
dayjs(record.startDate).add(1, 'month'),
|
||||
record.id,
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '教室', width: 120,
|
||||
dataIndex: 'classroom',
|
||||
render: (c: any) =>
|
||||
c ? (
|
||||
<span>
|
||||
{c.building ? `${c.building} · ` : ''}
|
||||
{c.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '租赁方', width: 100,
|
||||
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', width: 110 },
|
||||
{ title: '结束日期', dataIndex: 'endDate', width: 110 },
|
||||
{
|
||||
title: '时长', width: 80,
|
||||
render: (_: any, r: any) => {
|
||||
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
|
||||
return `${d}天`;
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '教室',
|
||||
width: 120,
|
||||
dataIndex: 'classroom',
|
||||
render: (c: any) =>
|
||||
c ? (
|
||||
<span>
|
||||
{c.building ? `${c.building} · ` : ''}
|
||||
{c.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
},
|
||||
{ title: '日租金', dataIndex: 'dailyRate', width: 100, render: (v: any) => (v ? `¥${v}` : '-') },
|
||||
{ title: '总额', dataIndex: 'totalAmount', width: 100, render: (v: any) => (v ? `¥${v}` : '-') },
|
||||
{
|
||||
title: '合同', width: 120,
|
||||
dataIndex: 'contractPath',
|
||||
render: (v: string, r: any) =>
|
||||
v ? (
|
||||
<Space>
|
||||
<Tooltip title={r.contractOriginalName}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}
|
||||
>
|
||||
下载
|
||||
{
|
||||
title: '租赁方',
|
||||
width: 100,
|
||||
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', width: 110 },
|
||||
{ title: '结束日期', dataIndex: 'endDate', width: 110 },
|
||||
{
|
||||
title: '时长',
|
||||
width: 80,
|
||||
render: (_: any, r: any) => {
|
||||
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
|
||||
return `${d}天`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '日租金',
|
||||
dataIndex: 'dailyRate',
|
||||
width: 100,
|
||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '总额',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 100,
|
||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '合同',
|
||||
width: 120,
|
||||
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 />} aria-label="删除合同文件" />
|
||||
</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>
|
||||
</Tooltip>
|
||||
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除合同文件" />
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
@@ -301,6 +416,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
resetUnavailableDates();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -323,6 +439,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
resetUnavailableDates();
|
||||
}}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
@@ -334,6 +451,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择教室"
|
||||
onChange={handleClassroomChange}
|
||||
options={classrooms.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`,
|
||||
@@ -353,6 +471,9 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
format="YYYY-MM-DD"
|
||||
disabled={!selectedClassroomId}
|
||||
disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)}
|
||||
onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dailyRate" label="日租金(可选)">
|
||||
|
||||
Reference in New Issue
Block a user