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

@ -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,
}),
];