Files
gongxue-base/apps/admin/src/pages/Schedules/index.tsx
wangziqi f19e72ca40 feat(admin): 排课冲突检测与教师选择状态收敛
- findTimeConflicts:半选值/无效日期防护、日期未选不误报、坏时间按必定冲突、10 分钟缓冲命名常量
- 教师下拉完全由表单驱动,移除冗余 teacherKey 状态(消除整类双源 desync 问题)
- 教师加载加请求序号防过期覆盖 + loading 禁用下拉 + 失败提示不清空表单
- 编辑模式 teacherKey 按实际选项推导,fallback 优先任课老师行并同步科目
- 切班/切老师自动带出:仅真匹配时自动选中,显式科目无人教授时清空防错配
- 选项按复合 key 去重,冲突提示标注基于当前视图
2026-08-11 11:53:58 +08:00

725 lines
27 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.

// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
import React, { useMemo, useRef, useState } from 'react';
import { Button, Card, Form, Segmented, Select, Space } from 'antd';
import { CalendarOutlined, CloudSyncOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { NextStepHint } from '../../components/NextStepHint';
import { usePermission } from '../../hooks/usePermission';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { scheduleLookupsSchema, weeklyScheduleSchema } from '../../api/schemas';
import {
buildSchedulePayload,
scheduleToFormValues,
type ScheduleFormValues,
} from './schedule-form';
import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility';
import { classifySyncResult } from './sync-result';
import { getErrorMessage } from '../../utils/error';
import { ScheduleGrid } from './ScheduleGrids';
import type {
ClassItem,
ClassScheduleItem,
ClassTeacherOption,
ClassroomItem,
} from './ScheduleGrids';
import { ScheduleModal, SyncModal } from './ScheduleModals';
interface ScheduleSyncResult {
scheduleCount: number;
shiftCount: number;
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
groups: Array<{ className: string; groupId: number; itemCount: number }>;
}
const SchedulesPage: React.FC = () => {
const { hasPermission } = usePermission();
const [viewMode, setViewMode] = useState<'week' | 'month'>('week');
const [viewDate, setViewDate] = useState<Dayjs>(() => dayjs().weekday(1).startOf('day'));
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
const [classTeachers, setClassTeachers] = useState<ClassTeacherOption[]>([]);
const [classTeachersLoading, setClassTeachersLoading] = useState(false);
const [filterClassroomIds, setFilterClassroomIds] = useState<number[]>([]);
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<'create' | 'edit' | 'detail'>('create');
const [editingSchedule, setEditingSchedule] = useState<ClassScheduleItem | null>(null);
const [selectedCell, setSelectedCell] = useState<{
classroomId: number;
weekDay: number;
} | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<ClassScheduleItem[]>([]);
const [submitting, setSubmitting] = useState(false);
const [syncModalOpen, setSyncModalOpen] = useState(false);
const [syncing, setSyncing] = useState(false);
// 排课创建成功后的「下一步:同步钉钉」引导
const [syncHint, setSyncHint] = useState(false);
const [syncStatus, setSyncStatus] = useState<{
activeSchedules: number;
mappedClasses: number;
totalClasses: number;
} | null>(null);
const [syncStatusError, setSyncStatusError] = useState(false);
const [syncResult, setSyncResult] = useState<ScheduleSyncResult | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
const [syncDays, setSyncDays] = useState(30);
const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false);
const [form] = Form.useForm<ScheduleFormValues>();
const openSyncModal = async () => {
setSyncModalOpen(true);
setSyncResult(null);
setSyncStatusError(false);
try {
const res = await api.get<{
success: boolean;
data: { activeSchedules: number; mappedClasses: number; totalClasses: number };
}>('/sync/schedule/status');
setSyncStatus(res.data);
} catch {
setSyncStatusError(true);
setSyncStatus(null);
}
};
const handleSyncSchedule = async () => {
setSyncing(true);
try {
const res = await api.post<{
success: boolean;
data: ScheduleSyncResult;
}>('/sync/schedule/sync', null, {
params: {
dateFrom: syncDateFrom.format('YYYY-MM-DD'),
days: syncDays,
attendanceMachineOnly,
},
});
setSyncResult(res.data);
const classification = classifySyncResult(res.data);
if (classification.level === 'error') {
message.error(classification.message);
} else if (classification.level === 'warning') {
message.warning(classification.message);
setSyncHint(false);
} else {
message.success(classification.message);
setSyncHint(false);
}
} catch (e: unknown) {
message.error(getErrorMessage(e, '同步失败'));
} finally {
setSyncing(false);
}
};
const weekStart = useMemo(() => viewDate.weekday(1).startOf('day'), [viewDate]);
const monthStart = useMemo(() => viewDate.startOf('month'), [viewDate]);
const weekEnd = useMemo(() => weekStart.add(6, 'day'), [weekStart]);
const weekNum = useMemo(() => weekStart.week(), [weekStart]);
const weekYear = useMemo(() => weekStart.year(), [weekStart]);
const calendarDays = useMemo(() => {
const monthEnd = monthStart.endOf('month');
const startOffset = (monthStart.day() + 6) % 7;
const endOffset = (7 - monthEnd.day()) % 7;
const start = monthStart.subtract(startOffset, 'day');
const end = monthEnd.add(endOffset, 'day');
const totalDays = end.diff(start, 'day') + 1;
const days: Dayjs[] = [];
for (let i = 0; i < totalDays; i++) {
days.push(start.add(i, 'day'));
}
return days;
}, [monthStart]);
const weeks = useMemo(() => {
const result: Dayjs[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) {
result.push(calendarDays.slice(i, i + 7));
}
return result;
}, [calendarDays]);
const startDateStr = useMemo(() => {
if (viewMode === 'month') return calendarDays[0].format('YYYY-MM-DD');
return weekStart.format('YYYY-MM-DD');
}, [viewMode, weekStart, calendarDays]);
const endDateStr = useMemo(() => {
if (viewMode === 'month') return calendarDays[calendarDays.length - 1].format('YYYY-MM-DD');
return weekEnd.format('YYYY-MM-DD');
}, [viewMode, weekEnd, calendarDays]);
const {
data: fetchResult = { classrooms: [], classes: [], matrix: {} },
isLoading,
isFetching,
isError,
refetch,
} = useQuery<{
classrooms: ClassroomItem[];
classes: ClassItem[];
matrix: Record<number, Record<number, ClassScheduleItem[]>>;
}>({
queryKey: ['class-schedules', startDateStr, endDateStr, filterClassroomIds],
queryFn: async () => {
const [lookups, schedulesRes] = await Promise.all([
api.get('/class-schedules/lookups') as Promise<{
classrooms: ClassroomItem[];
classes: ClassItem[];
}>,
api.get('/class-schedules/weekly', {
params: {
startDate: startDateStr,
endDate: endDateStr,
...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}),
},
}) as Promise<Record<string, Record<string, ClassScheduleItem[]>>>,
]);
const validatedLookups = validateResponse<{
classrooms: ClassroomItem[];
classes: ClassItem[];
}>(scheduleLookupsSchema, lookups);
const validatedWeekly = validateResponse<
Record<string, Record<string, ClassScheduleItem[]>>
>(weeklyScheduleSchema, schedulesRes);
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
for (const [cId, dayMap] of Object.entries(validatedWeekly)) {
const classroomId = Number(cId);
typedMatrix[classroomId] = {};
for (const [wd, schedules] of Object.entries(dayMap)) {
typedMatrix[classroomId][Number(wd)] = schedules;
}
}
return {
classrooms: validatedLookups.classrooms,
classes: validatedLookups.classes,
matrix: typedMatrix,
};
},
});
const classrooms = fetchResult.classrooms;
const classes = fetchResult.classes;
const matrix = fetchResult.matrix;
const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新排课数据
useVisibleRefetch(['class-schedules']);
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
modalMode === 'edit' && editingSchedule
? api.put(`/class-schedules/${editingSchedule.id}`, payload)
: api.post('/class-schedules', payload),
{ invalidate: [['class-schedules']] },
);
const disableMutation = useApiMutation(
async (id: number) => api.put(`/class-schedules/${id}`, { status: 'inactive' }),
{ invalidate: [['class-schedules']] },
);
const filteredClassrooms = useMemo(() => {
if (filterClassroomIds.length === 0) return classrooms;
const idSet = new Set(filterClassroomIds);
return classrooms.filter((c) => idSet.has(c.id));
}, [classrooms, filterClassroomIds]);
const displayMatrix = useMemo(() => {
if (filterClassId == null) return matrix;
const filtered: Record<number, Record<number, ClassScheduleItem[]>> = {};
for (const [cId, dayMap] of Object.entries(matrix)) {
const classroomId = Number(cId);
filtered[classroomId] = {};
for (const [wd, schedules] of Object.entries(dayMap)) {
const matched = filterSchedulesForClass(schedules, filterClassId);
if (matched.length > 0) {
filtered[classroomId][Number(wd)] = matched;
}
}
}
return filtered;
}, [matrix, filterClassId]);
const monthScheduleMap = useMemo(() => {
const map: Record<string, ClassScheduleItem[]> = {};
for (const day of calendarDays) {
const wd = day.day() === 0 ? 7 : day.day();
const dateStr = day.format('YYYY-MM-DD');
const result: ClassScheduleItem[] = [];
for (const classroom of filteredClassrooms) {
const daySchedules = displayMatrix[classroom.id]?.[wd] || [];
for (const s of daySchedules) {
if (dateStr >= s.startDate && dateStr <= s.endDate) {
result.push(s);
}
}
}
map[dateStr] = result;
}
return map;
}, [calendarDays, displayMatrix, filteredClassrooms]);
const handleCellClick = (classroomId: number, weekDay: number) => {
const schedules = displayMatrix[classroomId]?.[weekDay] || [];
setSelectedCell({ classroomId, weekDay });
setSelectedDate(null);
if (schedules.length > 0) {
setSelectedSchedules(schedules);
setModalMode('detail');
setModalOpen(true);
} else if (hasPermission('schedule:create')) {
setSelectedSchedules([]);
setEditingSchedule(null);
setModalMode('create');
// 作废编辑/默认值流程中在途的教师请求,避免过期响应回填刚重置的表单
teacherReqSeq.current += 1;
form.resetFields();
form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 });
setModalOpen(true);
}
};
const getSchedulesForDate = (date: Dayjs): ClassScheduleItem[] => {
const wd = date.day() === 0 ? 7 : date.day();
const dateStr = date.format('YYYY-MM-DD');
const result: ClassScheduleItem[] = [];
for (const classroom of filteredClassrooms) {
const daySchedules = displayMatrix[classroom.id]?.[wd] || [];
for (const s of daySchedules) {
if (dateStr >= s.startDate && dateStr <= s.endDate) {
result.push(s);
}
}
}
return result;
};
const handleDateClick = (date: Dayjs) => {
const dateKey = date.format('YYYY-MM-DD');
const schedules = monthScheduleMap[dateKey] || getSchedulesForDate(date);
setSelectedDate(date);
setSelectedCell(null);
setSelectedSchedules(schedules);
setModalMode('detail');
setModalOpen(true);
};
// 教师列表/默认值请求序号applyClassTeacherDefaults 与 openEditSchedule 可能并发,
// 过期响应不得覆盖共享 classTeachers 状态或表单默认值
const teacherReqSeq = useRef(0);
const loadClassTeachers = async (classId: number): Promise<ClassTeacherOption[] | null> => {
const seq = teacherReqSeq.current;
setClassTeachersLoading(true);
try {
const teachers = await api.get<ClassTeacherOption[]>(
`/class-schedules/classes/${classId}/teachers`,
);
if (seq === teacherReqSeq.current) {
setClassTeachers(teachers);
setClassTeachersLoading(false);
}
return teachers;
} catch {
// 失败:清空选项,避免展示上一个班级的教师列表并误导选择;
// 返回 null 区分「加载失败」与「真无教师」,由调用方提示且不触碰表单
if (seq === teacherReqSeq.current) {
setClassTeachers([]);
setClassTeachersLoading(false);
}
return null;
}
};
// 每行一科目 -> 按老师合并一个老师一行subjects 为该班全部科目)
const buildTeacherGroups = (teachers: ClassTeacherOption[]) => {
const groups = new Map<
number,
{ userId: number; name?: string; username?: string; roleType: string; subjects: string[] }
>();
for (const teacher of teachers) {
const group = groups.get(teacher.userId) ?? {
userId: teacher.userId,
name: teacher.name,
username: teacher.username,
roleType: teacher.roleType,
subjects: [],
};
// 同一用户可同时持多个角色行(唯一键含 role_type优先归类为任课老师
// 避免班主任行排序在前时整个分组被误排除出 subjectTeachers
if (teacher.roleType === 'subject_teacher') group.roleType = 'subject_teacher';
if (teacher.subject) group.subjects.push(teacher.subject);
groups.set(teacher.userId, group);
}
return [...groups.values()];
};
const applyClassTeacherDefaults = async (classId: number, subject?: string) => {
teacherReqSeq.current += 1;
const seq = teacherReqSeq.current;
const teachers = await loadClassTeachers(classId);
if (seq !== teacherReqSeq.current) return; // 期间发起了更新的请求,丢弃过期结果
if (teachers === null) {
message.error('教师列表加载失败,请重试');
return;
}
const groups = buildTeacherGroups(teachers);
const subjectTeachers = groups.filter((teacher) => teacher.roleType === 'subject_teacher');
const matchedBySubject = subject
? subjectTeachers.filter((teacher) => teacher.subjects.includes(subject))
: [];
if (matchedBySubject.length === 1) {
form.setFieldValue('teacherId', matchedBySubject[0].userId);
return;
}
if (matchedBySubject.length > 1) {
// 多人都教该科目:无法自动判定,清空老师让用户选择;科目本身有效,保留
form.setFieldValue('teacherId', undefined);
return;
}
// 无科目(新增/清空科目):仅当存在唯一任课老师时自动带出老师与首个科目
if (!subject && subjectTeachers.length === 1) {
form.setFieldValue('teacherId', subjectTeachers[0].userId);
if (subjectTeachers[0].subjects.length > 0) {
form.setFieldValue('subject', subjectTeachers[0].subjects[0]);
}
return;
}
// 显式科目无人教授(含切班残留的旧科目):清空老师与科目,避免错配提交
form.setFieldValue('teacherId', undefined);
if (subject) form.setFieldValue('subject', undefined);
};
// 选择任课老师行key = userId__科目后带出对应科目科目为空非任课老师只记录老师
const handleTeacherChange = (key?: string) => {
if (!key) {
form.setFieldValue('teacherId', undefined);
form.setFieldValue('subject', undefined);
return;
}
const [userIdRaw, ...subjectParts] = key.split('__');
const userId = Number(userIdRaw);
if (!Number.isInteger(userId)) return;
form.setFieldValue('teacherId', userId);
const subject = subjectParts.join('__');
// 非任课老师行key 无科目部分)必须清空科目,避免残留旧科目与错误老师配对提交
form.setFieldValue('subject', subject || undefined);
};
const handleSubmit = async () => {
// 周视图走 selectedCell月视图走 selectedDate此时 selectedCell 为 null
if (modalMode === 'create' && !selectedCell && !selectedDate) return;
if (modalMode === 'edit' && !editingSchedule) return;
try {
const values = (await form.validateFields()) as ScheduleFormValues;
setSubmitting(true);
const payload = buildSchedulePayload(values);
await saveMutation.mutateAsync(payload);
message.success(
modalMode === 'edit' ? '排课更新成功,请重新同步到钉钉排班' : '排课创建成功',
);
if (modalMode === 'create') setSyncHint(true);
setModalOpen(false);
setEditingSchedule(null);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSubmitting(false);
}
};
const openEditSchedule = (schedule: ClassScheduleItem) => {
if (isMaskedSchedule(schedule) || schedule.id === null || schedule.classId === null) return;
if (schedule.scheduleType === 'RENTAL') {
message.warning('租赁排课请在租赁订单中修改');
return;
}
setEditingSchedule(schedule);
setModalMode('edit');
form.setFieldsValue(
scheduleToFormValues({
...schedule,
id: schedule.id ?? undefined,
classId: schedule.classId ?? undefined,
}),
);
// 排课科目可能不在该老师当前配置的科目中(科目已调整)或老师为非任课老师行:
// 等老师选项加载完后再校验/同步表单值,保证下拉与表单一致。
// 闭包内 TS 无法保留属性收窄,先捕获收窄后的值
const classId = schedule.classId;
const teacherId = schedule.teacherId;
const resolveTeacherKey = async () => {
teacherReqSeq.current += 1;
const seq = teacherReqSeq.current;
const teachers = await loadClassTeachers(classId);
if (seq !== teacherReqSeq.current) return; // 期间打开了其他排课/发起了新请求,丢弃
if (teachers === null) {
// 加载失败下拉选项已清空loadClassTeachers 失败时置空),表单保留排课原值,用户可重试
message.error('教师列表加载失败,请重试');
return;
}
if (teacherId === null || teacherId === undefined) {
form.setFieldValue('teacherId', undefined);
return;
}
const exact = `${teacherId}__${schedule.subject ?? ''}`;
if (teachers.some((t) => `${t.userId}__${t.subject ?? ''}` === exact)) {
return; // 表单值本就与选项一致,无需改动
}
// 无精确匹配优先取该老师的任课老师行getClassTeachers 按 roleType 排序,
// 非科目行在前,直接 find 首个会匹配到 subject 为空的班主任/生活老师行并误清科目),
// 科目同步为该老师当前科目,避免下拉与表单不一致;无任何行时清空老师与科目
const fallback =
teachers.find((t) => t.userId === teacherId && t.roleType === 'subject_teacher') ??
teachers.find((t) => t.userId === teacherId);
if (fallback) {
form.setFieldValue('subject', fallback.subject ?? undefined);
} else {
form.setFieldValue('teacherId', undefined);
form.setFieldValue('subject', undefined);
}
};
void resolveTeacherKey();
};
const removeScheduleFromSelection = (id: number) => {
const remaining = selectedSchedules.filter((s) => s.id !== id);
setSelectedSchedules(remaining);
if (remaining.length === 0) {
setModalOpen(false);
}
};
const handleDisable = async (id: number | null) => {
if (id === null) return;
try {
await disableMutation.mutateAsync(id);
message.success('排课已停用,历史考勤记录已保留,教室占用已释放');
removeScheduleFromSelection(id);
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const classroomOptions = useMemo(
() =>
classrooms.map((c) => ({
value: c.id,
label: `${c.building ? c.building + ' · ' : ''}${c.name}`,
})),
[classrooms],
);
const classOptions = useMemo(
() =>
classes.map((c) => ({
value: c.id,
label: `${c.name}${c.code ? ` (${c.code})` : ''}`,
})),
[classes],
);
const selectedClassroom = selectedCell
? classrooms.find((c) => c.id === selectedCell.classroomId)
: undefined;
return (
<div>
{syncHint && (
<NextStepHint
title="排课已创建"
description="同步到钉钉后,教师端与考勤设备才能看到新排课。"
action={{ label: '同步钉钉', onClick: () => void handleSyncSchedule() }}
onClose={() => setSyncHint(false)}
/>
)}
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space>
<CalendarOutlined style={{ fontSize: 20 }} />
<h3 style={{ margin: 0 }}></h3>
</Space>
<Space>
<Segmented
value={viewMode}
onChange={(v) => {
setViewMode(v as 'week' | 'month');
setSelectedDate(null);
}}
options={[
{ label: '周视图', value: 'week' },
{ label: '月视图', value: 'month' },
]}
/>
<PermissionButton
permission="sync:trigger"
type="primary"
icon={<CloudSyncOutlined />}
onClick={() => void openSyncModal()}
>
</PermissionButton>
{viewMode === 'week' ? (
<>
<Button icon={<LeftOutlined />} onClick={() => setViewDate(viewDate.subtract(7, 'day'))}>
</Button>
<span style={{ fontWeight: 500, fontSize: 15 }}>
{weekYear} W{weekNum}
<span style={{ color: '#8c8c8c', fontWeight: 400, fontSize: 13, marginLeft: 6 }}>
({startDateStr} ~ {endDateStr})
</span>
</span>
<Button icon={<RightOutlined />} onClick={() => setViewDate(viewDate.add(7, 'day'))}>
</Button>
</>
) : (
<>
<Button icon={<LeftOutlined />} onClick={() => setViewDate(viewDate.subtract(1, 'month'))}>
</Button>
<span style={{ fontWeight: 500, fontSize: 15 }}>{monthStart.format('YYYY年 M月')}</span>
<Button icon={<RightOutlined />} onClick={() => setViewDate(viewDate.add(1, 'month'))}>
</Button>
</>
)}
</Space>
</div>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Select
mode="multiple"
placeholder="筛选教室"
allowClear
style={{ minWidth: 200 }}
value={filterClassroomIds}
onChange={(v) => setFilterClassroomIds(v)}
options={classroomOptions}
maxTagCount={3}
/>
<Select
placeholder="筛选班级"
allowClear
style={{ minWidth: 160 }}
value={filterClassId}
onChange={(v) => setFilterClassId(v)}
options={classOptions}
/>
</Space>
</Card>
{isError ? (
<QueryErrorState
title="排课数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<ScheduleGrid
loading={loading}
viewMode={viewMode}
classrooms={classrooms}
filteredClassrooms={filteredClassrooms}
displayMatrix={displayMatrix}
weeks={weeks}
monthStart={monthStart}
monthScheduleMap={monthScheduleMap}
onCellClick={handleCellClick}
onDateClick={handleDateClick}
/>
)}
<ScheduleModal
open={modalOpen}
mode={modalMode}
submitting={submitting}
form={form}
selectedCell={selectedCell}
selectedDate={selectedDate}
selectedSchedules={selectedSchedules}
editingSchedule={editingSchedule}
selectedClassroom={selectedClassroom}
classOptions={classOptions}
classroomOptions={classroomOptions}
classTeachers={classTeachers}
classes={classes}
onCancel={() => {
setModalOpen(false);
setEditingSchedule(null);
}}
onSubmit={handleSubmit}
onStartCreate={() => {
setEditingSchedule(null);
setModalMode('create');
teacherReqSeq.current += 1; // 作废在途教师请求,防止过期响应污染新建表单
form.resetFields();
form.setFieldsValue({
classroomId: selectedCell?.classroomId,
weekDay:
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
attendanceAdvanceMinutes: 30,
});
}}
onEdit={openEditSchedule}
onDisable={handleDisable}
onClassChange={(classId) => {
form.setFieldValue('teacherId', undefined);
void applyClassTeacherDefaults(classId, form.getFieldValue('subject'));
}}
onSubjectBlur={(value) => {
const classId = form.getFieldValue('classId');
if (classId) void applyClassTeacherDefaults(classId, value);
}}
onTeacherChange={handleTeacherChange}
classTeachersLoading={classTeachersLoading}
weekMatrix={matrix}
/>
<SyncModal
open={syncModalOpen}
syncing={syncing}
syncStatus={syncStatus}
syncStatusError={syncStatusError}
syncResult={syncResult}
syncDateFrom={syncDateFrom}
syncDays={syncDays}
attendanceMachineOnly={attendanceMachineOnly}
onClose={() => {
setSyncModalOpen(false);
setSyncResult(null);
}}
onRetryStatus={() => void openSyncModal()}
onSync={handleSyncSchedule}
onDateChange={setSyncDateFrom}
onDaysChange={setSyncDays}
onMachineOnlyChange={setAttendanceMachineOnly}
/>
</div>
);
};
export default SchedulesPage;