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

@ -1,3 +1,4 @@
import { useState } from 'react';
import {
Container,
Typography,
@ -5,26 +6,30 @@ import {
Button,
IconButton,
Tooltip,
Chip,
Avatar,
Tabs,
Tab,
} from '@mui/material';
import { Add, Logout } from '@mui/icons-material';
import { Add, Logout, Person, Lightbulb, Group } from '@mui/icons-material';
import { IdeasTable } from './components/IdeasTable';
import { IdeasFilters } from './components/IdeasFilters';
import { CreateIdeaModal } from './components/CreateIdeaModal';
import { TeamPage } from './components/TeamPage';
import { useIdeasStore } from './store/ideas';
import keycloak from './services/keycloak';
import { useAuth } from './hooks/useAuth';
function App() {
const { setCreateModalOpen } = useIdeasStore();
const handleLogout = () => {
void keycloak.logout();
};
const { user, logout } = useAuth();
const [tab, setTab] = useState(0);
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
{/* Header */}
<Box
sx={{
mb: 4,
mb: 3,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
@ -39,28 +44,50 @@ function App() {
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setCreateModalOpen(true)}
>
Новая идея
</Button>
<Chip
avatar={
<Avatar sx={{ bgcolor: 'primary.main' }}>
<Person sx={{ fontSize: 16 }} />
</Avatar>
}
label={user?.name ?? 'Пользователь'}
variant="outlined"
/>
<Tooltip title="Выйти">
<IconButton onClick={handleLogout} color="default">
<IconButton onClick={logout} color="default" size="small">
<Logout />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box sx={{ mb: 3 }}>
<IdeasFilters />
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)}>
<Tab icon={<Lightbulb />} iconPosition="start" label="Идеи" />
<Tab icon={<Group />} iconPosition="start" label="Команда" />
</Tabs>
</Box>
<IdeasTable />
{/* Content */}
{tab === 0 && (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<IdeasFilters />
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setCreateModalOpen(true)}
>
Новая идея
</Button>
</Box>
<IdeasTable />
<CreateIdeaModal />
</>
)}
<CreateIdeaModal />
{tab === 1 && <TeamPage />}
</Container>
);
}

View File

@ -0,0 +1,136 @@
import { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
IconButton,
CircularProgress,
Paper,
} from '@mui/material';
import { Delete, Send } from '@mui/icons-material';
import {
useCommentsQuery,
useCreateComment,
useDeleteComment,
} from '../../hooks/useComments';
import { useAuth } from '../../hooks/useAuth';
interface CommentsPanelProps {
ideaId: string;
}
export function CommentsPanel({ ideaId }: CommentsPanelProps) {
const { data: comments = [], isLoading } = useCommentsQuery(ideaId);
const createComment = useCreateComment();
const deleteComment = useDeleteComment();
const { user } = useAuth();
const [newComment, setNewComment] = useState('');
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!newComment.trim() || createComment.isPending) return;
await createComment.mutateAsync({
ideaId,
dto: { text: newComment.trim(), author: user?.name },
});
setNewComment('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void handleSubmit();
}
};
const handleDelete = (commentId: string) => {
deleteComment.mutate({ id: commentId, ideaId });
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Box sx={{ p: 2, backgroundColor: 'grey.50' }} data-testid="comments-panel">
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Комментарии ({comments.length})
</Typography>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : comments.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }} data-testid="comments-empty">
Пока нет комментариев
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }} data-testid="comments-list">
{comments.map((comment) => (
<Paper
key={comment.id}
variant="outlined"
data-testid={`comment-${comment.id}`}
sx={{ p: 1.5, display: 'flex', alignItems: 'flex-start', gap: 1 }}
>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }} data-testid="comment-text">
{comment.text}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(comment.createdAt)}
{comment.author && `${comment.author}`}
</Typography>
</Box>
<IconButton
size="small"
onClick={() => handleDelete(comment.id)}
data-testid="delete-comment-button"
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<Delete fontSize="small" />
</IconButton>
</Paper>
))}
</Box>
)}
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', gap: 1 }} data-testid="comment-form">
<TextField
size="small"
placeholder="Добавить комментарий... (Ctrl+Enter)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown}
fullWidth
multiline
maxRows={3}
inputProps={{ 'data-testid': 'comment-input' }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!newComment.trim() || createComment.isPending}
data-testid="submit-comment-button"
sx={{ minWidth: 'auto', px: 2 }}
>
{createComment.isPending ? (
<CircularProgress size={20} color="inherit" />
) : (
<Send fontSize="small" />
)}
</Button>
</Box>
</Box>
);
}

