uchill/front_minimal/src/sections/account-platform/view/account-platform-view.jsx

1146 lines
43 KiB
JavaScript
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.

'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>
);
}