1411 lines
64 KiB
TypeScript
1411 lines
64 KiB
TypeScript
'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} · Оценка: 1–5
|
||
</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();
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|