uchill/front_material/components/dashboard/CreateLessonDialog.tsx

752 lines
24 KiB
TypeScript
Raw Permalink 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.

/**
* Диалог создания занятия в Material Design 3
*/
'use client';
import React, { useState, useEffect } from 'react';
import { loadComponent } from '@/lib/material-components';
import { createLesson } from '@/api/schedule';
import { getErrorMessage } from '@/lib/error-utils';
import { getStudents, Student } from '@/api/students';
import { getSubjects, getMentorSubjects, createMentorSubject, Subject, MentorSubject } from '@/api/subjects';
import { getCurrentUser, User } from '@/api/auth';
import { format } from 'date-fns';
import { DatePicker } from '@/components/common/DatePicker';
import { TimePicker } from '@/components/common/TimePicker';
import { createDateTimeInUserTimezone } from '@/utils/timezone';
interface CreateLessonDialogProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
defaultDate?: Date;
}
interface LessonFormData {
client: string;
title: string;
subject_id?: number;
mentor_subject_id?: number;
subject_name?: string;
description: string;
start_date: string; // YYYY-MM-DD
start_time: string; // HH:mm
duration: number;
price?: number;
is_recurring: boolean;
}
export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
open,
onClose,
onSuccess,
defaultDate,
}) => {
const [componentsLoaded, setComponentsLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [students, setStudents] = useState<Student[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [mentorSubjects, setMentorSubjects] = useState<MentorSubject[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
const [selectedMentorSubjectId, setSelectedMentorSubjectId] = useState<number | null>(null);
const [customSubjectName, setCustomSubjectName] = useState<string>('');
const [formData, setFormData] = useState<LessonFormData>({
client: '',
title: '',
description: '',
start_date: '',
start_time: '',
duration: 60,
price: undefined,
is_recurring: false,
});
useEffect(() => {
if (open) {
loadComponents();
loadData();
}
}, [open]);
useEffect(() => {
if (open && defaultDate) {
const start = new Date(defaultDate);
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatTime = (date: Date) => {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
};
setFormData(prev => ({
...prev,
start_date: formatDate(start),
start_time: formatTime(start),
}));
}
}, [open, defaultDate]);
const loadComponents = async () => {
try {
await Promise.all([
loadComponent('dialog'),
loadComponent('filled-button'),
loadComponent('text-button'),
loadComponent('outlined-field'),
loadComponent('select'),
loadComponent('switch'),
loadComponent('icon'),
]);
setComponentsLoaded(true);
} catch (error) {
console.error('[CreateLessonDialog] Ошибка загрузки компонентов:', error);
}
};
const loadData = async () => {
try {
const [studentsData, subjectsData, mentorSubjectsData, userData] = await Promise.all([
getStudents({ page_size: 100 }),
getSubjects(),
getMentorSubjects(),
getCurrentUser(),
]);
const studentsList = Array.isArray(studentsData) ? studentsData : (studentsData.results || []);
setStudents(studentsList);
setSubjects(Array.isArray(subjectsData) ? subjectsData : []);
setMentorSubjects(Array.isArray(mentorSubjectsData) ? mentorSubjectsData : []);
setCurrentUser(userData);
} catch (error) {
console.error('[CreateLessonDialog] Ошибка загрузки данных:', error);
setError('Ошибка загрузки данных');
}
};
const generateTitle = (student: Student | null, subjectName: string): string => {
if (!student || !subjectName || !currentUser) return '';
const mentorName = `${currentUser.last_name} ${currentUser.first_name?.charAt(0) || ''}.`.trim();
const studentName = `${student.user?.last_name || ''} ${student.user?.first_name?.charAt(0) || ''}.`.trim();
return `${mentorName} ${studentName} ${subjectName}`.trim();
};
const getSubjectName = (): string => {
if (selectedSubjectId) {
const subject = subjects.find(s => s.id === selectedSubjectId);
return subject?.name || '';
}
if (selectedMentorSubjectId) {
const subject = mentorSubjects.find(s => s.id === selectedMentorSubjectId);
return subject?.name || '';
}
return customSubjectName || '';
};
// Автогенерация названия
useEffect(() => {
if (formData.client && getSubjectName() && currentUser) {
const selectedStudent = students.find(s => String(s.id) === formData.client);
if (selectedStudent) {
const subjectName = getSubjectName();
const generatedTitle = generateTitle(selectedStudent, subjectName);
setFormData(prev => ({ ...prev, title: generatedTitle }));
}
}
}, [formData.client, selectedSubjectId, selectedMentorSubjectId, customSubjectName, students, currentUser, subjects, mentorSubjects]);
const handleSubjectChange = async (value: string | number | null, isCustom: boolean, customName?: string) => {
if (isCustom && customName) {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
setCustomSubjectName(customName);
} else if (value) {
const subject = subjects.find(s => s.id === value);
const mentorSubject = mentorSubjects.find(s => s.id === value);
if (subject) {
setSelectedSubjectId(subject.id);
setSelectedMentorSubjectId(null);
setCustomSubjectName('');
} else if (mentorSubject) {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(mentorSubject.id);
setCustomSubjectName('');
}
} else {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
setCustomSubjectName('');
}
};
const handleCustomSubjectCreate = async (name: string): Promise<void> => {
const newSubject = await createMentorSubject(name);
setMentorSubjects(prev => [...prev, newSubject]);
setSelectedMentorSubjectId(newSubject.id);
setCustomSubjectName('');
};
const handleDateChange = (value: string) => {
setFormData(prev => ({
...prev,
start_date: value,
}));
};
const handleTimeChange = (value: string) => {
setFormData(prev => ({
...prev,
start_time: value,
}));
};
const handleDurationChange = (value: number) => {
setFormData(prev => ({
...prev,
duration: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
if (!formData.client) {
setError('Выберите ученика');
setLoading(false);
return;
}
if (!selectedSubjectId && !selectedMentorSubjectId && !customSubjectName) {
setError('Выберите или введите предмет');
setLoading(false);
return;
}
if (!formData.start_date || !formData.start_time) {
setError('Укажите дату и время начала');
setLoading(false);
return;
}
if (!formData.price || formData.price <= 0) {
setError('Укажите стоимость занятия');
setLoading(false);
return;
}
// Объединяем дату и время в ISO строку с учётом timezone пользователя
const startUtc = createDateTimeInUserTimezone(
formData.start_date,
formData.start_time,
currentUser?.timezone
);
const payload: any = {
client: formData.client,
title: formData.title || generateTitle(
students.find(s => String(s.id) === formData.client) || null,
getSubjectName()
),
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;
} else if (customSubjectName) {
payload.subject_name = customSubjectName;
}
await createLesson(payload);
// Сброс формы
setFormData({
client: '',
title: '',
description: '',
start_date: '',
start_time: '',
duration: 60,
price: undefined,
is_recurring: false,
});
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
setCustomSubjectName('');
onSuccess();
onClose();
} catch (err: any) {
console.error('[CreateLessonDialog] Ошибка создания занятия:', err);
setError(getErrorMessage(err, 'Не удалось создать занятие. Проверьте данные.'));
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
setFormData({
client: '',
title: '',
description: '',
start_date: '',
start_time: '',
duration: 60,
price: undefined,
is_recurring: false,
});
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
setCustomSubjectName('');
setError(null);
onClose();
}
};
if (!open || !componentsLoaded) return null;
return (
<>
<md-dialog
open={open}
onCancel={handleClose}
style={{
'--md-dialog-container-color': 'var(--md-sys-color-surface)',
'--md-dialog-headline-color': 'var(--md-sys-color-on-surface)',
'--md-dialog-supporting-text-color': 'var(--md-sys-color-on-surface-variant)',
} as React.CSSProperties}
>
<div slot="headline" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>Создать занятие</span>
<button
type="button"
onClick={handleClose}
className="create-lesson-dialog-close"
aria-label="Закрыть"
style={{
all: 'unset',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '50%',
color: 'var(--md-sys-color-on-surface-variant)',
flexShrink: 0,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>close</span>
</button>
</div>
<form slot="content" method="dialog" onSubmit={(e) => { e.preventDefault(); handleSubmit(e); }} className="create-lesson-dialog" style={{ display: 'flex', flexDirection: 'column', gap: '20px', minWidth: 'min(500px, 90vw)', maxWidth: '600px' }}>
{error && (
<div style={{
padding: '12px',
borderRadius: '8px',
background: 'var(--md-sys-color-error-container)',
color: 'var(--md-sys-color-on-error-container)',
fontSize: '14px',
whiteSpace: 'pre-wrap',
}}>
{error}
</div>
)}
{/* Ученик */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
marginBottom: '8px',
}}>
Ученик *
</label>
<md-outlined-field label="Выберите ученика" style={{ width: '100%' }}>
<select
slot="input"
value={formData.client}
onChange={(e) => setFormData(prev => ({ ...prev, client: e.target.value }))}
required
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
fontFamily: 'inherit',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
<option value="">Выберите ученика</option>
{students.map(student => (
<option key={student.id} value={String(student.id)}>
{`${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim() || student.user?.email || ''}
</option>
))}
</select>
</md-outlined-field>
</div>
{/* Предмет */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
marginBottom: '8px',
}}>
Предмет *
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<md-outlined-field label="Выберите предмет" style={{ width: '100%' }}>
<select
slot="input"
value={selectedSubjectId ? String(selectedSubjectId) : selectedMentorSubjectId ? String(selectedMentorSubjectId) : ''}
onChange={(e: any) => {
const value = e.target.value;
if (value) {
handleSubjectChange(Number(value), false);
} else {
handleSubjectChange(null, false);
}
}}
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
fontFamily: 'inherit',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
<option value="">Выберите предмет</option>
{subjects.map(subject => (
<option key={subject.id} value={String(subject.id)}>
{subject.name}
</option>
))}
{mentorSubjects.map(subject => (
<option key={subject.id} value={String(subject.id)}>
{subject.name}
</option>
))}
</select>
</md-outlined-field>
<md-outlined-field label="Или введите свой предмет" style={{ width: '100%' }}>
<input
slot="input"
type="text"
value={customSubjectName}
onChange={(e) => {
setCustomSubjectName(e.target.value);
if (e.target.value) {
handleSubjectChange(null, true, e.target.value);
} else {
handleSubjectChange(null, false);
}
}}
placeholder="Название предмета"
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
}}
/>
</md-outlined-field>
</div>
</div>
{/* Название */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
marginBottom: '8px',
}}>
Название занятия
</label>
<md-outlined-field label="Название" style={{ width: '100%' }}>
<input
slot="input"
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="Автоматически генерируется"
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
}}
/>
</md-outlined-field>
</div>
{/* Описание */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
marginBottom: '8px',
}}>
Описание
</label>
<md-outlined-field label="Описание" style={{ width: '100%' }}>
<textarea
slot="input"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Дополнительная информация о занятии"
disabled={loading}
rows={2}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
resize: 'none',
fontFamily: 'inherit',
}}
/>
</md-outlined-field>
</div>
{/* Дата начала */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
marginBottom: '8px',
}}>
Дата начала *
</label>
<DatePicker
value={formData.start_date}
onChange={(v) => handleDateChange(v)}
disabled={loading}
required
label="Дата начала"
/>
</div>
{/* Время начала и длительность */}
<div className="create-lesson-time-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
marginBottom: '8px',
}}>
Время начала *
</label>
<TimePicker
value={formData.start_time}
onChange={(v) => handleTimeChange(v)}
disabled={loading}
required
/>
</div>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
marginBottom: '8px',
}}>
Длительность (минуты) *
</label>
<md-outlined-field label="Длительность" style={{ width: '100%' }}>
<select
slot="input"
value={formData.duration}
onChange={(e) => handleDurationChange(Number(e.target.value))}
required
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
fontFamily: 'inherit',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
<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>
</md-outlined-field>
</div>
</div>
{/* Цена */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
marginBottom: '8px',
}}>
Стоимость () *
</label>
<md-outlined-field label="Стоимость" style={{ width: '100%' }}>
<input
slot="input"
type="number"
value={formData.price || ''}
onChange={(e) => setFormData(prev => ({ ...prev, price: Number(e.target.value) }))}
required
min={0}
step={100}
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
}}
/>
</md-outlined-field>
</div>
{/* Постоянное занятие */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<md-switch
checked={formData.is_recurring}
onInput={(e: any) => setFormData(prev => ({ ...prev, is_recurring: e.target.checked }))}
disabled={loading}
/>
<label style={{
fontSize: '14px',
color: 'var(--md-sys-color-on-surface)',
cursor: 'pointer',
}}>
Постоянное занятие (повторяется еженедельно)
</label>
</div>
</form>
<div slot="actions">
<md-text-button onClick={handleClose} disabled={loading}>
Отмена
</md-text-button>
<md-filled-button onClick={(e) => { e.preventDefault(); handleSubmit(e); }} disabled={loading}>
{loading ? 'Создание...' : 'Создать'}
</md-filled-button>
</div>
</md-dialog>
<style jsx global>{`
md-dialog {
--md-dialog-container-shape: 28px;
--md-dialog-container-max-width: 600px;
--md-dialog-container-min-width: min(500px, 90vw);
}
md-dialog::part(container) {
margin: auto;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
overflow-y: auto;
}
md-dialog form {
padding: 24px;
}
md-dialog [slot="actions"] {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 8px 24px 24px 24px;
}
md-outlined-field {
width: 100%;
}
md-outlined-field input[type="date"],
md-outlined-field input[type="time"] {
font-family: inherit;
font-size: 16px;
color: var(--md-sys-color-on-surface);
}
md-outlined-field input[type="date"]::-webkit-calendar-picker-indicator,
md-outlined-field input[type="time"]::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.6;
}
md-outlined-field input[type="date"]::-webkit-calendar-picker-indicator:hover,
md-outlined-field input[type="time"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
`}</style>
</>
);
};