802 lines
32 KiB
TypeScript
802 lines
32 KiB
TypeScript
'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)' }}>
|
||
Оценка за занятие (0–100)
|
||
</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)' }}>
|
||
Оценка в школе (0–100)
|
||
</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>
|
||
);
|
||
}
|