Files
team-planner/frontend/src/components/SpecificationModal/SpecificationModal.tsx
vigdorov 2e46cc41a1
Some checks failed
continuous-integration/drone/push Build is failing
fix lint
2026-01-15 02:36:24 +03:00

542 lines
18 KiB
TypeScript
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.

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