uchill/front_material/components/dashboard/LessonCard.tsx

280 lines
8.8 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.

/**
* Карточка урока для Dashboard
*/
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { LessonPreview } from '@/api/dashboard';
import { createLiveKitRoom } from '@/api/livekit';
import { useAuth } from '@/contexts/AuthContext';
import { parseISOToUserTimezone } from '@/utils/timezone';
interface LessonCardProps {
lesson: LessonPreview;
showMentor?: boolean;
showClient?: boolean;
onClick?: () => void;
}
/** Подключение доступно за 10 минут до начала и до 15 минут после окончания */
function canJoinLesson(lesson: LessonPreview): boolean {
if (!lesson.start_time || !lesson.end_time) return false;
if (lesson.status === 'cancelled') return false;
const now = new Date();
const startTime = new Date(lesson.start_time);
const endTime = new Date(lesson.end_time);
const allowedStart = new Date(startTime.getTime() - 10 * 60 * 1000); // за 10 минут до начала
const allowedEnd = new Date(endTime.getTime() + 15 * 60 * 1000); // до 15 минут после окончания
return now >= allowedStart && now <= allowedEnd;
}
export const LessonCard: React.FC<LessonCardProps> = ({
lesson,
showMentor = false,
showClient = false,
onClick,
}) => {
const router = useRouter();
const { user } = useAuth();
const [connectLoading, setConnectLoading] = useState(false);
const [canJoin, setCanJoin] = useState(false);
// Проверяем каждые 10 секунд, чтобы кнопка появилась вовремя (решение проблемы с кэшированием)
useEffect(() => {
const check = () => setCanJoin(canJoinLesson(lesson));
check();
const interval = setInterval(check, 10000); // 10 секунд
return () => clearInterval(interval);
}, [lesson.start_time, lesson.end_time, lesson.status]);
const handleConnect = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!canJoin || connectLoading) return;
setConnectLoading(true);
try {
const lessonId = typeof lesson.id === 'string' ? parseInt(lesson.id, 10) : lesson.id;
const res = await createLiveKitRoom(lessonId);
router.push(
`/livekit/${res.room_name}?lesson_id=${lesson.id}&token=${encodeURIComponent(res.access_token)}`
);
} catch (err) {
console.error('LiveKit connect error:', err);
setConnectLoading(false);
}
},
[canJoin, connectLoading, lesson.id, router]
);
// Парсим время с учётом timezone пользователя
const { startParsed, endParsed } = useMemo(() => {
return {
startParsed: parseISOToUserTimezone(lesson.start_time, user?.timezone),
endParsed: parseISOToUserTimezone(lesson.end_time, user?.timezone),
};
}, [lesson.start_time, lesson.end_time, user?.timezone]);
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
// более серый фон для завершённых занятий
return 'color-mix(in srgb, var(--md-sys-color-surface-variant) 70%, #000 10%)';
case 'in_progress':
return 'var(--md-sys-color-tertiary)';
case 'cancelled':
return 'var(--md-sys-color-error)';
case 'scheduled':
default:
return 'var(--md-sys-color-primary)';
}
};
const getStatusTextColor = (status: string) => {
switch (status) {
case 'completed':
return 'var(--md-sys-color-on-surface-variant)';
case 'in_progress':
return 'var(--md-sys-color-on-tertiary)';
case 'cancelled':
return 'var(--md-sys-color-on-error)';
case 'scheduled':
default:
return 'var(--md-sys-color-on-primary)';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'completed':
return 'Завершено';
case 'in_progress':
return 'В процессе';
case 'cancelled':
return 'Отменено';
default:
return 'Запланировано';
}
};
const statusColor = getStatusColor(lesson.status);
const textColor = getStatusTextColor(lesson.status);
return (
<div
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } } : undefined}
style={{
background: statusColor,
borderRadius: '16px',
padding: '16px',
marginBottom: '12px',
transition: 'all 0.2s ease',
cursor: 'pointer',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '12px'
}}>
<div style={{ flex: 1 }}>
<h4 style={{
fontSize: '16px',
fontWeight: '500',
color: textColor,
margin: '0 0 4px 0'
}}>
{lesson.title}
</h4>
{lesson.subject && (
<p style={{
fontSize: '14px',
color: textColor,
margin: '0',
opacity: 0.9
}}>
{lesson.subject}
</p>
)}
</div>
<span style={{
fontSize: '12px',
padding: '4px 10px',
borderRadius: '12px',
background: 'rgba(255, 255, 255, 0.25)',
color: textColor,
fontWeight: '500'
}}>
{getStatusText(lesson.status)}
</span>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
fontSize: '14px',
color: textColor,
opacity: 0.9
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span>
{startParsed.dateObj.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short'
})}
{' в '}
{startParsed.time}
{' - '}
{endParsed.time}
</span>
</div>
</div>
{(showMentor && lesson.mentor) && (
<div style={{
marginTop: '12px',
paddingTop: '12px',
borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`,
fontSize: '14px',
color: textColor,
opacity: 0.9
}}>
Ментор: {lesson.mentor.first_name} {lesson.mentor.last_name}
</div>
)}
{(showClient && lesson.client) && (
<div style={{
marginTop: '12px',
paddingTop: '12px',
borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`,
fontSize: '14px',
color: textColor,
opacity: 0.9
}}>
Ученик: {lesson.client.first_name} {lesson.client.last_name}
</div>
)}
{canJoin && (lesson.status === 'scheduled' || lesson.status === 'in_progress') && (
<button
type="button"
onClick={handleConnect}
disabled={connectLoading}
style={{
marginTop: '12px',
width: '100%',
padding: '10px 16px',
borderRadius: '12px',
border: 'none',
background: 'rgba(255, 255, 255, 0.9)',
color: statusColor,
fontSize: '14px',
fontWeight: '600',
cursor: connectLoading ? 'wait' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
{connectLoading ? (
<>
<span className="material-symbols-outlined" style={{ fontSize: 18, animation: 'spin 1s linear infinite' }}>
progress_activity
</span>
Подключение...
</>
) : (
<>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>
videocam
</span>
Подключиться к уроку
</>
)}
</button>
)}
</div>
);
};