commit
9f8d0072c2
|
|
@ -0,0 +1,258 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
TextField, Box, Typography, IconButton, Divider,
|
||||||
|
CircularProgress, Alert, Collapse, Tooltip, Button
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
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 = (metricIndex, rangeIndex, field, value) => {
|
||||||
|
const newRanges = [...ranges];
|
||||||
|
newRanges[metricIndex].ranges[rangeIndex][field] = Number(value);
|
||||||
|
setRanges(newRanges);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавление диапазона
|
||||||
|
const addRange = (metricIndex) => {
|
||||||
|
const newRanges = [...ranges];
|
||||||
|
newRanges[metricIndex].ranges.push({ min: 0, max: 100, status: 1 });
|
||||||
|
setRanges(newRanges);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удаление диапазона
|
||||||
|
const deleteRange = (metricIndex, rangeIndex) => {
|
||||||
|
const newRanges = [...ranges];
|
||||||
|
newRanges[metricIndex].ranges.splice(rangeIndex, 1);
|
||||||
|
setRanges(newRanges);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChanges = 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавление новой метрики
|
||||||
|
const addNewMetric = () => {
|
||||||
|
if (!newMetricName.trim()) {
|
||||||
|
setError('Введите название метрики');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ranges.some(r => r.name === newMetricName)) {
|
||||||
|
setError('Метрика с таким именем уже существует');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRanges([...ranges, {
|
||||||
|
name: newMetricName,
|
||||||
|
ranges: [{ min: 0, max: 100, status: 1 }]
|
||||||
|
}]);
|
||||||
|
setNewMetricName('');
|
||||||
|
setHasChanges(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = filter
|
||||||
|
? ranges.filter(r => r.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
: ranges;
|
||||||
|
|
||||||
|
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 }}>
|
||||||
|
<SearchIcon sx={{ color: 'action.active', mr: 1 }} />
|
||||||
|
<TextField
|
||||||
|
label="Поиск по метрике"
|
||||||
|
fullWidth
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', 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 />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{filtered.map((metric, i) => (
|
||||||
|
<Box
|
||||||
|
key={metric.name}
|
||||||
|
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: 'center',
|
||||||
|
mt: 1,
|
||||||
|
'& > *': { flex: 1 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Минимум"
|
||||||
|
type="number"
|
||||||
|
value={r.min}
|
||||||
|
onChange={(e) => updateRange(i, j, 'min', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Максимум"
|
||||||
|
type="number"
|
||||||
|
value={r.max}
|
||||||
|
onChange={(e) => updateRange(i, j, 'max', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Статус"
|
||||||
|
type="number"
|
||||||
|
value={r.status}
|
||||||
|
onChange={(e) => updateRange(i, j, 'status', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Tooltip title="Удалить диапазон">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => deleteRange(i, j)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 'none' }}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => addRange(i)}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
Добавить диапазон
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<Typography color="text.secondary" textAlign="center" py={3}>
|
||||||
|
{filter ? 'Ничего не найдено' : 'Нет метрик для отображения'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Передаем состояние изменений в родительский компонент */}
|
||||||
|
{useEffect(() => {
|
||||||
|
if (onSave) {
|
||||||
|
onSave({ hasChanges, saveChanges });
|
||||||
|
}
|
||||||
|
}, [hasChanges, onSave])}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetricRangeEditor;
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
// components/SettingsModal.jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
styled,
|
||||||
|
CircularProgress,
|
||||||
|
Slide,
|
||||||
|
Snackbar,
|
||||||
|
Alert
|
||||||
|
} from '@mui/material';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import MetricRangeEditor from './SettingsComponents/MetricRangeEditor';
|
||||||
|
|
||||||
|
const Transition = React.forwardRef(function Transition(props, ref) {
|
||||||
|
return <Slide direction="up" ref={ref} {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
const StyledDialog = styled(Dialog)(({ theme }) => ({
|
||||||
|
'& .MuiDialog-paper': {
|
||||||
|
minWidth: 600,
|
||||||
|
maxHeight: '80vh',
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TabPanel = (props) => {
|
||||||
|
const { children, value, index, ...other } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`settings-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`settings-tab-${index}`}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{value === index && (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography component="div">{children}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SettingsModal = ({ open, onClose, onMenuUpdate }) => {
|
||||||
|
const [tabValue, setTabValue] = useState(0);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [showConfirmClose, setShowConfirmClose] = useState(false);
|
||||||
|
const [metricEditorState, setMetricEditorState] = useState({
|
||||||
|
hasChanges: false,
|
||||||
|
save: () => { }
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTabChange = (event, newValue) => {
|
||||||
|
if (hasChanges) {
|
||||||
|
setShowConfirmClose(true);
|
||||||
|
} else {
|
||||||
|
setTabValue(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
let success = true;
|
||||||
|
if (tabValue === 1 && metricEditorState.hasChanges) {
|
||||||
|
success = await metricEditorState.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setShowSuccess(true);
|
||||||
|
setHasChanges(false);
|
||||||
|
if (onMenuUpdate) {
|
||||||
|
onMenuUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMetricEditorChange = ({ hasChanges, saveChanges }) => {
|
||||||
|
setMetricEditorState({ hasChanges, save: saveChanges });
|
||||||
|
setHasChanges(hasChanges);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (hasChanges) {
|
||||||
|
setShowConfirmClose(true);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmClose = (shouldClose) => {
|
||||||
|
setShowConfirmClose(false);
|
||||||
|
if (shouldClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Пример обработчика изменений
|
||||||
|
const handleSettingChange = () => {
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledDialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
aria-labelledby="settings-dialog-title"
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
TransitionComponent={Transition}
|
||||||
|
>
|
||||||
|
<DialogTitle id="settings-dialog-title">
|
||||||
|
Настройки
|
||||||
|
<IconButton
|
||||||
|
aria-label="close"
|
||||||
|
onClick={handleClose}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
color: (theme) => theme.palette.grey[500],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs">
|
||||||
|
<Tab label="Меню" id="settings-tab-0" aria-controls="settings-tabpanel-0" />
|
||||||
|
<Tab label="Границы метрик" id="settings-tab-1" aria-controls="settings-tabpanel-1" />
|
||||||
|
{/* Добавляйте новые вкладки здесь */}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DialogContent dividers>
|
||||||
|
<TabPanel value={tabValue} index={0}>
|
||||||
|
<Typography variant="h6">Настройки меню</Typography>
|
||||||
|
{/* Добавьте содержимое для вкладки меню */}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabValue} index={1}>
|
||||||
|
<MetricRangeEditor onSave={handleMetricEditorChange} />
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Добавляйте новые TabPanel для новых вкладок */}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>Закрыть</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
disabled={isSaving || !hasChanges}
|
||||||
|
startIcon={isSaving ? <CircularProgress size={20} /> : <SaveIcon />}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</StyledDialog>
|
||||||
|
|
||||||
|
{/* Уведомление об успешном сохранении */}
|
||||||
|
<Snackbar
|
||||||
|
open={showSuccess}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
onClose={() => setShowSuccess(false)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => setShowSuccess(false)} severity="success" sx={{ width: '100%' }}>
|
||||||
|
Настройки успешно сохранены!
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
{/* Диалог подтверждения закрытия */}
|
||||||
|
<Dialog
|
||||||
|
open={showConfirmClose}
|
||||||
|
onClose={() => handleConfirmClose(false)}
|
||||||
|
aria-labelledby="alert-dialog-title"
|
||||||
|
aria-describedby="alert-dialog-description"
|
||||||
|
>
|
||||||
|
<DialogTitle id="alert-dialog-title">Есть несохраненные изменения</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>Вы уверены, что хотите закрыть без сохранения изменений?</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => handleConfirmClose(false)}>Отмена</Button>
|
||||||
|
<Button onClick={() => handleConfirmClose(true)} autoFocus color="error">
|
||||||
|
Закрыть без сохранения
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsModal;
|
||||||
|
|
@ -3,17 +3,10 @@ import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
Typography,
|
|
||||||
styled,
|
styled,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Box,
|
Box,
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Button,
|
|
||||||
TextField
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
import MenuItem from "./SidebarMenuComponents/MenuItem";
|
||||||
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
import SidebarFooter from "./SidebarMenuComponents/SidebarFooter";
|
||||||
|
|
@ -27,12 +20,8 @@ const SidebarMenu = ({
|
||||||
data,
|
data,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
setIsDarkMode,
|
setIsDarkMode,
|
||||||
onEditItem,
|
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
editModalOpen,
|
forceRefreshMenu
|
||||||
editingItem,
|
|
||||||
onCloseEditModal,
|
|
||||||
onSaveChanges
|
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
const { sidebarWidth, startResizing } = useSidebarResize(290);
|
||||||
|
|
@ -148,7 +137,7 @@ const SidebarMenu = ({
|
||||||
item={data}
|
item={data}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
level={0}
|
level={0}
|
||||||
onEdit={onEditItem}
|
|
||||||
onSelectItem={onSelectItem}
|
onSelectItem={onSelectItem}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -158,76 +147,15 @@ const SidebarMenu = ({
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
setIsDarkMode={setIsDarkMode}
|
setIsDarkMode={setIsDarkMode}
|
||||||
|
forceRefreshMenu={forceRefreshMenu}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<SidebarResizer onMouseDown={startResizing} />
|
<SidebarResizer onMouseDown={startResizing} />
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* Модальное окно редактирования */}
|
|
||||||
<EditMenuItemDialog
|
|
||||||
open={editModalOpen}
|
|
||||||
item={editingItem}
|
|
||||||
onClose={onCloseEditModal}
|
|
||||||
onSave={onSaveChanges}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditMenuItemDialog = ({ open, item, onClose, onSave }) => {
|
|
||||||
const [formData, setFormData] = useState(item || {});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFormData(item || {});
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
onSave(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!item) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
|
||||||
<DialogTitle>Редактирование элемента меню</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Название"
|
|
||||||
name="title"
|
|
||||||
value={formData.title || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="ID"
|
|
||||||
name="id"
|
|
||||||
value={formData.id || ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
{/* Дополнительные поля для редактирования */}
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose}>Отмена</Button>
|
|
||||||
<Button onClick={handleSubmit} variant="contained" color="primary">
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SidebarMenu;
|
export default SidebarMenu;
|
||||||
|
|
@ -7,11 +7,10 @@ import {
|
||||||
Collapse,
|
Collapse,
|
||||||
List,
|
List,
|
||||||
styled,
|
styled,
|
||||||
IconButton,
|
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem as MuiMenuItem
|
MenuItem as MuiMenuItem
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { ExpandLess, ExpandMore, Folder, FolderOpen, Edit } from "@mui/icons-material";
|
import { ExpandLess, ExpandMore, Folder, FolderOpen } from "@mui/icons-material";
|
||||||
import StatusIndicator from "./StatusIndicator";
|
import StatusIndicator from "./StatusIndicator";
|
||||||
|
|
||||||
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
const StyledListItem = styled(ListItem)(({ theme, level }) => ({
|
||||||
|
|
@ -44,11 +43,6 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditClick = () => {
|
|
||||||
onEdit(item);
|
|
||||||
handleCloseContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = (e) => {
|
const handleToggle = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
|
|
@ -87,19 +81,6 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
{hasChildren && (isOpen ? <ExpandLess /> : <ExpandMore />)}
|
||||||
|
|
||||||
{level > 0 && (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEditClick();
|
|
||||||
}}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
<Edit fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</StyledListItem>
|
</StyledListItem>
|
||||||
|
|
@ -114,9 +95,7 @@ const MenuItem = ({ item, onSelectItem, level = 0, collapsed, onEdit }) => {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MuiMenuItem onClick={handleEditClick}>
|
|
||||||
<Edit fontSize="small" sx={{ mr: 1 }} /> Редактировать
|
|
||||||
</MuiMenuItem>
|
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{hasChildren && !collapsed && (
|
{hasChildren && !collapsed && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
// components/SidebarMenuComponents/SidebarFooter.jsx
|
||||||
|
import React, { useState } from "react";
|
||||||
import { Brightness4, Brightness7 } from "@mui/icons-material";
|
import { Brightness4, Brightness7 } from "@mui/icons-material";
|
||||||
import { IconButton, Tooltip } from "@mui/material";
|
import { IconButton, Tooltip } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,8 +8,10 @@ import {
|
||||||
ListItemText,
|
ListItemText,
|
||||||
styled,
|
styled,
|
||||||
Switch,
|
Switch,
|
||||||
Box
|
Box,
|
||||||
|
Button
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import SettingsModal from "../SettingsModal";
|
||||||
|
|
||||||
const FooterList = styled(List)(({ theme }) => ({
|
const FooterList = styled(List)(({ theme }) => ({
|
||||||
backgroundColor: theme.palette.custom.sidebar,
|
backgroundColor: theme.palette.custom.sidebar,
|
||||||
|
|
@ -27,51 +30,78 @@ const FooterListItem = styled(ListItem)(({ theme }) => ({
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SidebarFooter = ({ collapsed, isDarkMode, setIsDarkMode }) => {
|
const SidebarFooter = ({ collapsed, isDarkMode, setIsDarkMode, forceRefreshMenu }) => {
|
||||||
return (
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
<FooterList>
|
|
||||||
{!collapsed && (
|
|
||||||
<FooterListItem button>
|
|
||||||
<ListItemText
|
|
||||||
primary="Помощь"
|
|
||||||
primaryTypographyProps={{
|
|
||||||
color: 'custom.sidebarText',
|
|
||||||
variant: 'body2'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FooterListItem>
|
|
||||||
)}
|
|
||||||
<FooterListItem>
|
|
||||||
{!collapsed && (
|
|
||||||
<ListItemText
|
|
||||||
primary="Настройка"
|
|
||||||
primaryTypographyProps={{
|
|
||||||
color: 'custom.sidebarText',
|
|
||||||
variant: 'body2'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
const handleSettingsOpen = () => {
|
||||||
<Tooltip title="Переключить тему">
|
setSettingsOpen(true);
|
||||||
<IconButton
|
};
|
||||||
size="small"
|
|
||||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
const handleSettingsClose = () => {
|
||||||
sx={{ color: 'custom.sidebarText' }}
|
setSettingsOpen(false);
|
||||||
>
|
};
|
||||||
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
|
|
||||||
</IconButton>
|
return (
|
||||||
</Tooltip>
|
<>
|
||||||
{!collapsed && (
|
<FooterList>
|
||||||
<Switch
|
{!collapsed && (
|
||||||
checked={isDarkMode}
|
<FooterListItem button>
|
||||||
onChange={() => setIsDarkMode(!isDarkMode)}
|
<ListItemText
|
||||||
size="small"
|
primary="Помощь"
|
||||||
|
primaryTypographyProps={{
|
||||||
|
color: 'custom.sidebarText',
|
||||||
|
variant: 'body2'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</FooterListItem>
|
||||||
|
)}
|
||||||
|
<FooterListItem>
|
||||||
|
{!collapsed && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSettingsOpen}
|
||||||
|
sx={{
|
||||||
|
color: 'custom.sidebarText',
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
marginRight: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary="Настройки"
|
||||||
|
primaryTypographyProps={{
|
||||||
|
color: 'custom.sidebarText',
|
||||||
|
variant: 'body2'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
|
||||||
</FooterListItem>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
</FooterList>
|
<Tooltip title="Переключить тему">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||||
|
sx={{ color: 'custom.sidebarText' }}
|
||||||
|
>
|
||||||
|
{isDarkMode ? <Brightness4 /> : <Brightness7 />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{!collapsed && (
|
||||||
|
<Switch
|
||||||
|
checked={isDarkMode}
|
||||||
|
onChange={() => setIsDarkMode(!isDarkMode)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</FooterListItem>
|
||||||
|
</FooterList>
|
||||||
|
|
||||||
|
<SettingsModal open={settingsOpen}
|
||||||
|
onClose={handleSettingsClose}
|
||||||
|
onMenuUpdate={forceRefreshMenu} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,61 @@ import axios from 'axios';
|
||||||
|
|
||||||
const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect }) => {
|
const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect }) => {
|
||||||
const [menuData, setMenuData] = useState(null);
|
const [menuData, setMenuData] = useState(null);
|
||||||
|
const [lastModified, setLastModified] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [backgroundLoading, setBackgroundLoading] = useState(false);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
|
const forceRefreshMenu = () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
localStorage.removeItem('menuCache'); // Очищаем кэш
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загружаем меню из localStorage при инициализации
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCachedMenu = () => {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem('menuCache');
|
||||||
|
if (cached) {
|
||||||
|
const { data, timestamp } = JSON.parse(cached);
|
||||||
|
setMenuData(data);
|
||||||
|
setLastModified(timestamp);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load menu from cache', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCachedMenu();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Основная загрузка меню
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMenuData = async () => {
|
const fetchMenuData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`);
|
setLoading(true);
|
||||||
setMenuData(response.data); // axios хранит данные в response.data
|
const headers = lastModified ? { 'If-Modified-Since': lastModified } : {};
|
||||||
|
|
||||||
|
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`, {
|
||||||
|
headers,
|
||||||
|
validateStatus: status => status === 200 || status === 304
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const newLastModified = response.headers['last-modified'];
|
||||||
|
setMenuData(response.data);
|
||||||
|
setLastModified(newLastModified);
|
||||||
|
|
||||||
|
// Сохраняем в кэш
|
||||||
|
localStorage.setItem('menuCache', JSON.stringify({
|
||||||
|
data: response.data,
|
||||||
|
timestamp: newLastModified
|
||||||
|
}));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching menu data:', err);
|
console.error('Error fetching menu data:', err);
|
||||||
setError(err.message || 'Failed to fetch menu data');
|
setError(err.message || 'Failed to fetch menu data');
|
||||||
|
|
@ -24,7 +69,41 @@ const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchMenuData();
|
fetchMenuData();
|
||||||
}, []);
|
}, [refreshTrigger]);
|
||||||
|
|
||||||
|
// Фоновая проверка обновлений
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastModified) return;
|
||||||
|
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
try {
|
||||||
|
setBackgroundLoading(true);
|
||||||
|
const response = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/check-updates`, {
|
||||||
|
headers: { 'If-Modified-Since': lastModified }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.hasUpdates) {
|
||||||
|
// Если есть обновления, загружаем их в фоне
|
||||||
|
const updateResponse = await axios.get(`${import.meta.env.VITE_BACK_URL}/api/menu/full`);
|
||||||
|
setMenuData(updateResponse.data);
|
||||||
|
setLastModified(updateResponse.headers['last-modified']);
|
||||||
|
|
||||||
|
localStorage.setItem('menuCache', JSON.stringify({
|
||||||
|
data: updateResponse.data,
|
||||||
|
timestamp: updateResponse.headers['last-modified']
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Background update check failed', err);
|
||||||
|
} finally {
|
||||||
|
setBackgroundLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Проверяем обновления каждые 5 минут
|
||||||
|
const interval = setInterval(checkForUpdates, 5 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [lastModified]);
|
||||||
|
|
||||||
const handleSaveChanges = async (updatedItem) => {
|
const handleSaveChanges = async (updatedItem) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -97,6 +176,7 @@ const SidebarMenuWrapper = ({ isDarkMode, setIsDarkMode, onMenuSelect }) => {
|
||||||
editingItem={editingItem}
|
editingItem={editingItem}
|
||||||
onCloseEditModal={() => setEditModalOpen(false)}
|
onCloseEditModal={() => setEditModalOpen(false)}
|
||||||
onSaveChanges={handleSaveChanges}
|
onSaveChanges={handleSaveChanges}
|
||||||
|
forceRefreshMenu={forceRefreshMenu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue