542 lines
18 KiB
TypeScript
542 lines
18 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import {
|
||
Dialog,
|
||
DialogTitle,
|
||
DialogContent,
|
||
DialogActions,
|
||
Button,
|
||
Typography,
|
||
Box,
|
||
LinearProgress,
|
||
Alert,
|
||
TextField,
|
||
IconButton,
|
||
Tooltip,
|
||
Tabs,
|
||
Tab,
|
||
List,
|
||
ListItem,
|
||
ListItemText,
|
||
Divider,
|
||
Chip,
|
||
} from '@mui/material';
|
||
import {
|
||
Edit,
|
||
Save,
|
||
Close,
|
||
Refresh,
|
||
Delete,
|
||
Restore,
|
||
Visibility,
|
||
History,
|
||
} from '@mui/icons-material';
|
||
import Markdown from 'react-markdown';
|
||
import type { Idea, SpecificationHistoryItem } from '../../types/idea';
|
||
|
||
interface SpecificationModalProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
idea: Idea | null;
|
||
specification: string | null;
|
||
isLoading: boolean;
|
||
error: Error | null;
|
||
onSave: (specification: string) => void;
|
||
isSaving: boolean;
|
||
onRegenerate: () => void;
|
||
history: SpecificationHistoryItem[];
|
||
isHistoryLoading: boolean;
|
||
onDeleteHistoryItem: (historyId: string) => void;
|
||
onRestoreFromHistory: (historyId: string) => void;
|
||
isRestoring: boolean;
|
||
}
|
||
|
||
interface TabPanelProps {
|
||
children?: React.ReactNode;
|
||
index: number;
|
||
value: number;
|
||
}
|
||
|
||
function TabPanel({ children, value, index }: TabPanelProps) {
|
||
return (
|
||
<div role="tabpanel" hidden={value !== index}>
|
||
{value === index && children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SpecificationModal({
|
||
open,
|
||
onClose,
|
||
idea,
|
||
specification,
|
||
isLoading,
|
||
error,
|
||
onSave,
|
||
isSaving,
|
||
onRegenerate,
|
||
history,
|
||
isHistoryLoading,
|
||
onDeleteHistoryItem,
|
||
onRestoreFromHistory,
|
||
isRestoring,
|
||
}: SpecificationModalProps) {
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [editedText, setEditedText] = useState('');
|
||
const [tabIndex, setTabIndex] = useState(0);
|
||
const [viewingHistoryItem, setViewingHistoryItem] =
|
||
useState<SpecificationHistoryItem | null>(null);
|
||
|
||
// Сбрасываем состояние при открытии/закрытии
|
||
useEffect(() => {
|
||
if (open && specification) {
|
||
setEditedText(specification);
|
||
setIsEditing(false);
|
||
setTabIndex(0);
|
||
setViewingHistoryItem(null);
|
||
}
|
||
}, [open, specification]);
|
||
|
||
const handleEdit = () => {
|
||
setEditedText(specification ?? '');
|
||
setIsEditing(true);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
setEditedText(specification ?? '');
|
||
setIsEditing(false);
|
||
};
|
||
|
||
const handleSave = () => {
|
||
onSave(editedText);
|
||
setIsEditing(false);
|
||
};
|
||
|
||
const handleRegenerate = () => {
|
||
setViewingHistoryItem(null);
|
||
setTabIndex(0);
|
||
onRegenerate();
|
||
};
|
||
|
||
const handleViewHistoryItem = (item: SpecificationHistoryItem) => {
|
||
setViewingHistoryItem(item);
|
||
};
|
||
|
||
const handleCloseHistoryView = () => {
|
||
setViewingHistoryItem(null);
|
||
};
|
||
|
||
const handleRestoreFromHistory = (historyId: string) => {
|
||
onRestoreFromHistory(historyId);
|
||
setViewingHistoryItem(null);
|
||
setTabIndex(0);
|
||
};
|
||
|
||
const formatDate = (dateString: string | null) => {
|
||
if (!dateString) return '';
|
||
return new Date(dateString).toLocaleString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
};
|
||
|
||
const hasHistory = history.length > 0;
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onClose={onClose}
|
||
maxWidth="md"
|
||
fullWidth
|
||
data-testid="specification-modal"
|
||
>
|
||
<DialogTitle
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
<Box>
|
||
<Typography variant="h6" component="span">
|
||
Техническое задание
|
||
</Typography>
|
||
{idea && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
{idea.title}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||
{specification && !isLoading && !isEditing && !viewingHistoryItem && (
|
||
<>
|
||
<Tooltip title="Перегенерировать ТЗ">
|
||
<IconButton
|
||
onClick={handleRegenerate}
|
||
size="small"
|
||
color="primary"
|
||
data-testid="specification-regenerate-button"
|
||
>
|
||
<Refresh />
|
||
</IconButton>
|
||
</Tooltip>
|
||
<Tooltip title="Редактировать">
|
||
<IconButton
|
||
onClick={handleEdit}
|
||
size="small"
|
||
data-testid="specification-edit-button"
|
||
>
|
||
<Edit />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</>
|
||
)}
|
||
</Box>
|
||
</DialogTitle>
|
||
|
||
{/* Табы появляются только если есть история */}
|
||
{hasHistory && !isEditing && !viewingHistoryItem && (
|
||
<Tabs
|
||
value={tabIndex}
|
||
onChange={(_, newValue: number) => setTabIndex(newValue)}
|
||
sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }}
|
||
>
|
||
<Tab label="Текущее ТЗ" data-testid="specification-tab-current" />
|
||
<Tab
|
||
label={
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<History fontSize="small" />
|
||
История ({history.length})
|
||
</Box>
|
||
}
|
||
data-testid="specification-tab-history"
|
||
/>
|
||
</Tabs>
|
||
)}
|
||
|
||
<DialogContent dividers>
|
||
{/* Просмотр исторического ТЗ */}
|
||
{viewingHistoryItem && (
|
||
<Box>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||
<IconButton size="small" onClick={handleCloseHistoryView}>
|
||
<Close />
|
||
</IconButton>
|
||
<Typography variant="subtitle2">
|
||
Версия от {formatDate(viewingHistoryItem.createdAt)}
|
||
</Typography>
|
||
<Tooltip title="Восстановить эту версию">
|
||
<IconButton
|
||
size="small"
|
||
color="primary"
|
||
onClick={() =>
|
||
handleRestoreFromHistory(viewingHistoryItem.id)
|
||
}
|
||
disabled={isRestoring}
|
||
data-testid="specification-restore-button"
|
||
>
|
||
<Restore />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
{viewingHistoryItem.ideaDescriptionSnapshot && (
|
||
<Alert severity="info" sx={{ mb: 2 }}>
|
||
<Typography variant="caption">
|
||
Описание идеи на момент генерации:{' '}
|
||
{viewingHistoryItem.ideaDescriptionSnapshot}
|
||
</Typography>
|
||
</Alert>
|
||
)}
|
||
<Box
|
||
data-testid="specification-history-content"
|
||
sx={{
|
||
p: 2,
|
||
bgcolor: 'grey.100',
|
||
borderRadius: 1,
|
||
maxHeight: '50vh',
|
||
overflow: 'auto',
|
||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||
mt: 2,
|
||
mb: 1,
|
||
'&:first-of-type': { mt: 0 },
|
||
},
|
||
'& h1': { fontSize: '1.5rem', fontWeight: 600 },
|
||
'& h2': { fontSize: '1.25rem', fontWeight: 600 },
|
||
'& h3': { fontSize: '1.1rem', fontWeight: 600 },
|
||
'& p': { mb: 1.5, lineHeight: 1.6 },
|
||
'& ul, & ol': { pl: 3, mb: 1.5 },
|
||
'& li': { mb: 0.5 },
|
||
'& strong': { fontWeight: 600 },
|
||
'& code': {
|
||
bgcolor: 'grey.200',
|
||
px: 0.5,
|
||
py: 0.25,
|
||
borderRadius: 0.5,
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.875em',
|
||
},
|
||
'& pre': {
|
||
bgcolor: 'grey.200',
|
||
p: 1.5,
|
||
borderRadius: 1,
|
||
overflow: 'auto',
|
||
'& code': { bgcolor: 'transparent', p: 0 },
|
||
},
|
||
'& blockquote': {
|
||
borderLeft: 3,
|
||
borderColor: 'primary.main',
|
||
pl: 2,
|
||
ml: 0,
|
||
fontStyle: 'italic',
|
||
color: 'text.secondary',
|
||
},
|
||
}}
|
||
>
|
||
<Markdown>{viewingHistoryItem.specification}</Markdown>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Основной контент (не историческая версия) */}
|
||
{!viewingHistoryItem && (
|
||
<>
|
||
<TabPanel value={tabIndex} index={0}>
|
||
{isLoading && (
|
||
<Box sx={{ py: 4 }} data-testid="specification-loading">
|
||
<Typography
|
||
variant="body2"
|
||
color="text.secondary"
|
||
gutterBottom
|
||
>
|
||
Генерируем техническое задание...
|
||
</Typography>
|
||
<LinearProgress />
|
||
</Box>
|
||
)}
|
||
|
||
{error && (
|
||
<Alert
|
||
severity="error"
|
||
sx={{ my: 2 }}
|
||
data-testid="specification-error"
|
||
>
|
||
{error.message || 'Не удалось сгенерировать ТЗ'}
|
||
</Alert>
|
||
)}
|
||
|
||
{!isLoading && !error && isEditing && (
|
||
<TextField
|
||
multiline
|
||
fullWidth
|
||
minRows={15}
|
||
maxRows={25}
|
||
value={editedText}
|
||
onChange={(e) => setEditedText(e.target.value)}
|
||
placeholder="Введите техническое задание..."
|
||
data-testid="specification-textarea"
|
||
sx={{
|
||
'& .MuiInputBase-input': {
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.875rem',
|
||
},
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{!isLoading && !error && !isEditing && specification && (
|
||
<Box>
|
||
{idea?.specificationGeneratedAt && (
|
||
<Typography
|
||
variant="caption"
|
||
color="text.secondary"
|
||
sx={{ mb: 1, display: 'block' }}
|
||
>
|
||
Сгенерировано: {formatDate(idea.specificationGeneratedAt)}
|
||
</Typography>
|
||
)}
|
||
<Box
|
||
data-testid="specification-content"
|
||
sx={{
|
||
p: 2,
|
||
bgcolor: 'grey.50',
|
||
borderRadius: 1,
|
||
maxHeight: '55vh',
|
||
overflow: 'auto',
|
||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||
mt: 2,
|
||
mb: 1,
|
||
'&:first-of-type': { mt: 0 },
|
||
},
|
||
'& h1': { fontSize: '1.5rem', fontWeight: 600 },
|
||
'& h2': { fontSize: '1.25rem', fontWeight: 600 },
|
||
'& h3': { fontSize: '1.1rem', fontWeight: 600 },
|
||
'& p': { mb: 1.5, lineHeight: 1.6 },
|
||
'& ul, & ol': { pl: 3, mb: 1.5 },
|
||
'& li': { mb: 0.5 },
|
||
'& strong': { fontWeight: 600 },
|
||
'& code': {
|
||
bgcolor: 'grey.200',
|
||
px: 0.5,
|
||
py: 0.25,
|
||
borderRadius: 0.5,
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.875em',
|
||
},
|
||
'& pre': {
|
||
bgcolor: 'grey.200',
|
||
p: 1.5,
|
||
borderRadius: 1,
|
||
overflow: 'auto',
|
||
'& code': { bgcolor: 'transparent', p: 0 },
|
||
},
|
||
'& blockquote': {
|
||
borderLeft: 3,
|
||
borderColor: 'primary.main',
|
||
pl: 2,
|
||
ml: 0,
|
||
fontStyle: 'italic',
|
||
color: 'text.secondary',
|
||
},
|
||
}}
|
||
>
|
||
<Markdown>{specification}</Markdown>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</TabPanel>
|
||
|
||
<TabPanel value={tabIndex} index={1}>
|
||
{isHistoryLoading ? (
|
||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||
<LinearProgress />
|
||
</Box>
|
||
) : history.length === 0 ? (
|
||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||
<Typography color="text.secondary">История пуста</Typography>
|
||
</Box>
|
||
) : (
|
||
<List data-testid="specification-history-list">
|
||
{history.map((item, index) => (
|
||
<Box key={item.id}>
|
||
{index > 0 && <Divider />}
|
||
<ListItem
|
||
data-testid={`specification-history-item-${String(index)}`}
|
||
sx={{ pr: 16 }}
|
||
secondaryAction={
|
||
<>
|
||
<Tooltip title="Просмотреть">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => handleViewHistoryItem(item)}
|
||
data-testid={`specification-history-view-${String(index)}`}
|
||
>
|
||
<Visibility fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
<Tooltip title="Восстановить">
|
||
<IconButton
|
||
size="small"
|
||
color="primary"
|
||
onClick={() =>
|
||
handleRestoreFromHistory(item.id)
|
||
}
|
||
disabled={isRestoring}
|
||
data-testid={`specification-history-restore-${String(index)}`}
|
||
>
|
||
<Restore fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
<Tooltip title="Удалить">
|
||
<IconButton
|
||
size="small"
|
||
color="error"
|
||
onClick={() => onDeleteHistoryItem(item.id)}
|
||
data-testid={`specification-history-delete-${String(index)}`}
|
||
>
|
||
<Delete fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</>
|
||
}
|
||
>
|
||
<ListItemText
|
||
primary={
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 1,
|
||
}}
|
||
>
|
||
<Typography variant="body2">
|
||
{formatDate(item.createdAt)}
|
||
</Typography>
|
||
{item.ideaDescriptionSnapshot && (
|
||
<Chip
|
||
label="Описание изменилось"
|
||
size="small"
|
||
variant="outlined"
|
||
color="info"
|
||
/>
|
||
)}
|
||
</Box>
|
||
}
|
||
secondary={
|
||
<Typography
|
||
variant="caption"
|
||
color="text.secondary"
|
||
sx={{
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: 'vertical',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{item.specification.slice(0, 150)}...
|
||
</Typography>
|
||
}
|
||
/>
|
||
</ListItem>
|
||
</Box>
|
||
))}
|
||
</List>
|
||
)}
|
||
</TabPanel>
|
||
</>
|
||
)}
|
||
</DialogContent>
|
||
|
||
<DialogActions>
|
||
{isEditing ? (
|
||
<>
|
||
<Button onClick={handleCancel} disabled={isSaving}>
|
||
Отмена
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
onClick={handleSave}
|
||
disabled={isSaving}
|
||
startIcon={<Save />}
|
||
data-testid="specification-save-button"
|
||
>
|
||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||
</Button>
|
||
</>
|
||
) : viewingHistoryItem ? (
|
||
<Button onClick={handleCloseHistoryView}>Назад к текущему ТЗ</Button>
|
||
) : (
|
||
<Button
|
||
onClick={onClose}
|
||
startIcon={<Close />}
|
||
data-testid="specification-close-button"
|
||
>
|
||
Закрыть
|
||
</Button>
|
||
)}
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|