399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { App, Button, DatePicker, Form, Input, Modal, Select, Table, Tag } from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import { PlusOutlined } from '@ant-design/icons';
|
||
import { useNavigate } from 'react-router';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import dayjs from 'dayjs';
|
||
import api from '../../api';
|
||
import { queryKeys } from '../../api/queryKeys';
|
||
import { message } from '../../ui/app-message';
|
||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import PermissionButton from '../PermissionButton';
|
||
import { EditableArchiveCell } from './EditableArchiveCell';
|
||
import { CLASS_TYPE_OPTIONS, COURSE_CATEGORY_OPTIONS, ENROLLMENT_STATUS_MAP, formatEnrollmentDisplayName, getClassTypeLabel, getCourseCategoryLabel, getEnrollmentStatus } from './shared';
|
||
import type { EnrollmentRecord, TabProps } from './shared';
|
||
|
||
const ENROLLMENT_FIELDS = {
|
||
courseCategory: 'courseCategory',
|
||
classType: 'classType',
|
||
className: 'className',
|
||
headTeacher: 'headTeacher',
|
||
subjectTeacher: 'subjectTeacher',
|
||
startDate: 'startDate',
|
||
endDate: 'endDate',
|
||
status: 'status',
|
||
} as const;
|
||
|
||
interface ClassOption {
|
||
id: number;
|
||
name: string;
|
||
classType: string;
|
||
startDate?: string | null;
|
||
endDate?: string | null;
|
||
isArchived: boolean;
|
||
}
|
||
|
||
interface ClassTeacher {
|
||
id: number;
|
||
userId: number;
|
||
name?: string | null;
|
||
roleType: string;
|
||
subject?: string | null;
|
||
}
|
||
|
||
export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
||
data,
|
||
studentId,
|
||
}) => {
|
||
const { modal } = App.useApp();
|
||
const navigate = useNavigate();
|
||
const { hasPermission } = usePermission();
|
||
const canPurgeArchive = hasPermission('archive:purge');
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [form] = Form.useForm();
|
||
const [saving, setSaving] = useState(false);
|
||
const [selectedClassId, setSelectedClassId] = useState<number>();
|
||
|
||
const addEnrollmentMutation = useApiMutation(
|
||
async (payload: Record<string, unknown>) =>
|
||
api.post(`/archive/${studentId}/enrollments`, payload),
|
||
{ invalidate: [['archive', studentId]] },
|
||
);
|
||
const saveEnrollmentCellMutation = useApiMutation(
|
||
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
|
||
api.put(`/archive/enrollments/${id}`, payload),
|
||
{ invalidate: [['archive', studentId]] },
|
||
);
|
||
const purgeEnrollmentMutation = useApiMutation(
|
||
async (id: number) => api.delete(`/archive/enrollments/${id}/permanent`),
|
||
{ invalidate: [['archive', studentId]] },
|
||
);
|
||
|
||
const {
|
||
data: classes = [],
|
||
} = useQuery<ClassOption[]>({
|
||
queryKey: queryKeys.classes.list({ archived: false }),
|
||
queryFn: async () => {
|
||
try {
|
||
return await api.get<ClassOption[]>('/classes', { params: { isArchived: false } });
|
||
} catch {
|
||
return [];
|
||
}
|
||
},
|
||
});
|
||
|
||
const enrolledClassNames = useMemo(
|
||
() => new Set(data.map((e) => e.className).filter((name): name is string => Boolean(name))),
|
||
[data],
|
||
);
|
||
|
||
const enrolledClassIds = useMemo(
|
||
() => new Set(data.map((e) => e.classId).filter((id): id is number => id != null)),
|
||
[data],
|
||
);
|
||
|
||
const {
|
||
data: teachers = [],
|
||
} = useQuery<ClassTeacher[]>({
|
||
queryKey: ['classes', 'detail', 'teachers', selectedClassId],
|
||
enabled: modalOpen && !!selectedClassId,
|
||
queryFn: async () => {
|
||
try {
|
||
return await api.get<ClassTeacher[]>(`/classes/${selectedClassId}/teachers`);
|
||
} catch {
|
||
return [];
|
||
}
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
const headTeacher = teachers.find((t) => t.roleType === 'head_teacher')?.name || '';
|
||
const subjectNames = Array.from(
|
||
new Set(
|
||
teachers
|
||
.filter((t) => t.roleType === 'subject_teacher')
|
||
.map((t) => t.name)
|
||
.filter((name): name is string => Boolean(name)),
|
||
),
|
||
);
|
||
form.setFieldsValue({ headTeacher, subjectTeacher: subjectNames.join('、') });
|
||
}, [modalOpen, selectedClassId, teachers, form]);
|
||
|
||
const handleClassChange = (classId: number) => {
|
||
setSelectedClassId(classId);
|
||
const cls = classes.find((c) => c.id === classId);
|
||
if (!cls) return;
|
||
const categoryOption = COURSE_CATEGORY_OPTIONS.find((o) => o.value === cls.classType);
|
||
form.setFieldsValue({
|
||
...(categoryOption ? { courseCategory: categoryOption.value } : {}),
|
||
// 切班时始终重置日期:新班级无起止日期时清空上一班级自动带出的值
|
||
startDate: cls.startDate ? dayjs(cls.startDate) : undefined,
|
||
endDate: cls.endDate ? dayjs(cls.endDate) : undefined,
|
||
headTeacher: '',
|
||
subjectTeacher: '',
|
||
});
|
||
};
|
||
|
||
const handleAdd = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
const { classId, ...rest } = values;
|
||
const selectedClass = classes.find((c) => c.id === classId);
|
||
await addEnrollmentMutation.mutateAsync({
|
||
...rest,
|
||
classId: classId ?? undefined,
|
||
className: selectedClass?.name,
|
||
startDate: values.startDate?.format('YYYY-MM-DD'),
|
||
endDate: values.endDate?.format('YYYY-MM-DD'),
|
||
});
|
||
message.success('报读记录已添加');
|
||
setModalOpen(false);
|
||
form.resetFields();
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => {
|
||
try {
|
||
if (field === ENROLLMENT_FIELDS.className) {
|
||
// 单元格编辑提交的是选中班级 id(含撤销回退的空值),映射回 className+classId 双字段
|
||
const classId = value === '' || value === null || value === undefined ? null : Number(value);
|
||
const cls = classId != null ? classes.find((c) => c.id === classId) : undefined;
|
||
await saveEnrollmentCellMutation.mutateAsync({
|
||
id: record.id,
|
||
payload: { className: cls?.name ?? '', classId },
|
||
});
|
||
} else {
|
||
await saveEnrollmentCellMutation.mutateAsync({
|
||
id: record.id,
|
||
payload: { [field]: value },
|
||
});
|
||
}
|
||
message.success('报读记录已保存');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
};
|
||
|
||
const handlePurge = (record: EnrollmentRecord) => {
|
||
modal.confirm({
|
||
title: `永久删除报读记录(${formatEnrollmentDisplayName(record)})?`,
|
||
content: '删除后不可恢复,被考试成绩引用时将无法删除。确定继续?',
|
||
okText: '永久删除',
|
||
okButtonProps: { danger: true },
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await purgeEnrollmentMutation.mutateAsync(record.id);
|
||
message.success('已永久删除(不可恢复)');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const columns: ColumnsType<EnrollmentRecord> = [
|
||
{
|
||
title: '课程类别',
|
||
dataIndex: 'courseCategory',
|
||
render: (v: string, r) => (
|
||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.courseCategory} record={r} editor="select" options={COURSE_CATEGORY_OPTIONS} onSave={saveCell}>
|
||
{getCourseCategoryLabel(v)}
|
||
</EditableArchiveCell>
|
||
),
|
||
},
|
||
{
|
||
title: '班型',
|
||
dataIndex: 'classType',
|
||
render: (v: string, r) => (
|
||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.classType} record={r} editor="select" options={CLASS_TYPE_OPTIONS} onSave={saveCell}>
|
||
{getClassTypeLabel(v)}
|
||
</EditableArchiveCell>
|
||
),
|
||
},
|
||
{
|
||
title: '班级名称',
|
||
dataIndex: 'className',
|
||
render: (v: string, r) => (
|
||
<EditableArchiveCell
|
||
value={r.classId ?? null}
|
||
field={ENROLLMENT_FIELDS.className}
|
||
record={r}
|
||
editor="select"
|
||
options={classes.map((c) => ({
|
||
value: c.id,
|
||
label: c.name,
|
||
disabled: enrolledClassIds.has(c.id) && c.id !== r.classId,
|
||
}))}
|
||
onSave={saveCell}
|
||
>
|
||
{r.classId != null ? (
|
||
<a onClick={() => navigate(`/classes/${r.classId}`)}>{v || '-'}</a>
|
||
) : (
|
||
v || '-'
|
||
)}
|
||
</EditableArchiveCell>
|
||
),
|
||
},
|
||
{
|
||
title: '班主任',
|
||
dataIndex: 'headTeacher',
|
||
render: (v: string, r) => (
|
||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.headTeacher} record={r} onSave={saveCell}>
|
||
{v || '-'}
|
||
</EditableArchiveCell>
|
||
),
|
||
},
|
||
{
|
||
title: '任课教师',
|
||
dataIndex: 'subjectTeacher',
|
||
render: (v: string, r) => (
|
||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.subjectTeacher} record={r} onSave={saveCell}>
|
||
{v || '-'}
|
||
</EditableArchiveCell>
|
||
),
|
||
},
|
||
{
|
||
title: '开始日期',
|
||
dataIndex: 'startDate',
|
||
render: (v: string, r) => (
|
||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.startDate} record={r} editor="date" onSave={saveCell}>
|
||
{v || '-'}
|
||
</EditableArchiveCell>
|
||
),
|
||
},
|
||
{
|
||
title: '结束日期',
|
||
dataIndex: 'endDate',
|
||
render: (v: string, r) => (
|
||
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.endDate} record={r} editor="date" onSave={saveCell}>
|
||
{v || '-'}
|
||
</EditableArchiveCell>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
render: (v: string, r) => {
|
||
const status = getEnrollmentStatus(v);
|
||
return (
|
||
<EditableArchiveCell
|
||
value={v}
|
||
field={ENROLLMENT_FIELDS.status}
|
||
record={r}
|
||
editor="select"
|
||
options={Object.entries(ENROLLMENT_STATUS_MAP).map(([value, item]) => ({
|
||
value,
|
||
label: item.text,
|
||
}))}
|
||
onSave={saveCell}
|
||
>
|
||
<Tag color={status.color}>{status.text}</Tag>
|
||
</EditableArchiveCell>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
render: (_: unknown, r: EnrollmentRecord) =>
|
||
r.status === 'archived' && canPurgeArchive ? (
|
||
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
|
||
删除
|
||
</Button>
|
||
) : null,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<PermissionButton
|
||
permission="student:edit"
|
||
icon={<PlusOutlined />}
|
||
type="primary"
|
||
onClick={() => {
|
||
form.resetFields();
|
||
setSelectedClassId(undefined);
|
||
setModalOpen(true);
|
||
}}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
添加报读记录
|
||
</PermissionButton>
|
||
<Table<EnrollmentRecord> scroll={{ x: 'max-content' }}
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||
pagination={{
|
||
defaultPageSize: 15,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [15, 30, 50],
|
||
}}
|
||
/>
|
||
<Modal
|
||
title="添加报读记录"
|
||
open={modalOpen && hasPermission('student:edit')}
|
||
onOk={hasPermission('student:edit') ? handleAdd : undefined}
|
||
onCancel={() => {
|
||
setSelectedClassId(undefined);
|
||
setModalOpen(false);
|
||
}}
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
name="courseCategory"
|
||
label="课程类别"
|
||
rules={[{ required: true, message: '请选择课程类别' }]}
|
||
>
|
||
<Select options={COURSE_CATEGORY_OPTIONS} placeholder="请选择" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="classType"
|
||
label="班型"
|
||
rules={[{ required: true, message: '请选择班型' }]}
|
||
>
|
||
<Select options={CLASS_TYPE_OPTIONS} placeholder="请选择" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="classId"
|
||
label="班级名称"
|
||
extra="选择班级后自动带出课程类别、日期与教师信息"
|
||
>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="搜索并选择班级"
|
||
onChange={handleClassChange}
|
||
options={classes.map((c) => ({
|
||
value: c.id,
|
||
label: enrolledClassNames.has(c.name) ? `${c.name}(已报读)` : c.name,
|
||
disabled: enrolledClassNames.has(c.name) || enrolledClassIds.has(c.id),
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="headTeacher" label="班主任">
|
||
<Input placeholder="班主任姓名" />
|
||
</Form.Item>
|
||
<Form.Item name="subjectTeacher" label="任课教师">
|
||
<Input placeholder="任课教师姓名" />
|
||
</Form.Item>
|
||
<Form.Item name="startDate" label="开始日期">
|
||
<DatePicker style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="endDate" label="结束日期">
|
||
<DatePicker style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|