1146 lines
43 KiB
JavaScript
1146 lines
43 KiB
JavaScript
'use client';
|
||
|
||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||
|
||
import Box from '@mui/material/Box';
|
||
import Card from '@mui/material/Card';
|
||
import Chip from '@mui/material/Chip';
|
||
import Stack from '@mui/material/Stack';
|
||
import Alert from '@mui/material/Alert';
|
||
import Paper from '@mui/material/Paper';
|
||
import Table from '@mui/material/Table';
|
||
import Avatar from '@mui/material/Avatar';
|
||
import Button from '@mui/material/Button';
|
||
import Dialog from '@mui/material/Dialog';
|
||
import Switch from '@mui/material/Switch';
|
||
import Divider from '@mui/material/Divider';
|
||
import Tooltip from '@mui/material/Tooltip';
|
||
import TableRow from '@mui/material/TableRow';
|
||
import Snackbar from '@mui/material/Snackbar';
|
||
import TableBody from '@mui/material/TableBody';
|
||
import TableCell from '@mui/material/TableCell';
|
||
import TableHead from '@mui/material/TableHead';
|
||
import TextField from '@mui/material/TextField';
|
||
import IconButton from '@mui/material/IconButton';
|
||
import Typography from '@mui/material/Typography';
|
||
import LoadingButton from '@mui/lab/LoadingButton';
|
||
import CardContent from '@mui/material/CardContent';
|
||
import DialogTitle from '@mui/material/DialogTitle';
|
||
import Autocomplete from '@mui/material/Autocomplete';
|
||
import DialogActions from '@mui/material/DialogActions';
|
||
import DialogContent from '@mui/material/DialogContent';
|
||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||
import CircularProgress from '@mui/material/CircularProgress';
|
||
|
||
import { paths } from 'src/routes/paths';
|
||
|
||
import axios, { resolveMediaUrl } from 'src/utils/axios';
|
||
import { unlinkTelegram, getTelegramStatus, getTelegramBotInfo, generateTelegramCode } from 'src/utils/telegram-api';
|
||
import {
|
||
searchCities,
|
||
deleteAvatar,
|
||
updateProfile,
|
||
loadTelegramAvatar,
|
||
getProfileSettings,
|
||
updateProfileSettings,
|
||
getNotificationPreferences,
|
||
updateNotificationPreferences,
|
||
} from 'src/utils/profile-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 ROLE_LABELS = {
|
||
mentor: 'Ментор',
|
||
client: 'Студент',
|
||
parent: 'Родитель',
|
||
};
|
||
|
||
const NOTIFICATION_TYPES = [
|
||
{ key: 'lesson_created', label: 'Занятие создано' },
|
||
{ key: 'lesson_cancelled', label: 'Занятие отменено' },
|
||
{ key: 'lesson_reminder', label: 'Напоминание о занятии' },
|
||
{ key: 'homework_assigned', label: 'ДЗ назначено' },
|
||
{ key: 'homework_submitted', label: 'ДЗ сдано' },
|
||
{ key: 'homework_reviewed', label: 'ДЗ проверено' },
|
||
{ key: 'message_received', label: 'Новое сообщение' },
|
||
{ key: 'subscription_expiring', label: 'Подписка истекает' },
|
||
{ key: 'subscription_expired', label: 'Подписка истекла' },
|
||
];
|
||
|
||
const PARENT_EXCLUDED_TYPES = [
|
||
'lesson_created', 'lesson_cancelled', 'lesson_reminder',
|
||
'homework_assigned', 'homework_submitted', 'homework_reviewed',
|
||
];
|
||
|
||
const CHANNELS = [
|
||
{ key: 'email', label: 'Email' },
|
||
{ key: 'telegram', label: 'Telegram' },
|
||
{ key: 'in_app', label: 'В приложении' },
|
||
];
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
const avatarSrc = (src) => resolveMediaUrl(src);
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
function TelegramSection({ onAvatarLoaded }) {
|
||
const [status, setStatus] = useState(null);
|
||
const [botInfo, setBotInfo] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [codeModal, setCodeModal] = useState(false);
|
||
const [code, setCode] = useState('');
|
||
const [codeInstructions, setCodeInstructions] = useState('');
|
||
const [generating, setGenerating] = useState(false);
|
||
const [unlinking, setUnlinking] = useState(false);
|
||
const [loadingTgAvatar, setLoadingTgAvatar] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
const pollRef = useRef(null);
|
||
|
||
const loadStatus = useCallback(async () => {
|
||
try {
|
||
const [s, b] = await Promise.all([
|
||
getTelegramStatus().catch(() => null),
|
||
getTelegramBotInfo().catch(() => null),
|
||
]);
|
||
setStatus(s);
|
||
setBotInfo(b);
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadStatus();
|
||
return () => {
|
||
if (pollRef.current) clearInterval(pollRef.current);
|
||
};
|
||
}, [loadStatus]);
|
||
|
||
const handleGenerate = async () => {
|
||
setGenerating(true);
|
||
try {
|
||
const res = await generateTelegramCode();
|
||
setCode(res.code || '');
|
||
setCodeInstructions(res.instructions || '');
|
||
setCodeModal(true);
|
||
// Poll status every 5s after opening modal
|
||
pollRef.current = setInterval(async () => {
|
||
const s = await getTelegramStatus().catch(() => null);
|
||
if (s?.linked) {
|
||
setStatus(s);
|
||
setCodeModal(false);
|
||
clearInterval(pollRef.current);
|
||
}
|
||
}, 5000);
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setGenerating(false);
|
||
}
|
||
};
|
||
|
||
const handleCloseModal = () => {
|
||
setCodeModal(false);
|
||
if (pollRef.current) clearInterval(pollRef.current);
|
||
loadStatus();
|
||
};
|
||
|
||
const handleUnlink = async () => {
|
||
setUnlinking(true);
|
||
try {
|
||
await unlinkTelegram();
|
||
await loadStatus();
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setUnlinking(false);
|
||
}
|
||
};
|
||
|
||
const handleLoadTgAvatar = async () => {
|
||
setLoadingTgAvatar(true);
|
||
try {
|
||
await loadTelegramAvatar();
|
||
if (onAvatarLoaded) onAvatarLoaded();
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setLoadingTgAvatar(false);
|
||
}
|
||
};
|
||
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(`/link ${code}`).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
});
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||
<CircularProgress size={24} />
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const botLink = botInfo?.link || (botInfo?.username ? `https://t.me/${botInfo.username}` : null);
|
||
|
||
return (
|
||
<>
|
||
<Box>
|
||
{status?.linked ? (
|
||
<Stack spacing={1}>
|
||
<Stack direction="row" alignItems="center" spacing={1}>
|
||
<Iconify icon="logos:telegram" width={20} />
|
||
<Typography variant="body2" color="success.main" fontWeight={600}>
|
||
Telegram подключён
|
||
</Typography>
|
||
{status.telegram_username && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
@{status.telegram_username}
|
||
</Typography>
|
||
)}
|
||
</Stack>
|
||
<Stack direction="row" spacing={1}>
|
||
<LoadingButton
|
||
size="small"
|
||
variant="soft"
|
||
color="info"
|
||
loading={loadingTgAvatar}
|
||
onClick={handleLoadTgAvatar}
|
||
startIcon={<Iconify icon="solar:user-id-bold" />}
|
||
>
|
||
Загрузить фото из Telegram
|
||
</LoadingButton>
|
||
<LoadingButton
|
||
size="small"
|
||
variant="soft"
|
||
color="error"
|
||
loading={unlinking}
|
||
onClick={handleUnlink}
|
||
startIcon={<Iconify icon="solar:link-broken-bold" />}
|
||
>
|
||
Отвязать
|
||
</LoadingButton>
|
||
</Stack>
|
||
</Stack>
|
||
) : (
|
||
<Stack spacing={1}>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Подключите Telegram для уведомлений
|
||
</Typography>
|
||
<LoadingButton
|
||
size="small"
|
||
variant="soft"
|
||
color="primary"
|
||
loading={generating}
|
||
onClick={handleGenerate}
|
||
startIcon={<Iconify icon="logos:telegram" />}
|
||
>
|
||
Привязать Telegram
|
||
</LoadingButton>
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Code Modal */}
|
||
<Dialog open={codeModal} onClose={handleCloseModal} maxWidth="xs" fullWidth>
|
||
<DialogTitle>Привязать Telegram</DialogTitle>
|
||
<DialogContent>
|
||
<Stack spacing={2} sx={{ pt: 1 }}>
|
||
<Typography variant="body2">
|
||
1. Откройте бота{' '}
|
||
{botLink ? (
|
||
<Box component="a" href={botLink} target="_blank" rel="noopener noreferrer" sx={{ color: 'primary.main' }}>
|
||
{botInfo?.username ? `@${botInfo.username}` : 'Telegram бот'}
|
||
</Box>
|
||
) : (
|
||
'Telegram бот'
|
||
)}{' '}
|
||
в Telegram
|
||
</Typography>
|
||
<Typography variant="body2">2. Отправьте боту команду:</Typography>
|
||
<Paper
|
||
variant="outlined"
|
||
sx={{
|
||
p: 1.5,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
bgcolor: 'background.neutral',
|
||
borderRadius: 1,
|
||
fontFamily: 'monospace',
|
||
}}
|
||
>
|
||
<Typography variant="body2" fontFamily="monospace" fontWeight={700}>
|
||
/link {code}
|
||
</Typography>
|
||
<Tooltip title={copied ? 'Скопировано!' : 'Скопировать'}>
|
||
<IconButton size="small" onClick={handleCopy}>
|
||
<Iconify icon={copied ? 'solar:check-circle-bold' : 'solar:copy-bold'} width={18} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Paper>
|
||
{codeInstructions && (
|
||
<Typography variant="caption" color="text.secondary">
|
||
{codeInstructions}
|
||
</Typography>
|
||
)}
|
||
<Stack direction="row" alignItems="center" spacing={1}>
|
||
<CircularProgress size={14} />
|
||
<Typography variant="caption" color="text.secondary">
|
||
Ожидаем подтверждения...
|
||
</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={handleCloseModal} color="inherit">
|
||
Закрыть
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
const CHILD_NOTIFICATION_TYPES = [
|
||
{ value: 'lesson_created', label: 'Создано занятие' },
|
||
{ value: 'lesson_updated', label: 'Занятие обновлено' },
|
||
{ value: 'lesson_cancelled', label: 'Занятие отменено' },
|
||
{ value: 'lesson_rescheduled', label: 'Занятие перенесено' },
|
||
{ value: 'lesson_reminder', label: 'Напоминание о занятии' },
|
||
{ value: 'lesson_completed', label: 'Занятие завершено' },
|
||
{ value: 'homework_assigned', label: 'Назначено домашнее задание' },
|
||
{ value: 'homework_submitted', label: 'ДЗ сдано' },
|
||
{ value: 'homework_reviewed', label: 'ДЗ проверено' },
|
||
{ value: 'homework_returned', label: 'ДЗ возвращено на доработку' },
|
||
{ value: 'homework_deadline_reminder', label: 'Напоминание о дедлайне ДЗ' },
|
||
{ value: 'material_added', label: 'Добавлен материал' },
|
||
];
|
||
|
||
function ParentChildNotifications() {
|
||
const [children, setChildren] = useState([]);
|
||
const [settings, setSettings] = useState({});
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState({});
|
||
const [expanded, setExpanded] = useState(null);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await axios.get('/parent/dashboard/');
|
||
const raw = res.data?.children ?? [];
|
||
const list = raw.map((item) => {
|
||
const c = item.child ?? item;
|
||
return { id: String(c.id), name: c.name || c.email || 'Ребёнок', avatar_url: c.avatar_url };
|
||
});
|
||
setChildren(list);
|
||
|
||
const map = {};
|
||
await Promise.all(list.map(async (child) => {
|
||
try {
|
||
const r = await axios.get(`/notifications/parent-child-settings/for_child/?child_id=${child.id}`);
|
||
map[child.id] = r.data;
|
||
} catch {
|
||
map[child.id] = { enabled: true, type_settings: {} };
|
||
}
|
||
}));
|
||
setSettings(map);
|
||
} catch {
|
||
setError('Не удалось загрузить настройки уведомлений для детей');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
load();
|
||
}, []);
|
||
|
||
const patch = async (childId, payload) => {
|
||
try {
|
||
setSaving((p) => ({ ...p, [childId]: true }));
|
||
const r = await axios.patch(`/notifications/parent-child-settings/for_child/?child_id=${childId}`, payload);
|
||
setSettings((p) => ({ ...p, [childId]: r.data }));
|
||
} catch {
|
||
setError('Не удалось сохранить');
|
||
} finally {
|
||
setSaving((p) => ({ ...p, [childId]: false }));
|
||
}
|
||
};
|
||
|
||
if (loading) return <CircularProgress size={20} sx={{ mt: 1 }} />;
|
||
if (children.length === 0) return null;
|
||
|
||
return (
|
||
<Card sx={{ mt: 0 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>Уведомления по детям</Typography>
|
||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||
<Stack spacing={1}>
|
||
{children.map((child) => {
|
||
const s = settings[child.id] || { enabled: true, type_settings: {} };
|
||
const isExpanded = expanded === child.id;
|
||
return (
|
||
<Box key={child.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1.5, overflow: 'hidden' }}>
|
||
{/* Header row */}
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
justifyContent="space-between"
|
||
sx={{ px: 2, py: 1.5, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
|
||
onClick={() => setExpanded(isExpanded ? null : child.id)}
|
||
>
|
||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||
<Avatar sx={{ width: 32, height: 32, fontSize: 13, bgcolor: 'secondary.main' }}>
|
||
{child.name[0]?.toUpperCase()}
|
||
</Avatar>
|
||
<Box>
|
||
<Typography variant="subtitle2">{child.name}</Typography>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{s.enabled ? 'Включены' : 'Выключены'}
|
||
</Typography>
|
||
</Box>
|
||
</Stack>
|
||
<Stack direction="row" alignItems="center" spacing={1} onClick={(e) => e.stopPropagation()}>
|
||
{saving[child.id] && <CircularProgress size={14} />}
|
||
<Switch
|
||
size="small"
|
||
checked={!!s.enabled}
|
||
onChange={() => patch(child.id, { enabled: !s.enabled })}
|
||
disabled={saving[child.id]}
|
||
/>
|
||
<Iconify
|
||
icon={isExpanded ? 'solar:alt-arrow-up-bold' : 'solar:alt-arrow-down-bold'}
|
||
width={16}
|
||
sx={{ color: 'text.disabled', cursor: 'pointer' }}
|
||
onClick={(e) => { e.stopPropagation(); setExpanded(isExpanded ? null : child.id); }}
|
||
/>
|
||
</Stack>
|
||
</Stack>
|
||
|
||
{/* Expanded type list */}
|
||
{isExpanded && (
|
||
<Box sx={{ px: 2, pb: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
||
{CHILD_NOTIFICATION_TYPES.map((type) => {
|
||
const isOn = s.type_settings[type.value] !== false;
|
||
return (
|
||
<Stack key={type.value} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 0.5, opacity: s.enabled ? 1 : 0.5 }}>
|
||
<Typography variant="body2">{type.label}</Typography>
|
||
<Switch
|
||
size="small"
|
||
checked={isOn}
|
||
disabled={saving[child.id] || !s.enabled}
|
||
onChange={() => patch(child.id, { type_settings: { ...s.type_settings, [type.value]: !isOn } })}
|
||
/>
|
||
</Stack>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
function NotificationMatrix({ prefs, onChange, role }) {
|
||
const visibleTypes = role === 'parent'
|
||
? NOTIFICATION_TYPES.filter((t) => !PARENT_EXCLUDED_TYPES.includes(t.key))
|
||
: NOTIFICATION_TYPES;
|
||
|
||
const getTypeValue = (typeKey, channelKey) => {
|
||
const tp = prefs?.type_preferences;
|
||
if (tp && tp[typeKey] && typeof tp[typeKey][channelKey] === 'boolean') {
|
||
return tp[typeKey][channelKey];
|
||
}
|
||
// Fallback to channel-level setting
|
||
const channelMap = { email: 'email_enabled', telegram: 'telegram_enabled', in_app: 'in_app_enabled' };
|
||
return !!prefs?.[channelMap[channelKey]];
|
||
};
|
||
|
||
const handleToggle = (typeKey, channelKey) => {
|
||
const current = getTypeValue(typeKey, channelKey);
|
||
onChange({
|
||
type_preferences: {
|
||
...prefs?.type_preferences,
|
||
[typeKey]: {
|
||
...(prefs?.type_preferences?.[typeKey] || {}),
|
||
[channelKey]: !current,
|
||
},
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Box sx={{ overflowX: 'auto' }}>
|
||
<Table size="small">
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableCell sx={{ minWidth: 180 }}>Тип уведомления</TableCell>
|
||
{CHANNELS.map((ch) => (
|
||
<TableCell key={ch.key} align="center" sx={{ minWidth: 100 }}>
|
||
{ch.label}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{visibleTypes.map((type) => (
|
||
<TableRow key={type.key} hover>
|
||
<TableCell>
|
||
<Typography variant="body2">{type.label}</Typography>
|
||
</TableCell>
|
||
{CHANNELS.map((ch) => (
|
||
<TableCell key={ch.key} align="center">
|
||
<Switch
|
||
size="small"
|
||
checked={getTypeValue(type.key, ch.key)}
|
||
onChange={() => handleToggle(type.key, ch.key)}
|
||
disabled={!prefs?.enabled}
|
||
/>
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
export function AccountPlatformView() {
|
||
const { user, checkUserSession } = useAuthContext();
|
||
|
||
// Profile fields
|
||
const [firstName, setFirstName] = useState('');
|
||
const [lastName, setLastName] = useState('');
|
||
const [email, setEmail] = useState('');
|
||
const [avatarPreview, setAvatarPreview] = useState(null);
|
||
const [avatarHovered, setAvatarHovered] = useState(false);
|
||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||
const [deletingAvatar, setDeletingAvatar] = useState(false);
|
||
|
||
// Settings
|
||
const [settings, setSettings] = useState(null);
|
||
const [settingsLoading, setSettingsLoading] = useState(true);
|
||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||
|
||
// Notification prefs
|
||
const [notifPrefs, setNotifPrefs] = useState(null);
|
||
const [notifSaving, setNotifSaving] = useState(false);
|
||
|
||
// City autocomplete
|
||
const [cityQuery, setCityQuery] = useState('');
|
||
const [cityOptions, setCityOptions] = useState([]);
|
||
const [citySearching, setCitySearching] = useState(false);
|
||
const cityDebounceRef = useRef(null);
|
||
|
||
// Auto-save debounce for settings
|
||
const settingsSaveRef = useRef(null);
|
||
|
||
// Snackbar
|
||
const [snack, setSnack] = useState({ open: false, message: '', severity: 'success' });
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
useEffect(() => {
|
||
if (user) {
|
||
setFirstName(user.first_name || '');
|
||
setLastName(user.last_name || '');
|
||
setEmail(user.email || '');
|
||
}
|
||
}, [user]);
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
setSettingsLoading(true);
|
||
try {
|
||
const [s, n] = await Promise.all([
|
||
getProfileSettings().catch(() => null),
|
||
getNotificationPreferences().catch(() => null),
|
||
]);
|
||
setSettings(s);
|
||
setNotifPrefs(n);
|
||
if (s?.preferences?.city) setCityQuery(s.preferences.city);
|
||
} finally {
|
||
setSettingsLoading(false);
|
||
}
|
||
}
|
||
load();
|
||
}, []);
|
||
|
||
// ----------------------------------------------------------------------
|
||
// City search
|
||
|
||
useEffect(() => {
|
||
if (cityDebounceRef.current) clearTimeout(cityDebounceRef.current);
|
||
if (!cityQuery || cityQuery.length < 2) {
|
||
setCityOptions([]);
|
||
return undefined;
|
||
}
|
||
cityDebounceRef.current = setTimeout(async () => {
|
||
setCitySearching(true);
|
||
const results = await searchCities(cityQuery, 20);
|
||
setCityOptions(results);
|
||
setCitySearching(false);
|
||
}, 400);
|
||
return () => {
|
||
if (cityDebounceRef.current) clearTimeout(cityDebounceRef.current);
|
||
};
|
||
}, [cityQuery]);
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Avatar
|
||
|
||
const handleAvatarChange = async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setAvatarPreview(URL.createObjectURL(file));
|
||
setUploadingAvatar(true);
|
||
try {
|
||
await updateProfile({ avatar: file });
|
||
if (checkUserSession) await checkUserSession();
|
||
showSnack('Аватар обновлён');
|
||
} catch {
|
||
showSnack('Ошибка загрузки аватара', 'error');
|
||
} finally {
|
||
setUploadingAvatar(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteAvatar = async () => {
|
||
setDeletingAvatar(true);
|
||
try {
|
||
await deleteAvatar();
|
||
setAvatarPreview(null);
|
||
if (checkUserSession) await checkUserSession();
|
||
showSnack('Аватар удалён');
|
||
} catch {
|
||
showSnack('Ошибка удаления аватара', 'error');
|
||
} finally {
|
||
setDeletingAvatar(false);
|
||
}
|
||
};
|
||
|
||
const handleTgAvatarLoaded = async () => {
|
||
if (checkUserSession) await checkUserSession();
|
||
showSnack('Фото из Telegram загружено');
|
||
};
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Profile field auto-save on blur
|
||
|
||
const handleProfileBlur = useCallback(async (field, value) => {
|
||
try {
|
||
await updateProfile({ [field]: value });
|
||
if (checkUserSession) await checkUserSession();
|
||
} catch {
|
||
showSnack('Ошибка сохранения', 'error');
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [checkUserSession]);
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Settings save (debounced)
|
||
|
||
const scheduleSettingsSave = useCallback((newSettings) => {
|
||
setSettings(newSettings);
|
||
if (settingsSaveRef.current) clearTimeout(settingsSaveRef.current);
|
||
settingsSaveRef.current = setTimeout(async () => {
|
||
setSettingsSaving(true);
|
||
try {
|
||
await updateProfileSettings({
|
||
notifications: newSettings?.notifications,
|
||
preferences: newSettings?.preferences,
|
||
mentor_homework_ai: newSettings?.mentor_homework_ai,
|
||
});
|
||
} catch {
|
||
showSnack('Ошибка сохранения настроек', 'error');
|
||
} finally {
|
||
setSettingsSaving(false);
|
||
}
|
||
}, 800);
|
||
}, []);
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Notification prefs save (debounced)
|
||
|
||
const notifSaveRef = useRef(null);
|
||
|
||
const scheduleNotifSave = useCallback((updated) => {
|
||
setNotifPrefs(updated);
|
||
if (notifSaveRef.current) clearTimeout(notifSaveRef.current);
|
||
notifSaveRef.current = setTimeout(async () => {
|
||
setNotifSaving(true);
|
||
try {
|
||
await updateNotificationPreferences(updated);
|
||
} catch {
|
||
showSnack('Ошибка сохранения уведомлений', 'error');
|
||
} finally {
|
||
setNotifSaving(false);
|
||
}
|
||
}, 800);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const handleNotifChange = (partial) => {
|
||
scheduleNotifSave({ ...notifPrefs, ...partial });
|
||
};
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
const showSnack = (message, severity = 'success') => {
|
||
setSnack({ open: true, message, severity });
|
||
};
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
const currentAvatar = avatarPreview || avatarSrc(user?.avatar);
|
||
const displayName = `${firstName} ${lastName}`.trim() || user?.email || '';
|
||
const roleLabel = ROLE_LABELS[user?.role] || user?.role || '';
|
||
|
||
return (
|
||
<DashboardContent>
|
||
<CustomBreadcrumbs
|
||
heading="Профиль"
|
||
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Профиль' }]}
|
||
sx={{ mb: 3 }}
|
||
/>
|
||
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: { xs: '1fr', md: '440px 1fr' },
|
||
gap: 3,
|
||
alignItems: 'start',
|
||
}}
|
||
>
|
||
{/* ─── LEFT COLUMN ─── */}
|
||
<Stack spacing={3}>
|
||
{/* Avatar card */}
|
||
<Card>
|
||
<CardContent sx={{ pb: '16px !important' }}>
|
||
<Stack alignItems="center" spacing={2}>
|
||
{/* Square avatar with hover overlay */}
|
||
<Box
|
||
sx={{ position: 'relative', width: 160, height: 160, cursor: 'pointer' }}
|
||
onMouseEnter={() => setAvatarHovered(true)}
|
||
onMouseLeave={() => setAvatarHovered(false)}
|
||
>
|
||
<Avatar
|
||
src={currentAvatar}
|
||
alt={displayName}
|
||
sx={{ width: 160, height: 160, borderRadius: 2, fontSize: 48 }}
|
||
>
|
||
{!currentAvatar && displayName[0]?.toUpperCase()}
|
||
</Avatar>
|
||
|
||
{/* Hover overlay */}
|
||
{avatarHovered && (
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
borderRadius: 2,
|
||
bgcolor: 'rgba(0,0,0,0.55)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 0.5,
|
||
}}
|
||
>
|
||
<input
|
||
type="file"
|
||
id="avatar-upload"
|
||
accept="image/*"
|
||
onChange={handleAvatarChange}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<Tooltip title="Загрузить фото">
|
||
<IconButton
|
||
component="label"
|
||
htmlFor="avatar-upload"
|
||
size="small"
|
||
sx={{ color: 'white' }}
|
||
disabled={uploadingAvatar}
|
||
>
|
||
{uploadingAvatar ? (
|
||
<CircularProgress size={20} color="inherit" />
|
||
) : (
|
||
<Iconify icon="solar:camera-bold" />
|
||
)}
|
||
</IconButton>
|
||
</Tooltip>
|
||
|
||
{currentAvatar && (
|
||
<Tooltip title="Удалить фото">
|
||
<IconButton
|
||
size="small"
|
||
sx={{ color: 'white' }}
|
||
onClick={handleDeleteAvatar}
|
||
disabled={deletingAvatar}
|
||
>
|
||
{deletingAvatar ? (
|
||
<CircularProgress size={20} color="inherit" />
|
||
) : (
|
||
<Iconify icon="solar:trash-bin-trash-bold" />
|
||
)}
|
||
</IconButton>
|
||
</Tooltip>
|
||
)}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
<Box sx={{ textAlign: 'center' }}>
|
||
<Typography variant="subtitle1">{displayName}</Typography>
|
||
<Typography variant="body2" color="text.secondary">{user?.email}</Typography>
|
||
{roleLabel && (
|
||
<Chip label={roleLabel} size="small" sx={{ mt: 0.5 }} />
|
||
)}
|
||
</Box>
|
||
</Stack>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Profile fields */}
|
||
<Card>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Личные данные
|
||
</Typography>
|
||
<Stack spacing={2}>
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||
<TextField
|
||
label="Имя"
|
||
value={firstName}
|
||
onChange={(e) => setFirstName(e.target.value)}
|
||
onBlur={(e) => handleProfileBlur('first_name', e.target.value.trim())}
|
||
fullWidth
|
||
/>
|
||
<TextField
|
||
label="Фамилия"
|
||
value={lastName}
|
||
onChange={(e) => setLastName(e.target.value)}
|
||
onBlur={(e) => handleProfileBlur('last_name', e.target.value.trim())}
|
||
fullWidth
|
||
/>
|
||
</Stack>
|
||
<TextField
|
||
label="Email"
|
||
value={email}
|
||
fullWidth
|
||
disabled
|
||
helperText="Изменить email нельзя"
|
||
/>
|
||
{user?.universal_code && (
|
||
<TextField
|
||
label="Мой код"
|
||
value={user.universal_code}
|
||
fullWidth
|
||
disabled
|
||
helperText="Передайте этот код ментору, чтобы он мог добавить вас"
|
||
inputProps={{ style: { letterSpacing: 4, fontWeight: 700, fontSize: 18 } }}
|
||
InputProps={{
|
||
endAdornment: (
|
||
<Tooltip title="Скопировать код">
|
||
<IconButton
|
||
edge="end"
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(user.universal_code);
|
||
showSnack('Код скопирован');
|
||
}}
|
||
>
|
||
<Iconify icon="solar:copy-bold" width={18} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
),
|
||
}}
|
||
/>
|
||
)}
|
||
</Stack>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* City + Timezone */}
|
||
<Card>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Местоположение
|
||
</Typography>
|
||
<Stack spacing={2}>
|
||
{settingsLoading ? (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||
<CircularProgress size={24} />
|
||
</Box>
|
||
) : (
|
||
<>
|
||
<Autocomplete
|
||
freeSolo
|
||
options={cityOptions}
|
||
getOptionLabel={(opt) =>
|
||
typeof opt === 'string' ? opt : `${opt.name}${opt.region ? `, ${opt.region}` : ''}`
|
||
}
|
||
inputValue={cityQuery}
|
||
onInputChange={(_, val) => setCityQuery(val)}
|
||
onChange={(_, opt) => {
|
||
if (opt && typeof opt === 'object') {
|
||
setCityQuery(opt.name || '');
|
||
const newSettings = {
|
||
...settings,
|
||
preferences: {
|
||
...settings?.preferences,
|
||
city: opt.name || '',
|
||
timezone: opt.timezone || settings?.preferences?.timezone || '',
|
||
},
|
||
};
|
||
scheduleSettingsSave(newSettings);
|
||
}
|
||
}}
|
||
loading={citySearching}
|
||
renderInput={(params) => (
|
||
<TextField
|
||
{...params}
|
||
label="Город"
|
||
fullWidth
|
||
onBlur={() => {
|
||
const newSettings = {
|
||
...settings,
|
||
preferences: {
|
||
...settings?.preferences,
|
||
city: cityQuery,
|
||
},
|
||
};
|
||
scheduleSettingsSave(newSettings);
|
||
}}
|
||
InputProps={{
|
||
...params.InputProps,
|
||
endAdornment: (
|
||
<>
|
||
{citySearching ? <CircularProgress size={16} /> : null}
|
||
{params.InputProps.endAdornment}
|
||
</>
|
||
),
|
||
}}
|
||
/>
|
||
)}
|
||
renderOption={(props, opt) => (
|
||
<Box component="li" {...props} key={`${opt.name}-${opt.timezone}`}>
|
||
<Stack>
|
||
<Typography variant="body2">
|
||
{opt.name}{opt.region ? `, ${opt.region}` : ''}
|
||
</Typography>
|
||
{opt.timezone && (
|
||
<Typography variant="caption" color="text.secondary">
|
||
{opt.timezone}
|
||
</Typography>
|
||
)}
|
||
</Stack>
|
||
</Box>
|
||
)}
|
||
/>
|
||
<TextField
|
||
label="Часовой пояс"
|
||
value={settings?.preferences?.timezone || ''}
|
||
fullWidth
|
||
disabled
|
||
helperText="Заполняется автоматически при выборе города"
|
||
/>
|
||
</>
|
||
)}
|
||
</Stack>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Telegram */}
|
||
<Card>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Telegram
|
||
</Typography>
|
||
<TelegramSection onAvatarLoaded={handleTgAvatarLoaded} />
|
||
</CardContent>
|
||
</Card>
|
||
</Stack>
|
||
|
||
{/* ─── RIGHT COLUMN ─── */}
|
||
<Stack spacing={3}>
|
||
{/* Notification preferences */}
|
||
<Card>
|
||
<CardContent>
|
||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||
<Typography variant="subtitle1">Уведомления</Typography>
|
||
{notifSaving && <CircularProgress size={16} />}
|
||
</Stack>
|
||
|
||
{settingsLoading ? (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||
<CircularProgress size={24} />
|
||
</Box>
|
||
) : (
|
||
<Stack spacing={2}>
|
||
{/* Global toggle */}
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={!!notifPrefs?.enabled}
|
||
onChange={() => handleNotifChange({ enabled: !notifPrefs?.enabled })}
|
||
/>
|
||
}
|
||
label="Включить уведомления"
|
||
/>
|
||
|
||
{notifPrefs?.enabled && (
|
||
<>
|
||
<Divider />
|
||
{/* Channel toggles */}
|
||
<Stack spacing={1}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
size="small"
|
||
checked={!!notifPrefs?.email_enabled}
|
||
onChange={() => handleNotifChange({ email_enabled: !notifPrefs?.email_enabled })}
|
||
/>
|
||
}
|
||
label="Email"
|
||
/>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
size="small"
|
||
checked={!!notifPrefs?.telegram_enabled}
|
||
onChange={() => handleNotifChange({ telegram_enabled: !notifPrefs?.telegram_enabled })}
|
||
/>
|
||
}
|
||
label="Telegram"
|
||
/>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
size="small"
|
||
checked={!!notifPrefs?.in_app_enabled}
|
||
onChange={() => handleNotifChange({ in_app_enabled: !notifPrefs?.in_app_enabled })}
|
||
/>
|
||
}
|
||
label="В приложении"
|
||
/>
|
||
</Stack>
|
||
|
||
<Divider />
|
||
<Typography variant="subtitle2" color="text.secondary">
|
||
Настройки по типу уведомлений
|
||
</Typography>
|
||
<NotificationMatrix
|
||
prefs={notifPrefs}
|
||
onChange={handleNotifChange}
|
||
role={user?.role}
|
||
/>
|
||
</>
|
||
)}
|
||
</Stack>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Per-child notification settings — parent only */}
|
||
{user?.role === 'parent' && <ParentChildNotifications />}
|
||
|
||
{/* AI homework settings (mentor only) */}
|
||
{user?.role === 'mentor' && settings && (
|
||
<Card>
|
||
<CardContent>
|
||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||
<Typography variant="subtitle1">AI проверка домашних заданий</Typography>
|
||
{settingsSaving && <CircularProgress size={16} />}
|
||
</Stack>
|
||
<Stack spacing={2}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={!!settings?.mentor_homework_ai?.ai_trust_draft}
|
||
onChange={() => {
|
||
const newVal = !settings?.mentor_homework_ai?.ai_trust_draft;
|
||
scheduleSettingsSave({
|
||
...settings,
|
||
mentor_homework_ai: {
|
||
...settings?.mentor_homework_ai,
|
||
ai_trust_draft: newVal,
|
||
// Mutually exclusive
|
||
ai_trust_publish: newVal ? false : settings?.mentor_homework_ai?.ai_trust_publish,
|
||
},
|
||
});
|
||
}}
|
||
/>
|
||
}
|
||
label="AI сохраняет как черновик (нужна ваша проверка)"
|
||
/>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={!!settings?.mentor_homework_ai?.ai_trust_publish}
|
||
onChange={() => {
|
||
const newVal = !settings?.mentor_homework_ai?.ai_trust_publish;
|
||
scheduleSettingsSave({
|
||
...settings,
|
||
mentor_homework_ai: {
|
||
...settings?.mentor_homework_ai,
|
||
ai_trust_publish: newVal,
|
||
// Mutually exclusive
|
||
ai_trust_draft: newVal ? false : settings?.mentor_homework_ai?.ai_trust_draft,
|
||
},
|
||
});
|
||
}}
|
||
/>
|
||
}
|
||
label="AI публикует результат автоматически"
|
||
/>
|
||
<Typography variant="caption" color="text.secondary">
|
||
Эти опции взаимно исключают друг друга
|
||
</Typography>
|
||
</Stack>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</Stack>
|
||
</Box>
|
||
|
||
<Snackbar
|
||
open={snack.open}
|
||
autoHideDuration={3000}
|
||
onClose={() => setSnack((prev) => ({ ...prev, open: false }))}
|
||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||
>
|
||
<Alert severity={snack.severity} onClose={() => setSnack((prev) => ({ ...prev, open: false }))}>
|
||
{snack.message}
|
||
</Alert>
|
||
</Snackbar>
|
||
</DashboardContent>
|
||
);
|
||
}
|