View File

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

View File

@ -74,8 +74,9 @@ export function CreateIdeaModal() {
onClose={handleClose}
maxWidth="sm"
fullWidth
data-testid="create-idea-modal"
>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} data-testid="create-idea-form">
<DialogTitle>Новая идея</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
@ -91,6 +92,7 @@ export function CreateIdeaModal() {
onChange={(e) => handleChange('title', e.target.value)}
required
autoFocus
data-testid="idea-title-input"
/>
<TextField
@ -178,11 +180,12 @@ export function CreateIdeaModal() {
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Отмена</Button>
<Button onClick={handleClose} data-testid="cancel-create-idea">Отмена</Button>
<Button
type="submit"
variant="contained"
disabled={!formData.title || createIdea.isPending}
data-testid="submit-create-idea"
>
{createIdea.isPending ? 'Создание...' : 'Создать'}
</Button>

View File

@ -9,11 +9,22 @@ import {
Button,
InputAdornment,
} from '@mui/material';
import { Search, Clear } from '@mui/icons-material';
import { Search, Clear, Circle } from '@mui/icons-material';
import { useIdeasStore } from '../../store/ideas';
import { useModulesQuery } from '../../hooks/useIdeas';
import type { IdeaStatus, IdeaPriority } from '../../types/idea';
const colorOptions = [
{ value: '#ef5350', label: 'Красный' },
{ value: '#ff7043', label: 'Оранжевый' },
{ value: '#ffca28', label: 'Жёлтый' },
{ value: '#66bb6a', label: 'Зелёный' },
{ value: '#42a5f5', label: 'Синий' },
{ value: '#ab47bc', label: 'Фиолетовый' },
{ value: '#8d6e63', label: 'Коричневый' },
{ value: '#78909c', label: 'Серый' },
];
const statusOptions: { value: IdeaStatus; label: string }[] = [
{ value: 'backlog', label: 'Бэклог' },
{ value: 'todo', label: 'К выполнению' },
@ -43,12 +54,13 @@ export function IdeasFilters() {
}, [searchValue, setFilter]);
const hasFilters = Boolean(
filters.status ?? filters.priority ?? filters.module ?? filters.search,
filters.status ?? filters.priority ?? filters.module ?? filters.search ?? filters.color,
);
return (
<Box
sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}
data-testid="ideas-filters"
>
<TextField
size="small"
@ -56,6 +68,7 @@ export function IdeasFilters() {
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
sx={{ minWidth: 200 }}
data-testid="search-input"
slotProps={{
input: {
startAdornment: (
@ -67,7 +80,7 @@ export function IdeasFilters() {
}}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-status">
<InputLabel>Статус</InputLabel>
<Select<IdeaStatus | ''>
value={filters.status ?? ''}
@ -86,7 +99,7 @@ export function IdeasFilters() {
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 120 }}>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-priority">
<InputLabel>Приоритет</InputLabel>
<Select<IdeaPriority | ''>
value={filters.priority ?? ''}
@ -105,7 +118,7 @@ export function IdeasFilters() {
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 120 }}>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-module">
<InputLabel>Модуль</InputLabel>
<Select
value={filters.module ?? ''}
@ -121,6 +134,35 @@ export function IdeasFilters() {
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-color">
<InputLabel>Цвет</InputLabel>
<Select
value={filters.color ?? ''}
label="Цвет"
onChange={(e) => setFilter('color', e.target.value || undefined)}
renderValue={(value) => {
if (!value) return 'Все';
const opt = colorOptions.find((o) => o.value === value);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Circle sx={{ color: value, fontSize: 16 }} />
{opt?.label}
</Box>
);
}}
>
<MenuItem value="">Все</MenuItem>
{colorOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Circle sx={{ color: opt.value, fontSize: 16 }} />
{opt.label}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
{hasFilters && (
<Button
size="small"
@ -129,6 +171,7 @@ export function IdeasFilters() {
clearFilters();
setSearchValue('');
}}
data-testid="clear-filters-button"
>
Сбросить
</Button>

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

View File

@ -0,0 +1,250 @@
import { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Skeleton,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
} from '@mui/material';
import { Add, Edit, Delete } from '@mui/icons-material';
import { useRolesQuery, useCreateRole, useUpdateRole, useDeleteRole } from '../../hooks/useRoles';
import type { Role, CreateRoleDto } from '../../types/team';
interface RoleModalProps {
open: boolean;
onClose: () => void;
role?: Role | null;
}
function RoleModal({ open, onClose, role }: RoleModalProps) {
const [name, setName] = useState('');
const [label, setLabel] = useState('');
const [error, setError] = useState('');
const createRole = useCreateRole();
useEffect(() => {
if (open) {
setName(role?.name ?? '');
setLabel(role?.label ?? '');
setError('');
}
}, [open, role]);
const updateRole = useUpdateRole();
const isEditing = !!role;
const isPending = createRole.isPending || updateRole.isPending;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim() || !label.trim()) return;
const dto: CreateRoleDto = {
name: name.trim().toLowerCase().replace(/\s+/g, '_'),
label: label.trim(),
};
try {
if (isEditing) {
await updateRole.mutateAsync({ id: role.id, dto });
} else {
await createRole.mutateAsync(dto);
}
onClose();
} catch (err) {
if (err instanceof Error) {
setError(err.message);
}
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth data-testid="role-modal">
<form onSubmit={handleSubmit} data-testid="role-form">
<DialogTitle>
{isEditing ? 'Редактировать роль' : 'Добавить роль'}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{error && <Alert severity="error">{error}</Alert>}
<TextField
label="Название (идентификатор)"
value={name}
onChange={(e) => setName(e.target.value)}
required
fullWidth
autoFocus
helperText="Латиница, без пробелов. Например: frontend, backend, devops"
disabled={isEditing}
data-testid="role-name-input"
/>
<TextField
label="Отображаемое название"
value={label}
onChange={(e) => setLabel(e.target.value)}
required
fullWidth
helperText="Как роль будет отображаться в интерфейсе"
data-testid="role-label-input"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} data-testid="cancel-role-button">Отмена</Button>
<Button
type="submit"
variant="contained"
disabled={!name.trim() || !label.trim() || isPending}
data-testid="submit-role-button"
>
{isEditing ? 'Сохранить' : 'Добавить'}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
export function RolesManager() {
const { data: roles = [], isLoading } = useRolesQuery();
const deleteRole = useDeleteRole();
const [modalOpen, setModalOpen] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [deleteError, setDeleteError] = useState('');
const handleAdd = () => {
setEditingRole(null);
setModalOpen(true);
};
const handleEdit = (role: Role) => {
setEditingRole(role);
setModalOpen(true);
};
const handleDelete = async (role: Role) => {
if (!confirm(`Удалить роль "${role.label}"?`)) return;
setDeleteError('');
try {
await deleteRole.mutateAsync(role.id);
} catch (err) {
if (err instanceof Error) {
setDeleteError(`Не удалось удалить роль: ${err.message}`);
}
}
};
const handleModalClose = () => {
setModalOpen(false);
setEditingRole(null);
};
return (
<Box data-testid="roles-manager">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Управление ролями</Typography>
<Button variant="contained" startIcon={<Add />} onClick={handleAdd} data-testid="add-role-button">
Добавить роль
</Button>
</Box>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError('')}>
{deleteError}
</Alert>
)}
<TableContainer component={Paper}>
<Table size="small" data-testid="roles-table">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>
Идентификатор
</TableCell>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>
Отображаемое название
</TableCell>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} align="center">
Порядок
</TableCell>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} />
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton /></TableCell>
<TableCell><Skeleton /></TableCell>
<TableCell><Skeleton /></TableCell>
<TableCell><Skeleton /></TableCell>
</TableRow>
))
) : roles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary" data-testid="roles-empty-state">
Нет ролей. Добавьте первую роль.
</Typography>
</TableCell>
</TableRow>
) : (
roles.map((role) => (
<TableRow key={role.id} hover data-testid={`role-row-${role.id}`}>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{role.name}
</Typography>
</TableCell>
<TableCell sx={{ fontWeight: 500 }}>{role.label}</TableCell>
<TableCell align="center">{role.sortOrder}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
size="small"
onClick={() => handleEdit(role)}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
data-testid="edit-role-button"
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(role)}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
data-testid="delete-role-button"
>
<Delete fontSize="small" />
</IconButton>
</Box>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<RoleModal open={modalOpen} onClose={handleModalClose} role={editingRole} />
</Box>
);
}

