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:
491
frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx
Normal file
491
frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/IdeaDetailModal/index.ts
Normal file
1
frontend/src/components/IdeaDetailModal/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { IdeaDetailModal } from './IdeaDetailModal';
|
||||
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