Files
team-planner/frontend/src/components/IdeasTable/IdeasTable.tsx
vigdorov 7421f33de8
All checks were successful
continuous-integration/drone/push Build is passing
fix bus phase 3/2
2026-01-15 12:05:57 +03:00

581 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 { useMemo, useState, Fragment, useCallback } from 'react';
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from '@dnd-kit/core';
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TableSortLabel,
Skeleton,
Box,
Typography,
TablePagination,
Collapse,
} from '@mui/material';
import { Inbox } from '@mui/icons-material';
import { ColumnVisibility } from './ColumnVisibility';
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 { IdeaDetailModal } from '../IdeaDetailModal';
import type { EstimateResult } from '../../services/ai';
import type { Idea, UpdateIdeaDto } from '../../types/idea';
const SKELETON_COLUMNS_COUNT = 13;
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();
// ID активно перетаскиваемого элемента
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 [detailModalOpen, setDetailModalOpen] = useState(false);
const [detailIdea, setDetailIdea] = useState<Idea | null>(null);
// История ТЗ
const specificationHistory = useSpecificationHistory(
specificationIdea?.id ?? null,
);
const handleToggleComments = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
const handleEstimate = useCallback(
(id: string) => {
setEstimatingId(id);
setEstimateModalOpen(true);
setEstimateResult(null);
estimateIdea.mutate(id, {
onSuccess: (result) => {
setEstimateResult(result);
setEstimatingId(null);
},
onError: () => {
setEstimatingId(null);
},
});
},
[estimateIdea],
);
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 ?? 'medium',
breakdown: idea.estimateDetails.breakdown,
recommendations: idea.estimateDetails.recommendations,
estimatedAt: idea.estimatedAt ?? new Date().toISOString(),
});
setEstimateModalOpen(true);
};
const handleSpecification = useCallback(
(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);
},
});
},
[generateSpecification],
);
const handleCloseSpecificationModal = () => {
setSpecificationModalOpen(false);
setSpecificationIdea(null);
setGeneratedSpecification(null);
};
const handleSaveSpecification = (specification: string) => {
if (!specificationIdea) return;
updateIdea.mutate(
{ id: specificationIdea.id, dto: { 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 handleViewDetails = useCallback((idea: Idea) => {
setDetailIdea(idea);
setDetailModalOpen(true);
}, []);
const handleCloseDetailModal = () => {
setDetailModalOpen(false);
setDetailIdea(null);
};
const handleSaveDetail = (id: string, dto: UpdateIdeaDto) => {
updateIdea.mutate(
{ id, dto },
{
onSuccess: () => {
// Обновляем только те поля, которые были отправлены в dto
// Это сохраняет specification и другие поля которые не редактировались
setDetailIdea((prev) => {
if (!prev) return prev;
const updates: Partial<Idea> = {};
(Object.keys(dto) as (keyof UpdateIdeaDto)[]).forEach((key) => {
if (dto[key] !== undefined) {
(updates as Record<string, unknown>)[key] = dto[key];
}
});
return { ...prev, ...updates };
});
},
},
);
};
const handleOpenSpecificationFromDetail = (idea: Idea) => {
handleCloseDetailModal();
handleSpecification(idea);
};
const handleOpenEstimateFromDetail = (idea: Idea) => {
handleCloseDetailModal();
if (idea.estimatedHours) {
handleViewEstimate(idea);
} else {
handleEstimate(idea.id);
}
};
const columns = useMemo(
() =>
createColumns({
onDelete: (id) => deleteIdea.mutate(id),
onToggleComments: handleToggleComments,
onEstimate: handleEstimate,
onViewEstimate: handleViewEstimate,
onSpecification: handleSpecification,
onViewDetails: handleViewDetails,
expandedId,
estimatingId,
generatingSpecificationId,
}),
[
deleteIdea,
expandedId,
estimatingId,
generatingSpecificationId,
handleEstimate,
handleSpecification,
handleViewDetails,
],
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: data?.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
manualPagination: true,
getRowId: (row) => row.id,
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (over && active.id !== over.id && data?.data) {
const oldIndex = data.data.findIndex((item) => item.id === active.id);
const newIndex = data.data.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
// Создаём новый порядок
const items = [...data.data];
const [movedItem] = items.splice(oldIndex, 1);
items.splice(newIndex, 0, movedItem);
// Отправляем на сервер новый порядок
const reorderItems = items.map((item, index) => ({
id: item.id,
order: index,
}));
reorderIdeas.mutate(reorderItems);
}
}
};
const handleSort = (columnId: string) => {
setSorting(columnId);
};
const handleChangePage = (_: unknown, newPage: number) => {
setPage(newPage + 1);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setLimit(parseInt(event.target.value, 10));
};
if (isError) {
return (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography color="error">Не удалось загрузить идеи</Typography>
</Box>
);
}
const rows = table.getRowModel().rows;
const rowIds = rows.map((row) => row.original.id);
const activeRow = activeId
? rows.find((row) => row.original.id === activeId)
: null;
return (
<Paper
sx={{ width: '100%', overflow: 'hidden' }}
data-testid="ideas-table-container"
>
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
px: 2,
py: 1,
borderBottom: 1,
borderColor: 'divider',
}}
>
<ColumnVisibility table={table} />
</Box>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<TableContainer>
<Table stickyHeader size="small" data-testid="ideas-table">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell
key={header.id}
sx={{
fontWeight: 600,
backgroundColor: 'grey.100',
width: header.getSize(),
}}
>
{header.column.getCanSort() ? (
<TableSortLabel
active={sorting.sortBy === header.id}
direction={
sorting.sortBy === header.id
? (sorting.sortOrder.toLowerCase() as
| 'asc'
| 'desc')
: 'asc'
}
onClick={() => handleSort(header.id)}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableSortLabel>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</TableCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
(_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton variant="text" />
</TableCell>
),
)}
</TableRow>
))
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={SKELETON_COLUMNS_COUNT}>
<Box
sx={{
py: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: 'text.secondary',
}}
data-testid="ideas-empty-state"
>
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="h6">Идей пока нет</Typography>
<Typography variant="body2">
Создайте первую идею, чтобы начать
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
<SortableContext
items={rowIds}
strategy={verticalListSortingStrategy}
>
{rows.map((row) => (
<Fragment key={row.id}>
<DraggableRow row={row} />
<TableRow>
<TableCell
colSpan={SKELETON_COLUMNS_COUNT}
sx={{
p: 0,
borderBottom:
expandedId === row.original.id ? 1 : 0,
borderColor: 'divider',
}}
>
<Collapse
in={expandedId === row.original.id}
unmountOnExit
>
<CommentsPanel ideaId={row.original.id} />
</Collapse>
</TableCell>
</TableRow>
</Fragment>
))}
</SortableContext>
)}
</TableBody>
</Table>
</TableContainer>
<DragOverlay dropAnimation={null}>
{activeRow ? (
<Table
size="small"
sx={{
backgroundColor: 'background.paper',
boxShadow: 6,
borderRadius: 1,
'& td': {
backgroundColor: activeRow.original.color
? `${activeRow.original.color}30`
: 'action.selected',
},
}}
>
<TableBody>
<TableRow>
{activeRow.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
sx={{ width: cell.column.getSize() }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
) : null}
</DragOverlay>
</DndContext>
{data && (
<TablePagination
component="div"
count={data.meta.total}
page={pagination.page - 1}
rowsPerPage={pagination.limit}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
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}
/>
<IdeaDetailModal
open={detailModalOpen}
onClose={handleCloseDetailModal}
idea={detailIdea}
onSave={handleSaveDetail}
isSaving={updateIdea.isPending}
onOpenSpecification={handleOpenSpecificationFromDetail}
onOpenEstimate={handleOpenEstimateFromDetail}
/>
</Paper>
);
}