View File

@ -0,0 +1,154 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
Typography,
InputAdornment,
Skeleton,
} from '@mui/material';
import type { TeamMember, ProductivityMatrix } from '../../types/team';
import { complexityLabels } from '../../types/team';
import { useCreateTeamMember, useUpdateTeamMember } from '../../hooks/useTeam';
import { useRolesQuery } from '../../hooks/useRoles';
interface TeamMemberModalProps {
open: boolean;
onClose: () => void;
member?: TeamMember | null;
}
const defaultProductivity: ProductivityMatrix = {
trivial: 1,
simple: 4,
medium: 12,
complex: 32,
veryComplex: 60,
};
export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps) {
const [name, setName] = useState('');
const [roleId, setRoleId] = useState('');
const [productivity, setProductivity] = useState<ProductivityMatrix>(defaultProductivity);
const { data: roles = [], isLoading: rolesLoading } = useRolesQuery();
const createMember = useCreateTeamMember();
const updateMember = useUpdateTeamMember();
const isEditing = !!member;
useEffect(() => {
if (member) {
setName(member.name);
setRoleId(member.roleId);
setProductivity(member.productivity);
} else {
setName('');
setRoleId(roles[0]?.id ?? '');
setProductivity(defaultProductivity);
}
}, [member, open, roles]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !roleId) return;
const dto = { name: name.trim(), roleId, productivity };
if (isEditing) {
await updateMember.mutateAsync({ id: member.id, dto });
} else {
await createMember.mutateAsync(dto);
}
onClose();
};
const handleProductivityChange = (key: keyof ProductivityMatrix, value: string) => {
const num = parseFloat(value) || 0;
setProductivity((prev) => ({ ...prev, [key]: num }));
};
const isPending = createMember.isPending || updateMember.isPending;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth data-testid="team-member-modal">
<form onSubmit={handleSubmit} data-testid="team-member-form">
<DialogTitle>
{isEditing ? 'Редактировать участника' : 'Добавить участника'}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Имя"
value={name}
onChange={(e) => setName(e.target.value)}
required
fullWidth
autoFocus
data-testid="member-name-input"
/>
<FormControl fullWidth data-testid="member-role-select">
<InputLabel>Роль</InputLabel>
{rolesLoading ? (
<Skeleton variant="rectangular" height={56} />
) : (
<Select
value={roleId}
label="Роль"
onChange={(e) => setRoleId(e.target.value)}
>
{roles.map((role) => (
<MenuItem key={role.id} value={role.id}>
{role.label}
</MenuItem>
))}
</Select>
)}
</FormControl>
<Typography variant="subtitle2" sx={{ mt: 1 }}>
Производительность (часы на задачу)
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
{(Object.entries(complexityLabels) as [keyof ProductivityMatrix, string][]).map(
([key, label]) => (
<TextField
key={key}
label={label}
type="number"
size="small"
value={productivity[key]}
onChange={(e) => handleProductivityChange(key, e.target.value)}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">ч</InputAdornment>,
},
htmlInput: { min: 0, step: 0.5 },
}}
/>
),
)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} data-testid="cancel-member-button">Отмена</Button>
<Button type="submit" variant="contained" disabled={!name.trim() || !roleId || isPending} data-testid="submit-member-button">
{isEditing ? 'Сохранить' : 'Добавить'}
</Button>
</DialogActions>
</form>
</Dialog>
);
}

