uchill/front_material/components/schedule/CompleteLessonModal.tsx

802 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.

'use client';
/**
* Отдельный экран (breakpoint) завершения занятия — как в старом frontend.
* 3 шага: Оценки → Комментарий → Домашнее задание.
*/
import React, { useEffect, useState, useRef } from 'react';
import {
getLesson,
completeLesson,
getLessonFiles,
createLessonFile,
deleteLessonFile,
} from '@/api/schedule';
import type { Lesson, LessonFile } from '@/api/schedule';
import { getMyMaterials } from '@/api/materials';
import type { Material } from '@/api/materials';
const MAX_LESSON_FILES = 10;
const MAX_FILE_SIZE_MB = 10;
export interface CompleteLessonModalProps {
isOpen: boolean;
lessonId: number | null;
onClose: () => void;
onSuccess: () => void;
}
type Step = 1 | 2 | 3;
export function CompleteLessonModal({
isOpen,
lessonId,
onClose,
onSuccess,
}: CompleteLessonModalProps) {
const [lesson, setLesson] = useState<Lesson | null>(null);
const [loadingLesson, setLoadingLesson] = useState(false);
const [step, setStep] = useState<Step>(1);
const [formData, setFormData] = useState({
mentor_grade: '',
school_grade: '',
mentor_notes: '',
homework_assigned: false,
homework_text: '',
});
const [lessonFiles, setLessonFiles] = useState<LessonFile[]>([]);
const [materials, setMaterials] = useState<Material[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [selectedMaterialIds, setSelectedMaterialIds] = useState<string[]>([]);
const [selectedFileIds, setSelectedFileIds] = useState<Set<string>>(new Set());
const [newFiles, setNewFiles] = useState<File[]>([]);
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
const [materialsSearch, setMaterialsSearch] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const step3JustRenderedRef = useRef(false);
useEffect(() => {
if (!isOpen || lessonId == null) return;
setStep(1);
setFormData({
mentor_grade: '',
school_grade: '',
mentor_notes: '',
homework_assigned: false,
homework_text: '',
});
setError(null);
setLoadingLesson(true);
getLesson(String(lessonId))
.then((l) => setLesson(l))
.catch(() => setLesson(null))
.finally(() => setLoadingLesson(false));
}, [isOpen, lessonId]);
useEffect(() => {
if (!isOpen || !lessonId) return;
setFilesLoading(true);
getLessonFiles(String(lessonId))
.then((files) => setLessonFiles(files ?? []))
.catch(() => setLessonFiles([]))
.finally(() => setFilesLoading(false));
}, [isOpen, lessonId]);
useEffect(() => {
if (!isOpen) return;
setMaterialsLoading(true);
getMyMaterials()
.then((list) => setMaterials(Array.isArray(list) ? list : []))
.catch(() => setMaterials([]))
.finally(() => setMaterialsLoading(false));
}, [isOpen]);
useEffect(() => {
if (step === 3) {
step3JustRenderedRef.current = true;
const t = setTimeout(() => {
step3JustRenderedRef.current = false;
}, 300);
return () => clearTimeout(t);
}
}, [step]);
if (!isOpen) return null;
const handleClose = () => {
if (!loading) {
onClose();
}
};
const handleNext = () => {
if (step < 3) setStep((s) => (s + 1) as Step);
};
const handleBack = () => {
if (step > 1) setStep((s) => (s - 1) as Step);
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length || !lessonId) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) continue;
const fileId = `${file.name}-${file.size}-${i}-${Date.now()}`;
setUploadingFiles((prev) => new Set(prev).add(fileId));
try {
const created = await createLessonFile({
lesson: String(lessonId),
file,
filename: file.name,
});
setLessonFiles((prev) => [created, ...prev]);
setSelectedFileIds((prev) => new Set(prev).add(String(created.id)));
} catch (err) {
console.error('Ошибка загрузки файла урока', file.name, err);
} finally {
setUploadingFiles((prev) => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
}
}
e.target.value = '';
};
const handleMaterialToggle = (materialId: string, attach: boolean) => {
setSelectedMaterialIds((prev) =>
attach ? [...prev, materialId] : prev.filter((id) => id !== materialId)
);
};
const handleDeleteFile = async (fileId: string) => {
if (!window.confirm('Удалить этот файл из урока?')) return;
try {
await deleteLessonFile(fileId);
setLessonFiles((prev) => prev.filter((f) => String(f.id) !== fileId));
setSelectedFileIds((prev) => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
} catch (err) {
console.error('Ошибка удаления файла урока', err);
}
};
const getFileUrl = (file: LessonFile): string | null => {
const base = typeof window !== 'undefined' ? `${window.location.origin}` : '';
const path = file.file_url || file.file;
if (!path) return null;
return path.startsWith('http') ? path : `${base}${path}`;
};
const handleSubmit = async () => {
if (step !== 3 || !lessonId || step3JustRenderedRef.current) return;
setError(null);
setLoading(true);
try {
const currentAttachedMaterialIds = lessonFiles
.map((f) => (f.material ? String(f.material) : null))
.filter((id): id is string => !!id);
const materialsToAttach = selectedMaterialIds.filter(
(id) => !currentAttachedMaterialIds.includes(id)
);
const materialsToDetach = currentAttachedMaterialIds.filter(
(id) => !selectedMaterialIds.includes(id)
);
for (const materialId of materialsToAttach) {
try {
await createLessonFile({
lesson: String(lessonId),
material: materialId,
});
} catch (err) {
console.error('Ошибка прикрепления материала', materialId, err);
}
}
for (const materialId of materialsToDetach) {
const fileToDelete = lessonFiles.find(
(f) => f.material != null && String(f.material) === materialId
);
if (fileToDelete) {
try {
await deleteLessonFile(String(fileToDelete.id));
} catch (err) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
// уже удалено
} else {
console.error('Ошибка открепления материала', materialId, err);
}
}
}
}
const notes = (formData.mentor_notes || '').trim();
const mentorGrade = formData.mentor_grade
? parseInt(formData.mentor_grade, 10)
: undefined;
const schoolGrade = formData.school_grade
? parseInt(formData.school_grade, 10)
: undefined;
const homeworkText = formData.homework_assigned
? (formData.homework_text || '').trim()
: undefined;
const uploadedFiles = lessonFiles.filter(
(f) => f.file || (f as { source?: string }).source === 'uploaded'
);
const hasHomeworkFiles =
uploadedFiles.some((f) => selectedFileIds.has(String(f.id))) ||
selectedMaterialIds.length > 0;
const result = await completeLesson(
String(lessonId),
notes,
mentorGrade,
schoolGrade,
homeworkText || undefined,
hasHomeworkFiles
);
if (!result?.success) {
setError(result?.message || 'Не удалось завершить занятие.');
return;
}
onSuccess();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка завершения занятия');
} finally {
setLoading(false);
}
};
if (loadingLesson || !lesson) {
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10003]"
style={{ background: 'rgba(0,0,0,0.5)' }}
>
<div
style={{
background: 'var(--md-sys-color-surface)',
borderRadius: 16,
padding: 32,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Загрузка
</div>
</div>
);
}
return (
<div
className="fixed inset-0 z-[10003] flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.5)' }}
>
<div
style={{
background: 'var(--md-sys-color-surface)',
borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
maxWidth: 560,
width: '100%',
maxHeight: '90vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
padding: '20px 24px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h2
style={{
fontSize: 20,
fontWeight: 600,
margin: 0,
color: 'var(--md-sys-color-on-surface)',
}}
>
Завершить занятие
</h2>
<p
style={{
fontSize: 14,
margin: '4px 0 0 0',
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
{lesson.title} {lesson.subject ? `${lesson.subject}` : ''}
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginTop: 12,
}}
>
{[1, 2, 3].map((s) => (
<React.Fragment key={s}>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background:
step >= s
? 'var(--md-sys-color-primary)'
: 'var(--md-sys-color-surface-variant)',
color: step > s ? 'var(--md-sys-color-on-primary)' : 'var(--md-sys-color-on-surface-variant)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
fontWeight: 600,
}}
>
{step > s ? '✓' : s}
</div>
{s < 3 && (
<div
style={{
width: 24,
height: 2,
background:
step >= 2 && s === 1
? 'var(--md-sys-color-primary)'
: step >= 3 && s === 2
? 'var(--md-sys-color-primary)'
: 'var(--md-sys-color-outline-variant)',
}}
/>
)}
</React.Fragment>
))}
<span style={{ fontSize: 12, marginLeft: 4, color: 'var(--md-sys-color-on-surface-variant)' }}>
{step === 1 && 'Оценки'}
{step === 2 && 'Комментарий'}
{step === 3 && 'ДЗ'}
</span>
</div>
</div>
<button
type="button"
onClick={handleClose}
disabled={loading}
style={{
width: 44,
height: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
background: 'none',
borderRadius: 12,
cursor: loading ? 'not-allowed' : 'pointer',
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>close</span>
</button>
</div>
<div style={{ padding: 24, overflowY: 'auto', flex: 1 }}>
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0, color: 'var(--md-sys-color-on-surface)' }}>
Оценки <span style={{ fontWeight: 400, color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>(необязательно)</span>
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>
Оценка за занятие (0100)
</label>
<input
type="number"
min={0}
max={100}
value={formData.mentor_grade}
onChange={(e) => setFormData({ ...formData, mentor_grade: e.target.value })}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>
Оценка в школе (0100)
</label>
<input
type="number"
min={0}
max={100}
value={formData.school_grade}
onChange={(e) => setFormData({ ...formData, school_grade: e.target.value })}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
}}
/>
</div>
</div>
</div>
)}
{step === 2 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0, color: 'var(--md-sys-color-on-surface)' }}>
Комментарий к занятию <span style={{ fontWeight: 400, color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>(необязательно)</span>
</h3>
<textarea
value={formData.mentor_notes}
onChange={(e) => setFormData({ ...formData, mentor_notes: e.target.value })}
rows={6}
placeholder="Что прошли на занятии, успехи студента, рекомендации..."
disabled={loading}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
resize: 'vertical',
}}
/>
</div>
)}
{step === 3 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0, color: 'var(--md-sys-color-on-surface)' }}>
Выдать домашнее задание
</h3>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => setFormData({ ...formData, homework_assigned: true })}
disabled={loading}
style={{
padding: '10px 20px',
borderRadius: 12,
border: 'none',
background: formData.homework_assigned ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-surface-variant)',
color: formData.homework_assigned ? 'var(--md-sys-color-on-primary)' : 'var(--md-sys-color-on-surface-variant)',
fontSize: 15,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Да
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, homework_assigned: false })}
disabled={loading}
style={{
padding: '10px 20px',
borderRadius: 12,
border: 'none',
background: !formData.homework_assigned ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-surface-variant)',
color: !formData.homework_assigned ? 'var(--md-sys-color-on-primary)' : 'var(--md-sys-color-on-surface-variant)',
fontSize: 15,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Нет
</button>
</div>
{formData.homework_assigned && (
<>
<div>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>
Текст задания
</label>
<textarea
value={formData.homework_text}
onChange={(e) => setFormData({ ...formData, homework_text: e.target.value })}
rows={3}
placeholder="Опишите задание, шаги, ссылки..."
disabled={loading}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
resize: 'vertical',
}}
/>
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>
Файлы и материалы к ДЗ
</div>
<input
type="file"
multiple
className="hidden"
id="complete-lesson-file"
onChange={handleFileChange}
disabled={loading || uploadingFiles.size > 0}
accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.zip,.rar"
/>
<label
htmlFor="complete-lesson-file"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: '14px 20px',
borderRadius: 12,
border: '2px dashed var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
cursor: loading || uploadingFiles.size > 0 ? 'not-allowed' : 'pointer',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>upload_file</span>
{uploadingFiles.size > 0 ? `Загрузка ${uploadingFiles.size}` : 'Загрузить файлы'}
</label>
{(filesLoading || uploadingFiles.size > 0) && lessonFiles.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 8 }}>Загрузка</p>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
{lessonFiles
.filter((f) => f.file || (f as { source?: string }).source === 'uploaded')
.map((file) => {
const fileId = String(file.id);
const isSelected = selectedFileIds.has(fileId);
const url = getFileUrl(file);
return (
<div
key={fileId}
role="button"
tabIndex={0}
onClick={() => {
setSelectedFileIds((prev) => {
const next = new Set(prev);
if (isSelected) next.delete(fileId);
else next.add(fileId);
return next;
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedFileIds((prev) => {
const next = new Set(prev);
if (isSelected) next.delete(fileId);
else next.add(fileId);
return next;
});
}
}}
style={{
width: 80,
aspectRatio: '1',
borderRadius: 12,
overflow: 'hidden',
border: `2px solid ${isSelected ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline-variant)'}`,
background: 'var(--md-sys-color-surface-variant)',
cursor: 'pointer',
position: 'relative',
}}
>
{url && /\.(jpe?g|png|gif|webp|bmp)$/i.test(file.filename) ? (
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="material-symbols-outlined" style={{ fontSize: 28, color: 'var(--md-sys-color-primary)' }}>description</span>
</div>
)}
<span style={{ position: 'absolute', bottom: 0, left: 0, right: 0, fontSize: 10, padding: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.filename}
</span>
</div>
);
})}
{materials
.filter((m) => {
if (!materialsSearch.trim()) return true;
const q = materialsSearch.toLowerCase();
return (m.title || '').toLowerCase().includes(q) || (m.description || '').toLowerCase().includes(q);
})
.map((m) => {
const materialId = String(m.id);
const isAttached = selectedMaterialIds.includes(materialId);
return (
<div
key={materialId}
role="button"
tabIndex={0}
onClick={() => handleMaterialToggle(materialId, !isAttached)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleMaterialToggle(materialId, !isAttached);
}
}}
style={{
padding: '8px 12px',
borderRadius: 12,
border: `2px solid ${isAttached ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline-variant)'}`,
background: isAttached ? 'var(--md-sys-color-primary-container)' : 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 13,
cursor: 'pointer',
maxWidth: 140,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{m.title}
</div>
);
})}
</div>
{materials.length > 0 && (
<input
type="text"
value={materialsSearch}
onChange={(e) => setMaterialsSearch(e.target.value)}
placeholder="Поиск материала..."
style={{
width: '100%',
marginTop: 8,
padding: '8px 12px',
borderRadius: 8,
border: '1px solid var(--md-sys-color-outline)',
fontSize: 14,
}}
/>
)}
</div>
</>
)}
</div>
)}
{error && (
<div
style={{
marginTop: 16,
padding: 12,
borderRadius: 12,
background: 'rgba(186,26,26,0.1)',
border: '1px solid var(--md-sys-color-error)',
color: 'var(--md-sys-color-error)',
fontSize: 14,
}}
>
{error}
</div>
)}
<div
style={{
display: 'flex',
gap: 12,
marginTop: 24,
paddingTop: 16,
borderTop: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<button
type="button"
onClick={handleClose}
disabled={loading}
style={{
padding: '12px 20px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 15,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Отмена
</button>
{step > 1 && (
<button
type="button"
onClick={handleBack}
disabled={loading}
style={{
padding: '12px 20px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 15,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Назад
</button>
)}
{step < 3 ? (
<button
type="button"
onClick={handleNext}
disabled={loading}
style={{
flex: 1,
padding: '12px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 15,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Далее
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={loading}
style={{
flex: 1,
padding: '12px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 15,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? 'Сохранение…' : 'Завершить и сохранить'}
</button>
)}
</div>
</div>
</div>
</div>
);
}