513 lines
19 KiB
TypeScript
513 lines
19 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import {
|
||
startOfDay,
|
||
format,
|
||
addDays,
|
||
subDays,
|
||
subMonths,
|
||
addMonths,
|
||
startOfMonth,
|
||
endOfMonth,
|
||
differenceInMinutes,
|
||
} from 'date-fns';
|
||
import { ru } from 'date-fns/locale';
|
||
import { Calendar } from '@/components/calendar/calendar';
|
||
import { CheckLesson } from '@/components/checklesson/checklesson';
|
||
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
|
||
import { getStudents } from '@/api/students';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { createDateTimeInUserTimezone, parseISOToUserTimezone } from '@/utils/timezone';
|
||
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
||
import { getSubjects, getMentorSubjects } from '@/api/subjects';
|
||
import { loadComponent } from '@/lib/material-components';
|
||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||
import { ErrorDisplay } from '@/components/common/ErrorDisplay';
|
||
import { getErrorMessage } from '@/lib/error-utils';
|
||
import type { CalendarLesson } from '@/components/calendar/calendar';
|
||
import type { CheckLessonFormData, CheckLessonProps } from '@/components/checklesson/checklesson';
|
||
import type { LessonPreview } from '@/api/dashboard';
|
||
import type { Student } from '@/api/students';
|
||
import type { Subject, MentorSubject } from '@/api/subjects';
|
||
|
||
export default function SchedulePage() {
|
||
const { user } = useAuth();
|
||
const { selectedChild } = useSelectedChild();
|
||
const isMentor = user?.role === 'mentor';
|
||
const [selectedDate, setSelectedDate] = useState<Date>(startOfDay(new Date()));
|
||
const [displayDate, setDisplayDate] = useState<Date>(startOfDay(new Date()));
|
||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => startOfMonth(new Date()));
|
||
const [lessons, setLessons] = useState<CalendarLesson[]>([]);
|
||
const [lessonsLoading, setLessonsLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const formDataLoadedRef = useRef(false);
|
||
const hasLoadedLessonsOnceRef = useRef(false);
|
||
|
||
// Форма
|
||
const [isFormVisible, setIsFormVisible] = useState(false);
|
||
const [isEditingMode, setIsEditingMode] = useState(false);
|
||
const [formLoading, setFormLoading] = useState(false);
|
||
const [formError, setFormError] = useState<string | null>(null);
|
||
const [formData, setFormData] = useState<CheckLessonFormData>({
|
||
client: '',
|
||
title: '',
|
||
description: '',
|
||
start_date: format(selectedDate, 'yyyy-MM-dd'),
|
||
start_time: '14:00',
|
||
duration: 60,
|
||
price: undefined,
|
||
is_recurring: false,
|
||
});
|
||
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
|
||
const [selectedMentorSubjectId, setSelectedMentorSubjectId] = useState<number | null>(null);
|
||
const [editingLessonId, setEditingLessonId] = useState<string | null>(null);
|
||
const [editingLessonStatus, setEditingLessonStatus] = useState<string | null>(null);
|
||
|
||
// Компоненты Material Web
|
||
const [buttonComponentsLoaded, setButtonComponentsLoaded] = useState(false);
|
||
const [formComponentsLoaded, setFormComponentsLoaded] = useState(false);
|
||
|
||
// Данные для формы
|
||
const [students, setStudents] = useState<Student[]>([]);
|
||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||
const [mentorSubjects, setMentorSubjects] = useState<MentorSubject[]>([]);
|
||
const [lessonEditLoading, setLessonEditLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
loadComponent('elevated-card'),
|
||
loadComponent('filled-button'),
|
||
loadComponent('icon'),
|
||
]).then(() => {
|
||
setButtonComponentsLoaded(true);
|
||
});
|
||
|
||
Promise.all([
|
||
loadComponent('filled-button'),
|
||
loadComponent('outlined-button'),
|
||
loadComponent('text-field'),
|
||
loadComponent('select'),
|
||
loadComponent('switch'),
|
||
]).then(() => {
|
||
setFormComponentsLoaded(true);
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isFormVisible || formDataLoadedRef.current) return;
|
||
(async () => {
|
||
try {
|
||
const [studentsResp, subjectsResp, mentorSubjectsResp] = await Promise.all([
|
||
getStudents({ page: 1, page_size: 200 }),
|
||
getSubjects(),
|
||
getMentorSubjects(),
|
||
]);
|
||
setStudents(studentsResp.results || []);
|
||
setSubjects(subjectsResp || []);
|
||
setMentorSubjects(mentorSubjectsResp || []);
|
||
formDataLoadedRef.current = true;
|
||
} catch (err) {
|
||
console.error('Error loading form data:', err);
|
||
}
|
||
})();
|
||
}, [isFormVisible]);
|
||
|
||
const loadLessons = useCallback(
|
||
async (merge?: boolean) => {
|
||
const start = startOfMonth(subMonths(visibleMonth, 1));
|
||
const end = endOfMonth(addMonths(visibleMonth, 1));
|
||
const doMerge = merge ?? hasLoadedLessonsOnceRef.current;
|
||
const isInitial = !hasLoadedLessonsOnceRef.current && !doMerge;
|
||
try {
|
||
setLessonsLoading(true);
|
||
setError(null);
|
||
const { lessons: lessonsData } = await getLessonsCalendar({
|
||
start_date: format(start, 'yyyy-MM-dd'),
|
||
end_date: format(end, 'yyyy-MM-dd'),
|
||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||
});
|
||
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
|
||
id: lesson.id,
|
||
title: lesson.title,
|
||
start_time: lesson.start_time,
|
||
end_time: lesson.end_time,
|
||
status: lesson.status,
|
||
client: lesson.client?.id,
|
||
client_name: lesson.client_name ?? (lesson.client?.user
|
||
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
||
: undefined),
|
||
mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name
|
||
? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim()
|
||
: undefined),
|
||
subject: lesson.subject ?? lesson.subject_name ?? '',
|
||
}));
|
||
if (doMerge) {
|
||
setLessons((prev) => {
|
||
const startStr = format(start, 'yyyy-MM-dd');
|
||
const endStr = format(end, 'yyyy-MM-dd');
|
||
const byId = new Map<string, CalendarLesson>();
|
||
prev.forEach((l) => {
|
||
const lessonDateStr = l.start_time?.slice(0, 10) ?? '';
|
||
if (lessonDateStr < startStr || lessonDateStr > endStr) {
|
||
byId.set(String(l.id), l);
|
||
}
|
||
});
|
||
mappedLessons.forEach((l) => byId.set(String(l.id), l));
|
||
return Array.from(byId.values());
|
||
});
|
||
} else {
|
||
setLessons(mappedLessons);
|
||
}
|
||
hasLoadedLessonsOnceRef.current = true;
|
||
} catch (err: any) {
|
||
console.error('Error loading lessons:', err);
|
||
setError(err?.message || 'Ошибка загрузки занятий');
|
||
} finally {
|
||
setLessonsLoading(false);
|
||
}
|
||
},
|
||
[visibleMonth, selectedChild?.id]
|
||
);
|
||
|
||
useEffect(() => {
|
||
loadLessons();
|
||
}, [loadLessons]);
|
||
|
||
const handleMonthChange = useCallback((start: Date, _end: Date) => {
|
||
const key = format(start, 'yyyy-MM');
|
||
if (key === format(visibleMonth, 'yyyy-MM')) return;
|
||
setVisibleMonth(startOfMonth(start));
|
||
}, [visibleMonth]);
|
||
|
||
const lessonsForSelectedDate: LessonPreview[] = lessons
|
||
.filter((lesson) => {
|
||
// Парсим дату в timezone пользователя для правильной фильтрации
|
||
const parsed = parseISOToUserTimezone(lesson.start_time, user?.timezone);
|
||
const lessonDate = startOfDay(parsed.dateObj);
|
||
return lessonDate.getTime() === selectedDate.getTime();
|
||
})
|
||
.sort((a, b) => {
|
||
// Сортируем по времени начала (раньше → первые)
|
||
return new Date(a.start_time).getTime() - new Date(b.start_time).getTime();
|
||
})
|
||
.map((lesson) => ({
|
||
id: String(lesson.id),
|
||
title: lesson.title || 'Занятие',
|
||
subject: lesson.subject ?? '',
|
||
start_time: lesson.start_time,
|
||
end_time: lesson.end_time,
|
||
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||
client: lesson.client_name
|
||
? {
|
||
id: String(lesson.client || ''),
|
||
name: lesson.client_name,
|
||
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
|
||
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
|
||
}
|
||
: undefined,
|
||
}));
|
||
|
||
const handleSelectSlot = (date: Date) => {
|
||
const dayStart = startOfDay(date);
|
||
setSelectedDate(dayStart);
|
||
setDisplayDate(dayStart);
|
||
setIsFormVisible(false);
|
||
};
|
||
|
||
const handleSelectEvent = (lesson: { id: string }) => {
|
||
if (isMentor) handleLessonClick(lesson);
|
||
};
|
||
|
||
const handlePrevDay = () => {
|
||
const prev = subDays(displayDate, 1);
|
||
setDisplayDate(prev);
|
||
setSelectedDate(startOfDay(prev));
|
||
setIsFormVisible(false);
|
||
};
|
||
|
||
const handleNextDay = () => {
|
||
const next = addDays(displayDate, 1);
|
||
setDisplayDate(next);
|
||
setSelectedDate(startOfDay(next));
|
||
setIsFormVisible(false);
|
||
};
|
||
|
||
const handleAddLesson = () => {
|
||
setIsEditingMode(false);
|
||
setFormData({
|
||
client: '',
|
||
title: '',
|
||
description: '',
|
||
start_date: format(selectedDate, 'yyyy-MM-dd'),
|
||
start_time: '14:00',
|
||
duration: 60,
|
||
price: undefined,
|
||
is_recurring: false,
|
||
});
|
||
setSelectedSubjectId(null);
|
||
setSelectedMentorSubjectId(null);
|
||
setIsFormVisible(true);
|
||
};
|
||
|
||
const handleLessonClick = (lesson: { id: string }) => {
|
||
if (!isMentor) return; // Добавить/редактировать/просмотр — только для ментора
|
||
setIsEditingMode(true);
|
||
setIsFormVisible(true);
|
||
setLessonEditLoading(true);
|
||
setFormError(null);
|
||
setEditingLessonId(lesson.id);
|
||
|
||
(async () => {
|
||
try {
|
||
const details = await getLesson(String(lesson.id));
|
||
|
||
// Парсим время в timezone пользователя
|
||
const startParsed = parseISOToUserTimezone(details.start_time, user?.timezone);
|
||
const safeStart = startOfDay(startParsed.dateObj);
|
||
|
||
// синхронизируем правую панель с датой урока
|
||
setSelectedDate(safeStart);
|
||
setDisplayDate(safeStart);
|
||
|
||
const duration = (() => {
|
||
const start = new Date(details.start_time);
|
||
const end = new Date(details.end_time);
|
||
const mins = differenceInMinutes(end, start);
|
||
return Number.isFinite(mins) && mins > 0 ? mins : 60;
|
||
})();
|
||
|
||
setFormData({
|
||
client: details.client?.id ? String(details.client.id) : '',
|
||
title: details.title ?? '',
|
||
description: details.description ?? '',
|
||
start_date: startParsed.date,
|
||
start_time: startParsed.time,
|
||
duration,
|
||
price: typeof details.price === 'number' ? details.price : undefined,
|
||
is_recurring: !!(details as any).is_recurring,
|
||
status: (details as any).status ?? 'completed',
|
||
});
|
||
setEditingLessonStatus((details as any).status ?? null);
|
||
|
||
// пробуем выставить предмет по названию
|
||
const subjName = (details as any).subject_name || (details as any).subject || '';
|
||
if (subjName) {
|
||
const foundSubject = subjects.find((s) => s.name === subjName);
|
||
const foundMentorSubject = mentorSubjects.find((s) => s.name === subjName);
|
||
if (foundMentorSubject) {
|
||
setSelectedSubjectId(null);
|
||
setSelectedMentorSubjectId(foundMentorSubject.id);
|
||
} else if (foundSubject) {
|
||
setSelectedSubjectId(foundSubject.id);
|
||
setSelectedMentorSubjectId(null);
|
||
} else {
|
||
setSelectedSubjectId(null);
|
||
setSelectedMentorSubjectId(null);
|
||
}
|
||
} else {
|
||
setSelectedSubjectId(null);
|
||
setSelectedMentorSubjectId(null);
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Error loading lesson:', err);
|
||
setFormError(err?.message || 'Не удалось загрузить данные занятия');
|
||
} finally {
|
||
setLessonEditLoading(false);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const handleSubjectChange = (subjectId: number | null, mentorSubjectId: number | null) => {
|
||
setSelectedSubjectId(subjectId);
|
||
setSelectedMentorSubjectId(mentorSubjectId);
|
||
};
|
||
|
||
const getSubjectName = () => {
|
||
if (selectedSubjectId) {
|
||
const s = subjects.find((x) => x.id === selectedSubjectId);
|
||
return s?.name ?? '';
|
||
}
|
||
if (selectedMentorSubjectId) {
|
||
const s = mentorSubjects.find((x) => x.id === selectedMentorSubjectId);
|
||
return s?.name ?? '';
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const generateTitle = () => {
|
||
const student = students.find((s) => String(s.id) === formData.client);
|
||
const studentName = student
|
||
? `${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim() || student.user?.email
|
||
: '';
|
||
const subjectName = getSubjectName();
|
||
if (studentName && subjectName) return `${subjectName} — ${studentName}`;
|
||
if (studentName) return studentName;
|
||
if (subjectName) return subjectName;
|
||
return formData.title || 'Занятие';
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setFormLoading(true);
|
||
setFormError(null);
|
||
|
||
try {
|
||
const isCompleted = editingLessonStatus === 'completed';
|
||
if (!isCompleted) {
|
||
if (!formData.client) {
|
||
setFormError('Выберите ученика');
|
||
setFormLoading(false);
|
||
return;
|
||
}
|
||
if (!selectedSubjectId && !selectedMentorSubjectId) {
|
||
setFormError('Выберите предмет');
|
||
setFormLoading(false);
|
||
return;
|
||
}
|
||
if (!formData.start_date || !formData.start_time) {
|
||
setFormError('Укажите дату и время');
|
||
setFormLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
if (formData.price == null || formData.price < 0) {
|
||
setFormError('Укажите стоимость занятия');
|
||
setFormLoading(false);
|
||
return;
|
||
}
|
||
|
||
const startUtc = !isCompleted
|
||
? createDateTimeInUserTimezone(formData.start_date, formData.start_time, user?.timezone)
|
||
: '';
|
||
const title = generateTitle();
|
||
|
||
if (isEditingMode && editingLessonId) {
|
||
if (editingLessonStatus === 'completed') {
|
||
await updateLesson(editingLessonId, {
|
||
price: formData.price,
|
||
status: formData.status ?? 'completed',
|
||
});
|
||
} else {
|
||
await updateLesson(editingLessonId, {
|
||
title,
|
||
description: formData.description,
|
||
start_time: startUtc,
|
||
duration: formData.duration,
|
||
price: formData.price,
|
||
});
|
||
}
|
||
} else {
|
||
const payload: any = {
|
||
client: formData.client,
|
||
title,
|
||
description: formData.description,
|
||
start_time: startUtc,
|
||
duration: formData.duration,
|
||
price: formData.price,
|
||
is_recurring: formData.is_recurring,
|
||
};
|
||
if (selectedSubjectId) payload.subject_id = selectedSubjectId;
|
||
else if (selectedMentorSubjectId) payload.mentor_subject_id = selectedMentorSubjectId;
|
||
|
||
await createLesson(payload);
|
||
}
|
||
|
||
setIsFormVisible(false);
|
||
setEditingLessonId(null);
|
||
setEditingLessonStatus(null);
|
||
loadLessons();
|
||
} catch (err: any) {
|
||
setFormError(getErrorMessage(err, 'Не удалось сохранить занятие. Проверьте данные.'));
|
||
} finally {
|
||
setFormLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (deleteAllFuture: boolean) => {
|
||
if (!editingLessonId) return;
|
||
setFormLoading(true);
|
||
setFormError(null);
|
||
try {
|
||
await deleteLesson(editingLessonId, deleteAllFuture);
|
||
setIsFormVisible(false);
|
||
setEditingLessonId(null);
|
||
setEditingLessonStatus(null);
|
||
loadLessons();
|
||
} catch (err: any) {
|
||
setFormError(getErrorMessage(err, 'Не удалось удалить занятие.'));
|
||
} finally {
|
||
setFormLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
setIsFormVisible(false);
|
||
setIsEditingMode(false);
|
||
setFormError(null);
|
||
setEditingLessonId(null);
|
||
setEditingLessonStatus(null);
|
||
};
|
||
|
||
return (
|
||
<div className="ios26-dashboard ios26-schedule-page" style={{ padding: '16px' }}>
|
||
{error && <ErrorDisplay error={error} onRetry={loadLessons} />}
|
||
|
||
<div className="ios26-schedule-layout" style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '5fr 2fr',
|
||
gap: 'var(--ios26-spacing)',
|
||
alignItems: 'stretch',
|
||
// стабилизируем высоту секции (без фиксированных px),
|
||
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
|
||
minHeight: 'min(calc(100vh - 160px), 600px)',
|
||
}}>
|
||
<div className="ios26-schedule-calendar-wrap" data-tour="schedule-calendar">
|
||
<Calendar
|
||
lessons={lessons}
|
||
lessonsLoading={lessonsLoading}
|
||
selectedDate={selectedDate}
|
||
onSelectSlot={handleSelectSlot}
|
||
onSelectEvent={handleSelectEvent}
|
||
onMonthChange={handleMonthChange}
|
||
isMentor={isMentor}
|
||
userTimezone={user?.timezone}
|
||
/>
|
||
</div>
|
||
<div className="ios26-schedule-right-wrap" data-tour="schedule-form">
|
||
<CheckLesson
|
||
selectedDate={selectedDate}
|
||
displayDate={displayDate}
|
||
lessonsLoading={lessonsLoading}
|
||
lessonsForSelectedDate={lessonsForSelectedDate}
|
||
isFormVisible={isFormVisible}
|
||
isMentor={isMentor}
|
||
onPrevDay={handlePrevDay}
|
||
onNextDay={handleNextDay}
|
||
onAddLesson={handleAddLesson}
|
||
onLessonClick={handleLessonClick}
|
||
buttonComponentsLoaded={buttonComponentsLoaded}
|
||
formComponentsLoaded={formComponentsLoaded}
|
||
lessonEditLoading={lessonEditLoading}
|
||
isEditingMode={isEditingMode}
|
||
formLoading={formLoading}
|
||
formError={formError}
|
||
formData={formData}
|
||
setFormData={setFormData}
|
||
selectedSubjectId={selectedSubjectId}
|
||
selectedMentorSubjectId={selectedMentorSubjectId}
|
||
onSubjectChange={handleSubjectChange}
|
||
students={students}
|
||
subjects={subjects}
|
||
mentorSubjects={mentorSubjects}
|
||
onSubmit={handleSubmit}
|
||
onCancel={handleCancel}
|
||
onDelete={isEditingMode ? handleDelete : undefined}
|
||
isCompletedLesson={editingLessonStatus === 'completed'}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|