/
viktorphp
/
front-socket
Обзор
Документация
Войти
/
viktorphp
/
front-socket
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/features/ancestor/ui/AddRelationModal.tsx
573 строки
19 KB
Иванов Виктор Евгеньевич
[feat-tree]: добавить страницу дерева предков, поправить основные баги
13 апр 2026, 21:32
13 апр 2026, 21:32
6a6f509
Код
Авторство
О чём код?
'use client'; import { useEffect, useState } from 'react'; import CloseIcon from '@mui/icons-material/Close'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import SearchIcon from '@mui/icons-material/Search'; import { Avatar, Box, Button, CircularProgress, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputAdornment, InputLabel, List, ListItemAvatar, ListItemButton, ListItemText, MenuItem, Select, Stack, Tab, Tabs, TextField, Typography, } from '@mui/material'; import { useAncestorStore } from '@entities/ancestor'; import { useUserStore } from '@entities/user'; import { ancestorsApi } from '@shared/api/rest'; import { Gender, IAncestor, RelationType } from '@shared/types'; interface IAddRelationModalProps { /** ID предка, к которому добавляем связь */ fromAncestorId: string | null; onClose: () => void; } const RELATION_OPTIONS: { value: RelationType; label: string }[] = [ { value: RelationType.FATHER, label: 'Отец' }, { value: RelationType.MOTHER, label: 'Мать' }, { value: RelationType.CHILD, label: 'Ребёнок' }, { value: RelationType.SIBLING, label: 'Брат / Сестра' }, ]; function getFilteredOptions(gender: Gender | null | undefined) { if (gender === Gender.MALE) return RELATION_OPTIONS.filter((o) => o.value !== RelationType.MOTHER); if (gender === Gender.FEMALE) return RELATION_OPTIONS.filter((o) => o.value !== RelationType.FATHER); return RELATION_OPTIONS; } export const AddRelationModal = ({ fromAncestorId, onClose, }: IAddRelationModalProps) => { const { user } = useUserStore(); const { setTree, setFreeAncestors } = useAncestorStore(); const [tab, setTab] = useState(0); const [relationType, setRelationType] = useState<RelationType>( RelationType.FATHER ); // Поиск существующего предка const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState<IAncestor[]>([]); const [searching, setSearching] = useState(false); const [selectedExisting, setSelectedExisting] = useState<IAncestor | null>( null ); // Создание нового предка const [newForm, setNewForm] = useState({ firstName: '', lastName: '', patronymic: '', gender: '' as Gender | '', birthYear: '', deathYear: '', isAlive: true, }); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState<string | null>(null); useEffect(() => { if (!fromAncestorId) { setTab(0); setSearchQuery(''); setSearchResults([]); setSelectedExisting(null); setNewForm({ firstName: '', lastName: '', patronymic: '', gender: '', birthYear: '', deathYear: '', isAlive: true, }); setError(null); } }, [fromAncestorId]); const filteredOptions = getFilteredOptions(selectedExisting?.gender); // Сбрасываем тип связи если он недоступен для выбранного человека useEffect(() => { if ( selectedExisting && !filteredOptions.find((o) => o.value === relationType) ) { setRelationType(filteredOptions[0]?.value ?? RelationType.CHILD); } }, [selectedExisting]); // eslint-disable-line react-hooks/exhaustive-deps const handleSearch = async () => { if (!searchQuery.trim()) return; setSearching(true); try { const results = await ancestorsApi.searchAncestors(searchQuery.trim()); setSearchResults(results); } finally { setSearching(false); } }; const reloadTree = async () => { if (!user) return; const [tree, free] = await Promise.all([ ancestorsApi.getUserTree(user.id, 3), ancestorsApi.getFreeAncestors(user.id), ]); setTree(tree); setFreeAncestors(free); }; const handleAddExisting = async () => { if (!fromAncestorId || !selectedExisting) return; setSubmitting(true); setError(null); try { // Backend relationType — от лица fromAncestor: // 'father' → fromAncestor является отцом toAncestor (toAncestor.fatherId = from.id) // 'mother' → fromAncestor является матерью toAncestor // 'child' → fromAncestor является ребёнком toAncestor // В UI: fromAncestorId — текущая карточка, новый — добавляемый родственник. // Для 'father'/'mother'/'child' нужно поставить новую карточку в позицию fromAncestor, // а текущую — в toAncestor, чтобы связь ставилась правильно. const swapIds = relationType === RelationType.FATHER || relationType === RelationType.MOTHER || relationType === RelationType.CHILD; const result = await ancestorsApi.addRelation( swapIds ? selectedExisting.id : fromAncestorId, { relatedAncestorId: swapIds ? fromAncestorId : selectedExisting.id, relationType, } ); if (result.immediate) { await reloadTree(); onClose(); } else { setError( 'Запрос отправлен пользователю. Связь будет установлена после подтверждения.' ); } } catch (e: any) { setError(e?.response?.data?.message ?? 'Ошибка при добавлении связи'); } finally { setSubmitting(false); } }; const handleCreateAndAdd = async () => { if (!fromAncestorId) return; setSubmitting(true); setError(null); try { // Сначала создаём нового предка const created = await ancestorsApi.createAncestor({ firstName: newForm.firstName || undefined, lastName: newForm.lastName || undefined, patronymic: newForm.patronymic || undefined, gender: newForm.gender || undefined, birthYear: newForm.birthYear ? parseInt(newForm.birthYear) : undefined, deathYear: newForm.deathYear ? parseInt(newForm.deathYear) : undefined, isAlive: newForm.isAlive, }); // Добавляем связь. // Backend: relationType от лица fromAncestor. // Для 'father'/'mother'/'child' ставим новую карточку как fromAncestor, // текущую — как toAncestor, чтобы связь ставилась в нужном направлении. const swapIds = relationType === RelationType.FATHER || relationType === RelationType.MOTHER || relationType === RelationType.CHILD; await ancestorsApi.addRelation(swapIds ? created.id : fromAncestorId, { relatedAncestorId: swapIds ? fromAncestorId : created.id, relationType, }); await reloadTree(); onClose(); } catch (e: any) { setError(e?.response?.data?.message ?? 'Ошибка при создании предка'); } finally { setSubmitting(false); } }; const fieldSx = {}; return ( <Dialog open={!!fromAncestorId} onClose={onClose} maxWidth="sm" fullWidth PaperProps={{ sx: { bgcolor: '#ffffff', backgroundImage: 'none', borderRadius: 3, border: '1px solid rgba(0,0,0,0.08)', }, }} > <DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 0, }} > <Typography variant="h6" component="span" color="text.primary" fontWeight={700} > Добавить родственника </Typography> <IconButton onClick={onClose} size="small" sx={{ color: 'rgba(0,0,0,0.5)' }} > <CloseIcon /> </IconButton> </DialogTitle> <DialogContent> <Stack spacing={2.5} mt={1}> {/* Тип связи */} <FormControl size="small" fullWidth> <InputLabel>Тип связи</InputLabel> <Select value={relationType} label="Тип связи" onChange={(e) => setRelationType(e.target.value as RelationType)} > {filteredOptions.map((opt) => ( <MenuItem key={opt.value} value={opt.value}> {opt.label} </MenuItem> ))} </Select> </FormControl> <Divider /> {/* Табы: поиск / создать */} <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ '& .MuiTab-root': { color: 'rgba(0,0,0,0.55)', textTransform: 'none', }, '& .Mui-selected': { color: '#6CC444 !important' }, '& .MuiTabs-indicator': { bgcolor: '#6CC444' }, }} > <Tab label="Найти существующего" /> <Tab label="Создать нового" /> </Tabs> {/* Поиск */} {tab === 0 && ( <Stack spacing={2}> <Box sx={{ display: 'flex', gap: 1 }}> <TextField label="Имя или фамилия" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} size="small" fullWidth sx={fieldSx} slotProps={{ input: { endAdornment: ( <InputAdornment position="end"> {searching ? ( <CircularProgress size={16} /> ) : ( <IconButton size="small" onClick={handleSearch} sx={{ color: 'rgba(0,0,0,0.45)' }} > <SearchIcon fontSize="small" /> </IconButton> )} </InputAdornment> ), }, }} /> </Box> {searchResults.length > 0 && ( <List dense sx={{ maxHeight: 240, overflow: 'auto', borderRadius: 2, border: '1px solid rgba(0,0,0,0.1)', }} > {searchResults.map((a) => { const fullName = [a.lastName, a.firstName, a.patronymic] .filter(Boolean) .join(' '); const isSelected = selectedExisting?.id === a.id; return ( <ListItemButton key={a.id} selected={isSelected} onClick={() => setSelectedExisting(isSelected ? null : a) } sx={{ borderRadius: 1, '&.Mui-selected': { bgcolor: 'rgba(108,196,68,0.12)', }, }} > <ListItemAvatar> <Avatar sx={{ width: 36, height: 36, bgcolor: a.gender === Gender.MALE ? '#6CC444' : a.gender === Gender.FEMALE ? '#89B994' : '#78909C', fontSize: 13, }} > {( (a.firstName?.[0] ?? '') + (a.lastName?.[0] ?? '') ).toUpperCase() || '?'} </Avatar> </ListItemAvatar> <ListItemText primary={ <Typography variant="body2" color="text.primary"> {fullName || 'Неизвестно'} </Typography> } secondary={ a.birthYear ? ( <Typography variant="caption" color="text.secondary" > {a.birthYear} {!a.isAlive && a.deathYear ? ` — ${a.deathYear}` : ''} </Typography> ) : undefined } /> </ListItemButton> ); })} </List> )} {searchResults.length === 0 && searchQuery && !searching && ( <Typography variant="body2" color="text.secondary" textAlign="center" > Ничего не найдено </Typography> )} <Button variant="contained" disabled={!selectedExisting || submitting} onClick={handleAddExisting} startIcon={ submitting ? ( <CircularProgress size={16} /> ) : ( <PersonAddIcon /> ) } sx={{ bgcolor: '#6CC444', alignSelf: 'flex-start' }} > Добавить </Button> </Stack> )} {/* Создать нового */} {tab === 1 && ( <Stack spacing={2}> <Box sx={{ display: 'flex', gap: 2 }}> <TextField label="Фамилия" value={newForm.lastName} onChange={(e) => setNewForm((f) => ({ ...f, lastName: e.target.value })) } size="small" fullWidth sx={fieldSx} /> <TextField label="Имя" value={newForm.firstName} onChange={(e) => setNewForm((f) => ({ ...f, firstName: e.target.value })) } size="small" fullWidth sx={fieldSx} /> </Box> <TextField label="Отчество" value={newForm.patronymic} onChange={(e) => setNewForm((f) => ({ ...f, patronymic: e.target.value })) } size="small" fullWidth sx={fieldSx} /> <Box sx={{ display: 'flex', gap: 2 }}> <FormControl size="small" fullWidth> <InputLabel>Пол</InputLabel> <Select value={newForm.gender} label="Пол" onChange={(e) => setNewForm((f) => ({ ...f, gender: e.target.value as Gender | '', })) } > <MenuItem value="">Не указан</MenuItem> <MenuItem value={Gender.MALE}>Мужской</MenuItem> <MenuItem value={Gender.FEMALE}>Женский</MenuItem> </Select> </FormControl> <FormControl size="small" fullWidth> <InputLabel>Статус</InputLabel> <Select value={newForm.isAlive ? 'alive' : 'dead'} label="Статус" onChange={(e) => setNewForm((f) => ({ ...f, isAlive: e.target.value === 'alive', deathYear: e.target.value === 'alive' ? '' : f.deathYear, })) } > <MenuItem value="alive">Живёт</MenuItem> <MenuItem value="dead">Скончался</MenuItem> </Select> </FormControl> </Box> <Box sx={{ display: 'flex', gap: 2 }}> <TextField label="Год рождения" value={newForm.birthYear} onChange={(e) => setNewForm((f) => ({ ...f, birthYear: e.target.value })) } size="small" type="number" fullWidth sx={fieldSx} /> <TextField label="Год смерти" value={newForm.deathYear} onChange={(e) => setNewForm((f) => ({ ...f, deathYear: e.target.value })) } size="small" type="number" disabled={newForm.isAlive} fullWidth sx={fieldSx} /> </Box> <Button variant="contained" onClick={handleCreateAndAdd} disabled={submitting} startIcon={ submitting ? ( <CircularProgress size={16} /> ) : ( <PersonAddIcon /> ) } sx={{ bgcolor: '#6CC444', alignSelf: 'flex-start' }} > Создать и добавить </Button> </Stack> )} {/* Ошибка / сообщение */} {error && ( <Typography variant="body2" sx={{ color: error.includes('Запрос') ? '#4CAF7D' : '#EF5350', p: 1.5, borderRadius: 2, bgcolor: error.includes('Запрос') ? 'rgba(76,175,125,0.1)' : 'rgba(239,83,80,0.1)', }} > {error} </Typography> )} </Stack> </DialogContent> </Dialog> ); };