add view any columns and view mode for ideas
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-01-15 11:41:01 +03:00
parent 890d6de92e
commit 684e416588
8 changed files with 856 additions and 7 deletions

View File

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import {
IconButton,
Menu,
MenuItem,
Checkbox,
ListItemText,
Tooltip,
Divider,
Typography,
Box,
} from '@mui/material';
import { Settings } from '@mui/icons-material';
import type { Table } from '@tanstack/react-table';
import type { Idea } from '../../types/idea';
const STORAGE_KEY = 'team-planner-column-visibility';
const columnLabels: Record<string, string> = {
drag: 'Перетаскивание',
color: 'Цвет',
title: 'Название',
status: 'Статус',
priority: 'Приоритет',
module: 'Модуль',
targetAudience: 'Целевая аудитория',
pain: 'Боль',
aiRole: 'Роль AI',
verificationMethod: 'Способ проверки',
description: 'Описание',
estimatedHours: 'Оценка',
actions: 'Действия',
};
// Колонки, которые нельзя скрыть
const alwaysVisibleColumns = ['drag', 'title', 'actions'];
interface ColumnVisibilityProps {
table: Table<Idea>;
}
export function ColumnVisibility({ table }: ColumnVisibilityProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
// Загрузка сохранённых настроек при монтировании
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const visibility = JSON.parse(saved) as Record<string, boolean>;
table.setColumnVisibility(visibility);
} catch {
// Игнорируем ошибки парсинга
}
}
}, [table]);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleToggle = (columnId: string) => {
const column = table.getColumn(columnId);
if (!column) return;
const newVisibility = !column.getIsVisible();
column.toggleVisibility(newVisibility);
// Сохраняем в localStorage
const currentVisibility = table.getState().columnVisibility;
const updatedVisibility = {
...currentVisibility,
[columnId]: newVisibility,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedVisibility));
};
const handleShowAll = () => {
const allVisible: Record<string, boolean> = {};
table.getAllColumns().forEach((col) => {
allVisible[col.id] = true;
});
table.setColumnVisibility(allVisible);
localStorage.setItem(STORAGE_KEY, JSON.stringify(allVisible));
};
const columns = table.getAllColumns().filter((col) => col.id in columnLabels);
return (
<>
<Tooltip title="Настройка колонок">
<IconButton
onClick={handleClick}
size="small"
data-testid="column-visibility-button"
sx={{ ml: 1 }}
>
<Settings />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
data-testid="column-visibility-menu"
slotProps={{
paper: {
sx: { minWidth: 220 },
},
}}
>
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="subtitle2" color="text.secondary">
Показать колонки
</Typography>
</Box>
<Divider />
{columns.map((column) => {
const isAlwaysVisible = alwaysVisibleColumns.includes(column.id);
return (
<MenuItem
key={column.id}
onClick={() => !isAlwaysVisible && handleToggle(column.id)}
disabled={isAlwaysVisible}
dense
data-testid={`column-visibility-item-${column.id}`}
>
<Checkbox
checked={column.getIsVisible()}
disabled={isAlwaysVisible}
size="small"
sx={{ p: 0, mr: 1 }}
/>
<ListItemText primary={columnLabels[column.id]} />
</MenuItem>
);
})}
<Divider />
<MenuItem
onClick={handleShowAll}
dense
data-testid="column-visibility-show-all"
>
<ListItemText
primary="Показать все"
slotProps={{ primary: { color: 'primary' } }}
/>
</MenuItem>
</Menu>
</>
);
}

View File

@ -36,6 +36,7 @@ import {
Collapse,
} from '@mui/material';
import { Inbox } from '@mui/icons-material';
import { ColumnVisibility } from './ColumnVisibility';
import {
useIdeasQuery,
useDeleteIdea,
@ -55,10 +56,11 @@ 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 } from '../../types/idea';
import type { Idea, UpdateIdeaDto } from '../../types/idea';
const SKELETON_COLUMNS_COUNT = 10;
const SKELETON_COLUMNS_COUNT = 13;
export function IdeasTable() {
const { data, isLoading, isError } = useIdeasQuery();
@ -91,6 +93,9 @@ export function IdeasTable() {
const [generatingSpecificationId, setGeneratingSpecificationId] = useState<
string | null
>(null);
// Детальный просмотр идеи
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [detailIdea, setDetailIdea] = useState<Idea | null>(null);
// История ТЗ
const specificationHistory = useSpecificationHistory(
@ -211,6 +216,41 @@ export function IdeasTable() {
});
};
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: (updatedIdea) => {
setDetailIdea(updatedIdea);
},
},
);
};
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({
@ -219,6 +259,7 @@ export function IdeasTable() {
onEstimate: handleEstimate,
onViewEstimate: handleViewEstimate,
onSpecification: handleSpecification,
onViewDetails: handleViewDetails,
expandedId,
estimatingId,
generatingSpecificationId,
@ -230,6 +271,7 @@ export function IdeasTable() {
generatingSpecificationId,
handleEstimate,
handleSpecification,
handleViewDetails,
],
);
@ -316,6 +358,19 @@ export function IdeasTable() {
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}
@ -500,6 +555,15 @@ export function IdeasTable() {
onRestoreFromHistory={handleRestoreFromHistory}
isRestoring={restoreSpecificationFromHistory.isPending}
/>
<IdeaDetailModal
open={detailModalOpen}
onClose={handleCloseDetailModal}
idea={detailIdea}
onSave={handleSaveDetail}
isSaving={updateIdea.isPending}
onOpenSpecification={handleOpenSpecificationFromDetail}
onOpenEstimate={handleOpenEstimateFromDetail}
/>
</Paper>
);
}

View File

@ -14,6 +14,7 @@ import {
AutoAwesome,
AccessTime,
Description,
Visibility,
} from '@mui/icons-material';
import type {
Idea,
@ -82,6 +83,7 @@ interface ColumnsConfig {
onEstimate: (id: string) => void;
onViewEstimate: (idea: Idea) => void;
onSpecification: (idea: Idea) => void;
onViewDetails: (idea: Idea) => void;
expandedId: string | null;
estimatingId: string | null;
generatingSpecificationId: string | null;
@ -93,6 +95,7 @@ export const createColumns = ({
onEstimate,
onViewEstimate,
onSpecification,
onViewDetails,
expandedId,
estimatingId,
generatingSpecificationId,
@ -195,6 +198,60 @@ export const createColumns = ({
),
size: 150,
}),
columnHelper.accessor('pain', {
header: 'Боль',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="pain"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('aiRole', {
header: 'Роль AI',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="aiRole"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('verificationMethod', {
header: 'Способ проверки',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="verificationMethod"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('description', {
header: 'Описание',
cell: (info) => {
@ -274,6 +331,16 @@ export const createColumns = ({
const hasSpecification = !!idea.specification;
return (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Подробнее">
<IconButton
size="small"
onClick={() => onViewDetails(idea)}
data-testid="view-details-button"
sx={{ opacity: 0.6, '&:hover': { opacity: 1 } }}
>
<Visibility fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip
title={hasSpecification ? 'Просмотр ТЗ' : 'Сгенерировать ТЗ'}
>
@ -341,6 +408,6 @@ export const createColumns = ({
</Box>
);
},
size: 150,
size: 180,
}),
];