View File

@ -0,0 +1,184 @@
import { useState } from 'react';
import {
Box,
Paper,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Chip,
Skeleton,
Card,
CardContent,
Tabs,
Tab,
} from '@mui/material';
import { Add, Edit, Delete, Group, Settings } from '@mui/icons-material';
import { useTeamQuery, useTeamSummaryQuery, useDeleteTeamMember } from '../../hooks/useTeam';
import { complexityLabels } from '../../types/team';
import type { TeamMember, ProductivityMatrix } from '../../types/team';
import { TeamMemberModal } from './TeamMemberModal';
import { RolesManager } from './RolesManager';
export function TeamPage() {
const { data: members = [], isLoading } = useTeamQuery();
const { data: summary = [] } = useTeamSummaryQuery();
const deleteMember = useDeleteTeamMember();
const [modalOpen, setModalOpen] = useState(false);
const [editingMember, setEditingMember] = useState<TeamMember | null>(null);
const [activeTab, setActiveTab] = useState(0);
const handleAdd = () => {
setEditingMember(null);
setModalOpen(true);
};
const handleEdit = (member: TeamMember) => {
setEditingMember(member);
setModalOpen(true);
};
const handleDelete = (id: string) => {
if (confirm('Удалить участника команды?')) {
deleteMember.mutate(id);
}
};
const totalMembers = summary.reduce((acc, s) => acc + s.count, 0);
return (
<Box data-testid="team-page">
{/* Вкладки */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
<Tab icon={<Group />} iconPosition="start" label="Участники" data-testid="team-tab-members" />
<Tab icon={<Settings />} iconPosition="start" label="Роли" data-testid="team-tab-roles" />
</Tabs>
</Box>
{activeTab === 0 && (
<>
{/* Сводка по ролям */}
<Box sx={{ mb: 3 }} data-testid="team-summary">
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Group /> Состав команды ({totalMembers})
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{summary.map((item) => (
<Card key={item.roleId} variant="outlined" sx={{ minWidth: 150 }} data-testid={`role-card-${item.roleId}`}>
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="h4" sx={{ fontWeight: 600 }}>
{item.count}
</Typography>
<Typography variant="body2" color="text.secondary">
{item.label}
</Typography>
</CardContent>
</Card>
))}
</Box>
</Box>
{/* Таблица участников */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Участники</Typography>
<Button variant="contained" startIcon={<Add />} onClick={handleAdd} data-testid="add-team-member-button">
Добавить
</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small" data-testid="team-table">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>Имя</TableCell>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>Роль</TableCell>
{(Object.keys(complexityLabels) as (keyof ProductivityMatrix)[]).map((key) => (
<TableCell
key={key}
align="center"
sx={{ fontWeight: 600, backgroundColor: 'grey.100', fontSize: '0.75rem' }}
>
{complexityLabels[key]}
</TableCell>
))}
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} />
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton /></TableCell>
<TableCell><Skeleton /></TableCell>
{Array.from({ length: 5 }).map((_, j) => (
<TableCell key={j}><Skeleton /></TableCell>
))}
<TableCell><Skeleton /></TableCell>
</TableRow>
))
) : members.length === 0 ? (
<TableRow>
<TableCell colSpan={8} sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary" data-testid="team-empty-state">
Команда пока пуста. Добавьте первого участника.
</Typography>
</TableCell>
</TableRow>
) : (
members.map((member) => (
<TableRow key={member.id} hover data-testid={`team-member-row-${member.id}`}>
<TableCell sx={{ fontWeight: 500 }}>{member.name}</TableCell>
<TableCell>
<Chip label={member.role.label} size="small" variant="outlined" />
</TableCell>
{(Object.keys(complexityLabels) as (keyof ProductivityMatrix)[]).map((key) => (
<TableCell key={key} align="center">
{member.productivity[key]}ч
</TableCell>
))}
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
size="small"
onClick={() => handleEdit(member)}
data-testid="edit-team-member-button"
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(member.id)}
data-testid="delete-team-member-button"
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<Delete fontSize="small" />
</IconButton>
</Box>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TeamMemberModal
open={modalOpen}
onClose={() => setModalOpen(false)}
member={editingMember}
/>
</>
)}
{activeTab === 1 && <RolesManager />}
</Box>
);
}

