add ai functions
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Some checks reported errors
continuous-integration/drone/push Build encountered an error
This commit is contained in:
204
frontend/src/components/AiEstimateModal/AiEstimateModal.tsx
Normal file
204
frontend/src/components/AiEstimateModal/AiEstimateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/AiEstimateModal/index.ts
Normal file
1
frontend/src/components/AiEstimateModal/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AiEstimateModal } from './AiEstimateModal';
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/SpecificationModal/index.ts
Normal file
1
frontend/src/components/SpecificationModal/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './SpecificationModal';
|
||||
63
frontend/src/hooks/useAi.ts
Normal file
63
frontend/src/hooks/useAi.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
38
frontend/src/services/ai.ts
Normal file
38
frontend/src/services/ai.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user