end fase 2

This commit is contained in:
2026-01-15 00:18:35 +03:00
parent 85e7966c97
commit 739a7d172d
63 changed files with 3194 additions and 322 deletions

View File

@ -0,0 +1,118 @@
import { useState } from 'react';
import { Box, Popover, IconButton, Tooltip } from '@mui/material';
import { Circle, Clear } from '@mui/icons-material';
import type { Idea } from '../../types/idea';
import { useUpdateIdea } from '../../hooks/useIdeas';
// Предустановленные цвета
const COLORS = [
'#ef5350', // красный
'#ff7043', // оранжевый
'#ffca28', // жёлтый
'#66bb6a', // зелёный
'#42a5f5', // синий
'#ab47bc', // фиолетовый
'#8d6e63', // коричневый
'#78909c', // серый
];
interface ColorPickerCellProps {
idea: Idea;
}
export function ColorPickerCell({ idea }: ColorPickerCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const updateIdea = useUpdateIdea();
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleColorSelect = (color: string | null) => {
updateIdea.mutate({
id: idea.id,
dto: { color },
});
handleClose();
};
const open = Boolean(anchorEl);
return (
<>
<Tooltip title="Выбрать цвет">
<Box
onClick={handleClick}
data-testid="color-picker-trigger"
sx={{
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: idea.color ?? 'transparent',
border: idea.color ? 'none' : '2px dashed',
borderColor: 'divider',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': {
opacity: 0.8,
transform: 'scale(1.1)',
},
transition: 'all 0.2s',
}}
/>
</Tooltip>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
slotProps={{
paper: {
'data-testid': 'color-picker-popover',
} as React.HTMLAttributes<HTMLDivElement>,
}}
>
<Box sx={{ p: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap', maxWidth: 180 }}>
{COLORS.map((color) => (
<IconButton
key={color}
size="small"
onClick={() => handleColorSelect(color)}
data-testid={`color-option-${color.replace('#', '')}`}
sx={{
p: 0.5,
border: idea.color === color ? '2px solid' : 'none',
borderColor: 'primary.main',
}}
>
<Circle sx={{ color, fontSize: 24 }} />
</IconButton>
))}
<Tooltip title="Убрать цвет">
<IconButton
size="small"
onClick={() => handleColorSelect(null)}
data-testid="color-clear-button"
sx={{ p: 0.5 }}
>
<Clear sx={{ fontSize: 24, color: 'text.secondary' }} />
</IconButton>
</Tooltip>
</Box>
</Popover>
</>
);
}

View File

@ -30,6 +30,7 @@ export function DragHandle() {
<Box
{...attributes}
{...listeners}
data-testid="drag-handle"
sx={{
cursor: isDragging ? 'grabbing' : 'grab',
display: 'flex',
@ -79,7 +80,7 @@ export function DraggableRow({ row }: DraggableRowProps) {
return (
<DragHandleContext.Provider value={{ attributes, listeners, isDragging }}>
<TableRow ref={setNodeRef} hover sx={style}>
<TableRow ref={setNodeRef} hover sx={style} data-testid={`idea-row-${row.original.id}`}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, Fragment } from 'react';
import {
useReactTable,
getCoreRowModel,
@ -33,6 +33,7 @@ import {
Box,
Typography,
TablePagination,
Collapse,
} from '@mui/material';
import { Inbox } from '@mui/icons-material';
import {
@ -43,8 +44,9 @@ import {
import { useIdeasStore } from '../../store/ideas';
import { createColumns } from './columns';
import { DraggableRow } from './DraggableRow';
import { CommentsPanel } from '../CommentsPanel';
const SKELETON_COLUMNS_COUNT = 8;
const SKELETON_COLUMNS_COUNT = 9;
export function IdeasTable() {
const { data, isLoading, isError } = useIdeasQuery();
@ -55,10 +57,21 @@ export function IdeasTable() {
// ID активно перетаскиваемого элемента
const [activeId, setActiveId] = useState<string | null>(null);
// ID идеи с раскрытыми комментариями
const [expandedId, setExpandedId] = useState<string | null>(null);
const handleToggleComments = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
const columns = useMemo(
() => createColumns((id) => deleteIdea.mutate(id)),
[deleteIdea],
() =>
createColumns({
onDelete: (id) => deleteIdea.mutate(id),
onToggleComments: handleToggleComments,
expandedId,
}),
[deleteIdea, expandedId],
);
// eslint-disable-next-line react-hooks/incompatible-library
@ -140,7 +153,7 @@ export function IdeasTable() {
: null;
return (
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
<Paper sx={{ width: '100%', overflow: 'hidden' }} data-testid="ideas-table-container">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@ -149,7 +162,7 @@ export function IdeasTable() {
onDragEnd={handleDragEnd}
>
<TableContainer>
<Table stickyHeader size="small">
<Table stickyHeader size="small" data-testid="ideas-table">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@ -214,6 +227,7 @@ export function IdeasTable() {
alignItems: 'center',
color: 'text.secondary',
}}
data-testid="ideas-empty-state"
>
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="h6">Идей пока нет</Typography>
@ -229,7 +243,19 @@ export function IdeasTable() {
strategy={verticalListSortingStrategy}
>
{rows.map((row) => (
<DraggableRow key={row.id} row={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>
)}

View File

@ -1,8 +1,9 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Chip, Box, IconButton } from '@mui/material';
import { Delete } from '@mui/icons-material';
import { Chip, Box, IconButton, Tooltip } from '@mui/material';
import { Delete, Comment, ExpandLess } from '@mui/icons-material';
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
import { EditableCell } from './EditableCell';
import { ColorPickerCell } from './ColorPickerCell';
import { statusOptions, priorityOptions } from './constants';
import { DragHandle } from './DraggableRow';
@ -29,7 +30,13 @@ const priorityColors: Record<
critical: 'error',
};
export const createColumns = (onDelete: (id: string) => void) => [
interface ColumnsConfig {
onDelete: (id: string) => void;
onToggleComments: (id: string) => void;
expandedId: string | null;
}
export const createColumns = ({ onDelete, onToggleComments, expandedId }: ColumnsConfig) => [
columnHelper.display({
id: 'drag',
header: '',
@ -37,6 +44,12 @@ export const createColumns = (onDelete: (id: string) => void) => [
size: 40,
enableSorting: false,
}),
columnHelper.accessor('color', {
header: 'Цвет',
cell: (info) => <ColorPickerCell idea={info.row.original} />,
size: 60,
enableSorting: false,
}),
columnHelper.accessor('title', {
header: 'Название',
cell: (info) => (
@ -143,15 +156,33 @@ export const createColumns = (onDelete: (id: string) => void) => [
columnHelper.display({
id: 'actions',
header: '',
cell: (info) => (
<IconButton
size="small"
onClick={() => onDelete(info.row.original.id)}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<Delete fontSize="small" />
</IconButton>
),
size: 50,
cell: (info) => {
const ideaId = info.row.original.id;
const isExpanded = expandedId === ideaId;
return (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Комментарии">
<IconButton
size="small"
onClick={() => onToggleComments(ideaId)}
color={isExpanded ? 'primary' : 'default'}
data-testid="toggle-comments-button"
sx={{ opacity: isExpanded ? 1 : 0.5, '&:hover': { opacity: 1 } }}
>
{isExpanded ? <ExpandLess fontSize="small" /> : <Comment fontSize="small" />}
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={() => onDelete(ideaId)}
data-testid="delete-idea-button"
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<Delete fontSize="small" />
</IconButton>
</Box>
);
},
size: 90,
}),
];