View File

@ -0,0 +1,3 @@
export { TeamPage } from './TeamPage';
export { TeamMemberModal } from './TeamMemberModal';
export { RolesManager } from './RolesManager';

View File

@ -0,0 +1,38 @@
import keycloak from '../services/keycloak';
export interface User {
id: string;
name: string;
email: string;
username: string;
}
export function useAuth() {
const tokenParsed = keycloak.tokenParsed as {
sub?: string;
name?: string;
preferred_username?: string;
email?: string;
given_name?: string;
family_name?: string;
} | undefined;
const user: User | null = tokenParsed
? {
id: tokenParsed.sub ?? '',
name: tokenParsed.name ?? tokenParsed.preferred_username ?? 'Пользователь',
email: tokenParsed.email ?? '',
username: tokenParsed.preferred_username ?? '',
}
: null;
const logout = () => {
void keycloak.logout();
};
return {
user,
isAuthenticated: keycloak.authenticated ?? false,
logout,
};
}

View File

@ -0,0 +1,35 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { commentsApi } from '../services/comments';
import type { CreateCommentDto } from '../types/comment';
export function useCommentsQuery(ideaId: string | null) {
return useQuery({
queryKey: ['comments', ideaId],
queryFn: () => commentsApi.getByIdeaId(ideaId!),
enabled: !!ideaId,
});
}
export function useCreateComment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ideaId, dto }: { ideaId: string; dto: CreateCommentDto }) =>
commentsApi.create(ideaId, dto),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['comments', variables.ideaId] });
},
});
}
export function useDeleteComment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: { id: string; ideaId: string }) =>
commentsApi.delete(params.id),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['comments', variables.ideaId] });
},
});
}

