259 lines
9.0 KiB
TypeScript
259 lines
9.0 KiB
TypeScript
/**
|
||
* Секция «Последние сданные ДЗ» для дашборда ментора (iOS 26).
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React, { useMemo, useState } from 'react';
|
||
import { MentorDashboardResponse } from '@/api/dashboard';
|
||
import { Panel, SectionHeader, FlipCard } from '../ui';
|
||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||
import { getHomeworkSubmission, type HomeworkSubmission } from '@/api/homework';
|
||
|
||
export interface RecentSubmissionsSectionProps {
|
||
data: MentorDashboardResponse | null;
|
||
loading: boolean;
|
||
}
|
||
|
||
const formatDateTime = (dateTimeStr: string | null): string => {
|
||
if (!dateTimeStr) return '—';
|
||
try {
|
||
const date = new Date(dateTimeStr);
|
||
if (isNaN(date.getTime())) return '—';
|
||
|
||
const now = new Date();
|
||
const diffMs = now.getTime() - date.getTime();
|
||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||
const diffDays = Math.floor(diffHours / 24);
|
||
|
||
if (diffHours < 1) {
|
||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||
return diffMins <= 1 ? 'только что' : `${diffMins} мин назад`;
|
||
} else if (diffHours < 24) {
|
||
return `${diffHours} ч назад`;
|
||
} else if (diffDays === 1) {
|
||
return 'Вчера';
|
||
} else if (diffDays < 7) {
|
||
return `${diffDays} дн назад`;
|
||
} else {
|
||
return date.toLocaleDateString('ru-RU', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
});
|
||
}
|
||
} catch {
|
||
return '—';
|
||
}
|
||
};
|
||
|
||
const getStatusColor = (status: string): string => {
|
||
switch (status) {
|
||
case 'graded':
|
||
return 'var(--md-sys-color-tertiary)';
|
||
case 'returned':
|
||
return 'var(--md-sys-color-error)';
|
||
case 'submitted':
|
||
return 'var(--md-sys-color-on-surface-variant)';
|
||
default:
|
||
return 'var(--md-sys-color-on-surface-variant)';
|
||
}
|
||
};
|
||
|
||
const getStatusLabel = (status: string): string => {
|
||
switch (status) {
|
||
case 'graded':
|
||
return 'Проверено';
|
||
case 'returned':
|
||
return 'На доработке';
|
||
case 'submitted':
|
||
return 'Сдано';
|
||
default:
|
||
return status;
|
||
}
|
||
};
|
||
|
||
export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> = ({
|
||
data,
|
||
loading,
|
||
}) => {
|
||
const submissions = data?.recent_submissions?.slice(0, 4) || [];
|
||
|
||
const [flipped, setFlipped] = useState(false);
|
||
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
|
||
const [details, setDetails] = useState<HomeworkSubmission | null>(null);
|
||
const [loadingDetails, setLoadingDetails] = useState(false);
|
||
|
||
const selectedPreview = useMemo(
|
||
() => submissions.find((s) => s.id === selectedSubmissionId) || null,
|
||
[submissions, selectedSubmissionId],
|
||
);
|
||
|
||
const formatFullDateTime = (dateTimeStr: string | null): string => {
|
||
if (!dateTimeStr) return '—';
|
||
try {
|
||
const date = new Date(dateTimeStr);
|
||
if (isNaN(date.getTime())) return '—';
|
||
return date.toLocaleString('ru-RU', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
} catch {
|
||
return '—';
|
||
}
|
||
};
|
||
|
||
const openSubmissionDetails = async (submissionId: string) => {
|
||
setSelectedSubmissionId(submissionId);
|
||
setFlipped(true);
|
||
setLoadingDetails(true);
|
||
setDetails(null);
|
||
try {
|
||
const full = await getHomeworkSubmission(submissionId);
|
||
setDetails(full);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки решения ДЗ:', error);
|
||
} finally {
|
||
setLoadingDetails(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<FlipCard
|
||
flipped={flipped}
|
||
onFlippedChange={setFlipped}
|
||
front={
|
||
<Panel padding="md" data-tour="mentor-submissions">
|
||
<SectionHeader title="Последние сданные ДЗ" />
|
||
{loading && !data ? (
|
||
<LoadingSpinner size="medium" />
|
||
) : submissions.length === 0 ? (
|
||
<div
|
||
style={{
|
||
padding: 'var(--ios26-spacing-md) 0',
|
||
textAlign: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
Нет сданных ДЗ
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="recent-submissions-grid"
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||
gap: 'var(--ios26-spacing-sm)',
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{submissions.map((submission) => {
|
||
const studentName = submission.student?.first_name && submission.student?.last_name
|
||
? `${submission.student.first_name} ${submission.student.last_name}`.trim()
|
||
: submission.student?.name || 'Студент';
|
||
|
||
return (
|
||
<button
|
||
key={submission.id}
|
||
type="button"
|
||
onClick={() => openSubmissionDetails(submission.id)}
|
||
className="ios26-lesson-preview"
|
||
>
|
||
<div className="ios26-lesson-avatar">
|
||
{submission.student?.avatar ? (
|
||
<img src={submission.student.avatar} alt={studentName} />
|
||
) : (
|
||
<span>
|
||
{(studentName || 'С')[0]?.toUpperCase()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="ios26-lesson-text">
|
||
<p className="ios26-lesson-subject">
|
||
{submission.subject || 'Предмет не указан'}
|
||
</p>
|
||
<p className="ios26-lesson-student">
|
||
{studentName}
|
||
</p>
|
||
{submission.score != null && (
|
||
<p className="ios26-lesson-datetime">
|
||
{submission.score}/5 • {getStatusLabel(submission.status)}
|
||
</p>
|
||
)}
|
||
{submission.score == null && (
|
||
<p className="ios26-lesson-datetime">
|
||
{getStatusLabel(submission.status)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
}
|
||
back={
|
||
<Panel padding="md">
|
||
<SectionHeader
|
||
title="Детали ДЗ"
|
||
trailing={
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setFlipped(false);
|
||
}}
|
||
className="ios26-back-button"
|
||
>
|
||
Назад
|
||
</button>
|
||
}
|
||
/>
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
{loadingDetails ? (
|
||
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||
Загрузка...
|
||
</div>
|
||
) : details ? (
|
||
<div>
|
||
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
|
||
{details.homework?.title || selectedPreview?.homework?.title || 'ДЗ'}
|
||
</p>
|
||
{details.homework?.description && (
|
||
<p style={{ fontSize: 14, margin: '0 0 10px 0', lineHeight: 1.5, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||
{details.homework.description}
|
||
</p>
|
||
)}
|
||
<p style={{ fontSize: 14, margin: '0 0 6px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||
<strong>Студент:</strong> {details.student?.first_name} {details.student?.last_name}
|
||
</p>
|
||
<p style={{ fontSize: 14, margin: '0 0 6px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||
<strong>Сдано:</strong> {formatFullDateTime(details.submitted_at)}
|
||
</p>
|
||
{details.score != null && (
|
||
<p style={{ fontSize: 14, fontWeight: 600, margin: '0 0 6px 0', color: getStatusColor(details.status) }}>
|
||
Оценка: {details.score}/5
|
||
</p>
|
||
)}
|
||
{details.feedback && (
|
||
<p style={{ fontSize: 13, margin: '10px 0 0 0', lineHeight: 1.5, fontStyle: 'italic', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||
<strong>Отзыв:</strong> {details.feedback}
|
||
</p>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||
Не удалось загрузить детали
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Panel>
|
||
}
|
||
/>
|
||
);
|
||
};
|