322 lines
9.5 KiB
JavaScript
322 lines
9.5 KiB
JavaScript
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import {
|
||
TextField, Box, Typography, IconButton, Divider,
|
||
CircularProgress, Alert, Collapse, Tooltip, Button, Select, MenuItem
|
||
} from '@mui/material';
|
||
import DeleteIcon from '@mui/icons-material/Delete';
|
||
import AddIcon from '@mui/icons-material/Add';
|
||
import SearchIcon from '@mui/icons-material/Search';
|
||
import axios from 'axios';
|
||
import { statusConfig } from './statusConfig';
|
||
import { VariableSizeList as List } from 'react-window';
|
||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||
|
||
const MetricItem = React.memo(({ metric, index, updateRange, addRange, deleteRange }) => {
|
||
return (
|
||
<Box sx={{
|
||
mb: 3,
|
||
p: 2,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
borderRadius: 2,
|
||
backgroundColor: 'background.paper'
|
||
}}>
|
||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||
{metric.name}
|
||
</Typography>
|
||
|
||
{metric.ranges.map((r, j) => (
|
||
<Box
|
||
key={j}
|
||
sx={{
|
||
display: 'flex',
|
||
gap: 2,
|
||
alignItems: 'flex-end', // Изменено с 'center' на 'flex-end'
|
||
mt: 1,
|
||
'& > *': { flex: 1 }
|
||
}}
|
||
>
|
||
<TextField
|
||
label="Минимум"
|
||
type="number"
|
||
value={r.min}
|
||
onChange={(e) => updateRange(index, j, 'min', e.target.value)}
|
||
size="small"
|
||
variant="standard"
|
||
/>
|
||
<TextField
|
||
label="Максимум"
|
||
type="number"
|
||
value={r.max}
|
||
onChange={(e) => updateRange(index, j, 'max', e.target.value)}
|
||
size="small"
|
||
variant="standard"
|
||
/>
|
||
<Select
|
||
label="Статус"
|
||
value={r.status}
|
||
onChange={(e) => updateRange(index, j, 'status', e.target.value)}
|
||
size="small"
|
||
variant="standard"
|
||
sx={{
|
||
// Добавляем вертикальное выравнивание для label
|
||
'& .MuiInputLabel-root': {
|
||
transform: 'translate(0, -20px) scale(0.75)'
|
||
},
|
||
// Корректируем положение выбранного значения
|
||
'& .MuiSelect-select': {
|
||
paddingBottom: '8px'
|
||
}
|
||
}}
|
||
>
|
||
{statusConfig.getAvailableStatuses().map(({ value, text }) => (
|
||
<MenuItem key={value} value={value}>
|
||
{text}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
<Tooltip title="Удалить диапазон">
|
||
<IconButton
|
||
onClick={() => deleteRange(index, j)}
|
||
size="small"
|
||
sx={{
|
||
flex: 'none',
|
||
// Корректируем положение иконки
|
||
marginBottom: '8px'
|
||
}}
|
||
>
|
||
<DeleteIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
))}
|
||
|
||
<Button
|
||
onClick={() => addRange(index)}
|
||
startIcon={<AddIcon />}
|
||
size="small"
|
||
sx={{ mt: 1 }}
|
||
>
|
||
Добавить диапазон
|
||
</Button>
|
||
</Box>
|
||
);
|
||
});
|
||
|
||
const MetricRangeEditor = ({ onSave }) => {
|
||
const [ranges, setRanges] = useState([]);
|
||
const [filter, setFilter] = useState('');
|
||
const [newMetricName, setNewMetricName] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [hasChanges, setHasChanges] = useState(false);
|
||
const [success, setSuccess] = useState(false);
|
||
|
||
const loadRanges = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const res = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/ranges/list`);
|
||
setRanges(
|
||
Object.entries(res.data).map(([name, r]) => ({
|
||
name,
|
||
ranges: Array.isArray(r) ? r : []
|
||
}))
|
||
);
|
||
setError(null);
|
||
} catch (err) {
|
||
console.error('Ошибка при получении ranges:', err);
|
||
setError('Не удалось загрузить данные');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadRanges();
|
||
}, [loadRanges]);
|
||
|
||
const updateRange = useCallback((metricIndex, rangeIndex, field, value) => {
|
||
setRanges(prev => {
|
||
const newRanges = [...prev];
|
||
newRanges[metricIndex] = {
|
||
...newRanges[metricIndex],
|
||
ranges: [...newRanges[metricIndex].ranges]
|
||
};
|
||
newRanges[metricIndex].ranges[rangeIndex] = {
|
||
...newRanges[metricIndex].ranges[rangeIndex],
|
||
[field]: Number(value)
|
||
};
|
||
return newRanges;
|
||
});
|
||
setHasChanges(true);
|
||
}, []);
|
||
|
||
const getItemSize = (index) => {
|
||
const baseHeight = 80;
|
||
const rangeCount = filtered[index].ranges.length;
|
||
return baseHeight + rangeCount * 56 + 40;
|
||
};
|
||
|
||
const addRange = useCallback((metricIndex) => {
|
||
setRanges(prev => {
|
||
const newRanges = [...prev];
|
||
newRanges[metricIndex] = {
|
||
...newRanges[metricIndex],
|
||
ranges: [...newRanges[metricIndex].ranges, { min: 0, max: 100, status: 1 }]
|
||
};
|
||
return newRanges;
|
||
});
|
||
setHasChanges(true);
|
||
}, []);
|
||
|
||
const deleteRange = useCallback((metricIndex, rangeIndex) => {
|
||
setRanges(prev => {
|
||
const newRanges = [...prev];
|
||
newRanges[metricIndex] = {
|
||
...newRanges[metricIndex],
|
||
ranges: newRanges[metricIndex].ranges.filter((_, i) => i !== rangeIndex)
|
||
};
|
||
return newRanges;
|
||
});
|
||
setHasChanges(true);
|
||
}, []);
|
||
|
||
const saveChanges = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
await axios.post(`${import.meta.env.VITE_BACK_URL}/api/ranges/update`, ranges);
|
||
setHasChanges(false);
|
||
setSuccess(true);
|
||
setTimeout(() => setSuccess(false), 3000);
|
||
|
||
if (onSave) {
|
||
onSave({
|
||
hasChanges: false,
|
||
saveChanges: saveChanges
|
||
});
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
console.error('Ошибка при сохранении:', err);
|
||
setError('Ошибка при сохранении');
|
||
return false;
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [ranges, onSave]);
|
||
|
||
const addNewMetric = useCallback(() => {
|
||
if (!newMetricName.trim()) {
|
||
setError('Введите название метрики');
|
||
return;
|
||
}
|
||
if (ranges.some(r => r.name === newMetricName)) {
|
||
setError('Метрика с таким именем уже существует');
|
||
return;
|
||
}
|
||
setRanges(prev => [...prev, {
|
||
name: newMetricName,
|
||
ranges: [{ min: 0, max: 100, status: 1 }]
|
||
}]);
|
||
setNewMetricName('');
|
||
setHasChanges(true);
|
||
setError(null);
|
||
}, [newMetricName, ranges]);
|
||
|
||
const filtered = useMemo(() => {
|
||
return filter
|
||
? ranges.filter(r => r.name.toLowerCase().includes(filter.toLowerCase()))
|
||
: ranges;
|
||
}, [filter, ranges]);
|
||
|
||
useEffect(() => {
|
||
if (onSave) {
|
||
onSave({ hasChanges, saveChanges });
|
||
}
|
||
}, [hasChanges, onSave, saveChanges]);
|
||
|
||
return (
|
||
<Box sx={{ position: 'relative' }}>
|
||
{loading && (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||
<CircularProgress />
|
||
</Box>
|
||
)}
|
||
|
||
<Collapse in={!!error}>
|
||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||
{error}
|
||
</Alert>
|
||
</Collapse>
|
||
|
||
<Collapse in={success}>
|
||
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(false)}>
|
||
Изменения успешно сохранены!
|
||
</Alert>
|
||
</Collapse>
|
||
|
||
{!loading && (
|
||
<>
|
||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1, mb: 2 }}>
|
||
<TextField
|
||
label="Поиск по метрике"
|
||
fullWidth
|
||
value={filter}
|
||
onChange={(e) => setFilter(e.target.value)}
|
||
variant="standard"
|
||
/>
|
||
<SearchIcon sx={{ color: 'action.active', mr: 1 }} />
|
||
</Box>
|
||
|
||
<Box sx={{
|
||
display: 'flex',
|
||
gap: 2,
|
||
alignItems: 'flex-end', // меняем с 'center' на 'flex-end'
|
||
mb: 3
|
||
}}>
|
||
<TextField
|
||
label="Новая метрика"
|
||
value={newMetricName}
|
||
onChange={(e) => setNewMetricName(e.target.value)}
|
||
fullWidth
|
||
variant="standard"
|
||
/>
|
||
<Tooltip title="Добавить метрику">
|
||
<IconButton
|
||
onClick={addNewMetric}
|
||
color="primary"
|
||
disabled={!newMetricName.trim()}
|
||
>
|
||
<AddIcon sx={{ color: 'action.active' }} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
|
||
<Divider sx={{ mb: 3 }} />
|
||
|
||
<Box sx={{ maxHeight: '60vh', overflowY: 'auto', pr: 1 }}>
|
||
{filtered.map((metric, index) => (
|
||
<MetricItem
|
||
key={metric.name}
|
||
metric={metric}
|
||
index={index}
|
||
updateRange={updateRange}
|
||
addRange={addRange}
|
||
deleteRange={deleteRange}
|
||
/>
|
||
))}
|
||
</Box>
|
||
|
||
|
||
{filtered.length === 0 && (
|
||
<Typography color="text.secondary" textAlign="center" py={3}>
|
||
{filter ? 'Ничего не найдено' : 'Нет метрик для отображения'}
|
||
</Typography>
|
||
)}
|
||
</>
|
||
)}
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default React.memo(MetricRangeEditor); |