uchill/front_material/components/checklesson/checklesson.tsx

884 lines
32 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.

/**
* Блок «Список занятий на выбранный день» / «Форма создания/редактирования» с анимацией cube.
* Используется в Dashboard и других страницах.
*/
'use client';
import React, { useState } from 'react';
import { format, startOfDay, addDays, subDays } from 'date-fns';
import { ru } from 'date-fns/locale';
import { LessonCard } from '@/components/dashboard/LessonCard';
import { StudentSelect } from '@/components/dashboard/StudentSelect';
import { SubjectSelect } from '@/components/dashboard/SubjectSelect';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { Switch } from '@/components/common/Switch';
import { DatePicker } from '@/components/common/DatePicker';
import { TimePicker } from '@/components/common/TimePicker';
import type { LessonPreview } from '@/api/dashboard';
import type { Student } from '@/api/students';
import type { Subject, MentorSubject } from '@/api/subjects';
export interface CheckLessonFormData {
client: string;
title: string;
description: string;
start_date: string;
start_time: string;
duration: number;
price: number | undefined;
is_recurring: boolean;
}
export interface CheckLessonProps {
/** Выбранная дата */
selectedDate: Date;
/** Дата для отображения в заголовке (например, displayDate) */
displayDate: Date;
/** Идёт загрузка занятий */
lessonsLoading: boolean;
/** Занятия на выбранный день */
lessonsForSelectedDate: LessonPreview[];
/** Открыта ли форма (создание/редактирование) — переворот cube */
isFormVisible: boolean;
/** Предыдущий день */
onPrevDay: () => void;
/** Следующий день */
onNextDay: () => void;
/** Добавить занятие (открыть форму создания) */
onAddLesson: () => void;
/** Клик по карточке занятия (открыть форму редактирования или просмотра) */
onLessonClick: (lesson: { id: string }) => void;
/** Ментор — может добавлять и редактировать; для остальных только просмотр */
isMentor?: boolean;
/** Кнопка «Добавить» и компоненты загружены */
buttonComponentsLoaded?: boolean;
// Форма
formComponentsLoaded: boolean;
lessonEditLoading: boolean;
isEditingMode: boolean;
formLoading: boolean;
formError: string | null;
formData: CheckLessonFormData;
setFormData: React.Dispatch<React.SetStateAction<CheckLessonFormData>>;
selectedSubjectId: number | null;
selectedMentorSubjectId: number | null;
onSubjectChange: (subjectId: number | null, mentorSubjectId: number | null) => void;
students: Student[];
subjects: Subject[];
mentorSubjects: MentorSubject[];
onSubmit: (e: React.FormEvent) => void;
onCancel: () => void;
/** Удалить занятие (только в режиме редактирования). deleteAllFuture — удалить всю цепочку постоянных. */
onDelete?: (deleteAllFuture: boolean) => void;
}
export const CheckLesson: React.FC<CheckLessonProps> = ({
selectedDate,
displayDate,
lessonsLoading,
lessonsForSelectedDate,
isFormVisible,
onPrevDay,
onNextDay,
onAddLesson,
onLessonClick,
isMentor = false,
buttonComponentsLoaded = false,
formComponentsLoaded,
lessonEditLoading,
isEditingMode,
formLoading,
formError,
formData,
setFormData,
selectedSubjectId,
selectedMentorSubjectId,
onSubjectChange,
students,
subjects,
mentorSubjects,
onSubmit,
onCancel,
onDelete,
}) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const navDisabled = lessonsLoading || isFormVisible;
const formDisabled = formLoading || lessonEditLoading;
const headerDateLabel = React.useMemo(() => {
const now = new Date();
const sameYear = displayDate?.getFullYear?.() === now.getFullYear();
const fmt = sameYear ? 'd MMMM' : 'd MMMM yyyy';
try {
return format(displayDate, fmt, { locale: ru });
} catch {
return '';
}
}, [displayDate]);
const handleDeleteClick = () => {
if (!onDelete) return;
if (formData.is_recurring) {
setShowDeleteConfirm(true);
} else {
if (typeof window !== 'undefined' && window.confirm('Удалить занятие?')) {
onDelete(false);
}
}
};
const handleDeleteConfirm = (deleteAllFuture: boolean) => {
onDelete?.(deleteAllFuture);
setShowDeleteConfirm(false);
};
return (
<div
className="checklesson-root"
style={{
position: 'relative',
width: '100%',
height: '100%',
minHeight: '548px',
perspective: '1000px',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
transformStyle: 'preserve-3d',
transition: 'transform 0.6s ease-in-out',
transform: isFormVisible ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
>
{/* Лицевая сторона: Список занятий */}
<div
className="ios-glass-panel"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(0deg)',
borderRadius: '20px',
padding: '24px',
overflowY: 'hidden',
transformOrigin: 'center center',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', marginBottom: '20px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: '40px 1fr 40px',
alignItems: 'center',
columnGap: 12,
}}
>
<button
type="button"
onClick={onPrevDay}
disabled={navDisabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
padding: 0,
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)',
border: 'none',
borderRadius: 20,
cursor: navDisabled ? 'not-allowed' : 'pointer',
opacity: navDisabled ? 0.6 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!navDisabled) {
e.currentTarget.style.opacity = '0.8';
e.currentTarget.style.transform = 'scale(0.95)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = navDisabled ? '0.6' : '1';
e.currentTarget.style.transform = 'scale(1)';
}}
title="Предыдущий день"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<h3
style={{
fontSize: '20px',
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
margin: 0,
textAlign: 'center',
}}
>
{headerDateLabel}
</h3>
<button
type="button"
onClick={onNextDay}
disabled={navDisabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
padding: 0,
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)',
border: 'none',
borderRadius: 20,
cursor: navDisabled ? 'not-allowed' : 'pointer',
opacity: navDisabled ? 0.6 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!navDisabled) {
e.currentTarget.style.opacity = '0.8';
e.currentTarget.style.transform = 'scale(0.95)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = navDisabled ? '0.6' : '1';
e.currentTarget.style.transform = 'scale(1)';
}}
title="Следующий день"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
</div>
{/* Контент (скроллится) */}
<div style={{ flex: 1, overflowY: 'auto', paddingRight: 2 }}>
{lessonsLoading ? (
<LoadingSpinner size="medium" />
) : lessonsForSelectedDate.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '40px 20px',
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ margin: '0 auto 16px', opacity: 0.5 }}
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<p style={{ margin: 0, fontSize: '14px' }}>Нет занятий на этот день</p>
</div>
) : (
<div>
{lessonsForSelectedDate.map((lesson) => (
<LessonCard
key={lesson.id}
lesson={lesson}
showClient
onClick={isMentor ? () => onLessonClick(lesson) : undefined}
/>
))}
</div>
)}
</div>
{/* Footer — кнопка «Добавить занятие» только для ментора */}
{buttonComponentsLoaded && isMentor && (
<div
style={{
paddingTop: 16,
marginTop: 12,
borderTop: '1px solid var(--md-sys-color-outline-variant)',
display: 'flex',
justifyContent: 'center',
}}
>
<button
type="button"
onClick={onAddLesson}
disabled={navDisabled}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 24px',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
border: 'none',
borderRadius: 20,
fontSize: 14,
fontWeight: 600,
cursor: navDisabled ? 'not-allowed' : 'pointer',
opacity: navDisabled ? 0.6 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!navDisabled) e.currentTarget.style.opacity = '0.9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = navDisabled ? '0.6' : '1';
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Добавить занятие
</button>
</div>
)}
</div>
{/* Обратная сторона: Форма создания/редактирования */}
<div
className="ios-glass-panel"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
borderRadius: '20px',
padding: '24px',
overflowY: 'auto',
transformOrigin: 'center center',
display: 'flex',
flexDirection: 'column',
}}
>
{!formComponentsLoaded || (isEditingMode && lessonEditLoading) ? (
<LoadingSpinner size="medium" />
) : (
<>
<form
onSubmit={onSubmit}
className="checklesson-form"
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 16,
position: 'relative',
zIndex: 10,
pointerEvents: 'auto',
}}
>
<div
style={{
gridColumn: '1 / -1',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<h3
style={{
fontSize: '20px',
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
margin: 0,
}}
>
{isEditingMode ? 'Редактировать занятие' : 'Создать занятие'}
</h3>
<button
type="button"
onClick={onCancel}
disabled={formDisabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
padding: 0,
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)',
border: 'none',
borderRadius: 20,
cursor: formDisabled ? 'not-allowed' : 'pointer',
opacity: formDisabled ? 0.6 : 1,
transition: 'all 0.2s ease',
}}
title="Отмена"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{formError && (
<div
style={{
gridColumn: '1 / -1',
padding: 12,
borderRadius: 8,
background: 'var(--md-sys-color-error-container)',
color: 'var(--md-sys-color-on-error-container)',
fontSize: 14,
whiteSpace: 'pre-wrap' as const,
}}
>
{formError}
</div>
)}
<div style={{ gridColumn: 1 }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 4,
}}
>
Ученик *
</label>
<StudentSelect
students={students}
value={formData.client}
onChange={(value) => setFormData((prev) => ({ ...prev, client: value }))}
disabled={formLoading || isEditingMode}
required
/>
</div>
<div style={{ gridColumn: 2 }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 4,
}}
>
Предмет *
</label>
<SubjectSelect
subjects={subjects}
mentorSubjects={mentorSubjects}
value={selectedSubjectId ?? selectedMentorSubjectId ?? null}
onChange={(value) => {
if (value != null) {
const s = subjects.find((x) => x.id === value);
if (s) onSubjectChange(value, null);
else onSubjectChange(null, value);
} else {
onSubjectChange(null, null);
}
}}
disabled={formLoading}
required
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 4,
}}
>
Описание
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Дополнительная информация о занятии"
disabled={formLoading}
rows={2}
style={{
width: '100%',
padding: '12px 16px',
fontSize: 16,
color: 'var(--md-sys-color-on-surface)',
background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: 4,
fontFamily: 'inherit',
resize: 'vertical',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--md-sys-color-primary)';
e.currentTarget.style.borderWidth = '2px';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--md-sys-color-outline)';
e.currentTarget.style.borderWidth = '1px';
}}
/>
</div>
<div style={{ gridColumn: 1 }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 4,
}}
>
Дата начала *
</label>
<DatePicker
value={formData.start_date}
onChange={(value) => setFormData((prev) => ({ ...prev, start_date: value }))}
disabled={formLoading}
required
/>
</div>
<div style={{ gridColumn: 2 }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 4,
}}
>
Время начала *
</label>
<TimePicker
value={formData.start_time}
onChange={(value) => setFormData((prev) => ({ ...prev, start_time: value }))}
disabled={formLoading}
required
/>
</div>
<div style={{ gridColumn: 1 }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 4,
}}
>
Длительность (минуты) *
</label>
<select
value={formData.duration}
onChange={(e) => setFormData((prev) => ({ ...prev, duration: Number(e.target.value) }))}
required
disabled={formLoading}
style={{
width: '100%',
padding: '12px 16px',
fontSize: 16,
color: 'var(--md-sys-color-on-surface)',
background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: 4,
fontFamily: 'inherit',
cursor: formLoading ? 'not-allowed' : 'pointer',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--md-sys-color-primary)';
e.currentTarget.style.borderWidth = '2px';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--md-sys-color-outline)';
e.currentTarget.style.borderWidth = '1px';
}}
>
<option value={30}>30 минут</option>
<option value={45}>45 минут</option>
<option value={60}>1 час</option>
<option value={90}>1.5 часа</option>
<option value={120}>2 часа</option>
</select>
</div>
<div style={{ gridColumn: 2 }}>
<label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 4,
}}
>
Стоимость () *
</label>
<input
type="number"
value={formData.price ?? ''}
onChange={(e) => setFormData((prev) => ({ ...prev, price: Number(e.target.value) }))}
required
min={0}
step={100}
placeholder="1000"
disabled={formLoading}
style={{
width: '100%',
padding: '12px 16px',
fontSize: 16,
color: 'var(--md-sys-color-on-surface)',
background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: 4,
fontFamily: 'inherit',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--md-sys-color-primary)';
e.currentTarget.style.borderWidth = '2px';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--md-sys-color-outline)';
e.currentTarget.style.borderWidth = '1px';
}}
/>
</div>
<div style={{ gridColumn: '1 / -1', padding: '8px 0' }}>
<Switch
checked={formData.is_recurring}
onChange={(checked) => setFormData((prev) => ({ ...prev, is_recurring: checked }))}
disabled={formLoading}
label="Постоянное занятие (повторяется еженедельно)"
/>
</div>
<div
style={{
gridColumn: '1 / -1',
display: 'flex',
gap: 12,
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
}}
>
<div style={{ display: 'flex', gap: 12 }}>
{isEditingMode && onDelete && (
<button
type="button"
onClick={handleDeleteClick}
disabled={formDisabled}
style={{
padding: '10px 24px',
background: 'transparent',
color: 'var(--md-sys-color-error)',
border: '1px solid var(--md-sys-color-error)',
borderRadius: 20,
fontSize: 14,
fontWeight: 500,
cursor: formDisabled ? 'not-allowed' : 'pointer',
opacity: formDisabled ? 0.6 : 1,
}}
>
Удалить
</button>
)}
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button
type="button"
onClick={onCancel}
disabled={formDisabled}
style={{
padding: '10px 24px',
background: 'transparent',
color: 'var(--md-sys-color-primary)',
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 20,
fontSize: 14,
fontWeight: 500,
cursor: formDisabled ? 'not-allowed' : 'pointer',
opacity: formDisabled ? 0.6 : 1,
}}
>
Отмена
</button>
<button
type="submit"
disabled={formLoading}
style={{
padding: '10px 24px',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
border: 'none',
borderRadius: 20,
fontSize: 14,
fontWeight: 500,
cursor: formLoading ? 'not-allowed' : 'pointer',
opacity: formLoading ? 0.6 : 1,
}}
>
{formLoading
? isEditingMode
? 'Сохранение...'
: 'Создание...'
: isEditingMode
? 'Сохранить'
: 'Создать'}
</button>
</div>
</div>
</form>
{showDeleteConfirm && formData.is_recurring && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-dialog-title"
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.4)',
}}
onClick={() => setShowDeleteConfirm(false)}
>
<div
style={{
background: 'var(--md-sys-color-surface)',
borderRadius: 16,
padding: 24,
maxWidth: 400,
width: '90%',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}
onClick={(e) => e.stopPropagation()}
>
<h4
id="delete-dialog-title"
style={{
margin: '0 0 12px',
fontSize: 18,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
}}
>
Удалить занятие
</h4>
<p
style={{
margin: '0 0 20px',
fontSize: 14,
color: 'var(--md-sys-color-on-surface-variant)',
lineHeight: 1.4,
}}
>
Это постоянное занятие. Удалить только это занятие или всю цепочку будущих?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
type="button"
onClick={() => handleDeleteConfirm(false)}
disabled={formLoading}
style={{
padding: '10px 16px',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)',
border: 'none',
borderRadius: 12,
fontSize: 14,
fontWeight: 500,
cursor: formLoading ? 'not-allowed' : 'pointer',
}}
>
Только это занятие
</button>
<button
type="button"
onClick={() => handleDeleteConfirm(true)}
disabled={formLoading}
style={{
padding: '10px 16px',
background: 'var(--md-sys-color-error)',
color: 'var(--md-sys-color-on-error)',
border: 'none',
borderRadius: 12,
fontSize: 14,
fontWeight: 500,
cursor: formLoading ? 'not-allowed' : 'pointer',
}}
>
Всю цепочку занятий
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
disabled={formLoading}
style={{
padding: '10px 16px',
background: 'transparent',
color: 'var(--md-sys-color-primary)',
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 12,
fontSize: 14,
fontWeight: 500,
cursor: formLoading ? 'not-allowed' : 'pointer',
}}
>
Отмена
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};