365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
/**
|
||
* Кастомный селектор студентов с поиском и аватарами
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React, { useState, useRef, useEffect } from 'react';
|
||
import { Student } from '@/api/students';
|
||
|
||
interface StudentSelectProps {
|
||
students: Student[];
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
disabled?: boolean;
|
||
required?: boolean;
|
||
}
|
||
|
||
export const StudentSelect: React.FC<StudentSelectProps> = ({
|
||
students,
|
||
value,
|
||
onChange,
|
||
disabled = false,
|
||
required = false,
|
||
}) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Получаем выбранного студента
|
||
const selectedStudent = students.find(s => String(s.id) === value);
|
||
|
||
// Фильтруем студентов по поисковому запросу
|
||
const filteredStudents = students.filter(student => {
|
||
const fullName = `${student.user?.first_name || ''} ${student.user?.last_name || ''}`.toLowerCase();
|
||
const email = student.user?.email?.toLowerCase() || '';
|
||
const query = searchQuery.toLowerCase();
|
||
return fullName.includes(query) || email.includes(query);
|
||
});
|
||
|
||
// Закрываем dropdown при клике вне компонента
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||
setIsOpen(false);
|
||
setSearchQuery('');
|
||
}
|
||
};
|
||
|
||
if (isOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
// Фокусируем поле поиска при открытии
|
||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const handleSelect = (studentId: string) => {
|
||
onChange(studentId);
|
||
setIsOpen(false);
|
||
setSearchQuery('');
|
||
};
|
||
|
||
const getStudentDisplayName = (student: Student) => {
|
||
const name = `${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim();
|
||
return name || student.user?.email || 'Без имени';
|
||
};
|
||
|
||
const getAvatarUrl = (student: Student) => {
|
||
return student.user?.avatar || null;
|
||
};
|
||
|
||
const getInitials = (student: Student) => {
|
||
const firstName = student.user?.first_name || '';
|
||
const lastName = student.user?.last_name || '';
|
||
if (firstName && lastName) {
|
||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||
}
|
||
if (firstName) return firstName.charAt(0).toUpperCase();
|
||
if (lastName) return lastName.charAt(0).toUpperCase();
|
||
return student.user?.email?.charAt(0).toUpperCase() || '?';
|
||
};
|
||
|
||
return (
|
||
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
|
||
{/* Основная кнопка выбора */}
|
||
<button
|
||
type="button"
|
||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||
disabled={disabled}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
fontSize: '16px',
|
||
color: selectedStudent ? 'var(--md-sys-color-on-surface)' : 'var(--md-sys-color-on-surface-variant)',
|
||
background: 'var(--md-sys-color-surface)',
|
||
border: `1px solid ${isOpen ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'}`,
|
||
borderWidth: isOpen ? '2px' : '1px',
|
||
borderRadius: '4px',
|
||
fontFamily: 'inherit',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
outline: 'none',
|
||
transition: 'all 0.2s ease',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '12px',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1, minWidth: 0 }}>
|
||
{selectedStudent ? (
|
||
<>
|
||
{/* Аватар */}
|
||
<div style={{
|
||
width: '32px',
|
||
height: '32px',
|
||
borderRadius: '50%',
|
||
flexShrink: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: getAvatarUrl(selectedStudent)
|
||
? `url(${getAvatarUrl(selectedStudent)}) center/cover`
|
||
: 'var(--md-sys-color-primary-container)',
|
||
color: 'var(--md-sys-color-on-primary-container)',
|
||
fontSize: '14px',
|
||
fontWeight: '500',
|
||
}}>
|
||
{!getAvatarUrl(selectedStudent) && getInitials(selectedStudent)}
|
||
</div>
|
||
{/* Имя */}
|
||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{getStudentDisplayName(selectedStudent)}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span>Выберите ученика</span>
|
||
)}
|
||
</div>
|
||
{/* Иконка стрелки */}
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
style={{
|
||
flexShrink: 0,
|
||
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||
transition: 'transform 0.2s ease',
|
||
}}
|
||
>
|
||
<polyline points="6 9 12 15 18 9"></polyline>
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Dropdown меню */}
|
||
{isOpen && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 'calc(100% + 4px)',
|
||
left: 0,
|
||
right: 0,
|
||
background: 'var(--md-sys-color-surface)',
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
borderRadius: '12px',
|
||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)',
|
||
zIndex: 1000,
|
||
maxHeight: '320px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* Поле поиска */}
|
||
<div style={{
|
||
padding: '12px',
|
||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||
}}>
|
||
<div style={{
|
||
position: 'relative',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
}}>
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="var(--md-sys-color-on-surface-variant)"
|
||
strokeWidth="2"
|
||
style={{
|
||
position: 'absolute',
|
||
left: '12px',
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
<circle cx="11" cy="11" r="8"></circle>
|
||
<path d="m21 21-4.35-4.35"></path>
|
||
</svg>
|
||
<input
|
||
ref={searchInputRef}
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Поиск студента..."
|
||
style={{
|
||
width: '100%',
|
||
padding: '8px 12px 8px 40px',
|
||
fontSize: '14px',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
border: 'none',
|
||
borderRadius: '8px',
|
||
fontFamily: 'inherit',
|
||
outline: 'none',
|
||
}}
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchQuery('')}
|
||
style={{
|
||
position: 'absolute',
|
||
right: '8px',
|
||
width: '24px',
|
||
height: '24px',
|
||
padding: 0,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: '50%',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Список студентов */}
|
||
<div style={{
|
||
overflowY: 'auto',
|
||
flex: 1,
|
||
}}>
|
||
{filteredStudents.length === 0 ? (
|
||
<div style={{
|
||
padding: '24px',
|
||
textAlign: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: '14px',
|
||
}}>
|
||
{searchQuery ? 'Студенты не найдены' : 'Нет доступных студентов'}
|
||
</div>
|
||
) : (
|
||
filteredStudents.map((student) => (
|
||
<button
|
||
key={student.id}
|
||
type="button"
|
||
onClick={() => handleSelect(String(student.id))}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
background: value === String(student.id)
|
||
? 'var(--md-sys-color-secondary-container)'
|
||
: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
transition: 'background 0.2s ease',
|
||
textAlign: 'left',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (value !== String(student.id)) {
|
||
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (value !== String(student.id)) {
|
||
e.currentTarget.style.background = 'transparent';
|
||
}
|
||
}}
|
||
>
|
||
{/* Аватар */}
|
||
<div style={{
|
||
width: '40px',
|
||
height: '40px',
|
||
borderRadius: '50%',
|
||
flexShrink: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: getAvatarUrl(student)
|
||
? `url(${getAvatarUrl(student)}) center/cover`
|
||
: 'var(--md-sys-color-primary-container)',
|
||
color: 'var(--md-sys-color-on-primary-container)',
|
||
fontSize: '16px',
|
||
fontWeight: '500',
|
||
}}>
|
||
{!getAvatarUrl(student) && getInitials(student)}
|
||
</div>
|
||
|
||
{/* Информация о студенте */}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: '14px',
|
||
fontWeight: '500',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{getStudentDisplayName(student)}
|
||
</div>
|
||
{student.user?.email && (
|
||
<div style={{
|
||
fontSize: '12px',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
marginTop: '2px',
|
||
}}>
|
||
{student.user.email}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Галочка для выбранного элемента */}
|
||
{value === String(student.id) && (
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="var(--md-sys-color-primary)"
|
||
strokeWidth="2"
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<polyline points="20 6 9 17 4 12"></polyline>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|