View File

@ -0,0 +1,48 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { rolesApi } from '../services/roles';
import type { CreateRoleDto, UpdateRoleDto } from '../types/team';
export const ROLES_QUERY_KEY = ['roles'];
export function useRolesQuery() {
return useQuery({
queryKey: ROLES_QUERY_KEY,
queryFn: rolesApi.getAll,
});
}
export function useCreateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateRoleDto) => rolesApi.create(dto),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
},
});
}
export function useUpdateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateRoleDto }) =>
rolesApi.update(id, dto),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
void queryClient.invalidateQueries({ queryKey: ['team'] });
},
});
}
export function useDeleteRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => rolesApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
void queryClient.invalidateQueries({ queryKey: ['team'] });
},
});
}

View File

@ -0,0 +1,51 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { teamApi } from '../services/team';
import type { CreateTeamMemberDto, UpdateTeamMemberDto } from '../types/team';
export function useTeamQuery() {
return useQuery({
queryKey: ['team'],
queryFn: teamApi.getAll,
});
}
export function useTeamSummaryQuery() {
return useQuery({
queryKey: ['team', 'summary'],
queryFn: teamApi.getSummary,
});
}
export function useCreateTeamMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateTeamMemberDto) => teamApi.create(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] });
},
});
}
export function useUpdateTeamMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateTeamMemberDto }) =>
teamApi.update(id, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] });
},
});
}
export function useDeleteTeamMember() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => teamApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] });
},
});
}

View File

