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,491 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Select,
MenuItem,
FormControl,
Box,
Typography,
Chip,
Divider,
Grid,
IconButton,
} from '@mui/material';
import { Close, Edit, Description, AutoAwesome } from '@mui/icons-material';
import type {
Idea,
IdeaStatus,
IdeaPriority,
UpdateIdeaDto,
} from '../../types/idea';
import { statusOptions, priorityOptions } from '../IdeasTable/constants';
interface IdeaDetailModalProps {
open: boolean;
onClose: () => void;
idea: Idea | null;
onSave: (id: string, dto: UpdateIdeaDto) => void;
isSaving: boolean;
onOpenSpecification: (idea: Idea) => void;
onOpenEstimate: (idea: Idea) => void;
}
const statusColors: Record<
IdeaStatus,
'default' | 'primary' | 'secondary' | 'success' | 'error'
> = {
backlog: 'default',
todo: 'primary',
in_progress: 'secondary',
done: 'success',
cancelled: 'error',
};
const priorityColors: Record<
IdeaPriority,
'default' | 'info' | 'warning' | 'error'
> = {
low: 'default',
medium: 'info',
high: 'warning',
critical: 'error',
};
function formatDate(dateString: string | null): string {
if (!dateString) return '—';
return new Date(dateString).toLocaleString('ru-RU');
}
function formatHours(hours: number | null): string {
if (!hours) return '—';
if (hours < 8) return `${String(hours)} ч`;
const days = Math.floor(hours / 8);
const remainingHours = hours % 8;
if (remainingHours === 0) return `${String(days)} д`;
return `${String(days)} д ${String(remainingHours)} ч`;
}
export function IdeaDetailModal({
open,
onClose,
idea,
onSave,
isSaving,
onOpenSpecification,
onOpenEstimate,
}: IdeaDetailModalProps) {
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<UpdateIdeaDto>({});
// Сброс при открытии/закрытии или смене идеи
useEffect(() => {
if (idea) {
setFormData({
title: idea.title,
description: idea.description ?? '',
status: idea.status,
priority: idea.priority,
module: idea.module ?? '',
targetAudience: idea.targetAudience ?? '',
pain: idea.pain ?? '',
aiRole: idea.aiRole ?? '',
verificationMethod: idea.verificationMethod ?? '',
});
}
setIsEditing(false);
}, [idea, open]);
if (!idea) return null;
const handleChange = (field: keyof UpdateIdeaDto, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = () => {
onSave(idea.id, formData);
setIsEditing(false);
};
const handleCancel = () => {
setFormData({
title: idea.title,
description: idea.description ?? '',
status: idea.status,
priority: idea.priority,
module: idea.module ?? '',
targetAudience: idea.targetAudience ?? '',
pain: idea.pain ?? '',
aiRole: idea.aiRole ?? '',
verificationMethod: idea.verificationMethod ?? '',
});
setIsEditing(false);
};
const handleClose = () => {
setIsEditing(false);
onClose();
};
const statusLabel =
statusOptions.find((s) => s.value === idea.status)?.label ?? idea.status;
const priorityLabel =
priorityOptions.find((p) => p.value === idea.priority)?.label ??
idea.priority;
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
data-testid="idea-detail-modal"
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ flex: 1 }}>
{isEditing ? (
<TextField
fullWidth
value={formData.title ?? ''}
onChange={(e) => handleChange('title', e.target.value)}
variant="standard"
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-title-input' },
}}
sx={{ '& input': { fontSize: '1.25rem', fontWeight: 500 } }}
/>
) : (
<Typography variant="h6" data-testid="idea-detail-title">
{idea.title}
</Typography>
)}
</Box>
<IconButton onClick={handleClose} size="small">
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Grid container spacing={3}>
{/* Статус и приоритет */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Статус
</Typography>
{isEditing ? (
<FormControl fullWidth size="small">
<Select
value={formData.status ?? idea.status}
onChange={(e) => handleChange('status', e.target.value)}
data-testid="idea-detail-status-select"
>
{statusOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
) : (
<Chip
label={statusLabel}
color={statusColors[idea.status]}
size="small"
data-testid="idea-detail-status"
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Приоритет
</Typography>
{isEditing ? (
<FormControl fullWidth size="small">
<Select
value={formData.priority ?? idea.priority}
onChange={(e) => handleChange('priority', e.target.value)}
data-testid="idea-detail-priority-select"
>
{priorityOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
) : (
<Chip
label={priorityLabel}
color={priorityColors[idea.priority]}
size="small"
variant="outlined"
data-testid="idea-detail-priority"
/>
)}
</Grid>
{/* Модуль */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Модуль
</Typography>
{isEditing ? (
<TextField
fullWidth
size="small"
value={formData.module ?? ''}
onChange={(e) => handleChange('module', e.target.value)}
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-module-input' },
}}
/>
) : (
<Typography data-testid="idea-detail-module">
{idea.module ?? '—'}
</Typography>
)}
</Grid>
{/* Целевая аудитория */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Целевая аудитория
</Typography>
{isEditing ? (
<TextField
fullWidth
size="small"
value={formData.targetAudience ?? ''}
onChange={(e) => handleChange('targetAudience', e.target.value)}
slotProps={{
htmlInput: {
'data-testid': 'idea-detail-target-audience-input',
},
}}
/>
) : (
<Typography data-testid="idea-detail-target-audience">
{idea.targetAudience ?? '—'}
</Typography>
)}
</Grid>
{/* Описание */}
<Grid size={12}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Описание
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={3}
value={formData.description ?? ''}
onChange={(e) => handleChange('description', e.target.value)}
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-description-input' },
}}
/>
) : (
<Typography
data-testid="idea-detail-description"
sx={{ whiteSpace: 'pre-wrap' }}
>
{idea.description ?? '—'}
</Typography>
)}
</Grid>
{/* Боль */}
<Grid size={12}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Какую боль решает
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={2}
value={formData.pain ?? ''}
onChange={(e) => handleChange('pain', e.target.value)}
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-pain-input' },
}}
/>
) : (
<Typography
data-testid="idea-detail-pain"
sx={{ whiteSpace: 'pre-wrap' }}
>
{idea.pain ?? '—'}
</Typography>
)}
</Grid>
{/* Роль AI */}
<Grid size={12}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Роль AI
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={2}
value={formData.aiRole ?? ''}
onChange={(e) => handleChange('aiRole', e.target.value)}
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-ai-role-input' },
}}
/>
) : (
<Typography
data-testid="idea-detail-ai-role"
sx={{ whiteSpace: 'pre-wrap' }}
>
{idea.aiRole ?? '—'}
</Typography>
)}
</Grid>
{/* Способ проверки */}
<Grid size={12}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Способ проверки
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={2}
value={formData.verificationMethod ?? ''}
onChange={(e) =>
handleChange('verificationMethod', e.target.value)
}
slotProps={{
htmlInput: {
'data-testid': 'idea-detail-verification-method-input',
},
}}
/>
) : (
<Typography
data-testid="idea-detail-verification-method"
sx={{ whiteSpace: 'pre-wrap' }}
>
{idea.verificationMethod ?? '—'}
</Typography>
)}
</Grid>
<Grid size={12}>
<Divider sx={{ my: 1 }} />
</Grid>
{/* AI-оценка и ТЗ */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
AI-оценка
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography data-testid="idea-detail-estimate">
{formatHours(idea.estimatedHours)}
</Typography>
{idea.complexity && (
<Chip label={idea.complexity} size="small" variant="outlined" />
)}
<Button
size="small"
startIcon={<AutoAwesome />}
onClick={() => onOpenEstimate(idea)}
data-testid="idea-detail-open-estimate-button"
>
{idea.estimatedHours ? 'Подробнее' : 'Оценить'}
</Button>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Техническое задание
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography data-testid="idea-detail-specification-status">
{idea.specification ? 'Есть' : 'Нет'}
</Typography>
<Button
size="small"
startIcon={<Description />}
onClick={() => onOpenSpecification(idea)}
data-testid="idea-detail-open-specification-button"
>
{idea.specification ? 'Открыть' : 'Создать'}
</Button>
</Box>
</Grid>
<Grid size={12}>
<Divider sx={{ my: 1 }} />
</Grid>
{/* Метаданные */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Создано
</Typography>
<Typography variant="body2" data-testid="idea-detail-created-at">
{formatDate(idea.createdAt)}
</Typography>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Обновлено
</Typography>
<Typography variant="body2" data-testid="idea-detail-updated-at">
{formatDate(idea.updatedAt)}
</Typography>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
{isEditing ? (
<>
<Button
onClick={handleCancel}
data-testid="idea-detail-cancel-button"
>
Отмена
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving}
data-testid="idea-detail-save-button"
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
) : (
<>
<Button onClick={handleClose}>Закрыть</Button>
<Button
variant="contained"
startIcon={<Edit />}
onClick={() => setIsEditing(true)}
data-testid="idea-detail-edit-button"
>
Редактировать
</Button>
</>
)}
</DialogActions>
</Dialog>
);
}

View File

@ -0,0 +1 @@
export { IdeaDetailModal } from './IdeaDetailModal';

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