add view any columns and view mode for ideas
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
157
frontend/src/components/IdeasTable/ColumnVisibility.tsx
Normal file
157
frontend/src/components/IdeasTable/ColumnVisibility.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user