add ai functions
Some checks reported errors
continuous-integration/drone/push Build encountered an error

This commit is contained in:
2026-01-15 01:59:16 +03:00
parent 739a7d172d
commit dea0676169
33 changed files with 4850 additions and 104 deletions

View File

@ -0,0 +1,204 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Chip,
LinearProgress,
Alert,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Paper,
List,
ListItem,
ListItemIcon,
ListItemText,
} from '@mui/material';
import {
AccessTime,
TrendingUp,
Lightbulb,
CheckCircle,
} from '@mui/icons-material';
import type { EstimateResult } from '../../services/ai';
import type { IdeaComplexity } from '../../types/idea';
interface AiEstimateModalProps {
open: boolean;
onClose: () => void;
result: EstimateResult | null;
isLoading: boolean;
error: Error | null;
}
const complexityLabels: Record<IdeaComplexity, string> = {
trivial: 'Тривиальная',
simple: 'Простая',
medium: 'Средняя',
complex: 'Сложная',
veryComplex: 'Очень сложная',
};
const complexityColors: Record<
IdeaComplexity,
'success' | 'info' | 'warning' | 'error' | 'default'
> = {
trivial: 'success',
simple: 'success',
medium: 'info',
complex: 'warning',
veryComplex: 'error',
};
function formatHours(hours: number): string {
if (hours < 8) {
return `${hours} ч`;
}
const days = Math.floor(hours / 8);
const remainingHours = hours % 8;
if (remainingHours === 0) {
return `${days} д`;
}
return `${days} д ${remainingHours} ч`;
}
export function AiEstimateModal({
open,
onClose,
result,
isLoading,
error,
}: AiEstimateModalProps) {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
data-testid="ai-estimate-modal"
>
<DialogTitle>
AI-оценка трудозатрат
{result && (
<Typography variant="body2" color="text.secondary">
{result.ideaTitle}
</Typography>
)}
</DialogTitle>
<DialogContent dividers>
{isLoading && (
<Box sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary" gutterBottom>
Анализируем идею и состав команды...
</Typography>
<LinearProgress />
</Box>
)}
{error && (
<Alert severity="error" sx={{ my: 2 }}>
{error.message || 'Не удалось получить оценку'}
</Alert>
)}
{result && !isLoading && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Общая оценка */}
<Box
sx={{
display: 'flex',
gap: 3,
alignItems: 'center',
justifyContent: 'center',
py: 2,
}}
>
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<AccessTime color="primary" />
<Typography variant="h4" component="span">
{formatHours(result.totalHours)}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Общее время
</Typography>
</Box>
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<TrendingUp color="primary" />
<Chip
label={complexityLabels[result.complexity]}
color={complexityColors[result.complexity]}
size="medium"
/>
</Box>
<Typography variant="body2" color="text.secondary">
Сложность
</Typography>
</Box>
</Box>
{/* Разбивка по ролям */}
{result.breakdown.length > 0 && (
<Box>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
Разбивка по ролям
</Typography>
<Paper variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Роль</TableCell>
<TableCell align="right">Время</TableCell>
</TableRow>
</TableHead>
<TableBody>
{result.breakdown.map((item, index) => (
<TableRow key={index} data-testid={`estimate-breakdown-row-${index}`}>
<TableCell>{item.role}</TableCell>
<TableCell align="right">{formatHours(item.hours)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
</Box>
)}
{/* Рекомендации */}
{result.recommendations.length > 0 && (
<Box>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Lightbulb fontSize="small" color="warning" />
Рекомендации
</Typography>
<List dense disablePadding>
{result.recommendations.map((rec, index) => (
<ListItem key={index} disableGutters data-testid={`estimate-recommendation-${index}`}>
<ListItemIcon sx={{ minWidth: 32 }}>
<CheckCircle fontSize="small" color="success" />
</ListItemIcon>
<ListItemText primary={rec} />
</ListItem>
))}
</List>
</Box>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} data-testid="close-estimate-modal-button">
Закрыть
</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -0,0 +1 @@
export { AiEstimateModal } from './AiEstimateModal';

View File

@ -40,18 +40,35 @@ import {
useIdeasQuery,
useDeleteIdea,
useReorderIdeas,
useUpdateIdea,
} from '../../hooks/useIdeas';
import {
useEstimateIdea,
useGenerateSpecification,
useSpecificationHistory,
useDeleteSpecificationHistoryItem,
useRestoreSpecificationFromHistory,
} from '../../hooks/useAi';
import { useIdeasStore } from '../../store/ideas';
import { createColumns } from './columns';
import { DraggableRow } from './DraggableRow';
import { CommentsPanel } from '../CommentsPanel';
import { AiEstimateModal } from '../AiEstimateModal';
import { SpecificationModal } from '../SpecificationModal';
import type { EstimateResult } from '../../services/ai';
import type { Idea } from '../../types/idea';
const SKELETON_COLUMNS_COUNT = 9;
const SKELETON_COLUMNS_COUNT = 10;
export function IdeasTable() {
const { data, isLoading, isError } = useIdeasQuery();
const deleteIdea = useDeleteIdea();
const reorderIdeas = useReorderIdeas();
const updateIdea = useUpdateIdea();
const estimateIdea = useEstimateIdea();
const generateSpecification = useGenerateSpecification();
const deleteSpecificationHistoryItem = useDeleteSpecificationHistoryItem();
const restoreSpecificationFromHistory = useRestoreSpecificationFromHistory();
const { sorting, setSorting, pagination, setPage, setLimit } =
useIdeasStore();
@ -59,19 +76,140 @@ export function IdeasTable() {
const [activeId, setActiveId] = useState<string | null>(null);
// ID идеи с раскрытыми комментариями
const [expandedId, setExpandedId] = useState<string | null>(null);
// AI-оценка
const [estimatingId, setEstimatingId] = useState<string | null>(null);
const [estimateModalOpen, setEstimateModalOpen] = useState(false);
const [estimateResult, setEstimateResult] = useState<EstimateResult | null>(null);
// ТЗ (спецификация)
const [specificationModalOpen, setSpecificationModalOpen] = useState(false);
const [specificationIdea, setSpecificationIdea] = useState<Idea | null>(null);
const [generatedSpecification, setGeneratedSpecification] = useState<string | null>(null);
const [generatingSpecificationId, setGeneratingSpecificationId] = useState<string | null>(null);
// История ТЗ
const specificationHistory = useSpecificationHistory(specificationIdea?.id ?? null);
const handleToggleComments = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
const handleEstimate = (id: string) => {
setEstimatingId(id);
setEstimateModalOpen(true);
setEstimateResult(null);
estimateIdea.mutate(id, {
onSuccess: (result) => {
setEstimateResult(result);
setEstimatingId(null);
},
onError: () => {
setEstimatingId(null);
},
});
};
const handleCloseEstimateModal = () => {
setEstimateModalOpen(false);
setEstimateResult(null);
};
const handleViewEstimate = (idea: Idea) => {
if (!idea.estimatedHours || !idea.estimateDetails) return;
// Показываем сохранённые результаты оценки
setEstimateResult({
ideaId: idea.id,
ideaTitle: idea.title,
totalHours: idea.estimatedHours,
complexity: idea.complexity!,
breakdown: idea.estimateDetails.breakdown,
recommendations: idea.estimateDetails.recommendations,
estimatedAt: idea.estimatedAt!,
});
setEstimateModalOpen(true);
};
const handleSpecification = (idea: Idea) => {
setSpecificationIdea(idea);
setSpecificationModalOpen(true);
// Если ТЗ уже есть — показываем его
if (idea.specification) {
setGeneratedSpecification(idea.specification);
return;
}
// Иначе генерируем
setGeneratedSpecification(null);
setGeneratingSpecificationId(idea.id);
generateSpecification.mutate(idea.id, {
onSuccess: (result) => {
setGeneratedSpecification(result.specification);
setGeneratingSpecificationId(null);
},
onError: () => {
setGeneratingSpecificationId(null);
},
});
};
const handleCloseSpecificationModal = () => {
setSpecificationModalOpen(false);
setSpecificationIdea(null);
setGeneratedSpecification(null);
};
const handleSaveSpecification = (specification: string) => {
if (!specificationIdea) return;
updateIdea.mutate(
{ id: specificationIdea.id, data: { specification } },
{
onSuccess: () => {
setGeneratedSpecification(specification);
},
},
);
};
const handleRegenerateSpecification = () => {
if (!specificationIdea) return;
setGeneratingSpecificationId(specificationIdea.id);
generateSpecification.mutate(specificationIdea.id, {
onSuccess: (result) => {
setGeneratedSpecification(result.specification);
setGeneratingSpecificationId(null);
},
onError: () => {
setGeneratingSpecificationId(null);
},
});
};
const handleDeleteHistoryItem = (historyId: string) => {
deleteSpecificationHistoryItem.mutate(historyId);
};
const handleRestoreFromHistory = (historyId: string) => {
restoreSpecificationFromHistory.mutate(historyId, {
onSuccess: (result) => {
setGeneratedSpecification(result.specification);
},
});
};
const columns = useMemo(
() =>
createColumns({
onDelete: (id) => deleteIdea.mutate(id),
onToggleComments: handleToggleComments,
onEstimate: handleEstimate,
onViewEstimate: handleViewEstimate,
onSpecification: handleSpecification,
expandedId,
estimatingId,
generatingSpecificationId,
}),
[deleteIdea, expandedId],
[deleteIdea, expandedId, estimatingId, generatingSpecificationId],
);
// eslint-disable-next-line react-hooks/incompatible-library
@ -307,6 +445,29 @@ export function IdeasTable() {
rowsPerPageOptions={[10, 20, 50, 100]}
/>
)}
<AiEstimateModal
open={estimateModalOpen}
onClose={handleCloseEstimateModal}
result={estimateResult}
isLoading={estimateIdea.isPending && !estimateResult}
error={estimateIdea.error}
/>
<SpecificationModal
open={specificationModalOpen}
onClose={handleCloseSpecificationModal}
idea={specificationIdea}
specification={generatedSpecification}
isLoading={generateSpecification.isPending && !generatedSpecification}
error={generateSpecification.error}
onSave={handleSaveSpecification}
isSaving={updateIdea.isPending}
onRegenerate={handleRegenerateSpecification}
history={specificationHistory.data ?? []}
isHistoryLoading={specificationHistory.isLoading}
onDeleteHistoryItem={handleDeleteHistoryItem}
onRestoreFromHistory={handleRestoreFromHistory}
isRestoring={restoreSpecificationFromHistory.isPending}
/>
</Paper>
);
}

View File

@ -1,7 +1,7 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Chip, Box, IconButton, Tooltip } from '@mui/material';
import { Delete, Comment, ExpandLess } from '@mui/icons-material';
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
import { Chip, Box, IconButton, Tooltip, CircularProgress, Typography } from '@mui/material';
import { Delete, Comment, ExpandLess, AutoAwesome, AccessTime, Description } from '@mui/icons-material';
import type { Idea, IdeaStatus, IdeaPriority, IdeaComplexity } from '../../types/idea';
import { EditableCell } from './EditableCell';
import { ColorPickerCell } from './ColorPickerCell';
import { statusOptions, priorityOptions } from './constants';
@ -30,13 +30,45 @@ const priorityColors: Record<
critical: 'error',
};
const complexityLabels: Record<IdeaComplexity, string> = {
trivial: 'Триви.',
simple: 'Прост.',
medium: 'Сред.',
complex: 'Сложн.',
veryComplex: 'Оч.сложн.',
};
const complexityColors: Record<
IdeaComplexity,
'success' | 'info' | 'warning' | 'error' | 'default'
> = {
trivial: 'success',
simple: 'success',
medium: 'info',
complex: 'warning',
veryComplex: 'error',
};
function formatHoursShort(hours: number): string {
if (hours < 8) {
return `${hours}ч`;
}
const days = Math.floor(hours / 8);
return `${days}д`;
}
interface ColumnsConfig {
onDelete: (id: string) => void;
onToggleComments: (id: string) => void;
onEstimate: (id: string) => void;
onViewEstimate: (idea: Idea) => void;
onSpecification: (idea: Idea) => void;
expandedId: string | null;
estimatingId: string | null;
generatingSpecificationId: string | null;
}
export const createColumns = ({ onDelete, onToggleComments, expandedId }: ColumnsConfig) => [
export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEstimate, onSpecification, expandedId, estimatingId, generatingSpecificationId }: ColumnsConfig) => [
columnHelper.display({
id: 'drag',
header: '',
@ -153,14 +185,103 @@ export const createColumns = ({ onDelete, onToggleComments, expandedId }: Column
},
size: 200,
}),
columnHelper.accessor('estimatedHours', {
header: 'Оценка',
cell: (info) => {
const idea = info.row.original;
if (!idea.estimatedHours) {
return (
<Typography variant="body2" color="text.disabled">
</Typography>
);
}
return (
<Tooltip title="Нажмите, чтобы посмотреть детали оценки">
<Box
onClick={() => onViewEstimate(idea)}
data-testid="view-estimate-button"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
borderRadius: 1,
px: 0.5,
py: 0.25,
mx: -0.5,
'&:hover': {
backgroundColor: 'action.hover',
},
}}
>
<AccessTime fontSize="small" color="action" />
<Typography variant="body2">
{formatHoursShort(idea.estimatedHours)}
</Typography>
{idea.complexity && (
<Chip
label={complexityLabels[idea.complexity]}
color={complexityColors[idea.complexity]}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
</Box>
</Tooltip>
);
},
size: 130,
enableSorting: false,
}),
columnHelper.display({
id: 'actions',
header: '',
cell: (info) => {
const ideaId = info.row.original.id;
const idea = info.row.original;
const ideaId = idea.id;
const isExpanded = expandedId === ideaId;
const isEstimating = estimatingId === ideaId;
const isGeneratingSpec = generatingSpecificationId === ideaId;
const hasSpecification = !!idea.specification;
return (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={hasSpecification ? 'Просмотр ТЗ' : 'Сгенерировать ТЗ'}>
<span>
<IconButton
size="small"
onClick={() => onSpecification(idea)}
disabled={isGeneratingSpec}
color={hasSpecification ? 'primary' : 'default'}
data-testid="specification-button"
sx={{ opacity: hasSpecification ? 0.9 : 0.5, '&:hover': { opacity: 1 } }}
>
{isGeneratingSpec ? (
<CircularProgress size={18} />
) : (
<Description fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Tooltip title="AI-оценка">
<span>
<IconButton
size="small"
onClick={() => onEstimate(ideaId)}
disabled={isEstimating}
color="primary"
data-testid="estimate-idea-button"
sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }}
>
{isEstimating ? (
<CircularProgress size={18} />
) : (
<AutoAwesome fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Комментарии">
<IconButton
size="small"
@ -183,6 +304,6 @@ export const createColumns = ({ onDelete, onToggleComments, expandedId }: Column
</Box>
);
},
size: 90,
size: 150,
}),
];

View File

@ -0,0 +1,464 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
LinearProgress,
Alert,
TextField,
IconButton,
Tooltip,
Tabs,
Tab,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
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) => 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-${index}`}
sx={{ pr: 16 }}
>
<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>
}
/>
<ListItemSecondaryAction>
<Tooltip title="Просмотреть">
<IconButton
size="small"
onClick={() => handleViewHistoryItem(item)}
data-testid={`specification-history-view-${index}`}
>
<Visibility fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Восстановить">
<IconButton
size="small"
color="primary"
onClick={() => handleRestoreFromHistory(item.id)}
disabled={isRestoring}
data-testid={`specification-history-restore-${index}`}
>
<Restore fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Удалить">
<IconButton
size="small"
color="error"
onClick={() => onDeleteHistoryItem(item.id)}
data-testid={`specification-history-delete-${index}`}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</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>
);
}

View File

@ -0,0 +1 @@
export * from './SpecificationModal';

View File

@ -0,0 +1,63 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { aiApi } from '../services/ai';
const IDEAS_QUERY_KEY = 'ideas';
const SPECIFICATION_HISTORY_KEY = 'specification-history';
export function useEstimateIdea() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (ideaId: string) => aiApi.estimateIdea(ideaId),
onSuccess: () => {
// Инвалидируем кэш идей чтобы обновить данные с новой оценкой
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
},
});
}
export function useGenerateSpecification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (ideaId: string) => aiApi.generateSpecification(ideaId),
onSuccess: (_, ideaId) => {
// Инвалидируем кэш идей и историю
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY, ideaId] });
},
});
}
export function useSpecificationHistory(ideaId: string | null) {
return useQuery({
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
queryFn: () => aiApi.getSpecificationHistory(ideaId!),
enabled: !!ideaId,
});
}
export function useDeleteSpecificationHistoryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (historyId: string) => aiApi.deleteSpecificationHistoryItem(historyId),
onSuccess: () => {
// Инвалидируем все запросы истории
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] });
},
});
}
export function useRestoreSpecificationFromHistory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (historyId: string) => aiApi.restoreSpecificationFromHistory(historyId),
onSuccess: () => {
// Инвалидируем кэш идей и историю
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] });
},
});
}

View File

@ -0,0 +1,38 @@
import { api } from './api';
import type { IdeaComplexity, RoleEstimate, SpecificationResult, SpecificationHistoryItem } from '../types/idea';
export interface EstimateResult {
ideaId: string;
ideaTitle: string;
totalHours: number;
complexity: IdeaComplexity;
breakdown: RoleEstimate[];
recommendations: string[];
estimatedAt: string;
}
export const aiApi = {
estimateIdea: async (ideaId: string): Promise<EstimateResult> => {
const { data } = await api.post<EstimateResult>('/ai/estimate', { ideaId });
return data;
},
generateSpecification: async (ideaId: string): Promise<SpecificationResult> => {
const { data } = await api.post<SpecificationResult>('/ai/generate-specification', { ideaId });
return data;
},
getSpecificationHistory: async (ideaId: string): Promise<SpecificationHistoryItem[]> => {
const { data } = await api.get<SpecificationHistoryItem[]>(`/ai/specification-history/${ideaId}`);
return data;
},
deleteSpecificationHistoryItem: async (historyId: string): Promise<void> => {
await api.delete(`/ai/specification-history/${historyId}`);
},
restoreSpecificationFromHistory: async (historyId: string): Promise<SpecificationResult> => {
const { data } = await api.post<SpecificationResult>(`/ai/specification-history/${historyId}/restore`);
return data;
},
};

View File

@ -6,6 +6,18 @@ export type IdeaStatus =
| 'cancelled';
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
export type IdeaComplexity = 'trivial' | 'simple' | 'medium' | 'complex' | 'veryComplex';
export interface RoleEstimate {
role: string;
hours: number;
}
export interface EstimateDetails {
breakdown: RoleEstimate[];
recommendations: string[];
}
export interface Idea {
id: string;
title: string;
@ -19,6 +31,14 @@ export interface Idea {
verificationMethod: string | null;
color: string | null;
order: number;
// AI-оценка
estimatedHours: number | null;
complexity: IdeaComplexity | null;
estimateDetails: EstimateDetails | null;
estimatedAt: string | null;
// Мини-ТЗ
specification: string | null;
specificationGeneratedAt: string | null;
createdAt: string;
updatedAt: string;
}
@ -39,4 +59,19 @@ export interface CreateIdeaDto {
export interface UpdateIdeaDto extends Omit<Partial<CreateIdeaDto>, 'color'> {
order?: number;
color?: string | null;
specification?: string;
}
export interface SpecificationResult {
ideaId: string;
ideaTitle: string;
specification: string;
generatedAt: string;
}
export interface SpecificationHistoryItem {
id: string;
specification: string;
ideaDescriptionSnapshot: string | null;
createdAt: string;
}