From c5b6954f9c5dd67a46106983dd79159c3e48b4c1 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 18:55:34 +0800 Subject: [PATCH] feat(web): migrate course adjustment workflow --- .../CourseAdjustmentsController.cs | 22 ++- .../CourseAdjustmentsControllerTests.cs | 35 +++++ 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/CourseAdjustmentsPage.tsx | 147 ++++++++++++++++++ web-react/src/pages/course-adjustments.css | 1 + 8 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 web-react/src/pages/CourseAdjustmentsPage.tsx create mode 100644 web-react/src/pages/course-adjustments.css diff --git a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs index 4c05e1c..4a323dd 100644 --- a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs @@ -170,6 +170,25 @@ public sealed class CourseAdjustmentsController( return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods }); } + [HttpGet("teacher-options")] + [Authorize(Roles = Applicants)] + public async Task GetTeacherOptions(CancellationToken cancellationToken) + { + return Ok(await db.Teachers.AsNoTracking() + .Where(x => x.Status == TeacherStatus.Active && !x.IsArchived) + .OrderBy(x => x.College!.Name) + .ThenBy(x => x.TeacherNumber) + .Select(x => new + { + x.Id, + x.TeacherNumber, + x.Name, + x.Title, + CollegeName = x.College!.Name + }) + .ToListAsync(cancellationToken)); + } + // ═══════════════ My adjustments ═══════════════ [HttpGet("mine")] @@ -611,7 +630,8 @@ public sealed class CourseAdjustmentsController( return ValidationProblem("请选择代课教师。"); if (!await db.Teachers.AnyAsync(x => x.Id == request.SubstituteTeacherId && - x.Status == TeacherStatus.Active, cancellationToken)) + x.Status == TeacherStatus.Active && + !x.IsArchived, cancellationToken)) return ValidationProblem("代课教师不存在或已离职。"); break; case CourseAdjustmentType.Cancel: diff --git a/tests/Jiaowu.Api.Tests/CourseAdjustmentsControllerTests.cs b/tests/Jiaowu.Api.Tests/CourseAdjustmentsControllerTests.cs index bd19a33..9df3c0f 100644 --- a/tests/Jiaowu.Api.Tests/CourseAdjustmentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/CourseAdjustmentsControllerTests.cs @@ -6,11 +6,46 @@ using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace Jiaowu.Api.Tests; public sealed class CourseAdjustmentsControllerTests { + [Fact] + public async Task TeacherOptions_ReturnOnlyActiveNonArchivedTeachers() + { + await using var fixture = await Fixture.CreateAsync(); + var collegeId = await fixture.Db.Courses.Select(x => x.CollegeId).SingleAsync(); + fixture.Db.Teachers.AddRange( + new Teacher + { + TeacherNumber = "T002", + Name = "离职教师", + CollegeId = collegeId, + Status = TeacherStatus.Retired + }, + new Teacher + { + TeacherNumber = "T003", + Name = "归档教师", + CollegeId = collegeId, + IsArchived = true + }); + await fixture.Db.SaveChangesAsync(); + + var controller = new CourseAdjustmentsController( + fixture.Db, + new TestScope(fixture.TeacherUserId, DataScope.Self, SystemRoles.Teacher)); + var result = await controller.GetTeacherOptions(CancellationToken.None); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + Assert.Contains("T001", json); + Assert.DoesNotContain("T002", json); + Assert.DoesNotContain("T003", json); + } + [Fact] public async Task Reschedule_MovesOnlyTheSelectedOccurrence() { diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 9c68a57..6df72a5 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -51,6 +51,7 @@ | `/teaching-tasks` | 教学任务 | SuperAdmin、AcademicAdmin、CollegeAdmin | 游标分页、职责范围、草稿编制、授课资格、状态流转、批量操作与公共课合班生成 | verification | | `/schedules` | 排课管理 | SuperAdmin、AcademicAdmin | 版本草稿、手工排课、冲突校验、后台自动排课、异步发布、作息、优化策略与课程约束 | verification | | `/timetable`、`/class-timetable`、`/teacher-timetable/:teacherId` | 公开/管理/教师课表 | 公开;教务管理与领导可查草稿 | 班级/教师/教室资源、版本切换、周/日/总览、考试实验融合、单项与批量 Excel/PDF | verification | +| `/course-adjustments` | 调停课管理 | Teacher、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 d6c4b09..c661d72 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -50,6 +50,7 @@ npm.cmd run build - 迁移教学任务管理,覆盖权限职责范围、草稿编制、授课资格、发布/归档流转、批量操作与公共课合班生成。 - 迁移排课管理,覆盖版本修订、手工排课、后台自动排课和发布、作息、优化参数及课程约束。 - 迁移公开、管理和指定教师课表,支持班级/教师/教室查询、版本切换、考试实验融合与批量导出。 +- 迁移调停课申请与审核工作台,覆盖原课次锁定、调课/停课/补课/代课、草稿提交、冲突复核和通知定位。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index b77017f..243cfb9 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -49,6 +49,7 @@ const PersonnelRegistryPage = lazy(() => import('../pages/PersonnelRegistryPage' const TeachingTasksPage = lazy(() => import('../pages/TeachingTasksPage')) const SchedulesPage = lazy(() => import('../pages/SchedulesPage')) const TimetableQueryPage = lazy(() => import('../pages/TimetableQueryPage')) +const CourseAdjustmentsPage = lazy(() => import('../pages/CourseAdjustmentsPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -146,6 +147,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index cb0ef12..3ac0aad 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -110,6 +110,7 @@ export default function AppShell() { ] : []), ...(roles.some((role) => ['Teacher', 'SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [ { key: '/teaching-preferences', icon: , label: roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '授课科目申报' : '授课资格审核' }, + { key: '/course-adjustments', icon: , label: '调停课管理' }, ] : []), ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [ { key: '/teaching-tasks', icon: , label: '教学任务' }, diff --git a/web-react/src/pages/CourseAdjustmentsPage.tsx b/web-react/src/pages/CourseAdjustmentsPage.tsx new file mode 100644 index 0000000..5fed54b --- /dev/null +++ b/web-react/src/pages/CourseAdjustmentsPage.tsx @@ -0,0 +1,147 @@ +import { useEffect, useMemo, useState } from 'react' +import { CheckOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' +import { Alert, App, Button, Checkbox, Empty, Form, Input, InputNumber, Modal, Radio, Select, Skeleton, Tabs, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useSearchParams } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './course-adjustments.css' + +type AdjustmentType = 'Reschedule' | 'Cancel' | 'Makeup' | 'Substitute' +type AdjustmentStatus = 'Draft' | 'Submitted' | 'Approved' | 'Rejected' +interface Term { id: string; name: string; academicYear?: string; isCurrent?: boolean; isArchived?: boolean; isEnabled?: boolean } +interface TaskOption { id: string; taskNumber: string; name: string; academicTermId: string; termName: string; courseCode: string; courseName: string; classNames: string[] } +interface TeacherOption { id: string; teacherNumber: string; name: string; title?: string; collegeName: string } +interface Session { scheduleEntryId: string; week: number; date: string; dayOfWeek: number; startPeriod: number; periodCount: number; classroomId?: string; classroomName?: string; buildingName?: string; hasExistingAdjustment: boolean } +interface Classroom { id: string; name: string; buildingName: string; campusName: string; capacity: number } +interface Period { periodNumber: number; name: string; startsAt: string; endsAt: string } +interface SessionOptions { task: { id: string; taskNumber: string; courseName: string; academicTermId: string; termStartDate: string; termEndDate: string }; sessions: Session[]; classrooms: Classroom[]; periods: Period[] } +interface Adjustment { + id: string; teachingTaskId: string; taskNumber: string; taskName: string; courseCode: string; courseName: string; collegeName: string; + teacherNames: string[]; type: AdjustmentType; status: AdjustmentStatus; reason: string; reviewComment?: string; + sourceWeek?: number; sourceDate?: string; sourceStartPeriod?: number; sourcePeriodCount?: number; sourceClassroomName?: string; + targetDate?: string; dayOfWeek?: number; startPeriod?: number; periodCount?: number; classroomName?: string; buildingName?: string; + substituteTeacherName?: string; cancelWeek?: number; submittedAt?: string; createdAt: string +} +interface CursorPage { items: Adjustment[]; nextCursor?: string; hasMore: boolean; pageSize: number } +interface CreateValues { + teachingTaskId: string; sourceKey?: string; sourceScheduleEntryId?: string; sourceWeek?: number; type: AdjustmentType; + reason: string; submit: boolean; targetDate?: string; startPeriod?: number; periodCount?: number; classroomId?: string; + substituteTeacherId?: string +} + +const typeLabels: Record = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' } +const statusLabels: Record = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' } +const typeColors: Record = { Reschedule: 'blue', Cancel: 'red', Makeup: 'green', Substitute: 'gold' } +const statusColors: Record = { Draft: 'default', Submitted: 'orange', Approved: 'green', Rejected: 'red' } +const weekday = (day?: number) => day ? `周${['', '一', '二', '三', '四', '五', '六', '日'][day]}` : '' +const termLabel = (term: Term) => `${term.name}${term.isCurrent ? ' · 当前' : term.isArchived ? ' · 已归档' : ''}` +const defaultTerm = (terms: Term[]) => terms.find((item) => item.isCurrent)?.id ?? terms.find((item) => item.isEnabled !== false && !item.isArchived)?.id ?? terms[0]?.id +const sessionKey = (session: Session) => `${session.scheduleEntryId}:${session.week}` +const sessionLabel = (session: Session) => `第 ${session.week} 周 · ${session.date} ${weekday(session.dayOfWeek)} · 第 ${session.startPeriod}—${session.startPeriod + session.periodCount - 1} 节 · ${[session.buildingName, session.classroomName].filter(Boolean).join(' ') || '无固定教室'}` + +function AdjustmentCard({ item, reviewing, busy, onSubmit, onApprove, onReject }: { item: Adjustment; reviewing?: boolean; busy?: boolean; onSubmit: (item: Adjustment) => void; onApprove: (item: Adjustment) => void; onReject: (item: Adjustment) => void }) { + const hasSource = !!item.sourceDate && !!item.sourceStartPeriod && !!item.sourcePeriodCount + const targetRoom = item.classroomName ? [item.buildingName, item.classroomName].filter(Boolean).join(' ') : item.sourceClassroomName || '无固定教室' + return
+
{item.courseCode} · {item.taskNumber}