@ -0,0 +1,18 @@
import { api } from './api';
import type { Comment, CreateCommentDto } from '../types/comment';
export const commentsApi = {
getByIdeaId: async (ideaId: string): Promise<Comment[]> => {
const response = await api.get<Comment[]>(`/api/ideas/${ideaId}/comments`);
return response.data;
},
create: async (ideaId: string, dto: CreateCommentDto): Promise<Comment> => {
const response = await api.post<Comment>(`/api/ideas/${ideaId}/comments`, dto);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/api/comments/${id}`);
},
};

View File

@ -0,0 +1,28 @@
import { api } from './api';
import type { Role, CreateRoleDto, UpdateRoleDto } from '../types/team';
export const rolesApi = {
getAll: async (): Promise<Role[]> => {
const { data } = await api.get<Role[]>('/api/roles');
return data;
},
getById: async (id: string): Promise<Role> => {
const { data } = await api.get<Role>(`/api/roles/${id}`);
return data;
},
create: async (dto: CreateRoleDto): Promise<Role> => {
const { data } = await api.post<Role>('/api/roles', dto);
return data;
},
update: async (id: string, dto: UpdateRoleDto): Promise<Role> => {
const { data } = await api.patch<Role>(`/api/roles/${id}`, dto);
return data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/api/roles/${id}`);
},
};

View File

@ -0,0 +1,33 @@
import { api } from './api';
import type { TeamMember, CreateTeamMemberDto, UpdateTeamMemberDto, TeamSummary } from '../types/team';
export const teamApi = {
getAll: async (): Promise<TeamMember[]> => {
const response = await api.get<TeamMember[]>('/api/team');
return response.data;
},
getOne: async (id: string): Promise<TeamMember> => {
const response = await api.get<TeamMember>(`/api/team/${id}`);
return response.data;
},
getSummary: async (): Promise<TeamSummary[]> => {
const response = await api.get<TeamSummary[]>('/api/team/summary');
return response.data;
},
create: async (dto: CreateTeamMemberDto): Promise<TeamMember> => {
const response = await api.post<TeamMember>('/api/team', dto);
return response.data;
},
update: async (id: string, dto: UpdateTeamMemberDto): Promise<TeamMember> => {
const response = await api.patch<TeamMember>(`/api/team/${id}`, dto);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/api/team/${id}`);
},
};

View File

@ -6,6 +6,7 @@ interface IdeasFilters {
priority?: IdeaPriority;
module?: string;
search?: string;
color?: string;
}
interface IdeasSorting {

View File

@ -0,0 +1,13 @@
export interface Comment {
id: string;
text: string;
author: string | null;
ideaId: string;
createdAt: string;
updatedAt: string;
}
export interface CreateCommentDto {
text: string;
author?: string;
}

View File

@ -36,6 +36,7 @@ export interface CreateIdeaDto {
color?: string;
}
export interface UpdateIdeaDto extends Partial<CreateIdeaDto> {
export interface UpdateIdeaDto extends Omit<Partial<CreateIdeaDto>, 'color'> {
order?: number;
color?: string | null;
}

View File

@ -0,0 +1,56 @@
export interface Role {
id: string;
name: string;
label: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface CreateRoleDto {
name: string;
label: string;
sortOrder?: number;
}
export interface UpdateRoleDto extends Partial<CreateRoleDto> {}
export interface ProductivityMatrix {
trivial: number;
simple: number;
medium: number;
complex: number;
veryComplex: number;
}
export interface TeamMember {
id: string;
name: string;
role: Role;
roleId: string;
productivity: ProductivityMatrix;
createdAt: string;
updatedAt: string;
}
export interface CreateTeamMemberDto {
name: string;
roleId: string;
productivity?: ProductivityMatrix;
}
export interface UpdateTeamMemberDto extends Partial<CreateTeamMemberDto> {}
export interface TeamSummary {
roleId: string;
label: string;
count: number;
}
export const complexityLabels: Record<keyof ProductivityMatrix, string> = {
trivial: 'Тривиальная',
simple: 'Простая',
medium: 'Средняя',
complex: 'Сложная',
veryComplex: 'Очень сложная',
};