581 lines
18 KiB
TypeScript
581 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|