{item.courseName}

{item.teacherNames.join('、') || '未分配教师'} · {item.collegeName}

{typeLabels[item.type]}{!reviewing && {statusLabels[item.status]}}
+

申请原因:{item.reason}

+ {hasSource &&
原课次第 {item.sourceWeek} 周 · {item.sourceDate} · 第 {item.sourceStartPeriod}—{item.sourceStartPeriod! + item.sourcePeriodCount! - 1} 节{item.sourceClassroomName || '无固定教室'}
{item.type === 'Cancel' ? '×' : '→'}{item.type === 'Cancel' ?
处理结果仅停掉本次课程其他周次保持不变
:
{item.type === 'Makeup' ? '新增补课' : '调整至'}{item.targetDate} · 第 {item.startPeriod}—{(item.startPeriod ?? 0) + (item.periodCount ?? 1) - 1} 节{targetRoom}
}
} + {!hasSource &&
{item.targetDate && 日期{item.targetDate}}{item.dayOfWeek && 星期{weekday(item.dayOfWeek)}}{item.startPeriod && 节次第 {item.startPeriod}—{item.startPeriod + (item.periodCount ?? 1) - 1} 节}{item.substituteTeacherName && 代课教师{item.substituteTeacherName}}
} + {item.reviewComment && } + {item.submittedAt ? `提交于 ${new Date(item.submittedAt).toLocaleString('zh-CN', { hour12: false })}` : `创建于 ${new Date(item.createdAt).toLocaleString('zh-CN', { hour12: false })}`} +
+ {(reviewing || item.status === 'Draft') &&
{reviewing ? <> : }
} +
+} + +export default function CourseAdjustmentsPage() { + const { message, modal } = App.useApp() + const user = useAuthStore((state) => state.user) + const roles = user?.roles ?? [] + const isManager = roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) + const [params, setParams] = useSearchParams() + const targetId = params.get('adjustmentId') || undefined + const [activeTab, setActiveTab] = useState(isManager ? 'reviews' : 'mine') + const [termId, setTermId] = useState() + const [status, setStatus] = useState() + const [mineCursors, setMineCursors] = useState([]) + const [reviewCursors, setReviewCursors] = useState([]) + const [createOpen, setCreateOpen] = useState(false) + const [rejecting, setRejecting] = useState() + const [rejectComment, setRejectComment] = useState('') + const [busyId, setBusyId] = useState() + const [form] = Form.useForm() + const type = Form.useWatch('type', form) ?? 'Reschedule' + const taskId = Form.useWatch('teachingTaskId', form) + const sourceKeyValue = Form.useWatch('sourceKey', form) + const targetDate = Form.useWatch('targetDate', form) + const targetPeriod = Form.useWatch('startPeriod', form) + const submitNow = Form.useWatch('submit', form) + const sourceRequired = ['Reschedule', 'Cancel', 'Makeup'].includes(type) + + const references = useQuery({ + queryKey: ['course-adjustment-references'], + queryFn: async () => { + const [terms, teachers] = await Promise.all([http.get('/base-data/terms'), http.get('/course-adjustments/teacher-options')]) + return { terms: terms.data, teachers: teachers.data } + }, + }) + useEffect(() => { if (references.data?.terms.length) setTermId((current) => current ?? defaultTerm(references.data!.terms)) }, [references.data?.terms]) + useEffect(() => { + if (targetId && isManager) setActiveTab('reviews') + if (params.get('create') === '1') { setCreateOpen(true); setParams({}, { replace: true }) } + }, [targetId, isManager]) // eslint-disable-line react-hooks/exhaustive-deps + + const tasks = useQuery({ queryKey: ['course-adjustment-tasks', termId], enabled: references.isSuccess, queryFn: async () => (await http.get('/course-adjustments/task-options', { params: { academicTermId: termId } })).data }) + const sessions = useQuery({ queryKey: ['course-adjustment-sessions', taskId], enabled: !!taskId && createOpen, queryFn: async () => (await http.get(`/course-adjustments/tasks/${taskId}/session-options`)).data }) + const mineCursor = mineCursors.at(-1) + const reviewCursor = reviewCursors.at(-1) + const mine = useQuery({ queryKey: ['course-adjustments-mine', termId, status, mineCursor], enabled: activeTab === 'mine', queryFn: async () => (await http.get('/course-adjustments/mine', { params: { academicTermId: termId, status, cursor: mineCursor, pageSize: 20 } })).data }) + const reviews = useQuery({ queryKey: ['course-adjustments-reviews', targetId, termId, reviewCursor], enabled: isManager && activeTab === 'reviews', queryFn: async () => (await http.get('/course-adjustments/pending-reviews', { params: { adjustmentId: targetId, academicTermId: targetId ? undefined : termId, cursor: reviewCursor, pageSize: 20 } })).data }) + const selectedSession = useMemo(() => sessions.data?.sessions.find((item) => sessionKey(item) === sourceKeyValue), [sessions.data?.sessions, sourceKeyValue]) + const periods = sessions.data?.periods.length ? sessions.data.periods.map((item) => ({ value: item.periodNumber, label: `第 ${item.periodNumber} 节 · ${item.name} ${item.startsAt}—${item.endsAt}` })) : Array.from({ length: 12 }, (_, index) => ({ value: index + 1, label: `第 ${index + 1} 节` })) + + function openCreate() { + form.resetFields() + form.setFieldsValue({ type: 'Reschedule', submit: true, periodCount: 2 }) + setCreateOpen(true) + } + function resetPages(nextTerm?: string) { setTermId(nextTerm); setMineCursors([]); setReviewCursors([]) } + async function refresh() { await Promise.all([mine.refetch(), isManager ? reviews.refetch() : Promise.resolve()]) } + async function create(values: CreateValues) { + if (selectedSession?.hasExistingAdjustment) { message.warning('该课次已有待审核或已通过的申请'); return } + const { sourceKey: _, ...payload } = values + try { setBusyId('create'); await http.post('/course-adjustments', { ...payload, targetDate: payload.targetDate || null, startPeriod: payload.startPeriod || null, classroomId: payload.classroomId || null, substituteTeacherId: payload.substituteTeacherId || null }); message.success(payload.submit ? '申请已提交审核' : '草稿已保存'); setCreateOpen(false); setActiveTab('mine'); await mine.refetch() } + catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } + } + async function submitDraft(item: Adjustment) { try { setBusyId(item.id); await http.post(`/course-adjustments/${item.id}/submit`); message.success('已提交审核'); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } } + function approve(item: Adjustment) { modal.confirm({ title: `通过${typeLabels[item.type]}申请?`, content: '服务端会再次检查教师、班级和教室冲突;通过后将立即更新正式课表并通知相关人员。', okText: '确认通过', onOk: async () => { try { setBusyId(item.id); await http.post(`/course-adjustments/${item.id}/approve`); message.success('申请已通过'); await refresh() } catch (error) { message.error(apiErrorMessage(error)); throw error } finally { setBusyId(undefined) } } }) } + async function reject() { if (!rejecting) return; try { setBusyId(rejecting.id); await http.post(`/course-adjustments/${rejecting.id}/reject`, { comment: rejectComment.trim() || null }); message.success('申请已退回'); setRejecting(undefined); setRejectComment(''); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } } + + const page = activeTab === 'reviews' ? reviews : mine + const data = page.data?.items ?? [] + const referenceData = references.data ?? { terms: [], teachers: [] } + if (references.isLoading) return
+ if (references.isError) return
+ + return
+ } /> +
({ value, label }))} onChange={(value) => { setStatus(value); setMineCursors([]) }} />}{targetId && activeTab === 'reviews' ? '已按通知定位单条待审核申请' : '每批最多显示 20 条'}
+ + {page.isError ? : page.isLoading ? :
{data.map((item) => void submitDraft(value)} onApprove={approve} onReject={(value) => { setRejecting(value); setRejectComment('') }} />)}{!data.length && }
} + {((activeTab === 'mine' ? mineCursors : reviewCursors).length > 0 || page.data?.hasMore) &&
第 {(activeTab === 'mine' ? mineCursors : reviewCursors).length + 1} 批
} + + setCreateOpen(false)} onOk={() => form.submit()}> +
void create(values)}> +
1
选择业务类型每次申请只处理一个具体事项。
({ value, label }))} onChange={() => form.setFieldsValue({ targetDate: undefined, startPeriod: undefined, classroomId: selectedSession?.classroomId, substituteTeacherId: undefined })} />
+
2
{sourceRequired ? '锁定原课次' : '选择教学班'}{sourceRequired ? '从已发布课表中选择,不再手填周次和星期。' : '代课申请将在审核通过后把教师加入教学班。'}
({ value: sessionKey(item), label: `${sessionLabel(item)}${item.hasExistingAdjustment ? ' · 已有申请' : ''}`, disabled: item.hasExistingAdjustment }))} onChange={(value) => { const selected = sessions.data?.sessions.find((item) => sessionKey(item) === value); if (selected) form.setFieldsValue({ sourceScheduleEntryId: selected.scheduleEntryId, sourceWeek: selected.week, periodCount: selected.periodCount, classroomId: selected.classroomId }) }} />}{taskId && sourceRequired && !sessions.isLoading && !sessions.data?.sessions.length && }
+ {(type === 'Reschedule' || type === 'Makeup') &&
3
{type === 'Reschedule' ? '安排调课后课次' : '安排补课课次'}课程时长沿用原课次,审批时再次检查教师、班级和教室冲突。
{selectedSession &&
{sessionLabel(selectedSession)}→{targetDate || '待选日期'} · {targetPeriod ? `第 ${targetPeriod} 节起` : '待选节次'}
}
({ value: item.id, label: `${item.campusName} · ${item.buildingName} ${item.name} · ${item.capacity} 人` }))} />
} + {type === 'Substitute' &&
3
选择代课教师仅列出在职且未归档教师。
+ + + setRejecting(undefined)} onOk={() => void reject()}> +
+} diff --git a/web-react/src/pages/course-adjustments.css b/web-react/src/pages/course-adjustments.css new file mode 100644 index 0000000..d4b84f6 --- /dev/null +++ b/web-react/src/pages/course-adjustments.css @@ -0,0 +1 @@ +.course-adjustments-page{gap:16px}.adjustment-actions{display:flex;gap:8px}.adjustment-control{min-height:54px;padding:10px 14px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:#fff}.adjustment-control>.ant-select{width:210px}.adjustment-control>span{margin-left:auto;color:var(--ink-muted);font-size:9px}.course-adjustments-page>.ant-tabs .ant-tabs-nav{margin:0}.adjustment-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:12px}.adjustment-list>.ant-empty{grid-column:1/-1;padding:55px;border:1px solid var(--line);background:#fff}.adjustment-card{min-width:0;border:1px solid var(--line);border-left:4px solid #94a3b8;background:#fff}.adjustment-card.status-submitted{border-left-color:#d18a22}.adjustment-card.status-approved{border-left-color:var(--teal)}.adjustment-card.status-rejected{border-left-color:var(--danger)}.adjustment-card>header{padding:14px 16px;display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);background:#f8fafb}.adjustment-card>header span,.adjustment-card>header h3,.adjustment-card>header p{display:block;margin:0}.adjustment-card>header span{color:var(--teal);font:700 8px var(--font-data)}.adjustment-card>header h3{margin:5px 0 3px;color:var(--brand-deep);font-size:14px}.adjustment-card>header p{color:var(--ink-muted);font-size:8px}.adjustment-card>header>div:last-child{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px}.adjustment-card__body{padding:14px 16px}.adjustment-card__body>p{margin:0 0 12px;color:var(--ink);font-size:10px;line-height:1.65}.adjustment-card__body>.ant-alert{margin-top:11px}.adjustment-route{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:stretch;gap:7px;padding:9px;border:1px solid #dce4e8;background:#f4f7f9}.adjustment-route>div{padding:9px 10px;display:grid;gap:4px;border-left:3px solid var(--teal);background:white}.adjustment-route small,.adjustment-route span{color:var(--ink-muted);font-size:8px}.adjustment-route strong{font-size:9px;line-height:1.5}.adjustment-route>i{align-self:center;color:var(--brand);font:700 18px var(--font-data);text-align:center}.adjustment-facts{display:flex;flex-wrap:wrap;gap:8px}.adjustment-facts>span{min-width:130px;padding:9px 11px;display:grid;gap:4px;border:1px solid var(--line);font-size:9px}.adjustment-facts b{color:var(--ink-muted);font-size:8px}.adjustment-time{display:block;margin-top:11px;color:var(--ink-muted);font-size:8px}.adjustment-card>footer{padding:10px 16px;display:flex;justify-content:flex-end;gap:7px;border-top:1px solid var(--line)}.adjustment-pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;color:var(--ink-muted);font-size:9px}.adjustment-form{display:grid;gap:12px;margin-top:14px}.adjustment-form>section{padding:14px 15px;border:1px solid var(--line);background:#fbfcfd}.adjustment-form>section>header{margin-bottom:12px;display:flex;gap:10px}.adjustment-form>section>header>b{width:25px;height:25px;display:grid;place-items:center;flex:0 0 25px;color:#fff;background:var(--brand);font:700 10px var(--font-data)}.adjustment-form>section>header strong,.adjustment-form>section>header span{display:block}.adjustment-form>section>header strong{color:var(--brand-deep);font-size:11px}.adjustment-form>section>header span{margin-top:3px;color:var(--ink-muted);font-size:8px}.adjustment-form .ant-form-item:last-child{margin-bottom:0}.adjustment-form>.adjustment-target{border-color:#b9d9d4;background:#f3faf8}.adjustment-preview{margin-bottom:12px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:8px}.adjustment-preview span{padding:9px;border:1px solid var(--line);background:#fff;font-size:8px;line-height:1.5}.adjustment-preview i{color:var(--teal);font:700 18px var(--font-data)}.adjustment-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.reject-field{margin-top:14px;display:grid;gap:7px;font-weight:600}@media(max-width:900px){.adjustment-list{grid-template-columns:1fr}}@media(max-width:620px){.adjustment-actions,.adjustment-control{align-items:stretch;flex-direction:column}.adjustment-actions>*,.adjustment-control>*{width:100%!important}.adjustment-control>span{margin-left:0}.adjustment-route,.adjustment-preview,.adjustment-grid{grid-template-columns:1fr}.adjustment-route>i,.adjustment-preview>i{transform:rotate(90deg);text-align:center}}