894 lines
33 KiB
JavaScript
894 lines
33 KiB
JavaScript
'use client';
|
||
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
|
||
import Tab from '@mui/material/Tab';
|
||
import Box from '@mui/material/Box';
|
||
import Card from '@mui/material/Card';
|
||
import Chip from '@mui/material/Chip';
|
||
import Grid from '@mui/material/Grid';
|
||
import Tabs from '@mui/material/Tabs';
|
||
import List from '@mui/material/List';
|
||
import Stack from '@mui/material/Stack';
|
||
import Alert from '@mui/material/Alert';
|
||
import Avatar from '@mui/material/Avatar';
|
||
import Button from '@mui/material/Button';
|
||
import Dialog from '@mui/material/Dialog';
|
||
import Divider from '@mui/material/Divider';
|
||
import Tooltip from '@mui/material/Tooltip';
|
||
import ListItem from '@mui/material/ListItem';
|
||
import TextField from '@mui/material/TextField';
|
||
import Typography from '@mui/material/Typography';
|
||
import CardHeader from '@mui/material/CardHeader';
|
||
import IconButton from '@mui/material/IconButton';
|
||
import DialogTitle from '@mui/material/DialogTitle';
|
||
import CardContent from '@mui/material/CardContent';
|
||
import ListItemText from '@mui/material/ListItemText';
|
||
import DialogContent from '@mui/material/DialogContent';
|
||
import DialogActions from '@mui/material/DialogActions';
|
||
import InputAdornment from '@mui/material/InputAdornment';
|
||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||
import CircularProgress from '@mui/material/CircularProgress';
|
||
|
||
import { paths } from 'src/routes/paths';
|
||
import { useRouter } from 'src/routes/hooks';
|
||
|
||
import { resolveMediaUrl } from 'src/utils/axios';
|
||
import {
|
||
getStudents,
|
||
getMyMentors,
|
||
getMyRequests,
|
||
getMyInvitations,
|
||
addStudentInvitation,
|
||
generateInvitationLink,
|
||
sendMentorshipRequest,
|
||
acceptMentorshipRequest,
|
||
rejectMentorshipRequest,
|
||
rejectInvitationAsStudent,
|
||
confirmInvitationAsStudent,
|
||
getMentorshipRequestsPending,
|
||
removeStudent,
|
||
removeMentor,
|
||
} from 'src/utils/students-api';
|
||
|
||
import { DashboardContent } from 'src/layouts/dashboard';
|
||
|
||
import { Iconify } from 'src/components/iconify';
|
||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||
|
||
import { useAuthContext } from 'src/auth/hooks';
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
const avatarUrl = (href) => resolveMediaUrl(href) || null;
|
||
|
||
function initials(firstName, lastName) {
|
||
return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase();
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
function RemoveConnectionDialog({ open, onClose, onConfirm, name, type, loading }) {
|
||
return (
|
||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||
<DialogTitle>
|
||
{type === 'student' ? `Удалить ученика ${name}?` : `Удалить ментора ${name}?`}
|
||
</DialogTitle>
|
||
<DialogContent>
|
||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||
Это действие нельзя отменить автоматически
|
||
</Alert>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||
При удалении произойдёт следующее:
|
||
</Typography>
|
||
<Stack spacing={0.75}>
|
||
{[
|
||
'Все будущие занятия будут отменены',
|
||
'Доступ к общим доскам будет приостановлен',
|
||
'Доступ к материалам будет закрыт',
|
||
].map((item) => (
|
||
<Stack key={item} direction="row" spacing={1} alignItems="flex-start">
|
||
<Iconify icon="eva:close-circle-fill" width={16} sx={{ color: 'error.main', mt: 0.3, flexShrink: 0 }} />
|
||
<Typography variant="body2">{item}</Typography>
|
||
</Stack>
|
||
))}
|
||
</Stack>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||
<strong>Доски и файлы не удаляются.</strong> Если связь будет восстановлена — доступ вернётся автоматически.
|
||
</Typography>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={onClose} disabled={loading}>Отмена</Button>
|
||
<Button
|
||
variant="contained"
|
||
color="error"
|
||
onClick={onConfirm}
|
||
disabled={loading}
|
||
startIcon={loading ? <CircularProgress size={16} /> : null}
|
||
>
|
||
{loading ? 'Удаление...' : 'Удалить'}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// MENTOR VIEWS
|
||
|
||
function MentorStudentList({ onRefresh }) {
|
||
const router = useRouter();
|
||
const [students, setStudents] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [search, setSearch] = useState('');
|
||
const [error, setError] = useState(null);
|
||
const [removeTarget, setRemoveTarget] = useState(null); // { id, name }
|
||
const [removing, setRemoving] = useState(false);
|
||
|
||
const handleRemove = async () => {
|
||
try {
|
||
setRemoving(true);
|
||
await removeStudent(removeTarget.id);
|
||
setRemoveTarget(null);
|
||
load();
|
||
onRefresh?.();
|
||
} catch (e) {
|
||
setError(e?.response?.data?.error?.message || e?.message || 'Ошибка удаления');
|
||
setRemoveTarget(null);
|
||
} finally {
|
||
setRemoving(false);
|
||
}
|
||
};
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await getStudents({ page_size: 200 });
|
||
setStudents(res.results);
|
||
} catch (e) {
|
||
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const filtered = students.filter((s) => {
|
||
if (!search.trim()) return true;
|
||
const q = search.toLowerCase();
|
||
const u = s.user || {};
|
||
return (
|
||
(u.first_name || '').toLowerCase().includes(q) ||
|
||
(u.last_name || '').toLowerCase().includes(q) ||
|
||
(u.email || '').toLowerCase().includes(q)
|
||
);
|
||
});
|
||
|
||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||
|
||
return (
|
||
<Stack spacing={3}>
|
||
<TextField
|
||
placeholder="Поиск учеников..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
size="small"
|
||
InputProps={{
|
||
startAdornment: <InputAdornment position="start"><Iconify icon="eva:search-outline" /></InputAdornment>,
|
||
}}
|
||
/>
|
||
|
||
{error && <Alert severity="error">{error}</Alert>}
|
||
|
||
{filtered.length === 0 ? (
|
||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||
<Iconify icon="solar:users-group-rounded-bold" width={48} sx={{ color: 'text.disabled', mb: 2 }} />
|
||
<Typography variant="body2" color="text.secondary">
|
||
{search ? 'Ничего не найдено' : 'Нет учеников'}
|
||
</Typography>
|
||
</Box>
|
||
) : (
|
||
<Grid container spacing={2}>
|
||
{filtered.map((s) => {
|
||
const u = s.user || {};
|
||
const name = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || '—';
|
||
return (
|
||
<Grid item key={s.id} xs={12} sm={6} md={4} lg={3}>
|
||
<Card
|
||
sx={{
|
||
p: 3,
|
||
textAlign: 'center',
|
||
height: '100%',
|
||
position: 'relative',
|
||
transition: 'box-shadow 0.2s, transform 0.2s',
|
||
'&:hover': {
|
||
transform: 'translateY(-2px)',
|
||
boxShadow: (t) => t.customShadows?.z16 || '0 8px 24px rgba(0,0,0,0.12)',
|
||
},
|
||
}}
|
||
>
|
||
<Tooltip title="Удалить ученика">
|
||
<IconButton
|
||
size="small"
|
||
color="error"
|
||
onClick={(e) => { e.stopPropagation(); setRemoveTarget({ id: s.id, name }); }}
|
||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||
>
|
||
<Iconify icon="solar:trash-bin-trash-bold" width={18} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
|
||
<Box onClick={() => router.push(paths.dashboard.studentDetail(s.id))} sx={{ cursor: 'pointer' }}>
|
||
<Avatar
|
||
src={avatarUrl(u.avatar_url || u.avatar)}
|
||
sx={{ width: 72, height: 72, mx: 'auto', mb: 2, fontSize: 26, bgcolor: 'primary.main' }}
|
||
>
|
||
{initials(u.first_name, u.last_name)}
|
||
</Avatar>
|
||
<Typography variant="subtitle1" noWrap sx={{ mb: 0.5 }}>{name}</Typography>
|
||
<Typography variant="body2" color="text.secondary" noWrap sx={{ mb: 2 }}>{u.email || ''}</Typography>
|
||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap>
|
||
{s.total_lessons != null && (
|
||
<Chip label={`${s.total_lessons} уроков`} size="small" color="primary" variant="soft"
|
||
icon={<Iconify icon="solar:calendar-bold" width={14} />} />
|
||
)}
|
||
{s.subject && <Chip label={s.subject} size="small" variant="outlined" />}
|
||
</Stack>
|
||
</Box>
|
||
</Card>
|
||
</Grid>
|
||
);
|
||
})}
|
||
</Grid>
|
||
)}
|
||
|
||
<RemoveConnectionDialog
|
||
open={!!removeTarget}
|
||
onClose={() => setRemoveTarget(null)}
|
||
onConfirm={handleRemove}
|
||
name={removeTarget?.name || ''}
|
||
type="student"
|
||
loading={removing}
|
||
/>
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
function MentorRequests({ onRefresh }) {
|
||
const [requests, setRequests] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [processing, setProcessing] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await getMentorshipRequestsPending();
|
||
setRequests(Array.isArray(res) ? res : []);
|
||
} catch (e) {
|
||
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const handle = async (id, action) => {
|
||
try {
|
||
setProcessing(id);
|
||
if (action === 'accept') await acceptMentorshipRequest(id);
|
||
else await rejectMentorshipRequest(id);
|
||
await load();
|
||
onRefresh();
|
||
} catch (e) {
|
||
setError(e?.response?.data?.detail || e?.message || 'Ошибка');
|
||
} finally {
|
||
setProcessing(null);
|
||
}
|
||
};
|
||
|
||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}><CircularProgress /></Box>;
|
||
|
||
return (
|
||
<Stack spacing={2}>
|
||
{error && <Alert severity="error">{error}</Alert>}
|
||
{requests.length === 0 ? (
|
||
<Typography variant="body2" color="text.secondary">Нет входящих заявок</Typography>
|
||
) : (
|
||
<List disablePadding>
|
||
{requests.map((r) => {
|
||
const s = r.student || {};
|
||
return (
|
||
<ListItem
|
||
key={r.id}
|
||
divider
|
||
secondaryAction={
|
||
<Stack direction="row" spacing={1}>
|
||
<Button
|
||
size="small"
|
||
variant="contained"
|
||
color="success"
|
||
onClick={() => handle(r.id, 'accept')}
|
||
disabled={processing === r.id}
|
||
>
|
||
Принять
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
variant="outlined"
|
||
color="error"
|
||
onClick={() => handle(r.id, 'reject')}
|
||
disabled={processing === r.id}
|
||
>
|
||
Отклонить
|
||
</Button>
|
||
</Stack>
|
||
}
|
||
>
|
||
<ListItemAvatar>
|
||
<Avatar src={avatarUrl(s.avatar)}>
|
||
{initials(s.first_name, s.last_name)}
|
||
</Avatar>
|
||
</ListItemAvatar>
|
||
<ListItemText
|
||
primary={`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email}
|
||
secondary={s.email}
|
||
/>
|
||
</ListItem>
|
||
);
|
||
})}
|
||
</List>
|
||
)}
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
// 8 отдельных полей для ввода кода
|
||
function CodeInput({ value, onChange, disabled }) {
|
||
const handleChange = (e) => {
|
||
const v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
|
||
onChange(v);
|
||
};
|
||
|
||
return (
|
||
<TextField
|
||
value={value}
|
||
onChange={handleChange}
|
||
disabled={disabled}
|
||
fullWidth
|
||
placeholder="ABCD1234"
|
||
autoComplete="off"
|
||
inputProps={{
|
||
maxLength: 8,
|
||
style: {
|
||
textAlign: 'center',
|
||
fontWeight: 700,
|
||
fontSize: 22,
|
||
letterSpacing: 6,
|
||
textTransform: 'uppercase',
|
||
},
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function InviteDialog({ open, onClose, onSuccess }) {
|
||
const [mode, setMode] = useState('email'); // 'email' | 'code' | 'link'
|
||
const [emailValue, setEmailValue] = useState('');
|
||
const [codeValue, setCodeValue] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const [successMsg, setSuccessMsg] = useState(null);
|
||
const [linkData, setLinkData] = useState(null); // { invitation_link, expires_at }
|
||
const [linkLoading, setLinkLoading] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const reset = () => {
|
||
setEmailValue(''); setCodeValue(''); setError(null); setSuccessMsg(null);
|
||
setLinkData(null); setCopied(false);
|
||
};
|
||
|
||
const handleClose = () => { if (!loading && !linkLoading) { reset(); onClose(); } };
|
||
|
||
const handleSend = async () => {
|
||
const val = mode === 'email' ? emailValue.trim() : codeValue.trim();
|
||
if (!val) return;
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const payload = mode === 'email' ? { email: val } : { universal_code: val };
|
||
const res = await addStudentInvitation(payload);
|
||
setSuccessMsg(res?.message || 'Приглашение отправлено');
|
||
onSuccess();
|
||
} catch (e) {
|
||
const msg = e?.response?.data?.error?.message
|
||
|| e?.response?.data?.detail
|
||
|| e?.response?.data?.email?.[0]
|
||
|| e?.message || 'Ошибка';
|
||
setError(msg);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleGenerateLink = async () => {
|
||
try {
|
||
setLinkLoading(true);
|
||
setError(null);
|
||
const res = await generateInvitationLink();
|
||
setLinkData(res);
|
||
setCopied(false);
|
||
} catch (e) {
|
||
setError(e?.response?.data?.error?.message || e?.message || 'Ошибка генерации ссылки');
|
||
} finally {
|
||
setLinkLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCopyLink = () => {
|
||
navigator.clipboard.writeText(linkData.invitation_link);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
const expiresText = linkData?.expires_at
|
||
? `Действует до ${new Date(linkData.expires_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' })}`
|
||
: '';
|
||
|
||
const canSend = mode === 'email' ? !!emailValue.trim() : codeValue.length === 8;
|
||
|
||
return (
|
||
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
||
<DialogTitle>Пригласить ученика</DialogTitle>
|
||
<DialogContent>
|
||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||
<Tabs value={mode} onChange={(_, v) => { setMode(v); setError(null); setSuccessMsg(null); }}>
|
||
<Tab value="email" label="По email" />
|
||
<Tab value="code" label="По коду" />
|
||
<Tab value="link" label="По ссылке" />
|
||
</Tabs>
|
||
|
||
{mode === 'email' && (
|
||
<TextField
|
||
label="Email ученика"
|
||
value={emailValue}
|
||
onChange={(e) => setEmailValue(e.target.value)}
|
||
disabled={loading}
|
||
fullWidth
|
||
size="small"
|
||
autoFocus
|
||
/>
|
||
)}
|
||
|
||
{mode === 'code' && (
|
||
<Stack spacing={1}>
|
||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||
Введите 8-значный код ученика
|
||
</Typography>
|
||
<CodeInput value={codeValue} onChange={setCodeValue} disabled={loading} />
|
||
</Stack>
|
||
)}
|
||
|
||
{mode === 'link' && (
|
||
<Stack spacing={2}>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Сгенерируйте ссылку для регистрации ученика. Каждая ссылка действует 12 часов и может быть использована только 1 раз.
|
||
</Typography>
|
||
{!linkData ? (
|
||
<Button
|
||
variant="outlined"
|
||
onClick={handleGenerateLink}
|
||
disabled={linkLoading}
|
||
startIcon={linkLoading ? <CircularProgress size={16} /> : <Iconify icon="solar:link-bold" width={18} />}
|
||
>
|
||
Сгенерировать ссылку
|
||
</Button>
|
||
) : (
|
||
<Stack spacing={1}>
|
||
<TextField
|
||
value={linkData.invitation_link}
|
||
fullWidth
|
||
size="small"
|
||
InputProps={{
|
||
readOnly: true,
|
||
endAdornment: (
|
||
<InputAdornment position="end">
|
||
<Tooltip title={copied ? 'Скопировано!' : 'Скопировать'}>
|
||
<IconButton edge="end" onClick={handleCopyLink} size="small">
|
||
<Iconify icon={copied ? 'solar:check-circle-bold' : 'solar:copy-bold'} width={18} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</InputAdornment>
|
||
),
|
||
}}
|
||
/>
|
||
{expiresText && (
|
||
<Typography variant="caption" color="text.secondary">
|
||
{expiresText} · только 1 ученик
|
||
</Typography>
|
||
)}
|
||
<Button
|
||
size="small"
|
||
variant="text"
|
||
onClick={handleGenerateLink}
|
||
disabled={linkLoading}
|
||
startIcon={<Iconify icon="solar:refresh-bold" width={16} />}
|
||
>
|
||
Новая ссылка
|
||
</Button>
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
)}
|
||
|
||
{error && <Alert severity="error">{error}</Alert>}
|
||
{successMsg && <Alert severity="success">{successMsg}</Alert>}
|
||
</Stack>
|
||
</DialogContent>
|
||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||
<Button onClick={handleClose} disabled={loading || linkLoading}>Закрыть</Button>
|
||
{mode !== 'link' && (
|
||
<Button
|
||
variant="contained"
|
||
onClick={handleSend}
|
||
disabled={loading || !canSend}
|
||
startIcon={loading ? <CircularProgress size={16} /> : null}
|
||
>
|
||
{loading ? 'Отправка...' : 'Пригласить'}
|
||
</Button>
|
||
)}
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// CLIENT VIEWS
|
||
|
||
function ClientMentorList() {
|
||
const [mentors, setMentors] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [removeTarget, setRemoveTarget] = useState(null);
|
||
const [removing, setRemoving] = useState(false);
|
||
|
||
const load = useCallback(() => {
|
||
setLoading(true);
|
||
getMyMentors()
|
||
.then((list) => setMentors(Array.isArray(list) ? list : []))
|
||
.catch(() => setMentors([]))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const handleRemove = async () => {
|
||
try {
|
||
setRemoving(true);
|
||
await removeMentor(removeTarget.id);
|
||
setRemoveTarget(null);
|
||
load();
|
||
} catch (e) {
|
||
setRemoveTarget(null);
|
||
} finally {
|
||
setRemoving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||
|
||
if (mentors.length === 0) {
|
||
return (
|
||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||
<Iconify icon="solar:user-bold" width={48} sx={{ color: 'text.disabled', mb: 2 }} />
|
||
<Typography variant="body2" color="text.secondary">Нет подключённых менторов</Typography>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Grid container spacing={2}>
|
||
{mentors.map((m) => {
|
||
const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—';
|
||
return (
|
||
<Grid item key={m.id} xs={12} sm={6} md={4} lg={3}>
|
||
<Card
|
||
sx={{
|
||
p: 3,
|
||
textAlign: 'center',
|
||
height: '100%',
|
||
position: 'relative',
|
||
transition: 'box-shadow 0.2s, transform 0.2s',
|
||
'&:hover': {
|
||
transform: 'translateY(-2px)',
|
||
boxShadow: (t) => t.customShadows?.z16 || '0 8px 24px rgba(0,0,0,0.12)',
|
||
},
|
||
}}
|
||
>
|
||
<Tooltip title="Удалить ментора">
|
||
<IconButton
|
||
size="small"
|
||
color="error"
|
||
onClick={() => setRemoveTarget({ id: m.id, name })}
|
||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||
>
|
||
<Iconify icon="solar:trash-bin-trash-bold" width={18} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
|
||
<Avatar
|
||
src={avatarUrl(m.avatar_url)}
|
||
sx={{ width: 72, height: 72, mx: 'auto', mb: 2, fontSize: 26, bgcolor: 'secondary.main' }}
|
||
>
|
||
{initials(m.first_name, m.last_name)}
|
||
</Avatar>
|
||
<Typography variant="subtitle1" noWrap sx={{ mb: 0.5 }}>{name}</Typography>
|
||
<Typography variant="body2" color="text.secondary" noWrap>{m.email || ''}</Typography>
|
||
</Card>
|
||
</Grid>
|
||
);
|
||
})}
|
||
</Grid>
|
||
|
||
<RemoveConnectionDialog
|
||
open={!!removeTarget}
|
||
onClose={() => setRemoveTarget(null)}
|
||
onConfirm={handleRemove}
|
||
name={removeTarget?.name || ''}
|
||
type="mentor"
|
||
loading={removing}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ClientInvitations({ onRefresh }) {
|
||
const [invitations, setInvitations] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [processing, setProcessing] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await getMyInvitations();
|
||
setInvitations(Array.isArray(res) ? res.filter((i) => ['pending', 'pending_student', 'pending_parent'].includes(i.status)) : []);
|
||
} catch {
|
||
setInvitations([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const handle = async (id, action) => {
|
||
try {
|
||
setProcessing(id);
|
||
if (action === 'confirm') await confirmInvitationAsStudent(id);
|
||
else await rejectInvitationAsStudent(id);
|
||
await load();
|
||
onRefresh();
|
||
} catch (e) {
|
||
setError(e?.response?.data?.detail || e?.message || 'Ошибка');
|
||
} finally {
|
||
setProcessing(null);
|
||
}
|
||
};
|
||
|
||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||
|
||
if (invitations.length === 0) return (
|
||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||
<Iconify icon="solar:letter-bold" width={48} sx={{ color: 'text.disabled', mb: 2 }} />
|
||
<Typography variant="body2" color="text.secondary">Нет входящих приглашений</Typography>
|
||
</Box>
|
||
);
|
||
|
||
return (
|
||
<Card variant="outlined" sx={{ mb: 3 }}>
|
||
<CardHeader title="Приглашения от менторов" titleTypographyProps={{ variant: 'subtitle1' }} />
|
||
<Divider />
|
||
<CardContent sx={{ pt: 1 }}>
|
||
{error && <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert>}
|
||
<List disablePadding>
|
||
{invitations.map((inv) => {
|
||
const m = inv.mentor || {};
|
||
return (
|
||
<ListItem
|
||
key={inv.id}
|
||
divider
|
||
secondaryAction={
|
||
<Stack direction="row" spacing={1}>
|
||
<Button size="small" variant="contained" color="success" onClick={() => handle(inv.id, 'confirm')} disabled={processing === inv.id}>
|
||
Принять
|
||
</Button>
|
||
<Button size="small" variant="outlined" color="error" onClick={() => handle(inv.id, 'reject')} disabled={processing === inv.id}>
|
||
Отклонить
|
||
</Button>
|
||
</Stack>
|
||
}
|
||
>
|
||
<ListItemAvatar>
|
||
<Avatar>{initials(m.first_name, m.last_name)}</Avatar>
|
||
</ListItemAvatar>
|
||
<ListItemText
|
||
primary={`${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email}
|
||
secondary={m.email}
|
||
/>
|
||
</ListItem>
|
||
);
|
||
})}
|
||
</List>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function SendRequestDialog({ open, onClose, onSuccess }) {
|
||
const [code, setCode] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const [successMsg, setSuccessMsg] = useState(null);
|
||
|
||
const handleSend = async () => {
|
||
if (!code.trim()) return;
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const res = await sendMentorshipRequest(code.trim());
|
||
setSuccessMsg(res?.message || 'Заявка отправлена');
|
||
onSuccess();
|
||
} catch (e) {
|
||
setError(e?.response?.data?.detail || e?.response?.data?.mentor_code?.[0] || e?.message || 'Ошибка');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onClose={() => { if (!loading) { setCode(''); setError(null); setSuccessMsg(null); onClose(); } }} maxWidth="xs" fullWidth>
|
||
<DialogTitle>Найти ментора</DialogTitle>
|
||
<DialogContent>
|
||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||
<TextField
|
||
label="Код ментора"
|
||
value={code}
|
||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||
disabled={loading}
|
||
fullWidth
|
||
size="small"
|
||
placeholder="XXXXXXXX"
|
||
/>
|
||
{error && <Alert severity="error">{error}</Alert>}
|
||
{successMsg && <Alert severity="success">{successMsg}</Alert>}
|
||
</Stack>
|
||
</DialogContent>
|
||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||
<Button onClick={() => { setCode(''); setError(null); setSuccessMsg(null); onClose(); }} disabled={loading}>Закрыть</Button>
|
||
<Button variant="contained" onClick={handleSend} disabled={loading || !code.trim()}>
|
||
{loading ? 'Отправка...' : 'Отправить заявку'}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
const STATUS_LABELS = {
|
||
pending_mentor: { label: 'Ожидает ментора', color: 'warning' },
|
||
pending_student: { label: 'Ожидает вас', color: 'info' },
|
||
accepted: { label: 'Принято', color: 'success' },
|
||
rejected: { label: 'Отклонено', color: 'error' },
|
||
removed: { label: 'Удалено', color: 'default' },
|
||
};
|
||
|
||
function ClientOutgoingRequests() {
|
||
const [requests, setRequests] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
getMyRequests()
|
||
.then((data) => setRequests(Array.isArray(data) ? data : []))
|
||
.catch(() => setRequests([]))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||
|
||
if (requests.length === 0) return (
|
||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||
<Iconify icon="solar:outbox-bold" width={48} sx={{ color: 'text.disabled', mb: 2 }} />
|
||
<Typography variant="body2" color="text.secondary">Нет исходящих заявок</Typography>
|
||
</Box>
|
||
);
|
||
|
||
return (
|
||
<Card variant="outlined">
|
||
<CardHeader title="Исходящие заявки к менторам" titleTypographyProps={{ variant: 'subtitle1' }} />
|
||
<Divider />
|
||
<List disablePadding>
|
||
{requests.map((req) => {
|
||
const m = req.mentor || {};
|
||
const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—';
|
||
const statusInfo = STATUS_LABELS[req.status] || { label: req.status, color: 'default' };
|
||
return (
|
||
<ListItem key={req.id} divider>
|
||
<ListItemAvatar>
|
||
<Avatar src={avatarUrl(m.avatar_url)} sx={{ bgcolor: 'secondary.main' }}>
|
||
{initials(m.first_name, m.last_name)}
|
||
</Avatar>
|
||
</ListItemAvatar>
|
||
<ListItemText
|
||
primary={name}
|
||
secondary={m.email}
|
||
/>
|
||
<Chip label={statusInfo.label} color={statusInfo.color} size="small" variant="soft" />
|
||
</ListItem>
|
||
);
|
||
})}
|
||
</List>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
export function StudentsView() {
|
||
const { user } = useAuthContext();
|
||
const isMentor = user?.role === 'mentor';
|
||
|
||
const [tab, setTab] = useState(0);
|
||
const [inviteOpen, setInviteOpen] = useState(false);
|
||
const [requestOpen, setRequestOpen] = useState(false);
|
||
const [refreshKey, setRefreshKey] = useState(0);
|
||
|
||
const refresh = () => setRefreshKey((k) => k + 1);
|
||
|
||
return (
|
||
<DashboardContent>
|
||
<CustomBreadcrumbs
|
||
heading={isMentor ? 'Ученики' : 'Мои менторы'}
|
||
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: isMentor ? 'Ученики' : 'Менторы' }]}
|
||
action={
|
||
isMentor ? (
|
||
<Button variant="contained" startIcon={<Iconify icon="eva:person-add-outline" />} onClick={() => setInviteOpen(true)}>
|
||
Пригласить ученика
|
||
</Button>
|
||
) : (
|
||
<Button variant="contained" startIcon={<Iconify icon="eva:search-outline" />} onClick={() => setRequestOpen(true)}>
|
||
Найти ментора
|
||
</Button>
|
||
)
|
||
}
|
||
sx={{ mb: 3 }}
|
||
/>
|
||
|
||
{isMentor ? (
|
||
<>
|
||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
|
||
<Tab label="Мои ученики" />
|
||
<Tab label="Входящие заявки" />
|
||
</Tabs>
|
||
{tab === 0 && <MentorStudentList key={refreshKey} onRefresh={refresh} />}
|
||
{tab === 1 && <MentorRequests key={refreshKey} onRefresh={refresh} />}
|
||
<InviteDialog open={inviteOpen} onClose={() => setInviteOpen(false)} onSuccess={refresh} />
|
||
</>
|
||
) : (
|
||
<>
|
||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
|
||
<Tab label="Мои менторы" />
|
||
<Tab label="Входящие приглашения" />
|
||
<Tab label="Исходящие заявки" />
|
||
</Tabs>
|
||
{tab === 0 && <ClientMentorList key={refreshKey} />}
|
||
{tab === 1 && <ClientInvitations key={refreshKey} onRefresh={refresh} />}
|
||
{tab === 2 && <ClientOutgoingRequests key={refreshKey} />}
|
||
<SendRequestDialog open={requestOpen} onClose={() => setRequestOpen(false)} onSuccess={refresh} />
|
||
</>
|
||
)}
|
||
</DashboardContent>
|
||
);
|
||
}
|