From e11772f3aa04e50f5c67db1a6c4557d0bee3073d Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 16:54:35 +0800 Subject: [PATCH] feat(web): migrate teaching task administration --- web-react/MIGRATION_MATRIX.md | 1 + web-react/README.md | 1 + web-react/src/app/App.tsx | 2 + web-react/src/layout/AppShell.tsx | 1 + web-react/src/pages/TeachingTasksPage.tsx | 257 ++++++++++++++++++++++ web-react/src/pages/teaching-tasks.css | 1 + 6 files changed, 263 insertions(+) create mode 100644 web-react/src/pages/TeachingTasksPage.tsx create mode 100644 web-react/src/pages/teaching-tasks.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index bb225dc..e28b2af 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -48,6 +48,7 @@ | `/courses` | 课程库 | 已登录用户;教务与学院管理员可维护 | 游标分页、数据范围、先修关系、分类/性质、Excel、归档审计 | verification | | `/curriculum` | 培养方案管理 | SuperAdmin、AcademicAdmin、CollegeAdmin | 版本、模块、课程、课程组、发布替换与已发布受控编辑 | verification | | `/teachers`、`/students` | 教师/学生档案 | 教务、学院、辅导员;管理员可写 | 服务端分页、数据范围、完整档案、Excel、账号激活与归档审计 | verification | +| `/teaching-tasks` | 教学任务 | SuperAdmin、AcademicAdmin、CollegeAdmin | 游标分页、职责范围、草稿编制、授课资格、状态流转、批量操作与公共课合班生成 | verification | | `/activate` | 学生账号激活 | 公开 | 学籍选项、身份匹配、强密码和一次性激活 | verification | | `/verify/:verificationCode?` | 电子凭证验真 | 公开 | 唯一编号、实时状态、签发信息和 PDF 哈希 | verification | | `/classroom-repairs/report/:classroomId` | 教室扫码报修 | 公开 | 教室设备、故障类别、联系方式和工单提交 | verification | diff --git a/web-react/README.md b/web-react/README.md index 0c800c6..46bcfd7 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -47,6 +47,7 @@ npm.cmd run build - 迁移课程库游标台账,保留学院维护范围、先修关系、筛选导出、批量导入与归档审计。 - 迁移培养方案管理,覆盖版本复制、模块与课程编制、公共课程组、发布替换和已发布方案受控调整。 - 迁移教师与学生档案名册,覆盖分页筛选、完整个人资料、Excel、教师账号激活和人员归档审计。 +- 迁移教学任务管理,覆盖权限职责范围、草稿编制、授课资格、发布/归档流转、批量操作与公共课合班生成。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index a817e45..a6525c0 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -46,6 +46,7 @@ const BaseDataPage = lazy(() => import('../pages/BaseDataPage')) const CourseCataloguePage = lazy(() => import('../pages/CourseCataloguePage')) const CurriculumManagementPage = lazy(() => import('../pages/CurriculumManagementPage')) const PersonnelRegistryPage = lazy(() => import('../pages/PersonnelRegistryPage')) +const TeachingTasksPage = lazy(() => import('../pages/TeachingTasksPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -138,6 +139,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 2ff8120..7d2430b 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -112,6 +112,7 @@ export default function AppShell() { { key: '/teaching-preferences', icon: , label: roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '授课科目申报' : '授课资格审核' }, ] : []), ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [ + { key: '/teaching-tasks', icon: , label: '教学任务' }, { key: '/curriculum', icon: , label: '培养方案管理' }, { key: '/course-equivalency-rules', icon: , label: '等价课程规则' }, { key: '/course-enrollments', icon: , label: '修读课程管理' }, diff --git a/web-react/src/pages/TeachingTasksPage.tsx b/web-react/src/pages/TeachingTasksPage.tsx new file mode 100644 index 0000000..a5a5e8d --- /dev/null +++ b/web-react/src/pages/TeachingTasksPage.tsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useState } from 'react' +import { ApartmentOutlined, PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Form, Input, InputNumber, Modal, Radio, Select, Skeleton, Table, Tag } from 'antd' +import type { ColumnsType, TableRowSelection } from 'antd/es/table/interface' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './teaching-tasks.css' + +interface Term { id: string; name: string; isCurrent?: boolean; isArchived?: boolean } +interface Course { id: string; code: string; name: string; nature: string; collegeId: string; collegeName: string; totalHours: number; practiceHours: number; isEnabled?: boolean } +interface Major { id: string; name: string; collegeId: string; collegeName: string; isEnabled?: boolean } +interface AdministrativeClass { id: string; code: string; name: string; majorId: string; majorName: string; grade: number; isEnabled?: boolean } +interface EligibleTeacher { teacherId: string; teacherNumber: string; name: string; title?: string; collegeName?: string } +interface TeachingTask { + id: string; taskNumber: string; name: string; academicTermId: string; termName: string; courseId: string; + courseCode: string; courseName: string; courseNature: string; collegeId: string; collegeName: string; + capacity: number; startWeek: number; endWeek: number; weeklyHours: number; schedulingMode: string; + courseTotalHours: number; coursePracticeHours: number; courseRegularScheduleHours: number; generationBatchCode?: string; + status: 'Draft' | 'Published' | 'Closed'; teacherNames: string[]; classNames: string[]; studentCount: number +} +interface TaskDetail extends TeachingTask { teacherIds: string[]; primaryTeacherId?: string; classIds: string[]; notes?: string } +interface CursorPage { items: TeachingTask[]; nextCursor?: string; hasMore: boolean; pageSize: number; total: number } +interface Filters { keyword: string; academicTermId?: string; collegeId?: string; status?: string; teacherKeyword: string; classKeyword: string } +interface TaskForm { + taskNumber: string; name: string; academicTermId: string; courseId: string; capacity: number; startWeek: number; + endWeek: number; weeklyHours: number; schedulingMode: string; teacherIds: string[]; primaryTeacherId?: string; + classIds: string[]; notes?: string +} +interface GenerationForm { academicTermId: string; courseId: string; classesPerTask: number; startWeek: number; endWeek: number; weeklyHours: number; classIds: string[] } + +const statusLabels: Record = { Draft: '草稿', Published: '已发布', Closed: '已归档' } +const natureLabels: Record = { GeneralRequired: '公共必修', GeneralElective: '公共选修', MajorRequired: '专业必修', MajorElective: '专业选修', Practice: '实践教学' } +const publicNatures = new Set(['GeneralRequired', 'GeneralElective']) +const majorNatures = new Set(['MajorRequired', 'MajorElective', 'Practice']) +const termLabel = (term: Term) => `${term.name}${term.isCurrent ? ' · 当前' : term.isArchived ? ' · 已归档' : ' · 历史'}` +const defaultTermId = (terms: Term[]) => terms.find((item) => item.isCurrent)?.id ?? terms.find((item) => !item.isArchived)?.id ?? terms[0]?.id + +export default function TeachingTasksPage() { + const { message, modal } = App.useApp() + const client = useQueryClient() + const user = useAuthStore((state) => state.user) + const roles = user?.roles ?? [] + const isSuperAdmin = roles.includes('SuperAdmin') + const isAcademicAdmin = roles.includes('AcademicAdmin') + const isCollegeAdmin = roles.includes('CollegeAdmin') + const canGeneratePublic = isSuperAdmin || isAcademicAdmin + const [draft, setDraft] = useState({ keyword: '', teacherKeyword: '', classKeyword: '' }) + const [filters, setFilters] = useState({ keyword: '', teacherKeyword: '', classKeyword: '' }) + const [pageSize, setPageSize] = useState(20) + const [cursors, setCursors] = useState([]) + const [selectedRows, setSelectedRows] = useState([]) + const [editing, setEditing] = useState<{ id?: string }>() + const [generating, setGenerating] = useState(false) + const [generationOpen, setGenerationOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [eligibleTeachers, setEligibleTeachers] = useState([]) + const [generationTeachers, setGenerationTeachers] = useState([]) + const [form] = Form.useForm() + const [generationForm] = Form.useForm() + const cursor = cursors.at(-1) + const watchedCourseId = Form.useWatch('courseId', form) + const watchedTeacherIds = Form.useWatch('teacherIds', form) ?? [] + const watchedStartWeek = Form.useWatch('startWeek', form) ?? 0 + const watchedEndWeek = Form.useWatch('endWeek', form) ?? 0 + const watchedWeeklyHours = Form.useWatch('weeklyHours', form) ?? 0 + const generationCourseId = Form.useWatch('courseId', generationForm) + const generationClassIds = Form.useWatch('classIds', generationForm) ?? [] + const generationClassesPerTask = Form.useWatch('classesPerTask', generationForm) ?? 1 + const generationStartWeek = Form.useWatch('startWeek', generationForm) ?? 0 + const generationEndWeek = Form.useWatch('endWeek', generationForm) ?? 0 + const generationWeeklyHours = Form.useWatch('weeklyHours', generationForm) ?? 0 + + const references = useQuery({ + queryKey: ['teaching-task-references'], + queryFn: async () => { + const [terms, courses, classes, majors] = await Promise.all([ + http.get('/base-data/terms'), http.get('/courses/options'), + http.get('/base-data/classes'), http.get('/base-data/majors'), + ]) + return { terms: terms.data, courses: courses.data, classes: classes.data, majors: majors.data } + }, + }) + useEffect(() => { + const termId = defaultTermId(references.data?.terms ?? []) + if (termId && !filters.academicTermId) { + const next = { ...filters, academicTermId: termId } + setDraft(next); setFilters(next) + } + }, [references.data?.terms]) // eslint-disable-line react-hooks/exhaustive-deps + + const tasks = useQuery({ + queryKey: ['teaching-tasks', cursor, pageSize, filters], + enabled: !!references.data, + queryFn: async () => (await http.get('/teaching-tasks', { params: { + cursor, pageSize, keyword: filters.keyword || undefined, academicTermId: filters.academicTermId, + collegeId: filters.collegeId, status: filters.status, teacherKeyword: filters.teacherKeyword || undefined, + classKeyword: filters.classKeyword || undefined, + } })).data, + }) + const refs = references.data ?? { terms: [], courses: [], classes: [], majors: [] } + const manageableCourses = useMemo(() => refs.courses.filter((course) => { + if (isSuperAdmin) return true + if (isAcademicAdmin && publicNatures.has(course.nature)) return true + return isCollegeAdmin && course.collegeId === user?.collegeId && majorNatures.has(course.nature) + }), [refs.courses, isSuperAdmin, isAcademicAdmin, isCollegeAdmin, user?.collegeId]) + const publicCourses = refs.courses.filter((course) => publicNatures.has(course.nature)) + const availableClasses = useMemo(() => { + const enabled = refs.classes.filter((item) => item.isEnabled !== false) + if (!isCollegeAdmin || !user?.collegeId) return enabled + const majorIds = new Set(refs.majors.filter((major) => major.collegeId === user.collegeId).map((major) => major.id)) + return enabled.filter((item) => majorIds.has(item.majorId)) + }, [refs.classes, refs.majors, isCollegeAdmin, user?.collegeId]) + const courseColleges = useMemo(() => [...new Map(refs.courses.map((item) => [item.collegeId, item.collegeName])).entries()].map(([id, name]) => ({ id, name })), [refs.courses]) + const selectedCourse = manageableCourses.find((course) => course.id === watchedCourseId) + const selectedGenerationCourse = publicCourses.find((course) => course.id === generationCourseId) + const plannedHours = watchedEndWeek >= watchedStartWeek ? (watchedEndWeek - watchedStartWeek + 1) * watchedWeeklyHours : 0 + const generationHours = generationEndWeek >= generationStartWeek ? (generationEndWeek - generationStartWeek + 1) * generationWeeklyHours : 0 + const groups = Math.ceil(generationClassIds.length / Math.max(1, generationClassesPerTask)) + const responsibility = isSuperAdmin ? '可处理全部教学任务,并保留跨层级纠错权限。' : isAcademicAdmin && isCollegeAdmin ? '负责校级公共课,以及本学院专业课与实践课程。' : isCollegeAdmin ? '负责本学院专业课与实践课程。' : '负责公共必修与公共选修课程。' + + function canManage(row: TeachingTask) { + if (isSuperAdmin) return true + if (isAcademicAdmin && publicNatures.has(row.courseNature)) return true + return isCollegeAdmin && !publicNatures.has(row.courseNature) + } + function applyFilters() { setCursors([]); setSelectedRows([]); setFilters({ ...draft }) } + function resetFilters() { + const reset = { keyword: '', academicTermId: defaultTermId(refs.terms), teacherKeyword: '', classKeyword: '' } + setDraft(reset); setFilters(reset); setCursors([]); setSelectedRows([]) + } + async function loadEligible(target: 'manual' | 'generation', academicTermId?: string, courseId?: string) { + const setter = target === 'manual' ? setEligibleTeachers : setGenerationTeachers + setter([]) + if (!academicTermId || !courseId) return + try { setter((await http.get('/teacher-course-applications/eligible', { params: { academicTermId, courseId } })).data) } + catch (error) { message.error(apiErrorMessage(error)) } + } + function openCreate() { + const academicTermId = filters.academicTermId ?? defaultTermId(refs.terms) + form.setFieldsValue({ taskNumber: '', name: '', academicTermId: academicTermId!, courseId: undefined!, capacity: 60, startWeek: 1, endWeek: 16, weeklyHours: 2, schedulingMode: 'Standard', teacherIds: [], primaryTeacherId: undefined, classIds: [], notes: '' }) + setEligibleTeachers([]); setEditing({}) + } + async function openEdit(row: TeachingTask) { + try { + const detail = (await http.get(`/teaching-tasks/${row.id}`)).data + form.setFieldsValue(detail); setEditing({ id: row.id }) + await loadEligible('manual', detail.academicTermId, detail.courseId) + } catch (error) { message.error(apiErrorMessage(error)) } + } + async function save(values: TaskForm) { + if (values.startWeek > values.endWeek) return message.warning('开始周不能晚于结束周。') + const course = manageableCourses.find((item) => item.id === values.courseId) + const hours = (values.endWeek - values.startWeek + 1) * values.weeklyHours + if (course && hours !== course.totalHours) return message.warning(`该课程应安排 ${course.totalHours} 学时,当前安排 ${hours} 学时。`) + if (values.primaryTeacherId && !values.teacherIds.includes(values.primaryTeacherId)) return message.warning('主讲教师必须包含在授课教师中。') + setSaving(true) + try { + if (editing?.id) await http.put(`/teaching-tasks/${editing.id}`, values); else await http.post('/teaching-tasks', values) + message.success(editing?.id ? '教学任务已更新' : '教学任务草稿已创建'); setEditing(undefined) + await client.invalidateQueries({ queryKey: ['teaching-tasks'] }) + } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } + } + function confirmAction(row: TeachingTask, action: 'publish' | 'close' | 'unarchive' | 'delete') { + const copy = { + publish: ['发布教学任务', '发布后任务将锁定,并可进入排课与选课流程。', '发布'], + close: ['归档教学任务', '归档后将停止后续选课和排课调整。', '确认归档'], + unarchive: ['撤销教学任务归档', '撤销后将恢复为已发布状态,并重新进入相关业务流程。', '撤销归档'], + delete: [row.status === 'Closed' ? '删除已归档任务' : '删除草稿', row.status === 'Closed' ? '将永久删除该任务;如已被后续业务引用,系统会拒绝。' : '将永久删除该教学任务草稿。', '确认删除'], + }[action] + modal.confirm({ title: `${copy[0]}“${row.name}”?`, content: copy[1], okText: copy[2], okButtonProps: { danger: action === 'delete' }, onOk: async () => { + try { if (action === 'delete') await http.delete(`/teaching-tasks/${row.id}`); else await http.post(`/teaching-tasks/${row.id}/${action}`); message.success(`${copy[0]}成功`); await client.invalidateQueries({ queryKey: ['teaching-tasks'] }) } + catch (error) { message.error(apiErrorMessage(error)); throw error } + } }) + } + function batchAction(operation: 'Publish' | 'Delete' | 'Archive' | 'Unarchive', targets: TeachingTask[]) { + const labels = { Publish: '发布草稿', Delete: '永久删除', Archive: '归档已发布任务', Unarchive: '撤销归档' } + modal.confirm({ title: `批量${labels[operation]}`, content: `将对选中的 ${targets.length} 个教学任务执行“${labels[operation]}”。服务端会逐项校验状态、权限和引用关系。`, okText: `确认${labels[operation]}`, okButtonProps: { danger: operation === 'Delete' }, onOk: async () => { + try { const response = await http.post<{ affectedCount: number }>('/teaching-tasks/batch', { operation, ids: targets.map((item) => item.id) }); message.success(`操作完成,共处理 ${response.data.affectedCount} 个教学任务`); setSelectedRows([]); await client.invalidateQueries({ queryKey: ['teaching-tasks'] }) } + catch (error) { message.error(apiErrorMessage(error)); throw error } + } }) + } + function openGeneration() { + generationForm.setFieldsValue({ academicTermId: filters.academicTermId ?? defaultTermId(refs.terms)!, courseId: undefined!, classesPerTask: 3, startWeek: 1, endWeek: 16, weeklyHours: 2, classIds: [] }) + setGenerationTeachers([]); setGenerationOpen(true) + } + async function generate(values: GenerationForm) { + const course = publicCourses.find((item) => item.id === values.courseId) + const hours = (values.endWeek - values.startWeek + 1) * values.weeklyHours + if (!generationTeachers.length) return message.warning('该课程还没有审核通过的可授课教师。') + if (course && hours !== course.totalHours) return message.warning(`该课程应安排 ${course.totalHours} 学时,当前安排 ${hours} 学时。`) + modal.confirm({ title: '批量生成公共课教学任务', content: `将 ${values.classIds.length} 个行政班分成 ${Math.ceil(values.classIds.length / values.classesPerTask)} 个教学班,并在 ${generationTeachers.length} 名合格教师中均衡分配。生成结果为草稿。`, okText: '生成草稿', onOk: async () => { + setGenerating(true) + try { const response = await http.post<{ createdCount: number }>('/teaching-tasks/generate-public-course', values); message.success(`已生成 ${response.data.createdCount} 个教学任务草稿`); setGenerationOpen(false); await client.invalidateQueries({ queryKey: ['teaching-tasks'] }) } + catch (error) { message.error(apiErrorMessage(error)); throw error } finally { setGenerating(false) } + } }) + } + + const selectedDrafts = selectedRows.filter((item) => item.status === 'Draft') + const selectedPublished = selectedRows.filter((item) => item.status === 'Published') + const selectedClosed = selectedRows.filter((item) => item.status === 'Closed') + const rowSelection: TableRowSelection = { selectedRowKeys: selectedRows.map((item) => item.id), onChange: (_, rows) => setSelectedRows(rows), getCheckboxProps: (row) => ({ disabled: !canManage(row) }) } + const columns: ColumnsType = [ + { title: '任务编号', dataIndex: 'taskNumber', width: 150, render: (value: string) => {value} }, + { title: '教学班 / 课程', width: 225, render: (_, row) =>
{row.name}{row.courseCode} · {row.courseName}{natureLabels[row.courseNature] ?? row.courseNature} · {row.collegeName}
}, + { title: '开课学期', dataIndex: 'termName', width: 175 }, + { title: '授课教师', width: 130, render: (_, row) => row.teacherNames.join('、') || 未安排 }, + { title: '行政班', width: 150, render: (_, row) => row.classNames.join('、') || 开放选课 }, + { title: '人数 / 容量', width: 105, render: (_, row) => `${row.studentCount} / ${row.capacity}` }, + { title: '周次', width: 100, render: (_, row) => `${row.startWeek}—${row.endWeek} 周` }, + { title: '常规 / 实践', width: 115, render: (_, row) => `${row.courseRegularScheduleHours} / ${row.coursePracticeHours}` }, + { title: '状态', dataIndex: 'status', width: 90, render: (value: string) => {statusLabels[value] ?? value} }, + { title: '操作', width: 220, fixed: 'right', render: (_, row) => canManage(row) ?
{row.status === 'Draft' && <>}{row.status === 'Published' && }{row.status === 'Closed' && <>}
: 只读 }, + ] + + if (references.isLoading) return
+ if (references.isError) return
+ return
+ {canGeneratePublic && }} /> +
当前结果{tasks.data?.total ?? 0}个教学班

