uchill/front_material/components/homework/HomeworkDetailsModal.tsx

1411 lines
64 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';
import React, { useState, useEffect, lazy, Suspense } from 'react';
const PdfFirstPagePreview = lazy(() =>
import('./PdfFirstPagePreview').then((m) => ({ default: m.PdfFirstPagePreview }))
);
import {
getMySubmission,
getHomeworkSubmissions,
gradeSubmission,
returnSubmissionForRevision,
deleteSubmission,
checkSubmissionWithAi,
type Homework,
type HomeworkSubmission,
type TokenUsage,
} from '@/api/homework';
import { getBackendOrigin } from '@/lib/api-client';
import { SubmitHomeworkModal } from './SubmitHomeworkModal';
import { EditHomeworkDraftModal } from './EditHomeworkDraftModal';
interface HomeworkDetailsModalProps {
isOpen: boolean;
homework: Homework | null;
userRole: string;
/** Для родителя: user_id выбранного ребёнка — загрузить и показать его решение. */
childId?: string | null;
onClose: () => void;
onSuccess: () => void;
}
function formatDateTime(s: string | null): string {
if (!s) return '—';
const d = new Date(s);
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function getFileUrl(path: string): string {
if (!path) return '';
if (path.startsWith('http://') || path.startsWith('https://')) return path;
const base = typeof window !== 'undefined' ? getBackendOrigin() : '';
return base + (path.startsWith('/') ? path : '/' + path);
}
const IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'];
/** Расширения документов, которые можно открыть внутри платформы (PDF — iframe, остальные — текст). */
const VIEWABLE_DOC_EXT: Record<string, 'pdf' | 'text'> = {
pdf: 'pdf',
txt: 'text', py: 'text', md: 'text', json: 'text', csv: 'text', xml: 'text', html: 'text',
js: 'text', ts: 'text', jsx: 'text', tsx: 'text', css: 'text', sql: 'text', yaml: 'text', yml: 'text',
};
function isImageFilename(filename: string): boolean {
const ext = (filename || '').split('.').pop()?.toLowerCase() || '';
return IMAGE_EXT.includes(ext);
}
function isImageUrl(url: string): boolean {
const path = url.split('?')[0];
const name = path.split('/').pop() || '';
return isImageFilename(name);
}
function getDocViewType(url: string, filename?: string): 'pdf' | 'text' | null {
const name = filename || url.split('?')[0].split('/').pop() || '';
const ext = name.split('.').pop()?.toLowerCase() || '';
return VIEWABLE_DOC_EXT[ext] ?? null;
}
/** Превью файла задания: для PDF — первая страница, для текста — первые строки + «Открыть». */
function AssignmentFilePreview({
url,
label,
type,
onOpen,
}: {
url: string;
label: string;
type: 'pdf' | 'text';
onOpen: () => void;
}) {
const [mounted, setMounted] = useState(false);
const [textPreview, setTextPreview] = useState<string | null>(null);
const [textLoading, setTextLoading] = useState(type === 'text');
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (type !== 'text') return;
setTextLoading(true);
fetch(url, { credentials: 'include' })
.then((r) => (r.ok ? r.text() : ''))
.then((t) => (t ? t.split('\n').slice(0, 8).join('\n') : ''))
.then(setTextPreview)
.catch(() => setTextPreview(''))
.finally(() => setTextLoading(false));
}, [url, type]);
const cardStyle: React.CSSProperties = {
width: '100%',
maxWidth: 320,
padding: 14,
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline-variant)',
background: 'var(--md-sys-color-surface-variant)',
textAlign: 'left',
cursor: 'pointer',
};
if (type === 'pdf') {
const pdfPlaceholder = (
<div style={{ width: 200, height: 282, background: 'var(--md-sys-color-surface-variant)', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="material-symbols-outlined" style={{ fontSize: 32, color: 'var(--md-sys-color-primary)' }}>picture_as_pdf</span>
</div>
);
return (
<button type="button" onClick={onOpen} style={cardStyle}>
{mounted ? (
<Suspense fallback={pdfPlaceholder}>
<PdfFirstPagePreview url={url} alt={label} />
</Suspense>
) : (
pdfPlaceholder
)}
<div style={{ marginTop: 10 }}>
<span style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{label}</span>
<span style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>Нажмите, чтобы открыть</span>
</div>
</button>
);
}
return (
<button type="button" onClick={onOpen} style={{ ...cardStyle, display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ flexShrink: 0, fontSize: 13, color: 'var(--md-sys-color-primary)' }}>Открыть</span>
</div>
<div
style={{
maxHeight: 100,
overflow: 'auto',
borderRadius: 8,
padding: 10,
background: 'var(--md-sys-color-surface)',
fontSize: 12,
lineHeight: 1.4,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'ui-monospace, monospace',
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
{textLoading ? 'Загрузка превью…' : textPreview || '—'}
</div>
</button>
);
}
function DocumentViewerOverlay({
url,
filename,
type,
onClose,
}: {
url: string;
filename: string;
type: 'pdf' | 'text';
onClose: () => void;
}) {
const [textContent, setTextContent] = useState<string | null>(null);
const [textError, setTextError] = useState<string | null>(null);
const [loading, setLoading] = useState(type === 'text');
useEffect(() => {
if (type !== 'text') return;
setLoading(true);
setTextError(null);
setTextContent(null);
fetch(url, { credentials: 'include' })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(setTextContent)
.catch((e) => setTextError(e instanceof Error ? e.message : 'Не удалось загрузить файл'))
.finally(() => setLoading(false));
}, [url, type]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Просмотр документа"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.85)',
display: 'flex',
flexDirection: 'column',
zIndex: 1003,
}}
onClick={onClose}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
background: 'var(--md-sys-color-surface)',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<span style={{ fontSize: 16, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
style={{
width: 40,
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface)',
cursor: 'pointer',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>close</span>
</button>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 16 }} onClick={(e) => e.stopPropagation()}>
{type === 'pdf' && (
<iframe
title={filename}
src={url}
style={{
width: '100%',
flex: 1,
border: 'none',
borderRadius: 12,
background: '#fff',
}}
/>
)}
{type === 'text' && (
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--md-sys-color-surface)', borderRadius: 12, overflow: 'hidden' }}>
{loading && <p style={{ padding: 24, margin: 0 }}>Загрузка</p>}
{textError && <p style={{ padding: 24, margin: 0, color: 'var(--md-sys-color-error)' }}>{textError}</p>}
{textContent != null && !loading && (
<pre
style={{
flex: 1,
margin: 0,
padding: 16,
overflow: 'auto',
fontSize: 14,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'ui-monospace, monospace',
}}
>
{textContent}
</pre>
)}
</div>
)}
</div>
</div>
);
}
const PANEL_WIDTH_NARROW = 420;
const PANEL_WIDTH_EXPANDED_PERCENT = 80;
/** Пока скрыта кнопка «Удалить решение» у ученика. Включить: true */
const SHOW_DELETE_SUBMISSION_FOR_STUDENT = false;
export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onClose, onSuccess }: HomeworkDetailsModalProps) {
const [expanded, setExpanded] = useState(false);
const [mySubmission, setMySubmission] = useState<HomeworkSubmission | null>(null);
const [submissions, setSubmissions] = useState<HomeworkSubmission[]>([]);
const [loadingSubs, setLoadingSubs] = useState(false);
const [submitOpen, setSubmitOpen] = useState(false);
const [gradingId, setGradingId] = useState<number | null>(null);
const [gradeScore, setGradeScore] = useState('');
const [gradeFeedback, setGradeFeedback] = useState('');
const [returnId, setReturnId] = useState<number | null>(null);
const [returnFeedback, setReturnFeedback] = useState('');
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [aiCheckingId, setAiCheckingId] = useState<number | null>(null);
const [lastAiUsage, setLastAiUsage] = useState<TokenUsage | null>(null);
const [lastAiUsageSubId, setLastAiUsageSubId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [documentViewer, setDocumentViewer] = useState<{ url: string; filename: string; type: 'pdf' | 'text' } | null>(null);
// Модальное окно редактирования черновика ДЗ
const [editDraftOpen, setEditDraftOpen] = useState(false);
useEffect(() => {
if (!isOpen || !homework) return;
setError(null);
if (userRole === 'client') {
setLoadingSubs(true);
getMySubmission(homework.id)
.then(setMySubmission)
.catch(() => setMySubmission(null))
.finally(() => setLoadingSubs(false));
} else if (userRole === 'parent' && childId) {
setLoadingSubs(true);
getMySubmission(homework.id, { child_id: childId })
.then(setMySubmission)
.catch(() => setMySubmission(null))
.finally(() => setLoadingSubs(false));
} else if (userRole === 'mentor') {
setLoadingSubs(true);
getHomeworkSubmissions(homework.id)
.then(setSubmissions)
.catch(() => setSubmissions([]))
.finally(() => setLoadingSubs(false));
}
}, [isOpen, homework, userRole, childId]);
if (!isOpen || !homework) return null;
const MENTOR_GRADE_MIN = 1;
const MENTOR_GRADE_MAX = 5;
const handleGrade = async (subId: number) => {
const score = parseInt(gradeScore, 10);
if (isNaN(score) || score < MENTOR_GRADE_MIN || score > MENTOR_GRADE_MAX) {
setError(`Укажите оценку от ${MENTOR_GRADE_MIN} до ${MENTOR_GRADE_MAX}`);
return;
}
try {
setError(null);
await gradeSubmission(subId, { score, feedback: gradeFeedback.trim() || undefined });
setGradingId(null);
setGradeScore('');
setGradeFeedback('');
onSuccess();
getHomeworkSubmissions(homework.id).then(setSubmissions);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка');
}
};
const handleReturn = async (subId: number) => {
if (!returnFeedback.trim()) {
setError('Укажите причину возврата');
return;
}
try {
setError(null);
await returnSubmissionForRevision(subId, returnFeedback.trim());
setReturnId(null);
setReturnFeedback('');
onSuccess();
getHomeworkSubmissions(homework.id).then(setSubmissions);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка');
}
};
const openDeleteConfirm = () => setDeleteConfirmOpen(true);
const closeDeleteConfirm = () => setDeleteConfirmOpen(false);
const handleCheckWithAi = async (subId: number) => {
try {
setError(null);
setAiCheckingId(subId);
const result = await checkSubmissionWithAi(subId);
await getHomeworkSubmissions(homework!.id).then(setSubmissions);
setGradingId(subId);
setReturnId(null);
setGradeScore(String(result.ai_score));
setGradeFeedback(result.ai_feedback || '');
if (result.usage) {
setLastAiUsage(result.usage);
setLastAiUsageSubId(subId);
} else {
setLastAiUsage(null);
setLastAiUsageSubId(null);
}
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка проверки через ИИ');
} finally {
setAiCheckingId(null);
}
};
const handleDeleteMySubmission = async () => {
if (!mySubmission) return;
closeDeleteConfirm();
try {
setError(null);
setDeletingId(mySubmission.id);
await deleteSubmission(mySubmission.id);
setMySubmission(null);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка удаления');
} finally {
setDeletingId(null);
}
};
return (
<>
{/* Правая панель на всю высоту */}
<div
className="ios26-panel"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: expanded ? `${PANEL_WIDTH_EXPANDED_PERCENT}vw` : PANEL_WIDTH_NARROW,
maxWidth: expanded ? 'none' : PANEL_WIDTH_NARROW,
background: 'var(--md-sys-color-surface)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: 1001,
transition: 'width 0.25s ease',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '20px 24px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
flexShrink: 0,
}}
>
<button
type="button"
onClick={() => setExpanded(!expanded)}
title={expanded ? 'Сузить' : 'Развернуть'}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline-variant)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)',
cursor: 'pointer',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
{expanded ? 'chevron_right' : 'chevron_left'}
</span>
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0, color: 'var(--md-sys-color-on-surface)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{homework.title}
</h2>
<p style={{ fontSize: 15, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 4 }}>
{homework.mentor.first_name} {homework.mentor.last_name} · Оценка: 15
</p>
</div>
<button type="button" onClick={onClose} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, borderRadius: 12, border: 'none', background: 'none', cursor: 'pointer', color: 'var(--md-sys-color-on-surface-variant)' }}>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>close</span>
</button>
</div>
<div
style={{
padding: '24px 28px',
paddingBottom: 'max(24px, env(safe-area-inset-bottom, 0px) + 100px)',
overflowY: 'auto',
flex: 1,
}}
>
{/* Черновик fill_later — показываем кнопку редактирования */}
{userRole === 'mentor' && homework.fill_later && (
<div style={{ marginBottom: 28, padding: 20, background: 'var(--md-sys-color-tertiary-container)', borderRadius: 16 }}>
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: 'var(--md-sys-color-on-tertiary-container)' }}>
Черновик требуется заполнение
</h4>
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-tertiary-container)', marginBottom: 16, opacity: 0.8 }}>
Это домашнее задание было создано с пометкой «заполнить позже». Заполните детали задания и опубликуйте его для студента.
</p>
<button
type="button"
onClick={() => setEditDraftOpen(true)}
style={{
padding: '14px 28px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
Заполнить задание
</button>
</div>
)}
{/* Обычное отображение для опубликованных заданий */}
{homework.description && (
<div style={{ marginBottom: 28 }}>
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Описание</h4>
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)', whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
{homework.description}
</p>
</div>
)}
{homework.deadline && (
<p style={{ fontSize: 15, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 20 }}>
Дедлайн: {formatDateTime(homework.deadline)}
</p>
)}
{(homework.attachment || homework.attachment_url || (homework.files?.length ?? 0) > 0) && (
<div style={{ marginBottom: 28 }}>
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Файлы задания</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
{homework.attachment && (() => {
const url = getFileUrl(homework.attachment);
const isImage = isImageUrl(url);
if (isImage) {
return (
<button
key="attachment"
type="button"
onClick={() => setImagePreviewUrl(url)}
style={{
display: 'block',
padding: 0,
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 14,
overflow: 'hidden',
background: 'var(--md-sys-color-surface-variant)',
cursor: 'pointer',
width: 140,
height: 140,
}}
>
<img src={url} alt="Файл задания" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</button>
);
}
const viewType = getDocViewType(url, 'Файл задания');
return (
viewType ? (
<AssignmentFilePreview
key="attachment"
url={url}
label="Файл задания"
type={viewType}
onOpen={() => setDocumentViewer({ url, filename: 'Файл задания', type: viewType })}
/>
) : (
<a
key="attachment"
href={url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
padding: '12px 18px',
borderRadius: 14,
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-primary)',
fontSize: 16,
textDecoration: 'none',
border: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>download</span>
Файл задания
</a>
)
);
})()}
{(homework.files ?? []).filter((f) => f.file_type === 'assignment').map((f) => {
const url = f.file ? getFileUrl(f.file) : '';
const isImage = f.is_image === true || (f.filename && isImageFilename(f.filename));
const label = f.filename || 'Файл';
if (isImage && url) {
return (
<button
key={f.id}
type="button"
onClick={() => setImagePreviewUrl(url)}
style={{
display: 'block',
padding: 0,
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 14,
overflow: 'hidden',
background: 'var(--md-sys-color-surface-variant)',
cursor: 'pointer',
width: 140,
height: 140,
}}
>
<img src={url} alt={label} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</button>
);
}
const viewType = getDocViewType(url, label);
return viewType ? (
<AssignmentFilePreview
key={f.id}
url={url}
label={label}
type={viewType}
onOpen={() => setDocumentViewer({ url, filename: label, type: viewType })}
/>
) : (
<a
key={f.id}
href={url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
padding: '12px 18px',
borderRadius: 14,
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-primary)',
fontSize: 16,
textDecoration: 'none',
border: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>download</span>
{label}
</a>
);
})}
{homework.attachment_url?.trim() && (
<a
href={homework.attachment_url.trim()}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
padding: '12px 18px',
borderRadius: 14,
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-primary)',
fontSize: 16,
textDecoration: 'none',
border: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>link</span>
Ссылка на материал
</a>
)}
</div>
</div>
)}
{(userRole === 'client' || userRole === 'parent') && (
<div>
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>{userRole === 'parent' ? 'Решение ученика' : 'Моё решение'}</h4>
{loadingSubs ? (
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>Загрузка...</p>
) : mySubmission ? (
<div style={{ padding: 24, background: 'var(--md-sys-color-surface-variant)', borderRadius: 16, marginBottom: 20 }}>
<p style={{ fontSize: 16, marginBottom: 12, lineHeight: 1.5 }}>Отправлено: {formatDateTime(mySubmission.submitted_at)}</p>
{mySubmission.content && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6, color: 'var(--md-sys-color-on-surface-variant)' }}>Текст решения</div>
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface)', whiteSpace: 'pre-wrap', margin: 0, lineHeight: 1.5 }}>{mySubmission.content}</p>
</div>
)}
{(mySubmission.attachment || mySubmission.attachment_url || (mySubmission.files?.length ?? 0) > 0) && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>Прикреплённые файлы</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{mySubmission.attachment && (() => {
const url = getFileUrl(mySubmission.attachment);
const isImage = isImageUrl(url);
if (isImage) {
return (
<button
key="my-attachment"
type="button"
onClick={() => setImagePreviewUrl(url)}
style={{
display: 'block',
padding: 0,
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 12,
overflow: 'hidden',
background: 'var(--md-sys-color-surface)',
cursor: 'pointer',
width: 100,
height: 100,
}}
>
<img src={url} alt="Файл" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</button>
);
}
const viewType = getDocViewType(url, 'Файл');
return viewType ? (
<button
key="my-attachment"
type="button"
onClick={() => setDocumentViewer({ url, filename: 'Файл', type: viewType })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>description</span>
Файл
</button>
) : (
<a
key="my-attachment"
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', textDecoration: 'none' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>download</span>
Файл
</a>
);
})()}
{(mySubmission.files ?? []).filter((f) => f.file_type === 'submission').map((f) => {
const url = f.file ? getFileUrl(f.file) : '';
const isImage = f.is_image === true || (f.filename && isImageFilename(f.filename));
const label = f.filename || 'Файл';
if (isImage && url) {
return (
<button
key={f.id}
type="button"
onClick={() => setImagePreviewUrl(url)}
style={{
display: 'block',
padding: 0,
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 12,
overflow: 'hidden',
background: 'var(--md-sys-color-surface)',
cursor: 'pointer',
width: 100,
height: 100,
}}
>
<img src={url} alt={label} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</button>
);
}
const viewType = getDocViewType(url, label);
return viewType ? (
<button
key={f.id}
type="button"
onClick={() => setDocumentViewer({ url, filename: label, type: viewType })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>description</span>
{label}
</button>
) : (
<a
key={f.id}
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', textDecoration: 'none' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>download</span>
{label}
</a>
);
})}
{mySubmission.attachment_url?.trim() && (
<a
href={mySubmission.attachment_url.trim()}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', textDecoration: 'none' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>link</span>
Ссылка на решение
</a>
)}
</div>
</div>
)}
{(mySubmission.feedback || mySubmission.feedback_html) && (
<div style={{ fontSize: 16, marginBottom: 12, lineHeight: 1.5 }}>
<strong>Отзыв:</strong>{' '}
{mySubmission.feedback_html ? (
<span className="feedback-html" dangerouslySetInnerHTML={{ __html: mySubmission.feedback_html }} />
) : (
mySubmission.feedback
)}
</div>
)}
{mySubmission.score != null && (
<p style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-primary)', marginBottom: 0 }}>
Оценка: {mySubmission.score} / 5
</p>
)}
{userRole === 'client' && mySubmission.status === 'returned' && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 16 }}>
<button
type="button"
onClick={() => setSubmitOpen(true)}
style={{
padding: '12px 24px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit_note</span>
Доработать ДЗ
</button>
</div>
)}
{userRole === 'client' && SHOW_DELETE_SUBMISSION_FOR_STUDENT && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 16 }}>
<button
type="button"
onClick={openDeleteConfirm}
disabled={deletingId === mySubmission.id}
style={{
padding: '12px 24px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-error)',
background: 'transparent',
color: 'var(--md-sys-color-error)',
fontSize: 16,
fontWeight: 600,
cursor: deletingId === mySubmission.id ? 'not-allowed' : 'pointer',
opacity: deletingId === mySubmission.id ? 0.6 : 1,
}}
>
{deletingId === mySubmission.id ? 'Удаление...' : 'Удалить решение'}
</button>
</div>
)}
</div>
) : userRole === 'client' ? (
<button
type="button"
onClick={() => setSubmitOpen(true)}
style={{
padding: '14px 28px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
}}
>
Сдать ДЗ
</button>
) : (
<p style={{ fontSize: 15, color: 'var(--md-sys-color-on-surface-variant)' }}>Ученик ещё не сдал решение</p>
)}
</div>
)}
{userRole === 'mentor' && submissions.length > 0 && (
<div>
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>Решения ({submissions.length})</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{submissions.map((sub) => (
<div
key={sub.id}
style={{
display: 'flex',
flexDirection: 'column',
gap: 0,
padding: 24,
background: 'var(--md-sys-color-surface-variant)',
borderRadius: 16,
border: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 12 }}>
<span style={{ fontSize: 16, fontWeight: 500 }}>
{sub.student.first_name} {sub.student.last_name}
</span>
<span style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
{formatDateTime(sub.submitted_at)}
</span>
</div>
{userRole === 'mentor' && (
<div style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 12 }}>
{sub.status === 'pending' && sub.ai_checked_at
? 'Черновик от ИИ'
: sub.status === 'graded'
? sub.graded_by_ai
? 'Проверено: ИИ'
: sub.checked_by
? `Проверено: ${sub.checked_by.first_name} ${sub.checked_by.last_name}`
: 'Проверено: ментор'
: null}
</div>
)}
{sub.content && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6, color: 'var(--md-sys-color-on-surface-variant)' }}>Текст решения</div>
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface)', whiteSpace: 'pre-wrap', margin: 0, lineHeight: 1.5 }}>{sub.content}</p>
</div>
)}
{(sub.attachment || sub.attachment_url || (sub.files?.length ?? 0) > 0) && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>Файлы решения</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{sub.attachment && (() => {
const url = getFileUrl(sub.attachment);
const isImage = isImageUrl(url);
if (isImage) {
return (
<button
key="sub-attachment"
type="button"
onClick={() => setImagePreviewUrl(url)}
style={{
display: 'block',
padding: 0,
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 12,
overflow: 'hidden',
background: 'var(--md-sys-color-surface)',
cursor: 'pointer',
width: 100,
height: 100,
}}
>
<img src={url} alt="Файл решения" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</button>
);
}
const viewType = getDocViewType(url, 'Файл решения');
return viewType ? (
<button
key="sub-attachment"
type="button"
onClick={() => setDocumentViewer({ url, filename: 'Файл решения', type: viewType })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>description</span>
Файл решения
</button>
) : (
<a
key="sub-attachment"
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', textDecoration: 'none' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>download</span>
Файл решения
</a>
);
})()}
{(sub.files ?? []).filter((f) => f.file_type === 'submission').map((f) => {
const url = f.file ? getFileUrl(f.file) : '';
const isImage = f.is_image === true || (f.filename && isImageFilename(f.filename));
const label = f.filename || 'Файл';
if (isImage && url) {
return (
<button
key={f.id}
type="button"
onClick={() => setImagePreviewUrl(url)}
style={{
display: 'block',
padding: 0,
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 12,
overflow: 'hidden',
background: 'var(--md-sys-color-surface)',
cursor: 'pointer',
width: 100,
height: 100,
}}
>
<img src={url} alt={label} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</button>
);
}
const viewType = getDocViewType(url, label);
return viewType ? (
<button
key={f.id}
type="button"
onClick={() => setDocumentViewer({ url, filename: label, type: viewType })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>description</span>
{label}
</button>
) : (
<a
key={f.id}
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', textDecoration: 'none' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>download</span>
{label}
</a>
);
})}
{sub.attachment_url?.trim() && (
<a
href={sub.attachment_url.trim()}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, color: 'var(--md-sys-color-primary)', textDecoration: 'none' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>link</span>
Ссылка на решение
</a>
)}
</div>
</div>
)}
{sub.status !== 'graded' && gradingId !== sub.id && returnId !== sub.id && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 16 }}>
<button
type="button"
onClick={() => {
setGradingId(sub.id);
setReturnId(null);
if (sub.ai_checked_at && (sub.ai_score != null || sub.ai_feedback)) {
setGradeScore(sub.ai_score != null ? String(sub.ai_score) : '');
setGradeFeedback(sub.ai_feedback ?? '');
} else {
setGradeScore('');
setGradeFeedback('');
}
}}
style={{
padding: '12px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 15,
cursor: 'pointer',
}}
>
Оценить
</button>
<button
type="button"
onClick={() => handleCheckWithAi(sub.id)}
disabled={aiCheckingId === sub.id}
style={{
padding: '12px 20px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-tertiary)',
background: 'var(--md-sys-color-tertiary-container)',
color: 'var(--md-sys-color-on-tertiary-container)',
fontSize: 15,
cursor: aiCheckingId === sub.id ? 'not-allowed' : 'pointer',
opacity: aiCheckingId === sub.id ? 0.7 : 1,
}}
>
{aiCheckingId === sub.id ? 'Проверка…' : 'Проверить через ИИ'}
</button>
<button
type="button"
onClick={() => {
setReturnId(sub.id);
setGradingId(null);
setReturnFeedback('');
}}
style={{
padding: '12px 20px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-error)',
background: 'transparent',
color: 'var(--md-sys-color-error)',
fontSize: 15,
cursor: 'pointer',
}}
>
Вернуть
</button>
</div>
)}
{gradingId === sub.id && (
<div style={{ marginTop: 16 }}>
{lastAiUsage && lastAiUsageSubId === sub.id && (
<p style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 8 }}>
Токенов за эту проверку: {lastAiUsage.total_tokens} (вход: {lastAiUsage.prompt_tokens}, выход: {lastAiUsage.completion_tokens}). Остаток лимита в кабинете Timeweb.
</p>
)}
{sub.ai_feedback_html && gradeFeedback === (sub.ai_feedback ?? '') && (
<div style={{ marginBottom: 12, padding: 12, borderRadius: 12, background: 'var(--md-sys-color-surface-variant)', fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Предпросмотр черновика ИИ</div>
<div className="feedback-html" style={{ lineHeight: 1.5 }} dangerouslySetInnerHTML={{ __html: sub.ai_feedback_html }} />
</div>
)}
<input
type="number"
min={MENTOR_GRADE_MIN}
max={MENTOR_GRADE_MAX}
value={gradeScore}
onChange={(e) => setGradeScore(e.target.value)}
placeholder={`${MENTOR_GRADE_MIN}${MENTOR_GRADE_MAX}`}
style={{ width: 88, padding: 12, marginRight: 12, borderRadius: 12, border: '1px solid var(--md-sys-color-outline)', fontSize: 16 }}
/>
<textarea
value={gradeFeedback}
onChange={(e) => setGradeFeedback(e.target.value)}
placeholder="Комментарий"
rows={4}
style={{ width: '100%', padding: 12, marginTop: 12, borderRadius: 12, border: '1px solid var(--md-sys-color-outline)', fontSize: 16, lineHeight: 1.5 }}
/>
<div style={{ marginTop: 12, display: 'flex', gap: 12 }}>
<button type="button" onClick={() => handleGrade(sub.id)} style={{ padding: '12px 20px', borderRadius: 12, background: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-on-primary)', border: 'none', cursor: 'pointer', fontSize: 15 }}>
Сохранить
</button>
<button type="button" onClick={() => setGradingId(null)} style={{ padding: '12px 20px', borderRadius: 12, border: '1px solid var(--md-sys-color-outline)', background: 'transparent', cursor: 'pointer', fontSize: 15 }}>
Отмена
</button>
</div>
</div>
)}
{returnId === sub.id && (
<div style={{ marginTop: 16 }}>
<textarea
value={returnFeedback}
onChange={(e) => setReturnFeedback(e.target.value)}
placeholder="Причина возврата"
rows={4}
style={{ width: '100%', padding: 12, borderRadius: 12, border: '1px solid var(--md-sys-color-outline)', fontSize: 16, lineHeight: 1.5 }}
/>
<div style={{ marginTop: 12, display: 'flex', gap: 12 }}>
<button type="button" onClick={() => handleReturn(sub.id)} style={{ padding: '12px 20px', borderRadius: 12, background: 'var(--md-sys-color-error)', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 15 }}>
Вернуть
</button>
<button type="button" onClick={() => setReturnId(null)} style={{ padding: '12px 20px', borderRadius: 12, border: '1px solid var(--md-sys-color-outline)', background: 'transparent', cursor: 'pointer', fontSize: 15 }}>
Отмена
</button>
</div>
</div>
)}
</div>
{sub.status === 'graded' && (
<div
style={{
marginTop: 20,
paddingTop: 20,
borderTop: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 12 }}>
Комментарий проверки
</div>
{sub.score != null && (
<p style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-primary)', margin: '0 0 12px 0' }}>
Оценка: {sub.score} / 5
</p>
)}
{sub.feedback_html ? (
<div
className="feedback-html"
style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface)', margin: 0, lineHeight: 1.5 }}
dangerouslySetInnerHTML={{ __html: sub.feedback_html }}
/>
) : sub.feedback ? (
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface)', whiteSpace: 'pre-wrap', margin: 0, lineHeight: 1.5 }}>
{sub.feedback}
</p>
) : (
<p style={{ fontSize: 15, color: 'var(--md-sys-color-on-surface-variant)', margin: 0 }}></p>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
{error && (
<div style={{ marginTop: 20, padding: 16, background: 'rgba(186,26,26,0.1)', borderRadius: 14, color: 'var(--md-sys-color-error)', fontSize: 16, lineHeight: 1.5 }}>
{error}
</div>
)}
</div>
</div>
{deleteConfirmOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-confirm-title"
aria-describedby="delete-confirm-desc"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1002,
padding: 28,
}}
onClick={closeDeleteConfirm}
>
<div
className="ios26-panel"
style={{
background: 'var(--md-sys-color-surface)',
borderRadius: 24,
maxWidth: 420,
width: '100%',
padding: 32,
boxShadow: 'var(--md-sys-elevation-2, 0 4px 12px rgba(0,0,0,0.15))',
}}
onClick={(e) => e.stopPropagation()}
>
<h3
id="delete-confirm-title"
style={{
fontSize: 20,
fontWeight: 600,
margin: '0 0 12px 0',
color: 'var(--md-sys-color-on-surface)',
}}
>
Удалить решение?
</h3>
<p
id="delete-confirm-desc"
style={{
fontSize: 16,
color: 'var(--md-sys-color-on-surface-variant)',
margin: '0 0 28px 0',
lineHeight: 1.5,
}}
>
Задание снова будет ожидать загрузку решения. Это действие нельзя отменить.
</p>
<div style={{ display: 'flex', gap: 14, justifyContent: 'flex-end' }}>
<button
type="button"
onClick={closeDeleteConfirm}
style={{
padding: '12px 24px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
}}
>
Отмена
</button>
<button
type="button"
onClick={handleDeleteMySubmission}
disabled={deletingId !== null}
style={{
padding: '12px 24px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-error)',
color: 'var(--md-sys-color-on-error)',
fontSize: 16,
fontWeight: 600,
cursor: deletingId !== null ? 'not-allowed' : 'pointer',
opacity: deletingId !== null ? 0.7 : 1,
}}
>
{deletingId !== null ? 'Удаление…' : 'Удалить'}
</button>
</div>
</div>
</div>
)}
{imagePreviewUrl && (
<div
role="dialog"
aria-modal="true"
aria-label="Просмотр изображения"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1003,
padding: 24,
}}
onClick={() => setImagePreviewUrl(null)}
>
<button
type="button"
onClick={() => setImagePreviewUrl(null)}
aria-label="Закрыть"
style={{
position: 'absolute',
top: 16,
right: 16,
width: 48,
height: 48,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
border: 'none',
background: 'rgba(255,255,255,0.15)',
color: '#fff',
cursor: 'pointer',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 28 }}>close</span>
</button>
<img
src={imagePreviewUrl}
alt=""
style={{
maxWidth: '90vw',
maxHeight: '90vh',
objectFit: 'contain',
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{documentViewer && (
<DocumentViewerOverlay
url={documentViewer.url}
filename={documentViewer.filename}
type={documentViewer.type}
onClose={() => setDocumentViewer(null)}
/>
)}
<SubmitHomeworkModal
isOpen={submitOpen}
homeworkId={homework.id}
onClose={() => setSubmitOpen(false)}
onSuccess={() => {
Promise.resolve(onSuccess()).then(() => {
setSubmitOpen(false);
getMySubmission(homework.id).then(setMySubmission);
});
}}
/>
<EditHomeworkDraftModal
isOpen={editDraftOpen}
homework={homework}
onClose={() => setEditDraftOpen(false)}
onSuccess={() => {
setEditDraftOpen(false);
onSuccess();
}}
/>
</>
);
}