{responsibility} 发布时还会校验教师授课资格、容量和周次;选修课可不关联行政班。

+
+
setDraft((state) => ({ ...state, keyword: event.target.value }))} onPressEnter={applyFilters} /> ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, collegeId: value }))} /> setDraft((state) => ({ ...state, teacherKeyword: event.target.value }))} onPressEnter={applyFilters} /> setDraft((state) => ({ ...state, classKeyword: event.target.value }))} onPressEnter={applyFilters} />
+ {!!selectedRows.length &&
已选择 {selectedRows.length} 个教学任务草稿 {selectedDrafts.length} · 已发布 {selectedPublished.length} · 已归档 {selectedClosed.length}
} + {tasks.isError ? : rowKey="id" loading={tasks.isLoading} rowSelection={rowSelection} columns={columns} dataSource={tasks.data?.items} pagination={false} scroll={{ x: 1480 }} locale={{ emptyText: }} />} +
+} diff --git a/web-react/src/pages/teaching-tasks.css b/web-react/src/pages/teaching-tasks.css new file mode 100644 index 0000000..fb2d34e --- /dev/null +++ b/web-react/src/pages/teaching-tasks.css @@ -0,0 +1 @@ +.teaching-tasks-page{gap:16px}.task-header-actions,.task-actions,.task-batch>div:last-child{display:flex;align-items:center;flex-wrap:wrap;gap:7px}.task-summary{display:grid;grid-template-columns:180px 1fr;border:1px solid var(--line);background:#fff}.task-summary>div{padding:13px 18px;border-right:1px solid var(--line)}.task-summary span,.task-summary strong,.task-summary small{display:block}.task-summary span{color:var(--ink-muted);font-size:8px;text-transform:uppercase}.task-summary strong{margin:4px 0;color:var(--teal);font:700 23px var(--font-data)}.task-summary small{color:var(--ink-muted);font-size:8px}.task-summary p{display:flex;align-items:center;gap:10px;margin:0;padding:15px 20px;color:var(--ink-muted);line-height:1.65}.task-summary p>.anticon{color:var(--teal);font-size:22px}.task-card{min-width:0;padding:16px;border:1px solid var(--line);background:#fff}.task-filters{display:grid;grid-template-columns:1.35fr repeat(3,minmax(135px,1fr));gap:8px;margin-bottom:12px}.task-filters>.ant-input:nth-of-type(2),.task-filters>.ant-input:nth-of-type(3){min-width:150px}.task-batch{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:12px;padding:11px 13px;border:1px solid #b9d9d4;background:#f2fbf9}.task-batch>div:first-child{display:grid;gap:3px}.task-batch span{color:var(--ink-muted);font-size:9px}.task-number{font:700 9px var(--font-data);color:var(--brand-deep)}.task-course{display:grid;gap:3px}.task-course strong{color:var(--brand-deep)}.task-course span,.task-course small,.muted{color:var(--ink-muted)}.task-course small{font-size:8px}.task-pager{display:flex;align-items:center;justify-content:space-between;margin-top:15px;color:var(--ink-muted);font-size:9px}.task-pager>span>.ant-select{width:68px}.task-pager>div{display:flex;align-items:center;gap:10px}.task-form-grid{display:grid;grid-template-columns:1fr 1fr;align-items:start;gap:12px}.task-form-grid.three{grid-template-columns:repeat(3,1fr)}.task-form-grid .ant-input-number,.ant-form-item>.ant-row>.ant-form-item-control .ant-input-number{width:100%}.hours-check,.generation-teachers{margin-bottom:18px}.generation-preview{display:grid;grid-template-columns:auto auto 1fr;align-items:baseline;gap:8px;padding:13px 16px;border:1px solid #b9d9d4;background:#f2fbf9}.generation-preview strong{color:var(--teal);font:700 26px var(--font-data)}.generation-preview small{color:var(--ink-muted)}@media(max-width:900px){.task-filters{grid-template-columns:1fr 1fr}.task-batch{align-items:flex-start;flex-direction:column}}@media(max-width:650px){.task-header-actions,.task-batch>div:last-child{align-items:stretch;flex-direction:column}.task-header-actions>*,.task-batch button{width:100%}.task-summary{grid-template-columns:1fr}.task-summary>div{border-right:0;border-bottom:1px solid var(--line)}.task-filters,.task-form-grid,.task-form-grid.three{grid-template-columns:1fr}.task-pager{align-items:stretch;flex-direction:column}.task-pager>div{justify-content:space-between}}