diff --git a/app/layout.tsx b/app/layout.tsx index ffc6dec..6643dfc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,15 @@ import 'app/styles/global.css'; import { AppProviders } from 'app/providers/AppProviders'; +import { Inter } from 'next/font/google'; + +const inter = Inter({ + subsets: ['cyrillic'], +}); + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/components.json b/components.json index 0f116f6..3acd8e1 100644 --- a/components.json +++ b/components.json @@ -19,6 +19,7 @@ "hooks": "shared/hooks/shadcn" }, "registries": { - "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json" + "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json", + "@reui": "https://reui.io/r/{style}/{name}.json" } } diff --git a/src/entities/board/api/http.ts b/src/entities/board/api/http.ts new file mode 100644 index 0000000..ca6097d --- /dev/null +++ b/src/entities/board/api/http.ts @@ -0,0 +1,173 @@ +import { api } from 'shared/api'; +import * as SBoard from '../model/schemas'; +import * as TBoard from '../model/types'; + +export class BoardHttp { + static getBoardList(projectId: string, signal?: AbortSignal) { + return api({ + url: `/projects/${projectId}/boards`, + method: 'GET', + contracts: { + response: SBoard.BoardListResponse, + }, + signal, + }); + } + + static getBoard(projectId: string, id: string, signal?: AbortSignal) { + return api({ + url: `/projects/${projectId}/boards/${id}`, + method: 'GET', + contracts: { + response: SBoard.Board, + }, + signal, + }); + } + + static createBoard(projectId: string, data: TBoard.CreateBoardBody) { + return api({ + url: `/projects/${projectId}/boards`, + method: 'POST', + data, + contracts: { + body: SBoard.CreateBoardBody, + response: SBoard.CreateBoardResponse, + }, + }); + } + + static updateBoard(projectId: string, id: string, data: TBoard.UpdateBoardBody) { + return api({ + url: `/projects/${projectId}/boards/${id}`, + method: 'PATCH', + data, + contracts: { + body: SBoard.UpdateBoardBody, + response: SBoard.ActionResponse, + }, + }); + } + + static removeBoard(projectId: string, id: string) { + return api({ + url: `/projects/${projectId}/boards/${id}`, + method: 'DELETE', + contracts: { + response: SBoard.ActionResponse, + }, + }); + } + + static getBoardColumnList(boardId: string, signal?: AbortSignal) { + return api({ + url: `/boards/${boardId}/columns`, + method: 'GET', + contracts: { + response: SBoard.BoardColumnListResponse, + }, + signal, + }); + } + + static getBoardColumn(boardId: string, id: string, signal?: AbortSignal) { + return api({ + url: `/boards/${boardId}/columns/${id}`, + method: 'GET', + contracts: { + response: SBoard.BoardColumn, + }, + signal, + }); + } + + static createBoardColumn(boardId: string, data: TBoard.CreateBoardColumnBody) { + return api({ + url: `/boards/${boardId}/columns`, + method: 'POST', + data, + contracts: { + body: SBoard.CreateBoardColumnBody, + response: SBoard.CreateBoardColumnResponse, + }, + }); + } + + static updateBoardColumn(boardId: string, id: string, data: TBoard.UpdateBoardColumnBody) { + return api({ + url: `/boards/${boardId}/columns/${id}`, + method: 'PATCH', + data, + contracts: { + body: SBoard.UpdateBoardColumnBody, + response: SBoard.ActionResponse, + }, + }); + } + + static removeBoardColumn(boardId: string, id: string) { + return api({ + url: `/boards/${boardId}/columns/${id}`, + method: 'DELETE', + contracts: { + response: SBoard.ActionResponse, + }, + }); + } + + static getBoardViewList(boardId: string, signal?: AbortSignal) { + return api({ + url: `/boards/${boardId}/views`, + method: 'GET', + contracts: { + response: SBoard.BoardViewListResponse, + }, + signal, + }); + } + + static getBoardView(boardId: string, id: string, signal?: AbortSignal) { + return api({ + url: `/boards/${boardId}/views/${id}`, + method: 'GET', + contracts: { + response: SBoard.BoardView, + }, + signal, + }); + } + + static createBoardView(boardId: string, data: TBoard.CreateBoardViewBody) { + return api({ + url: `/boards/${boardId}/views`, + method: 'POST', + data, + contracts: { + body: SBoard.CreateBoardViewBody, + response: SBoard.CreateBoardViewResponse, + }, + }); + } + + static updateBoardView(boardId: string, id: string, data: TBoard.UpdateBoardViewBody) { + return api({ + url: `/boards/${boardId}/views/${id}`, + method: 'PATCH', + data, + contracts: { + body: SBoard.UpdateBoardViewBody, + response: SBoard.ActionResponse, + }, + }); + } + + static removeBoardView(boardId: string, id: string) { + return api({ + url: `/boards/${boardId}/views/${id}`, + method: 'DELETE', + contracts: { + response: SBoard.ActionResponse, + }, + }); + } +} diff --git a/src/entities/board/api/queries.ts b/src/entities/board/api/queries.ts new file mode 100644 index 0000000..9154c47 --- /dev/null +++ b/src/entities/board/api/queries.ts @@ -0,0 +1,53 @@ +import { queryOptions } from '@tanstack/react-query'; +import { boardFabricKeys } from '../model/conts'; +import { BoardHttp } from './http'; + +export class BoardQueries { + static getBoardList(projectId: string) { + return queryOptions({ + queryKey: boardFabricKeys.list(projectId), + queryFn: async ({ signal }) => BoardHttp.getBoardList(projectId, signal), + staleTime: 60_000, + }); + } + + static getBoard(projectId: string, id: string) { + return queryOptions({ + queryKey: boardFabricKeys.detail(projectId, id), + queryFn: async ({ signal }) => BoardHttp.getBoard(projectId, id, signal), + staleTime: 60_000, + }); + } + + static getBoardColumnList(boardId: string) { + return queryOptions({ + queryKey: boardFabricKeys.columns(boardId), + queryFn: async ({ signal }) => BoardHttp.getBoardColumnList(boardId, signal), + staleTime: 60_000, + }); + } + + static getBoardColumn(boardId: string, id: string) { + return queryOptions({ + queryKey: boardFabricKeys.column(boardId, id), + queryFn: async ({ signal }) => BoardHttp.getBoardColumn(boardId, id, signal), + staleTime: 60_000, + }); + } + + static getBoardViewList(boardId: string) { + return queryOptions({ + queryKey: boardFabricKeys.views(boardId), + queryFn: async ({ signal }) => BoardHttp.getBoardViewList(boardId, signal), + staleTime: 60_000, + }); + } + + static getBoardView(boardId: string, id: string) { + return queryOptions({ + queryKey: boardFabricKeys.view(boardId, id), + queryFn: async ({ signal }) => BoardHttp.getBoardView(boardId, id, signal), + staleTime: 60_000, + }); + } +} diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts new file mode 100644 index 0000000..b063b65 --- /dev/null +++ b/src/entities/board/index.ts @@ -0,0 +1,8 @@ +export type * as TBoard from './model/types'; +export * as SBoard from './model/schemas'; +export { boardFabricKeys } from './model/conts'; +export { BoardHttp } from './api/http'; +export { BoardQueries } from './api/queries'; +export { mockBoard } from './model/mock-data'; +export { BoardMapper, type BoardWithTasks } from './model/mapper'; +export { BOARD_COLUMN_COLORS } from './lib/colors'; diff --git a/src/entities/board/lib/colors.ts b/src/entities/board/lib/colors.ts new file mode 100644 index 0000000..b6bdfab --- /dev/null +++ b/src/entities/board/lib/colors.ts @@ -0,0 +1,14 @@ +export const BOARD_COLUMN_COLORS = [ + '#9FA8DA', + '#7E57C2', + '#9575CD', + '#AB47BC', + '#F06292', + '#FF8A65', + '#4FC3F7', + '#4DB6AC', + '#81C784', + '#DCE775', + '#FFF176', + '#FFB74D', +] as const; diff --git a/src/entities/board/model/conts.ts b/src/entities/board/model/conts.ts new file mode 100644 index 0000000..cbfe1d7 --- /dev/null +++ b/src/entities/board/model/conts.ts @@ -0,0 +1,9 @@ +import { createEntityKeys } from 'shared/lib/utils'; + +export const boardFabricKeys = createEntityKeys('board', { + detail: (projectId: string, id: string) => ['projects', projectId, 'boards', id], + columns: (boardId: string) => ['boards', boardId, 'columns'], + column: (boardId: string, id: string) => ['boards', boardId, 'columns', id], + views: (boardId: string) => ['boards', boardId, 'views'], + view: (boardId: string, id: string) => ['boards', boardId, 'views', id], +}); diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts new file mode 100644 index 0000000..d6407bc --- /dev/null +++ b/src/entities/board/model/mapper.ts @@ -0,0 +1,40 @@ +import { BoardColumnResponse, BoardResponse } from './types'; + +// TODO: добавить таски в типы, когда они появятся в API + +export type BoardWithTasks = { + board: BoardResponse; + columns: Record; + tasksByColumn: Record; +}; + +export class BoardMapper { + static toBoardWithTasks(board: BoardResponse): BoardWithTasks { + const sortedColumns = [...board.boardColumns].sort((a, b) => a.position - b.position); + const tasksByColumn: Record = {}; + const columns: Record = {}; + + sortedColumns.forEach((column) => { + tasksByColumn[column.id] = []; + columns[column.id] = column; + }); + + // tasks?.forEach((task) => { + // if (tasksByColumn[task.columnId]) { + // tasksByColumn[task.columnId].push(task); + // } else { + // console.warn(`Task ${task.id} references unknown column ${task.columnId}`); + // } + // }); + + // Object.keys(tasksByColumn).forEach((columnId) => { + // tasksByColumn[columnId].sort((a, b) => a.position - b.position); + // }); + + return { + board, + columns, + tasksByColumn, + }; + } +} diff --git a/src/entities/board/model/mock-data.ts b/src/entities/board/model/mock-data.ts new file mode 100644 index 0000000..3e6e7b1 --- /dev/null +++ b/src/entities/board/model/mock-data.ts @@ -0,0 +1,395 @@ +import { BoardResponse } from '../model/types'; + +export const mockTasks = [ + { + id: '1', + boardId: 'planning', + columnId: 'ideas', + title: 'Сбор вдохновения', + description: 'Изучить лучшие практики и собрать референсы', + priority: 'high', + assigneeId: '1', + dueDate: '2023-10-05', + position: 0, + createdAt: '2023-09-25T10:00:00Z', + updatedAt: '2023-09-25T10:00:00Z', + assignee: { name: 'Андрей', avatarUrl: null }, + }, + { + id: '2', + boardId: 'planning', + columnId: 'ideas', + title: 'Цели проекта', + description: 'Определить ключевые цели и метрики успеха', + priority: 'medium', + assigneeId: '2', + dueDate: '2023-10-06', + position: 1, + createdAt: '2023-09-26T11:00:00Z', + updatedAt: '2023-09-26T11:00:00Z', + assignee: { name: 'Мария', avatarUrl: null }, + }, + { + id: '3', + boardId: 'planning', + columnId: 'plan', + title: 'Дорожная карта', + description: 'Создать план реализации проекта по этапам', + priority: 'low', + assigneeId: '3', + dueDate: '2023-10-07', + position: 0, + createdAt: '2023-09-27T09:00:00Z', + updatedAt: '2023-09-27T09:00:00Z', + assignee: { name: 'Иван', avatarUrl: null }, + }, + { + id: '4', + boardId: 'planning', + columnId: 'plan', + title: 'Ресурсы', + description: 'Оценить необходимые ресурсы и бюджет', + priority: 'medium', + assigneeId: '4', + dueDate: '2023-10-08', + position: 1, + createdAt: '2023-09-28T14:00:00Z', + updatedAt: '2023-09-28T14:00:00Z', + assignee: { name: 'Сергей', avatarUrl: null }, + }, + { + id: '5', + boardId: 'planning', + columnId: 'docs', + title: 'Техническая документация', + description: 'Подготовить техническое задание', + priority: 'high', + assigneeId: '5', + dueDate: '2023-10-09', + position: 0, + createdAt: '2023-09-29T08:00:00Z', + updatedAt: '2023-09-29T08:00:00Z', + assignee: { name: 'Дмитрий', avatarUrl: null }, + }, + { + id: '6', + boardId: 'planning', + columnId: 'docs', + title: 'План коммуникации', + description: 'Разработать коммуникационную стратегию', + priority: 'medium', + assigneeId: '6', + dueDate: '2023-10-10', + position: 1, + createdAt: '2023-09-30T12:00:00Z', + updatedAt: '2023-09-30T12:00:00Z', + assignee: { name: 'Анна', avatarUrl: null }, + }, + { + id: '11', + boardId: 'in-progress', + columnId: 'todo', + title: 'Дизайн макета', + description: 'Разработать основной макет приложения', + priority: 'high', + assigneeId: '1', + dueDate: '2023-10-05', + position: 0, + createdAt: '2023-10-01T10:00:00Z', + updatedAt: '2023-10-01T10:00:00Z', + assignee: { name: 'Андрей', avatarUrl: null }, + }, + { + id: '31', + boardId: 'in-progress', + columnId: 'inProgress', + title: 'Подготовить бриф', + description: 'Подготовить бриф для дизайнеров', + priority: 'low', + assigneeId: '3', + dueDate: '2023-10-07', + position: 0, + createdAt: '2023-10-02T09:00:00Z', + updatedAt: '2023-10-02T09:00:00Z', + assignee: { name: 'Иван', avatarUrl: null }, + }, + { + id: '51', + boardId: 'in-progress', + columnId: 'review', + title: 'Техническая документация', + description: 'Проверить техническую документацию', + priority: 'high', + assigneeId: '5', + dueDate: '2023-10-09', + position: 0, + createdAt: '2023-10-03T08:00:00Z', + updatedAt: '2023-10-03T08:00:00Z', + assignee: { name: 'Дмитрий', avatarUrl: null }, + }, + { + id: '61', + boardId: 'in-progress', + columnId: 'review', + title: 'Коммуникация', + description: 'Проверить план коммуникации', + priority: 'medium', + assigneeId: '6', + dueDate: '2023-10-10', + position: 1, + createdAt: '2023-10-04T12:00:00Z', + updatedAt: '2023-10-04T12:00:00Z', + assignee: { name: 'Анна', avatarUrl: null }, + }, + { + id: 'results-1', + boardId: 'results', + columnId: 'reports', + title: 'Итоговый отчёт', + description: 'Собрать все метрики и графики', + priority: 'high', + assigneeId: '6', + dueDate: '2023-10-20', + position: 0, + createdAt: '2023-10-05T10:00:00Z', + updatedAt: '2023-10-05T10:00:00Z', + assignee: { name: 'Аналитик', avatarUrl: null }, + }, + { + id: 'results-2', + boardId: 'results', + columnId: 'insights', + title: 'Рефлексия', + description: 'Что получилось, а что нет', + priority: 'medium', + assigneeId: '7', + dueDate: '2023-10-22', + position: 0, + createdAt: '2023-10-06T11:00:00Z', + updatedAt: '2023-10-06T11:00:00Z', + assignee: { name: 'Тимлид', avatarUrl: null }, + }, + { + id: 'results-3', + boardId: 'results', + columnId: 'documentation', + title: 'Запись результатов', + description: 'Задокументировать выводы в Confluence', + priority: 'low', + assigneeId: '8', + dueDate: '2023-10-25', + position: 0, + createdAt: '2023-10-07T14:00:00Z', + updatedAt: '2023-10-07T14:00:00Z', + assignee: { name: 'Документалист', avatarUrl: null }, + }, +]; + +export const mockBoard: BoardResponse = { + id: 'planning', + name: 'Планирование проекта', + projectId: 'project-001', + settings: { + defaultView: 'kanban', + theme: 'light', + timezone: 'UTC+3', + }, + position: 1, + ownerId: 'user-001', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-03-20T14:30:00Z', + boardColumns: [ + { + id: 'ideas', + boardId: 'planning', + name: 'Идеи', + position: 0, + status: 'backlog', + color: '#9CA3AF', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + { + id: 'plan', + boardId: 'planning', + name: 'План', + position: 1, + status: 'todo', + color: '#F59E0B', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + { + id: 'docs', + boardId: 'planning', + name: 'Документы', + position: 2, + status: 'in_progress', + color: '#3B82F6', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + ], + boardViews: [ + { + id: 'view-001', + boardId: 'planning', + type: 'kanban', + name: 'Kanban Board', + settings: { + showEmptyColumns: 'true', + cardSize: 'medium', + }, + position: 0, + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + ], +}; + +export const mockBoardInProgress: BoardResponse = { + id: 'in-progress', + name: 'Задачи в работе', + projectId: 'project-001', + settings: { + defaultView: 'kanban', + theme: 'light', + timezone: 'UTC+3', + }, + position: 2, + ownerId: 'user-001', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-03-25T09:00:00Z', + boardColumns: [ + { + id: 'todo', + boardId: 'in-progress', + name: 'Ожидает выполнения', + position: 0, + status: 'todo', + color: '#F59E0B', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'inProgress', + boardId: 'in-progress', + name: 'В работе', + position: 1, + status: 'in_progress', + color: '#3B82F6', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'review', + boardId: 'in-progress', + name: 'На проверке', + position: 2, + status: 'in_progress', + color: '#8B5CF6', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'bankReview', + boardId: 'in-progress', + name: 'На проверке в банке', + position: 3, + status: 'in_progress', + color: '#EC4899', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'done', + boardId: 'in-progress', + name: 'Завершено', + position: 4, + status: 'done', + color: '#10B981', + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + ], + boardViews: [ + { + id: 'view-002', + boardId: 'in-progress', + type: 'kanban', + name: 'Kanban Board', + settings: { + showEmptyColumns: 'true', + cardSize: 'medium', + }, + position: 0, + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + ], +}; + +export const mockBoardResults: BoardResponse = { + id: 'results', + name: 'Фиксация результатов', + projectId: 'project-001', + settings: { + defaultView: 'list', + theme: 'dark', + timezone: 'UTC+3', + }, + position: 3, + ownerId: 'user-002', + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-28T16:00:00Z', + boardColumns: [ + { + id: 'reports', + boardId: 'results', + name: 'Отчёты', + position: 0, + status: 'todo', + color: '#F59E0B', + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-01T10:00:00Z', + }, + { + id: 'insights', + boardId: 'results', + name: 'Выводы', + position: 1, + status: 'in_progress', + color: '#3B82F6', + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-01T10:00:00Z', + }, + { + id: 'documentation', + boardId: 'results', + name: 'Документация', + position: 2, + status: 'done', + color: '#10B981', + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-01T10:00:00Z', + }, + ], + boardViews: [ + { + id: 'view-003', + boardId: 'results', + type: 'kanban', + name: 'List View', + settings: { + groupBy: 'status', + sortBy: 'position', + }, + position: 0, + createdAt: '2024-03-01T10:00:00Z', + updatedAt: '2024-03-01T10:00:00Z', + }, + ], +}; + +// Массив всех досок для удобства +export const mockBoards: BoardResponse[] = [mockBoard, mockBoardInProgress, mockBoardResults]; diff --git a/src/entities/board/model/schemas.ts b/src/entities/board/model/schemas.ts new file mode 100644 index 0000000..2af7ea0 --- /dev/null +++ b/src/entities/board/model/schemas.ts @@ -0,0 +1,112 @@ +import { DateTimeString, GlobalSuccess, PaginatedResponseSchema } from 'shared/api'; +import { z } from 'zod/v4'; + +export const ActionResponse = GlobalSuccess; + +export const ColumnStatusEnum = z.enum(['backlog', 'todo', 'in_progress', 'done', 'canceled']); +export const ViewTypeEnum = z.enum(['kanban', 'calendar', 'gantt_matrix']); +export const Settings = z.record(z.string(), z.unknown()); + +export const BoardColumn = z.object({ + id: z.string(), + boardId: z.string(), + name: z.string(), + position: z.number(), + status: ColumnStatusEnum, + color: z.string(), + createdAt: DateTimeString, + updatedAt: DateTimeString, +}); + +export const BoardView = z.object({ + id: z.string(), + boardId: z.string(), + type: ViewTypeEnum, + name: z.string(), + settings: Settings, + position: z.number(), + createdAt: DateTimeString, + updatedAt: DateTimeString, +}); + +export const Board = z.object({ + id: z.string(), + name: z.string(), + projectId: z.string(), + settings: Settings, + position: z.number(), + ownerId: z.string().nullable(), + createdAt: DateTimeString, + updatedAt: DateTimeString, + boardColumns: z.array(BoardColumn), + boardViews: z.array(BoardView), +}); + +export const BoardListResponse = PaginatedResponseSchema(Board); +export const BoardColumnListResponse = PaginatedResponseSchema(BoardColumn); +export const BoardViewListResponse = PaginatedResponseSchema(BoardView); + +export const CreateBoardBody = z.object({ + name: z + .string() + .min(1, 'Название доски не может быть пустым') + .max(100, 'Название доски не должно превышать 100 символов'), + position: z.number(), + settings: Settings.optional(), +}); + +export const UpdateBoardBody = CreateBoardBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const CreateBoardResponse = GlobalSuccess.extend({ boardId: z.string() }); + +export const CreateBoardViewBody = z.object({ + type: ViewTypeEnum, + name: z + .string() + .min(1, 'Название представления не может быть пустым') + .max(100, 'Название представления не должно превышать 100 символов'), + settings: Settings.optional(), + position: z.number(), +}); + +export const UpdateBoardViewBody = CreateBoardViewBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const CreateBoardViewResponse = GlobalSuccess.extend({ + viewId: z.string(), +}); + +export const CreateBoardColumnBody = z.object({ + name: z + .string() + .min(1, 'Название колонки не может быть пустым') + .max(50, 'Название колонки не должно превышать 50 символов'), + position: z.number(), + color: z + .string() + .regex(/^#[A-Fa-f0-9]{6}$/) + .optional(), +}); + +export const UpdateBoardColumnBody = CreateBoardColumnBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const CreateBoardColumnResponse = GlobalSuccess.extend({ + columnId: z.string(), +}); diff --git a/src/entities/board/model/types.ts b/src/entities/board/model/types.ts new file mode 100644 index 0000000..ef88b13 --- /dev/null +++ b/src/entities/board/model/types.ts @@ -0,0 +1,29 @@ +import { z } from 'zod/v4'; +import * as SBoard from './schemas'; + +export type BoardSettings = z.infer; +export type BoardColumnStatus = z.infer; +export type BoardViewType = z.infer; + +export type BoardColumnResponse = z.infer; +export type BoardColumnListResponse = z.infer; + +export type BoardViewResponse = z.infer; +export type BoardViewListResponse = z.infer; + +export type BoardResponse = z.infer; +export type BoardListResponse = z.infer; + +export type CreateBoardBody = z.infer; +export type UpdateBoardBody = z.infer; +export type CreateBoardResponse = z.infer; + +export type CreateBoardViewBody = z.infer; +export type UpdateBoardViewBody = z.infer; +export type CreateBoardViewResponse = z.infer; + +export type CreateBoardColumnBody = z.infer; +export type UpdateBoardColumnBody = z.infer; +export type CreateBoardColumnResponse = z.infer; + +export type ActionResponse = z.infer; diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index 190e163..6ce7f95 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -7,3 +7,5 @@ export { PROJECT_ICONS } from './config/icons'; export { PROJECT_COLORS } from './config/colors'; export { projectIconCodeToEmoji } from './lib/emoji'; export { buildProjectShareUrl } from './lib/share-url'; +export { useProjectStore } from './model/store'; +export { useInitProjectId } from './lib/useInitProjectId'; diff --git a/src/entities/project/lib/useInitProjectId.ts b/src/entities/project/lib/useInitProjectId.ts new file mode 100644 index 0000000..c115e5b --- /dev/null +++ b/src/entities/project/lib/useInitProjectId.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import { useProjectStore } from '../model/store'; + +export function useInitProjectId(projectId: string) { + const setProjectid = useProjectStore((s) => s.setProjectId); + const clearProjectId = useProjectStore((s) => s.clearProjectId); + + useEffect(() => { + setProjectid(projectId); + + return () => { + clearProjectId(); + }; + }, [clearProjectId, projectId, setProjectid]); + return null; +} diff --git a/src/entities/project/model/store.ts b/src/entities/project/model/store.ts new file mode 100644 index 0000000..d90c368 --- /dev/null +++ b/src/entities/project/model/store.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +interface ProjectStore { + projectId: string | null; + setProjectId: (id: string) => void; + clearProjectId: () => void; +} + +export const useProjectStore = create((set) => ({ + projectId: null, + setProjectId(id) { + set({ projectId: id }); + }, + clearProjectId() { + set({ projectId: null }); + }, +})); diff --git a/src/entities/task/api/http.ts b/src/entities/task/api/http.ts new file mode 100644 index 0000000..0319bb7 --- /dev/null +++ b/src/entities/task/api/http.ts @@ -0,0 +1,17 @@ +import { api } from 'shared/api'; +import * as STask from '../model/schemas'; +import * as TTask from '../model/types'; + +export class TaskHttp { + static createTask(data: TTask.CreateTaskBody) { + return api({ + url: `/teams/projects/`, + method: 'POST', + data, + contracts: { + response: STask.CreateTaskResponse, + body: STask.CreateTaskBody, + }, + }); + } +} diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts new file mode 100644 index 0000000..d810ae8 --- /dev/null +++ b/src/entities/task/index.ts @@ -0,0 +1,4 @@ +export type * as TTask from './model/types'; +export * as STask from './model/schemas'; +export { TaskHttp } from './api/http'; +export { taskFabricKeys } from './model/const'; diff --git a/src/entities/task/model/const.ts b/src/entities/task/model/const.ts new file mode 100644 index 0000000..90e87ad --- /dev/null +++ b/src/entities/task/model/const.ts @@ -0,0 +1,6 @@ +import { createEntityKeys } from 'shared/lib/utils'; + +export const taskFabricKeys = createEntityKeys('task', { + list: () => ['teams', 'tasks'], + detail: (taskId: string) => ['teams', 'projects', taskId], +}); diff --git a/src/entities/task/model/schemas.ts b/src/entities/task/model/schemas.ts new file mode 100644 index 0000000..5726f31 --- /dev/null +++ b/src/entities/task/model/schemas.ts @@ -0,0 +1,27 @@ +import { DateTimeString, GlobalSuccess } from 'shared/api'; +import { z } from 'zod/v4'; + +export const Task = z.object({ + id: z.string(), + boardId: z.string(), + columnId: z.string(), + title: z.string(), + description: z.string().optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']), + assigneeId: z.string().optional(), + assignee: z.object({ + name: z.string(), + avatarUrl: z.string().nullable(), + }), + dueDate: z.string().optional(), + position: z.number(), + createdAt: DateTimeString, + updatedAt: DateTimeString, +}); + +export const CreateTaskBody = z.object({ + title: z.string().min(1).max(300), + boardId: z.string(), + columnId: z.string(), +}); +export const CreateTaskResponse = GlobalSuccess; diff --git a/src/entities/task/model/types.ts b/src/entities/task/model/types.ts new file mode 100644 index 0000000..9e3eaa9 --- /dev/null +++ b/src/entities/task/model/types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod/v4'; +import * as STask from './schemas'; + +export type Task = z.infer; + +export type CreateTaskBody = z.infer; + +export type CreateTaskResponse = Task; diff --git a/src/features/boards/column/create/index.ts b/src/features/boards/column/create/index.ts new file mode 100644 index 0000000..ffaa0bf --- /dev/null +++ b/src/features/boards/column/create/index.ts @@ -0,0 +1,2 @@ +export { CreateBoardColumnDialog } from './ui/CreateBoardColumnDialog'; +export { CreateBoardColumnForm } from './ui/CreateBoardColumnForm'; diff --git a/src/features/boards/column/create/model/consts.ts b/src/features/boards/column/create/model/consts.ts new file mode 100644 index 0000000..1723308 --- /dev/null +++ b/src/features/boards/column/create/model/consts.ts @@ -0,0 +1,5 @@ +import { BOARD_COLUMN_COLORS } from 'entities/board'; + +export const DEFAULT_COLUMN_COLOR = '#64848B'; + +export const COLORS = [DEFAULT_COLUMN_COLOR, ...BOARD_COLUMN_COLORS]; diff --git a/src/features/boards/column/create/model/default-values.ts b/src/features/boards/column/create/model/default-values.ts new file mode 100644 index 0000000..710f1fc --- /dev/null +++ b/src/features/boards/column/create/model/default-values.ts @@ -0,0 +1,10 @@ +import { DEFAULT_COLUMN_COLOR } from './consts'; +import { CreateBoardColumnFormValues } from './type'; + +export function getDefaultCreateBoardColumnValues(position = 0): CreateBoardColumnFormValues { + return { + name: '', + position, + color: DEFAULT_COLUMN_COLOR, + }; +} diff --git a/src/features/boards/column/create/model/schemas.ts b/src/features/boards/column/create/model/schemas.ts new file mode 100644 index 0000000..dca9c6e --- /dev/null +++ b/src/features/boards/column/create/model/schemas.ts @@ -0,0 +1,7 @@ +import { SBoard } from 'entities/board'; +import { z } from 'zod/v4'; + +export const CreateBoardColumnFormSchema = SBoard.CreateBoardColumnBody.extend({ + position: z.union([z.string(), z.number()]).transform(Number), + color: z.string().regex(/^#[A-Fa-f0-9]{6}$/), +}); diff --git a/src/features/boards/column/create/model/type.ts b/src/features/boards/column/create/model/type.ts new file mode 100644 index 0000000..576da7c --- /dev/null +++ b/src/features/boards/column/create/model/type.ts @@ -0,0 +1,6 @@ +import { z } from 'zod/v4'; +import { CreateBoardColumnFormSchema } from './schemas'; + +export type CreateBoardColumnFormValues = z.input; + +export type CreateBoardColumnFormOutput = z.output; diff --git a/src/features/boards/column/create/model/useCreateBoardColumn.ts b/src/features/boards/column/create/model/useCreateBoardColumn.ts new file mode 100644 index 0000000..7dc82c9 --- /dev/null +++ b/src/features/boards/column/create/model/useCreateBoardColumn.ts @@ -0,0 +1,32 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +type CreateBoardColumnVariables = { + boardId: string; + projectId: string; + body: TBoard.CreateBoardColumnBody; +}; + +export type UseCreateBoardColumnOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateBoardColumn({ onSuccess, ...rest }: UseCreateBoardColumnOptions = {}) { + return useMutation({ + ...rest, + mutationFn: ({ boardId, body }) => BoardHttp.createBoardColumn(boardId, body), + onSuccess: async (res, variables, _r, context) => { + onSuccess?.(res, variables, _r, context); + toast.success(res.message ?? 'Колонка создана'); + + await context.client.invalidateQueries({ + queryKey: boardFabricKeys.list(variables.projectId), + }); + await context.client.invalidateQueries({ + queryKey: boardFabricKeys.columns(variables.boardId), + }); + }, + }); +} diff --git a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts new file mode 100644 index 0000000..429ebc7 --- /dev/null +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -0,0 +1,55 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { getDefaultCreateBoardColumnValues } from './default-values'; +import { useCreateBoardColumn, UseCreateBoardColumnOptions } from './useCreateBoardColumn'; +import { CreateBoardColumnFormSchema } from './schemas'; +import { CreateBoardColumnFormValues } from './type'; +import { setFormErrors } from 'shared/lib/utils'; +import { extractValidationIssues } from 'shared/api'; +import { TBoard } from 'entities/board'; +import { useProjectStore } from 'entities/project'; + +type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { + defaultPosition?: number; +}; + +export function useCreateBoardColumnForm( + boardId: string, + options: UseCreateBoardColumnFormOptions = {} +) { + const { defaultPosition = 0, ...mutationOptions } = options; + const projectId = useProjectStore((s) => s.projectId!); + const form = useForm({ + resolver: zodResolver(CreateBoardColumnFormSchema), + defaultValues: getDefaultCreateBoardColumnValues(defaultPosition), + }); + + const createBoardColumn = useCreateBoardColumn({ + ...mutationOptions, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err, ...args) => { + mutationOptions.onError?.(err, ...args); + setFormErrors(extractValidationIssues(err), form); + }, + }); + + const onSubmit = (data: CreateBoardColumnFormValues) => { + const body: TBoard.CreateBoardColumnBody = { + name: data.name, + position: Number(data.position), + ...(data.color ? { color: data.color } : {}), + }; + + createBoardColumn.mutate({ boardId, projectId, body }); + }; + + return { + form, + boardId, + projectId, + isPending: createBoardColumn.isPending, + handleSubmit: form.handleSubmit(onSubmit), + }; +} diff --git a/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx b/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx new file mode 100644 index 0000000..cef8757 --- /dev/null +++ b/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx @@ -0,0 +1,77 @@ +import { ComponentProps, useId, useState } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Spinner, +} from 'shared/ui'; +import { CreateBoardColumnForm } from './CreateBoardColumnForm'; + +interface CreateBoardColumnDialogProps extends ComponentProps { + boardId: string; + defaultPosition?: number; + dialog?: ComponentProps; +} + +export function CreateBoardColumnDialog({ + boardId, + defaultPosition, + dialog = {}, + ...props +}: CreateBoardColumnDialogProps) { + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + const formId = useId(); + const [pending, setPending] = useState(false); + + return ( + + + + + Новая колонка + + + + { + setPending(true); + }, + onSuccess: () => { + setOpen(false); + }, + onSettled: () => { + setPending(false); + }, + }} + /> + + + + + + + + + + ); +} diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx new file mode 100644 index 0000000..3f68167 --- /dev/null +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -0,0 +1,96 @@ +import { Controller, FormProvider } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { ColorPicker, Field, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; +import { useCreateBoardColumnForm } from '../model/useCreateBoardColumnForm'; +import { UseCreateBoardColumnOptions } from '../model/useCreateBoardColumn'; +import { ComponentProps } from 'react'; +import { COLORS } from '../model/consts'; + +interface CreateBoardColumnFormProps extends Omit, 'children' | 'onSubmit'> { + boardId: string; + defaultPosition?: number; + mutateOptions?: UseCreateBoardColumnOptions; +} + +export function CreateBoardColumnForm({ + boardId, + defaultPosition, + className, + mutateOptions, + ...props +}: CreateBoardColumnFormProps) { + const { form, isPending, handleSubmit } = useCreateBoardColumnForm(boardId, { + defaultPosition, + ...mutateOptions, + }); + + return ( + +
+ + ( + + Название + + {fieldState.invalid && } + + )} + /> + ( + + Цвет + { + form.setValue('color', c); + }} + {...field} + /> + + {fieldState.invalid && } + + )} + /> + ( + + Позиция колонки + + {fieldState.invalid && } + + )} + /> + +
+
+ ); +} diff --git a/src/features/boards/column/remove/index.ts b/src/features/boards/column/remove/index.ts new file mode 100644 index 0000000..56e318c --- /dev/null +++ b/src/features/boards/column/remove/index.ts @@ -0,0 +1 @@ +export { RemoveColumnDialog } from './ui/RemoveColumnDialog'; diff --git a/src/features/boards/column/remove/model/useRemoveColumn.ts b/src/features/boards/column/remove/model/useRemoveColumn.ts new file mode 100644 index 0000000..57da851 --- /dev/null +++ b/src/features/boards/column/remove/model/useRemoveColumn.ts @@ -0,0 +1,31 @@ +import { DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { useProjectStore } from 'entities/project'; +import { toast } from 'sonner'; + +export type RemoveColunmnVariables = { + columnId: string; + boardId: string; +}; + +export type UseDeleteColumnOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useRemoveColumn({ onSuccess, onSettled, ...rest }: UseDeleteColumnOptions = {}) { + const projectId = useProjectStore((s) => s.projectId); + return useMutation({ + ...rest, + mutationFn: (args) => BoardHttp.removeBoardColumn(args.boardId, args.columnId), + onSuccess: async (res, ...args) => { + onSuccess?.(res, ...args); + toast.success(res.message ?? 'Колонка удалена'); + }, + onSettled: async (_d, _e, _v, _m, context) => { + onSettled?.(_d, _e, _v, _m, context); + context.client.invalidateQueries({ queryKey: boardFabricKeys.columns(_v.boardId) }); + context.client.invalidateQueries({ queryKey: boardFabricKeys.list(projectId!) }); + }, + }); +} diff --git a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx new file mode 100644 index 0000000..d22f264 --- /dev/null +++ b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx @@ -0,0 +1,48 @@ +import { ComponentProps } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from 'shared/ui'; +import { + RemoveColunmnVariables, + UseDeleteColumnOptions, + useRemoveColumn, +} from '../model/useRemoveColumn'; + +type Props = ComponentProps & + RemoveColunmnVariables & { options?: UseDeleteColumnOptions }; + +export function RemoveColumnDialog({ columnId, boardId, options = {}, ...props }: Props) { + const removeBoard = useRemoveColumn(options); + + const onRemove = () => { + removeBoard.mutate({ columnId, boardId }); + }; + + return ( + + + + + Удалить колонку? + + При удалении колонки будут удалены все задачи в ней + + + + Отмена + + Удалить + + + + + ); +} diff --git a/src/features/boards/create/index.ts b/src/features/boards/create/index.ts new file mode 100644 index 0000000..34bc906 --- /dev/null +++ b/src/features/boards/create/index.ts @@ -0,0 +1,2 @@ +export { CreateBoardDialog } from './ui/CreateBoardDialog'; +export { CreateBoardForm } from './ui/CreateBoardForm'; diff --git a/src/features/boards/create/model/default-values.ts b/src/features/boards/create/model/default-values.ts new file mode 100644 index 0000000..66c28fb --- /dev/null +++ b/src/features/boards/create/model/default-values.ts @@ -0,0 +1,9 @@ +import { CreateBoardFormValues } from './type'; + +export function getDefaultCreateBoardValues(): CreateBoardFormValues { + return { + name: '', + position: 0, + settings: {}, + }; +} diff --git a/src/features/boards/create/model/schemas.ts b/src/features/boards/create/model/schemas.ts new file mode 100644 index 0000000..fae521c --- /dev/null +++ b/src/features/boards/create/model/schemas.ts @@ -0,0 +1,6 @@ +import { SBoard } from 'entities/board'; +import { z } from 'zod/v4'; + +export const CreateBoardFormSchema = SBoard.CreateBoardBody.extend({ + position: z.union([z.string(), z.number()]).transform(Number), +}); diff --git a/src/features/boards/create/model/type.ts b/src/features/boards/create/model/type.ts new file mode 100644 index 0000000..529d29f --- /dev/null +++ b/src/features/boards/create/model/type.ts @@ -0,0 +1,6 @@ +import { z } from 'zod/v4'; +import { CreateBoardFormSchema } from './schemas'; + +export type CreateBoardFormValues = z.input; + +export type CreateBoardFormOutput = z.output; diff --git a/src/features/boards/create/model/useCreateBoard.ts b/src/features/boards/create/model/useCreateBoard.ts new file mode 100644 index 0000000..8f182b7 --- /dev/null +++ b/src/features/boards/create/model/useCreateBoard.ts @@ -0,0 +1,28 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +type CreateBoardVariables = { + projectId: string; + body: TBoard.CreateBoardBody; +}; + +export type UseCreateBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateBoard({ onSuccess, ...rest }: UseCreateBoardOptions = {}) { + return useMutation({ + ...rest, + mutationFn: ({ projectId, body }) => BoardHttp.createBoard(projectId, body), + onSuccess: async (res, variables, _r, context) => { + onSuccess?.(res, variables, _r, context); + toast.success(res.message ?? 'Доска создана'); + + await context.client.invalidateQueries({ + queryKey: boardFabricKeys.list(variables.projectId), + }); + }, + }); +} diff --git a/src/features/boards/create/model/useCreateBoardForm.ts b/src/features/boards/create/model/useCreateBoardForm.ts new file mode 100644 index 0000000..ce62017 --- /dev/null +++ b/src/features/boards/create/model/useCreateBoardForm.ts @@ -0,0 +1,45 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { getDefaultCreateBoardValues } from './default-values'; +import { useCreateBoard, UseCreateBoardOptions } from './useCreateBoard'; +import { CreateBoardFormSchema } from './schemas'; +import { CreateBoardFormValues } from './type'; +import { setFormErrors } from 'shared/lib/utils'; +import { extractValidationIssues } from 'shared/api'; +import { TBoard } from 'entities/board'; +import { useProjectStore } from 'entities/project'; + +export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { + const projectId = useProjectStore((s) => s.projectId!); + const form = useForm({ + resolver: zodResolver(CreateBoardFormSchema), + defaultValues: getDefaultCreateBoardValues(), + }); + + const createBoard = useCreateBoard({ + ...options, + meta: { + skipGlobalValidationToast: true, + }, + onError: (err, ...args) => { + options.onError?.(err, ...args); + setFormErrors(extractValidationIssues(err), form); + }, + }); + + const onSubmit = (data: CreateBoardFormValues) => { + const body: TBoard.CreateBoardBody = { + ...data, + position: Number(data.position), + }; + + createBoard.mutate({ projectId, body }); + }; + + return { + form, + projectId, + isPending: createBoard.isPending, + handleSubmit: form.handleSubmit(onSubmit), + }; +} diff --git a/src/features/boards/create/ui/CreateBoardDialog.tsx b/src/features/boards/create/ui/CreateBoardDialog.tsx new file mode 100644 index 0000000..47e76e4 --- /dev/null +++ b/src/features/boards/create/ui/CreateBoardDialog.tsx @@ -0,0 +1,68 @@ +import { ComponentProps, useId, useState } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Spinner, +} from 'shared/ui'; +import { CreateBoardForm } from './CreateBoardForm'; + +interface CreateProjectDialogProps extends ComponentProps { + dialog?: ComponentProps; +} + +export function CreateBoardDialog({ dialog = {}, ...props }: CreateProjectDialogProps) { + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + const formId = useId(); + const [pending, setPending] = useState(false); + + return ( + + + + + Новая доска + + + + { + setPending(true); + }, + onSuccess: () => { + setOpen(false); + }, + onSettled: () => { + setPending(false); + }, + }} + /> + + + + + + + + + + ); +} diff --git a/src/features/boards/create/ui/CreateBoardForm.tsx b/src/features/boards/create/ui/CreateBoardForm.tsx new file mode 100644 index 0000000..7471e0d --- /dev/null +++ b/src/features/boards/create/ui/CreateBoardForm.tsx @@ -0,0 +1,73 @@ +import { Controller, FormProvider } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; +import { useCreateBoardForm } from '../model/useCreateBoardForm'; +import { UseCreateBoardOptions } from '../model/useCreateBoard'; +import { ComponentProps } from 'react'; + +interface CreateBoardFormProps extends Omit, 'children' | 'onSubmit'> { + mutateOptions?: UseCreateBoardOptions; +} + +export function CreateBoardForm({ className, mutateOptions, ...props }: CreateBoardFormProps) { + const { form, isPending, handleSubmit } = useCreateBoardForm(mutateOptions); + + return ( + +
+ + ( + + Название + + {fieldState.invalid && } + + )} + /> + ( + + Настройки + Не реализовано + {fieldState.invalid && } + + )} + /> + ( + + Позиция доски + + {fieldState.invalid && } + + )} + /> + +
+
+ ); +} diff --git a/src/features/boards/remove/index.ts b/src/features/boards/remove/index.ts new file mode 100644 index 0000000..fdcbe9b --- /dev/null +++ b/src/features/boards/remove/index.ts @@ -0,0 +1 @@ +export { RemoveBoardDialog } from './ui/RemoveBoardDialog'; diff --git a/src/features/boards/remove/model/useRemoveBoard.ts b/src/features/boards/remove/model/useRemoveBoard.ts new file mode 100644 index 0000000..3d10635 --- /dev/null +++ b/src/features/boards/remove/model/useRemoveBoard.ts @@ -0,0 +1,28 @@ +import { DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +export type RemoveBoardVariables = { + projectId: string; + boardId: string; +}; + +export type UseDeleteBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useRemoveBoard({ onSuccess, onSettled, ...rest }: UseDeleteBoardOptions = {}) { + return useMutation({ + ...rest, + mutationFn: (args) => BoardHttp.removeBoard(args.projectId, args.boardId), + onSuccess: async (res, ...args) => { + onSuccess?.(res, ...args); + toast.success(res.message ?? 'Доска удалена'); + }, + onSettled: async (_d, _e, _v, _m, context) => { + onSettled?.(_d, _e, _v, _m, context); + context.client.invalidateQueries({ queryKey: boardFabricKeys.list(_v.projectId) }); + }, + }); +} diff --git a/src/features/boards/remove/ui/RemoveBoardDialog.tsx b/src/features/boards/remove/ui/RemoveBoardDialog.tsx new file mode 100644 index 0000000..2ca7a08 --- /dev/null +++ b/src/features/boards/remove/ui/RemoveBoardDialog.tsx @@ -0,0 +1,39 @@ +import { ComponentProps } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from 'shared/ui'; +import { RemoveBoardVariables, useRemoveBoard } from '../model/useRemoveBoard'; + +type Props = ComponentProps & RemoveBoardVariables; + +export function RemoveBoardDialog({ projectId, boardId, ...props }: Props) { + const removeBoard = useRemoveBoard(); + + const onRemove = () => { + removeBoard.mutate({ projectId, boardId }); + }; + + return ( + + + + + Удалить доску? + + + Отмена + + Удалить + + + + + ); +} diff --git a/src/features/task/create/index.ts b/src/features/task/create/index.ts new file mode 100644 index 0000000..d01684f --- /dev/null +++ b/src/features/task/create/index.ts @@ -0,0 +1,2 @@ +export { CreateTaskField } from './ui/CreateTaskField'; +export { CreateTaskButton } from './ui/CreateTaskButton'; diff --git a/src/features/task/create/lib/useClickOutside.tsx b/src/features/task/create/lib/useClickOutside.tsx new file mode 100644 index 0000000..d50e78c --- /dev/null +++ b/src/features/task/create/lib/useClickOutside.tsx @@ -0,0 +1,14 @@ +/* eslint-disable check-file/filename-naming-convention */ +import { useLayoutEffect } from 'react'; + +export function useClickOutside(id: string, handler: () => void) { + useLayoutEffect(() => { + const listener = (e: MouseEvent) => { + const elem = document.getElementById(id); + if (!elem) return; + if (!elem?.contains(e.target as Node)) handler?.(); + }; + document.addEventListener('mouseup', listener); + return () => document.removeEventListener('mouseup', listener); + }, [handler, id]); +} diff --git a/src/features/task/create/model/useActiveFieldStore.ts b/src/features/task/create/model/useActiveFieldStore.ts new file mode 100644 index 0000000..e2b1ccd --- /dev/null +++ b/src/features/task/create/model/useActiveFieldStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface ActiveFieldStore { + activeId: string | null; + open: (id: string) => void; + close: () => void; +} + +export const useActiveFieldStore = create((set) => ({ + activeId: null, + open: (id) => set({ activeId: id }), + close: () => set({ activeId: null }), +})); diff --git a/src/features/task/create/model/useCreateTask.tsx b/src/features/task/create/model/useCreateTask.tsx new file mode 100644 index 0000000..257654c --- /dev/null +++ b/src/features/task/create/model/useCreateTask.tsx @@ -0,0 +1,25 @@ +/* eslint-disable check-file/filename-naming-convention */ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { type TTask } from 'entities/task'; +import { TaskHttp } from 'entities/task'; + +type CreateTaskVariables = { + body: TTask.CreateTaskBody; +}; + +export type UseCreateProjectOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateTask({ onSuccess, ...rest }: UseCreateProjectOptions = {}) { + // TODO + return useMutation({ + ...rest, + mutationFn: ({ body }) => TaskHttp.createTask(body), + onMutate: (data, ctx) => {}, + onSuccess: async (res, variables, _r, context) => { + onSuccess?.(res, variables, _r, context); + }, + }); +} diff --git a/src/features/task/create/ui/CreateTaskButton.tsx b/src/features/task/create/ui/CreateTaskButton.tsx new file mode 100644 index 0000000..7b6ddd7 --- /dev/null +++ b/src/features/task/create/ui/CreateTaskButton.tsx @@ -0,0 +1,12 @@ +import { Plus } from 'lucide-react'; +import { Button } from 'shared/ui'; +import { useActiveFieldStore } from '../model/useActiveFieldStore'; + +export function CreateTaskButton({ id }: { id: string }) { + const open = useActiveFieldStore((s) => s.open); + return ( + + ); +} diff --git a/src/features/task/create/ui/CreateTaskDialog.tsx b/src/features/task/create/ui/CreateTaskDialog.tsx new file mode 100644 index 0000000..3198d56 --- /dev/null +++ b/src/features/task/create/ui/CreateTaskDialog.tsx @@ -0,0 +1,3 @@ +export function CreateTaskDialog() { + return 'dialog'; +} diff --git a/src/features/task/create/ui/CreateTaskField.tsx b/src/features/task/create/ui/CreateTaskField.tsx new file mode 100644 index 0000000..4388674 --- /dev/null +++ b/src/features/task/create/ui/CreateTaskField.tsx @@ -0,0 +1,72 @@ +'use client'; +import React, { ComponentProps, InputEvent, KeyboardEvent, useRef } from 'react'; +import { Card, CardContent, Checkbox } from 'shared/ui'; +import { useActiveFieldStore } from '../model/useActiveFieldStore'; +import { useClickOutside } from '../lib/useClickOutside'; +import { useCreateTask } from '../model/useCreateTask'; +import { TTask } from 'entities/task'; + +interface Props + extends + Omit, 'children' | 'id'>, + Pick {} + +function CreateTaskField_({ boardId, columnId, ...props }: Props) { + const ref = useRef(null); + const refTextArea = useRef(null); + const { close, activeId } = useActiveFieldStore(); + const { mutateAsync } = useCreateTask(); + + const clearTextArea = () => { + const elem = refTextArea.current; + if (elem) { + elem.value = ''; + } + }; + + const handleSubmit = (body: TTask.CreateTaskBody) => { + // TODO: что-то сделать с данными + clearTextArea(); + mutateAsync({ body }); + }; + + const updateHeight = (e: InputEvent) => { + const textarea = e.target as HTMLTextAreaElement; + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + }; + const onInput = (e: InputEvent) => { + updateHeight(e); + }; + + const onKeyDown = (e: KeyboardEvent) => { + const textarea = e.target as HTMLTextAreaElement; + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit({ title: textarea.value, columnId, boardId }); + } + }; + + useClickOutside(`create-task-${columnId}`, close); + + if (activeId !== columnId) return null; + + return ( + + + + + + + ); +} + +export const CreateTaskField = React.memo(CreateTaskField_); diff --git a/src/pages/project/model/boards-mock.ts b/src/pages/project/model/boards-mock.ts index ae15903..137afa6 100644 --- a/src/pages/project/model/boards-mock.ts +++ b/src/pages/project/model/boards-mock.ts @@ -1,69 +1,204 @@ -export type MockBoardColumn = { +// TODO: вынести функцию и иконки в shared или сделать свои +import { PROJECT_ICONS } from 'entities/project'; + +export type MockBoardColumn = Record; + +export type MockAuthor = { id: string; name: string; + avatarUrl?: string; }; -export type MockBoardCard = { +export type MockBoardTask = { id: string; name: string; - column: string; + columnId: string; + assignee: MockAuthor; + dueDate: string; + priority: 'high' | 'medium' | 'low'; + description?: string; }; +export type ColumnTitles = Record; + export type MockBoard = { id: string; name: string; - columns: MockBoardColumn[]; - cards: MockBoardCard[]; + columns: MockBoardColumn; + columnTitles: ColumnTitles; }; export const MOCK_BOARDS: MockBoard[] = [ { id: 'planning', name: 'Планирование проекта', - columns: [ - { id: 'ideas', name: 'Идеи' }, - { id: 'plan', name: 'План' }, - { id: 'docs', name: 'Документация' }, - ], - cards: [ - { id: 'planning-1', name: 'Сбор вдохновения', column: 'ideas' }, - { id: 'planning-2', name: 'Цели проекта', column: 'ideas' }, - { id: 'planning-3', name: 'Дорожная карта', column: 'plan' }, - { id: 'planning-4', name: 'Ресурсы', column: 'plan' }, - { id: 'planning-5', name: 'Техническая', column: 'docs' }, - { id: 'planning-6', name: 'Коммуникация', column: 'docs' }, - ], + columnTitles: { + ideas: { title: 'Идеи', icon: PROJECT_ICONS[0] }, + plan: { title: 'План' }, + docs: { title: 'Документы' }, + }, + columns: { + ideas: [ + { + id: '1', + name: 'Сбор вдохновения', + columnId: 'ideas', + priority: 'high', + assignee: { id: '1', name: 'Андрей' }, + dueDate: '2023-10-05', + description: 'Description', + }, + { + id: '2', + name: 'Цели проекта', + columnId: 'ideas', + priority: 'medium', + assignee: { id: '2', name: 'Мария' }, + dueDate: '2023-10-06', + description: 'Description', + }, + ], + plan: [ + { + id: '3', + name: 'Дорожная карта', + columnId: 'plan', + priority: 'low', + assignee: { id: '3', name: 'Иван' }, + dueDate: '2023-10-07', + description: 'Description', + }, + { + id: '4', + name: 'Ресурсы', + columnId: 'plan', + priority: 'medium', + assignee: { id: '4', name: 'Сергей' }, + dueDate: '2023-10-08', + description: 'Description', + }, + ], + docs: [ + { + id: '5', + name: 'Техническая', + columnId: 'docs', + priority: 'high', + assignee: { id: '5', name: 'Дмитрий' }, + dueDate: '2023-10-09', + description: 'Description', + }, + { + id: '6', + name: 'Коммуникация', + columnId: 'docs', + priority: 'medium', + assignee: { id: '6', name: 'Анна' }, + dueDate: '2023-10-10', + description: 'Description', + }, + ], + }, }, { id: 'in-progress', name: 'Задачи в работе', - columns: [ - { id: 'todo', name: 'Ожидает выполнения' }, - { id: 'in-progress', name: 'В работе' }, - { id: 'review', name: 'На проверке' }, - { id: 'bank-review', name: 'На проверке в банке' }, - { id: 'done', name: 'Завершено' }, - ], - cards: [ - { id: 'work-1', name: 'Подготовить бриф', column: 'todo' }, - { id: 'work-2', name: 'Дизайн макета', column: 'in-progress' }, - { id: 'work-3', name: 'Проверка текстов', column: 'review' }, - { id: 'work-4', name: 'Проверить реквизиты', column: 'bank-review' }, - { id: 'work-5', name: 'Готово к запуску', column: 'done' }, - ], + columnTitles: { + todo: { title: 'Ожидает выполнения' }, + inProgress: { title: 'В работе' }, + review: { title: 'На проверке' }, + bankReview: { title: 'На проверке в банке' }, + done: { title: 'Завершено' }, + }, + columns: { + todo: [ + { + id: '11', + name: 'Дизайн макета', + columnId: 'ideas', + priority: 'high', + assignee: { id: '1', name: 'Андрей' }, + dueDate: '2023-10-05', + description: 'Description', + }, + ], + inProgress: [ + { + id: '31', + name: 'Подготовить бриф', + columnId: 'plan', + priority: 'low', + assignee: { id: '3', name: 'Иван' }, + dueDate: '2023-10-07', + description: 'Description', + }, + ], + review: [ + { + id: '51', + name: 'Техническая', + columnId: 'docs', + priority: 'high', + assignee: { id: '5', name: 'Дмитрий' }, + dueDate: '2023-10-09', + description: 'Description', + }, + { + id: '61', + name: 'Коммуникация', + columnId: 'docs', + priority: 'medium', + assignee: { id: '6', name: 'Анна' }, + dueDate: '2023-10-10', + description: 'Description', + }, + ], + bankReview: [], + done: [], + }, }, { id: 'results', name: 'Фиксация результатов', - columns: [ - { id: 'reports', name: 'Отчёты' }, - { id: 'insights', name: 'Выводы' }, - { id: 'documentation', name: 'Документация' }, - ], - cards: [ - { id: 'results-1', name: 'Итоговый отчёт', column: 'reports' }, - { id: 'results-2', name: 'Рефлексия', column: 'insights' }, - { id: 'results-3', name: 'Запись результатов', column: 'documentation' }, - ], + columnTitles: { + reports: { title: 'Отчёты', icon: '📊' }, + insights: { title: 'Выводы', icon: '💡' }, + documentation: { title: 'Документация', icon: '📄' }, + }, + columns: { + reports: [ + { + id: 'results-1', + name: 'Итоговый отчёт', + columnId: 'reports', + priority: 'high', + assignee: { id: '6', name: 'Аналитик' }, + dueDate: '2023-10-20', + description: 'Собрать все метрики и графики', + }, + ], + insights: [ + { + id: 'results-2', + name: 'Рефлексия', + columnId: 'insights', + priority: 'medium', + assignee: { id: '7', name: 'Тимлид' }, + dueDate: '2023-10-22', + description: 'Что получилось, а что нет', + }, + ], + documentation: [ + { + id: 'results-3', + name: 'Запись результатов', + columnId: 'documentation', + priority: 'low', + assignee: { id: '8', name: 'Документалист' }, + dueDate: '2023-10-25', + description: 'Задокументировать выводы в Confluence', + }, + ], + }, }, ]; diff --git a/src/pages/project/model/store.ts b/src/pages/project/model/store.ts new file mode 100644 index 0000000..ad4e6ec --- /dev/null +++ b/src/pages/project/model/store.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; + +interface BordStore { + activeBoardId: string | null; + activeColumnId: string | null; + projectId: string | null; + setProjectId: (id: string) => void; + clearProjectId: () => void; + setBoardId: (id: string | null) => void; + setColumnId: (id: string | null) => void; +} + +export const useBoardStore = create((set) => ({ + activeBoardId: null, + activeColumnId: null, + projectId: null, + setProjectId(id) { + set({ projectId: id }); + }, + clearProjectId() { + set({ projectId: null }); + }, + setBoardId(id) { + set({ activeBoardId: id }); + }, + setColumnId(id) { + set({ activeColumnId: id }); + }, +})); diff --git a/src/pages/project/model/useActiveBoard.ts b/src/pages/project/model/useActiveBoard.ts new file mode 100644 index 0000000..5fbd588 --- /dev/null +++ b/src/pages/project/model/useActiveBoard.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { useBoardStore } from './store'; +import { BoardWithTasks } from 'entities/board'; + +export const useActiveBoards = (boards: BoardWithTasks[]) => { + const activeBoardId = useBoardStore((s) => s.activeBoardId); + const setActiveBoardId = useBoardStore((s) => s.setBoardId); + + const activeBoard: BoardWithTasks | null = + boards?.find((v) => v.board.id === activeBoardId) ?? (boards.length > 0 ? boards[0] : null); + + useEffect(() => { + if (!activeBoardId && boards.length > 0) { + setActiveBoardId(boards[0].board.id); + } + }, [activeBoardId, boards, setActiveBoardId]); + + return { activeBoardId, setActiveBoardId, activeBoard }; +}; diff --git a/src/pages/project/model/useBoardsPage.ts b/src/pages/project/model/useBoardsPage.ts new file mode 100644 index 0000000..05c653d --- /dev/null +++ b/src/pages/project/model/useBoardsPage.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { BoardQueries } from 'entities/board'; +import { BoardMapper } from 'entities/board'; + +export const useBoardsPage = (projectId: string) => { + const { + data: dto, + isLoading, + isError, + error, + refetch, + } = useQuery(BoardQueries.getBoardList(projectId)); + const data = dto?.items.map(BoardMapper.toBoardWithTasks) ?? []; + + return { data, isLoading, isError, error, refetch }; +}; diff --git a/src/pages/project/ui/boards/BoardButton.skeleton.tsx b/src/pages/project/ui/boards/BoardButton.skeleton.tsx new file mode 100644 index 0000000..0f899af --- /dev/null +++ b/src/pages/project/ui/boards/BoardButton.skeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton, buttonVariants } from 'shared/ui'; +import { cn } from 'shared/lib/utils'; +import { ComponentProps } from 'react'; + +export function BoardButtonSkeleton({ className, ...props }: ComponentProps<'div'>) { + return ( +
+ + +
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectBoards.skeleton.tsx b/src/pages/project/ui/boards/ProjectBoards.skeleton.tsx new file mode 100644 index 0000000..f95739b --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoards.skeleton.tsx @@ -0,0 +1,24 @@ +import { Skeleton } from 'shared/ui'; +import { BoardButtonSkeleton } from './BoardButton.skeleton'; +import { TaskColumnSkeleton } from './TaskColumn.skeleton'; + +export function ProjectBoardsSkeleton() { + return ( +
+
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} + +
+ +
+
+ + + +
+
+
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectBoards.tsx b/src/pages/project/ui/boards/ProjectBoards.tsx new file mode 100644 index 0000000..11d3683 --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoards.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { + Button, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenu, + buttonVariants, +} from 'shared/ui'; +import { CreateBoardDialog } from 'features/boards/create'; +import { useInitProjectId } from 'entities/project'; +import { ComponentProps, PropsWithChildren } from 'react'; +import { TBoard } from 'entities/board'; +import { useBoardStore } from 'pages/project/model/store'; +import { EllipsisVertical } from 'lucide-react'; +import { ProjectKanban } from './ProjectKanban'; +import { useActiveBoards } from 'pages/project/model/useActiveBoard'; +import { useBoardsPage } from 'pages/project/model/useBoardsPage'; +import { cn } from 'shared/lib/utils'; +import { VariantProps } from 'class-variance-authority'; +import { RemoveBoardDialog } from 'features/boards/remove'; +import { ProjectBoardsSkeleton } from './ProjectBoards.skeleton'; +import { ProjectBoardsError } from './ProjectBoardsError'; + +export function ProjectBoards({ projectId }: PropsWithChildren<{ projectId: string }>) { + useInitProjectId(projectId); + + const { data, isLoading, isError, error, refetch } = useBoardsPage(projectId); + const { activeBoard, activeBoardId } = useActiveBoards(data); + if (isLoading) return ; + if (isError) { + return refetch()} />; + } + return ( +
+
+ {data?.map((item) => ( + + ))} + + + +
+
+ {activeBoard ? : null} +
+
+ ); +} + +type BoardButtonProps = ComponentProps<'div'> & + VariantProps & { + board: TBoard.BoardResponse; + }; + +function BoardButton({ + board, + className, + variant = 'outline', + size = 'default', + ...props +}: BoardButtonProps) { + const setActiveBoardId = useBoardStore((s) => s.setBoardId); + const activeBoardId = useBoardStore((s) => s.activeBoardId); + return ( +
+ + + + + + + + + e.preventDefault()}> + Удалить + + + + +
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectBoardsError.tsx b/src/pages/project/ui/boards/ProjectBoardsError.tsx new file mode 100644 index 0000000..8993795 --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoardsError.tsx @@ -0,0 +1,39 @@ +import { AlertTriangle, RefreshCw } from 'lucide-react'; +import { + Button, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from 'shared/ui'; + +interface ProjectBoardsErrorProps { + message?: string; + onRetry?: () => void; +} + +export function ProjectBoardsError({ message, onRetry }: ProjectBoardsErrorProps) { + return ( +
+ + + + + + Не удалось загрузить доски + + {message ?? 'Проверьте подключение и попробуйте обновить список досок.'} + + + + + + +
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectBoardsPage.tsx b/src/pages/project/ui/boards/ProjectBoardsPage.tsx index c6f185f..fb53319 100644 --- a/src/pages/project/ui/boards/ProjectBoardsPage.tsx +++ b/src/pages/project/ui/boards/ProjectBoardsPage.tsx @@ -1,33 +1,7 @@ -'use client'; +import { ProjectBoards } from './ProjectBoards'; -import { useState } from 'react'; -import { Button } from 'shared/ui'; -import { MOCK_BOARDS } from '../../model/boards-mock'; -import { ProjectKanban } from './ProjectKanban'; +export async function ProjectBoardsPage({ params }: { params: Promise<{ projectId: string }> }) { + const { projectId } = await params; -export function ProjectBoardsPage() { - const [activeBoardId, setActiveBoardId] = useState(MOCK_BOARDS[0].id); - const activeBoard = MOCK_BOARDS.find((board) => board.id === activeBoardId) ?? MOCK_BOARDS[0]; - - return ( -
-
- {MOCK_BOARDS.map((board) => ( - - ))} -
- -
- -
-
- ); + return ; } diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index 0de94d8..be73a7f 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -1,31 +1,52 @@ 'use client'; -import { useState } from 'react'; -import { KanbanBoard, KanbanCard, KanbanCards, KanbanHeader, KanbanProvider } from 'shared/ui'; -import type { MockBoard, MockBoardCard } from '../../model/boards-mock'; +import { useEffect, useState } from 'react'; +import { Kanban, KanbanBoard, KanbanOverlay, Button } from 'shared/ui'; +import { TaskColumn } from './task-column/TaskColumn'; +import { BoardWithTasks } from 'entities/board'; +import { TTask } from 'entities/task'; +import { CreateBoardColumnDialog } from 'features/boards/column/create'; interface ProjectKanbanProps { - board: MockBoard; + board: BoardWithTasks; } export function ProjectKanban({ board }: ProjectKanbanProps) { - const [cards, setCards] = useState(board.cards); + const [columns, setColumns] = useState(board.tasksByColumn); + + useEffect(() => { + setColumns(board.tasksByColumn); + }, [board]); + + const nextColumnPosition = Object.keys(board.columns).length; return ( - setColumns(v)} + // TODO: as TTask.Task - заглушка, пока нет тасок + getItemValue={(item) => (item as TTask.Task).id} > - {(column) => ( - - {column.name} - - {(item) => } - - - )} - + + {Object.entries(columns).map(([id, items]) => { + const column = board.columns[id]; + // TODO: as TTask.Task - заглушка, пока нет тасок + return ; + })} +
+ + + +
+
+ + ); } diff --git a/src/pages/project/ui/boards/Task.tsx b/src/pages/project/ui/boards/Task.tsx new file mode 100644 index 0000000..2ecad2a --- /dev/null +++ b/src/pages/project/ui/boards/Task.tsx @@ -0,0 +1,24 @@ +import React, { ComponentProps } from 'react'; +import { KanbanItem, KanbanItemHandle } from 'shared/ui'; +import { TaskCard } from './TaskCard'; +import { TTask } from 'entities/task/'; + +interface TaskCardProps extends Omit, 'value' | 'children'> { + task: TTask.Task; + asHandle?: boolean; + isOverlay?: boolean; +} + +export function TaskComponent({ task, asHandle, isOverlay, ...props }: TaskCardProps) { + return ( + + {asHandle && !isOverlay ? ( + {} + ) : ( + + )} + + ); +} + +export const Task = React.memo(TaskComponent); diff --git a/src/pages/project/ui/boards/TaskCard.tsx b/src/pages/project/ui/boards/TaskCard.tsx new file mode 100644 index 0000000..fad89ea --- /dev/null +++ b/src/pages/project/ui/boards/TaskCard.tsx @@ -0,0 +1,67 @@ +import { TTask } from 'entities/task'; +import { + Avatar, + AvatarFallback, + AvatarImage, + Card, + CardContent, + Label, + Tooltip, + TooltipContent, + TooltipTrigger, + Checkbox, + Badge, +} from 'shared/ui'; + +interface TaskCardProps { + task: TTask.Task; +} + +export function TaskCard({ task }: TaskCardProps) { + return ( + + +
+ +
+

{task.title}

+ {task.description} +
+
+
+ {task.assignee && ( +
+ + + + + {task.assignee.name.charAt(0)} + + + {task.assignee.name} + + {task.dueDate && ( + + )} +
+ )} + {task.priority && ( + + {task.priority} + + )} +
+
+
+ ); +} diff --git a/src/pages/project/ui/boards/TaskColumn.skeleton.tsx b/src/pages/project/ui/boards/TaskColumn.skeleton.tsx new file mode 100644 index 0000000..234f842 --- /dev/null +++ b/src/pages/project/ui/boards/TaskColumn.skeleton.tsx @@ -0,0 +1,43 @@ +import { Skeleton } from 'shared/ui'; +import { cn } from 'shared/lib/utils'; +import { ComponentProps } from 'react'; + +interface TaskColumnSkeletonProps extends ComponentProps<'div'> { + taskCount?: number; +} + +export function TaskColumnSkeleton({ + className, + taskCount = 3, + ...props +}: TaskColumnSkeletonProps) { + return ( +
+
+ +
+ +
+ + +
+
+
+ + + + {Array.from({ length: taskCount }).map((_, index) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+ ); +} diff --git a/src/pages/project/ui/boards/task-column/TaskColumn.tsx b/src/pages/project/ui/boards/task-column/TaskColumn.tsx new file mode 100644 index 0000000..3804da1 --- /dev/null +++ b/src/pages/project/ui/boards/task-column/TaskColumn.tsx @@ -0,0 +1,49 @@ +import { KanbanColumn, KanbanColumnContent } from 'shared/ui'; + +import { Task } from '../Task'; +import { CreateTaskField } from 'features/task/create'; +import { useBoardStore } from '../../../model/store'; +import { TBoard } from 'entities/board'; +import { ComponentProps } from 'react'; +import { TTask } from 'entities/task'; +import { TaskColumnHeader, TaskColumnHeaderProps } from './TaskColumnHeader'; + +interface TaskColumnProps extends Omit, 'children'> { + tasks: TTask.Task[]; + column: TBoard.BoardColumnResponse; + isOverlay?: boolean; +} + +export function TaskColumn({ + value, + tasks, + column, + className = '', + isOverlay, + ...props +}: TaskColumnProps) { + const columnId = value; + const boardId = useBoardStore((s) => s.activeBoardId!); + + const headerColumnData: TaskColumnHeaderProps = { + ...column, + tasksLength: tasks.length, + }; + + return ( + + + + + + {tasks.map((task) => ( + + ))} + + + ); +} diff --git a/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx new file mode 100644 index 0000000..3a1ee21 --- /dev/null +++ b/src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx @@ -0,0 +1,77 @@ +import { BOARD_COLUMN_COLORS, TBoard } from 'entities/board'; +import { RemoveColumnDialog } from 'features/boards/column/remove'; +import { CreateTaskButton } from 'features/task/create'; +import { Ellipsis } from 'lucide-react'; +import { useState } from 'react'; +import { + Button, + ColorPicker, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + KanbanColumnHandle, +} from 'shared/ui'; + +export interface TaskColumnHeaderProps extends TBoard.BoardColumnResponse { + tasksLength: number; +} + +export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { + const { tasksLength, name, id, boardId, color } = data; + const [activeColor, setActiveColor] = useState(color ?? BOARD_COLUMN_COLORS[0]); + + const existColor = BOARD_COLUMN_COLORS.findIndex((v) => v.toLowerCase() === data.color); + const isExistColor = existColor !== -1; + const newColors = isExistColor + ? [...BOARD_COLUMN_COLORS] + : color + ? [color, ...BOARD_COLUMN_COLORS] + : [...BOARD_COLUMN_COLORS]; + + return ( + +
+
+
+
+

{`${name} (${tasksLength})`}

+
+
+ + + + + + + + + e.preventDefault()} variant="destructive"> + Удалить + + + + + + Цвет колонки + + + + +
+
+
+ + ); +} diff --git a/src/shared/ui/Kanban.tsx b/src/shared/ui/Kanban.tsx index 59dcd0d..d28eedc 100644 --- a/src/shared/ui/Kanban.tsx +++ b/src/shared/ui/Kanban.tsx @@ -1,322 +1,689 @@ 'use client'; - -import type { - Announcements, - DndContextProps, - DragEndEvent, - DragOverEvent, - DragStartEvent, -} from '@dnd-kit/core'; +import * as React from 'react'; import { - closestCenter, + createContext, + CSSProperties, + HTMLAttributes, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { + defaultDropAnimationSideEffects, DndContext, + DragEndEvent, + DragOverEvent, DragOverlay, + DragStartEvent, + DropAnimation, KeyboardSensor, + MeasuringStrategy, + Modifiers, MouseSensor, TouchSensor, - useDroppable, + UniqueIdentifier, useSensor, useSensors, + type DraggableAttributes, + type DraggableSyntheticListeners, } from '@dnd-kit/core'; -import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable'; +import { + arrayMove, + defaultAnimateLayoutChanges, + rectSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + type AnimateLayoutChanges, +} from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { createContext, type HTMLAttributes, type ReactNode, useContext, useState } from 'react'; +import { Slot } from 'radix-ui'; import { createPortal } from 'react-dom'; + import { cn } from 'shared/lib/utils'; -import tunnel from 'tunnel-rat'; -import { Card } from './Card'; -import { ScrollArea, ScrollBar } from './ScrollArea'; - -const t = tunnel(); - -export type { DragEndEvent } from '@dnd-kit/core'; - -type KanbanItemProps = { - id: string; - name: string; - column: string; -} & Record; - -type KanbanColumnProps = { - id: string; - name: string; -} & Record; - -type KanbanContextProps< - T extends KanbanItemProps = KanbanItemProps, - C extends KanbanColumnProps = KanbanColumnProps, -> = { - columns: C[]; - data: T[]; - activeCardId: string | null; -}; -const KanbanContext = createContext({ - columns: [], - data: [], - activeCardId: null, +interface KanbanContextProps { + columns: Record; + setColumns: (columns: Record) => void; + getItemId: (item: T) => string; + columnIds: string[]; + activeId: UniqueIdentifier | null; + setActiveId: (id: UniqueIdentifier | null) => void; + findContainer: (id: UniqueIdentifier) => string | undefined; + isColumn: (id: UniqueIdentifier) => boolean; + modifiers?: Modifiers; +} + +const KanbanContext = createContext>({ + columns: {}, + setColumns: () => {}, + getItemId: () => '', + columnIds: [], + activeId: null, + setActiveId: () => {}, + findContainer: () => undefined, + isColumn: () => false, + modifiers: undefined, }); -export type KanbanBoardProps = { - id: string; - children: ReactNode; - className?: string; -}; +const ColumnContext = createContext<{ + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners | undefined; + isDragging?: boolean; + disabled?: boolean; +}>({ + attributes: {} as DraggableAttributes, + listeners: undefined, + isDragging: false, + disabled: false, +}); -export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => { - const { isOver, setNodeRef } = useDroppable({ - id, - }); +const ItemContext = createContext<{ + listeners: DraggableSyntheticListeners | undefined; + isDragging?: boolean; + disabled?: boolean; +}>({ + listeners: undefined, + isDragging: false, + disabled: false, +}); - return ( -
- {children} -
- ); -}; +const IsOverlayContext = createContext(false); -export type KanbanCardProps = T & { - children?: ReactNode; - className?: string; +const animateLayoutChanges: AnimateLayoutChanges = (args) => + defaultAnimateLayoutChanges({ ...args, wasDragging: true }); + +const dropAnimationConfig: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: '0.4', + }, + }, + }), }; -export const KanbanCard = ({ - id, - name, +export interface KanbanMoveEvent { + event: DragEndEvent; + activeContainer: string; + activeIndex: number; + overContainer: string; + overIndex: number; +} + +export interface KanbanRootProps extends HTMLAttributes { + value: Record; + onValueChange: (value: Record) => void; + getItemValue: (item: T) => string; + children: ReactNode; + onMove?: (event: KanbanMoveEvent) => void; + asChild?: boolean; + modifiers?: Modifiers; +} + +function Kanban({ + value, + onValueChange, + getItemValue, children, className, -}: KanbanCardProps) => { - const { attributes, listeners, setNodeRef, transition, transform, isDragging } = useSortable({ - id, - }); - const { activeCardId } = useContext(KanbanContext) as KanbanContextProps; + asChild = false, + onMove, + modifiers, + ...props +}: KanbanRootProps) { + const columns = value; + const setColumns = onValueChange; + const [activeId, setActiveId] = useState(null); - const style = { - transition, - transform: CSS.Transform.toString(transform), - }; + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const columnIds = useMemo(() => Object.keys(columns), [columns]); + + const isColumn = useCallback( + (id: UniqueIdentifier) => columnIds.includes(id as string), + [columnIds] + ); + + const findContainer = useCallback( + (id: UniqueIdentifier) => { + if (isColumn(id)) return id as string; + return columnIds.find((key) => columns[key].some((item) => getItemValue(item) === id)); + }, + [columns, columnIds, getItemValue, isColumn] + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(event.active.id); + }, []); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + if (onMove) { + return; + } + + const { active, over } = event; + if (!over) return; + + if (isColumn(active.id)) return; + + const activeContainer = findContainer(active.id); + const overContainer = findContainer(over.id); + + if (!activeContainer || !overContainer) { + return; + } + + if (activeContainer !== overContainer) { + const activeItems = columns[activeContainer]; + const overItems = columns[overContainer]; + + const activeIndex = activeItems.findIndex((item: T) => getItemValue(item) === active.id); + let overIndex = overItems.findIndex((item: T) => getItemValue(item) === over.id); + + // If dropping on the column itself, not an item + if (isColumn(over.id)) { + overIndex = overItems.length; + } + + const newActiveItems = [...activeItems]; + const newOverItems = [...overItems]; + const [movedItem] = newActiveItems.splice(activeIndex, 1); + newOverItems.splice(overIndex, 0, movedItem); + + setColumns({ + ...columns, + [activeContainer]: newActiveItems, + [overContainer]: newOverItems, + }); + } else { + const container = activeContainer; + const activeIndex = columns[container].findIndex( + (item: T) => getItemValue(item) === active.id + ); + const overIndex = columns[container].findIndex((item: T) => getItemValue(item) === over.id); + + if (activeIndex !== overIndex) { + setColumns({ + ...columns, + [container]: arrayMove(columns[container], activeIndex, overIndex), + }); + } + } + }, + [findContainer, getItemValue, isColumn, setColumns, columns, onMove] + ); + + const handleDragCancel = useCallback(() => { + setActiveId(null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + if (!over) return; + + // Handle item move callback + if (onMove && !isColumn(active.id)) { + const activeContainer = findContainer(active.id); + const overContainer = findContainer(over.id); + + if (activeContainer && overContainer) { + const activeIndex = columns[activeContainer].findIndex( + (item: T) => getItemValue(item) === active.id + ); + const overIndex = isColumn(over.id) + ? columns[overContainer].length + : columns[overContainer].findIndex((item: T) => getItemValue(item) === over.id); + + onMove({ + event, + activeContainer, + activeIndex, + overContainer, + overIndex, + }); + } + return; + } + + // Handle column reordering + if (isColumn(active.id) && isColumn(over.id)) { + const activeIndex = columnIds.indexOf(active.id as string); + const overIndex = columnIds.indexOf(over.id as string); + if (activeIndex !== overIndex) { + const newOrder = arrayMove(Object.keys(columns), activeIndex, overIndex); + const newColumns: Record = {}; + newOrder.forEach((key) => { + newColumns[key] = columns[key]; + }); + setColumns(newColumns); + } + return; + } + + const activeContainer = findContainer(active.id); + const overContainer = findContainer(over.id); + + // Handle item reordering within the same column + if (activeContainer && overContainer && activeContainer === overContainer) { + const container = activeContainer; + const activeIndex = columns[container].findIndex( + (item: T) => getItemValue(item) === active.id + ); + const overIndex = columns[container].findIndex((item: T) => getItemValue(item) === over.id); + + if (activeIndex !== overIndex) { + setColumns({ + ...columns, + [container]: arrayMove(columns[container], activeIndex, overIndex), + }); + } + } + }, + [columnIds, columns, findContainer, getItemValue, isColumn, setColumns, onMove] + ); + + const contextValue = useMemo( + () => ({ + columns, + setColumns, + getItemId: getItemValue, + columnIds, + activeId, + setActiveId, + findContainer, + isColumn, + modifiers, + }), + [columns, setColumns, getItemValue, columnIds, activeId, findContainer, isColumn, modifiers] + ); + + const Comp = asChild ? Slot.Root : 'div'; return ( - <> -
- }> + + - {children ??

{name}

} -
-
- {activeCardId === id && ( - - - {children ??

{name}

} -
-
- )} - + {children} + + + ); -}; +} -export type KanbanCardsProps = Omit< - HTMLAttributes, - 'children' | 'id' -> & { - children: (item: T) => ReactNode; - id: string; -}; - -export const KanbanCards = ({ - children, - className, - ...props -}: KanbanCardsProps) => { - const { data } = useContext(KanbanContext) as KanbanContextProps; - const filteredData = data.filter((item) => item.column === props.id); - const items = filteredData.map((item) => item.id); +export interface KanbanBoardProps extends HTMLAttributes { + asChild?: boolean; +} +function KanbanBoard({ className, asChild = false, children, ...props }: KanbanBoardProps) { + const { columnIds } = useContext(KanbanContext); + const Comp = asChild ? Slot.Root : 'div'; return ( - - -
- {filteredData.map(children)} -
-
- -
+ + + {children} + + ); -}; +} -export type KanbanHeaderProps = HTMLAttributes; - -export const KanbanHeader = ({ className, ...props }: KanbanHeaderProps) => ( -
-); - -export type KanbanProviderProps< - T extends KanbanItemProps = KanbanItemProps, - C extends KanbanColumnProps = KanbanColumnProps, -> = Omit & { - children: (column: C) => ReactNode; - className?: string; - columns: C[]; - data: T[]; - onDataChange?: (data: T[]) => void; - onDragStart?: (event: DragStartEvent) => void; - onDragEnd?: (event: DragEndEvent) => void; - onDragOver?: (event: DragOverEvent) => void; -}; +export interface KanbanColumnProps extends HTMLAttributes { + value: string; + disabled?: boolean; + asChild?: boolean; +} -export const KanbanProvider = < - T extends KanbanItemProps = KanbanItemProps, - C extends KanbanColumnProps = KanbanColumnProps, ->({ - children, - onDragStart, - onDragEnd, - onDragOver, +function KanbanColumn({ + value, className, - columns, - data, - onDataChange, + asChild = false, + disabled, + children, ...props -}: KanbanProviderProps) => { - const [activeCardId, setActiveCardId] = useState(null); +}: KanbanColumnProps) { + const isOverlay = useContext(IsOverlayContext); - const sensors = useSensors( - useSensor(MouseSensor), - useSensor(TouchSensor), - useSensor(KeyboardSensor) - ); - - const handleDragStart = (event: DragStartEvent) => { - const card = data.find((item) => item.id === event.active.id); - if (card) { - setActiveCardId(event.active.id as string); - } - onDragStart?.(event); - }; + const { + setNodeRef, + transform, + transition, + attributes, + listeners, + isDragging: isSortableDragging, + } = useSortable({ + id: value, + disabled: disabled || isOverlay, + animateLayoutChanges, + }); - const handleDragOver = (event: DragOverEvent) => { - const { active, over } = event; + const { activeId, isColumn } = useContext(KanbanContext); + const isColumnDragging = activeId ? isColumn(activeId) : false; - if (!over) { - return; - } + const style = { + transition, + transform: CSS.Transform.toString(transform), + } as CSSProperties; + + const Comp = asChild ? Slot.Root : 'div'; + + if (isOverlay) { + return ( + + + {children} + + + ); + } - const activeItem = data.find((item) => item.id === active.id); - const overItem = data.find((item) => item.id === over.id); + return ( + + + {children} + + + ); +} - if (!activeItem) { - return; - } +const variant = { + default: + 'max-w-0 opacity-0 transition-[opacity,max-width] group-hover/kanban-column:max-w-7 group-hover/kanban-column:opacity-100', + visible: '', +}; - const activeColumn = activeItem.column; - const overColumn = - overItem?.column || columns.find((col) => col.id === over.id)?.id || columns[0]?.id; +export interface KanbanColumnHandleProps extends HTMLAttributes { + cursor?: boolean; + asChild?: boolean; + variant?: keyof typeof variant; +} - if (activeColumn !== overColumn) { - let newData = [...data]; - const activeIndex = newData.findIndex((item) => item.id === active.id); - const overIndex = newData.findIndex((item) => item.id === over.id); +function KanbanColumnHandle({ + className, + asChild = false, + cursor = true, + children, + variant = 'default', + ...props +}: KanbanColumnHandleProps) { + const { attributes, listeners, isDragging, disabled } = useContext(ColumnContext); - newData[activeIndex].column = overColumn; - newData = arrayMove(newData, activeIndex, overIndex); + const Comp = asChild ? Slot.Root : 'div'; - onDataChange?.(newData); - } + return ( + + {children} + + ); +} - onDragOver?.(event); - }; +export interface KanbanItemProps extends HTMLAttributes { + value: string; + disabled?: boolean; + asChild?: boolean; +} - const handleDragEnd = (event: DragEndEvent) => { - setActiveCardId(null); +function KanbanItem({ + value, + className, + asChild = false, + disabled, + children, + ...props +}: KanbanItemProps) { + const isOverlay = useContext(IsOverlayContext); - onDragEnd?.(event); + const { + setNodeRef, + transform, + transition, + attributes, + listeners, + isDragging: isSortableDragging, + } = useSortable({ + id: value, + disabled: disabled || isOverlay, + animateLayoutChanges, + }); - const { active, over } = event; + const { activeId, isColumn } = useContext(KanbanContext); + const isItemDragging = activeId ? !isColumn(activeId) : false; - if (!over || active.id === over.id) { - return; - } + const style = { + transition, + transform: CSS.Transform.toString(transform), + } as CSSProperties; + + const Comp = asChild ? Slot.Root : 'div'; + + if (isOverlay) { + return ( + + + {children} + + + ); + } - let newData = [...data]; + return ( + + + {children} + + + ); +} - const oldIndex = newData.findIndex((item) => item.id === active.id); - const newIndex = newData.findIndex((item) => item.id === over.id); +export interface KanbanItemHandleProps extends HTMLAttributes { + cursor?: boolean; + asChild?: boolean; +} - newData = arrayMove(newData, oldIndex, newIndex); +function KanbanItemHandle({ + className, + asChild = false, + cursor = true, + children, + ...props +}: KanbanItemHandleProps) { + const { listeners, isDragging, disabled } = useContext(ItemContext); - onDataChange?.(newData); - }; + const Comp = asChild ? Slot.Root : 'div'; - const announcements: Announcements = { - onDragStart({ active }) { - const { name, column } = data.find((item) => item.id === active.id) ?? {}; + return ( + + {children} + + ); +} - return `Picked up the card "${name}" from the "${column}" column`; - }, - onDragOver({ active, over }) { - const { name } = data.find((item) => item.id === active.id) ?? {}; - const newColumn = columns.find((column) => column.id === over?.id)?.name; +export interface KanbanColumnContentProps extends HTMLAttributes { + value: string; + asChild?: boolean; +} - return `Dragged the card "${name}" over the "${newColumn}" column`; - }, - onDragEnd({ active, over }) { - const { name } = data.find((item) => item.id === active.id) ?? {}; - const newColumn = columns.find((column) => column.id === over?.id)?.name; +function KanbanColumnContent({ + value, + className, + asChild = false, + children, + ...props +}: KanbanColumnContentProps) { + const { columns, getItemId } = useContext(KanbanContext); - return `Dropped the card "${name}" into the "${newColumn}" column`; - }, - onDragCancel({ active }) { - const { name } = data.find((item) => item.id === active.id) ?? {}; + const itemIds = useMemo(() => columns[value].map(getItemId), [columns, getItemId, value]); - return `Cancelled dragging the card "${name}"`; - }, - }; + const Comp = asChild ? Slot.Root : 'div'; return ( - - + -
- {columns.map((column) => children(column))} -
- {typeof window !== 'undefined' && - createPortal( - - - , - document.body - )} -
-
+ {children} + + + ); +} + +export interface KanbanOverlayProps extends Omit< + React.ComponentProps, + 'children' +> { + children?: + | ReactNode + | ((params: { value: UniqueIdentifier; variant: 'column' | 'item' }) => ReactNode); +} + +function KanbanOverlay({ children, className, ...props }: KanbanOverlayProps) { + const { activeId, isColumn, modifiers } = useContext(KanbanContext); + + // Заменил useLayoutEffect на seSyncExternalStore + const emptySubscribe = () => () => {}; + + const isMounted = React.useSyncExternalStore( + emptySubscribe, + () => true, + () => false + ); + + const variant = activeId ? (isColumn(activeId) ? 'column' : 'item') : 'item'; + + const content = + activeId && children + ? typeof children === 'function' + ? children({ value: activeId, variant }) + : children + : null; + + if (!isMounted) return null; + + return createPortal( + + {content} + , + document.body ); +} + +export { + Kanban, + KanbanBoard, + KanbanColumn, + KanbanColumnHandle, + KanbanItem, + KanbanItemHandle, + KanbanColumnContent, + KanbanOverlay, }; diff --git a/src/shared/ui/checkbox/Checkbox.tsx b/src/shared/ui/checkbox/Checkbox.tsx new file mode 100644 index 0000000..384a81e --- /dev/null +++ b/src/shared/ui/checkbox/Checkbox.tsx @@ -0,0 +1,32 @@ +import { ComponentProps } from 'react'; +import { cn } from 'shared/lib/utils'; + +function Checkbox({ className, ...props }: Omit, 'type'>) { + return ( +
+ + + + +
+ ); +} + +export { Checkbox }; diff --git a/src/shared/ui/color-picker/ColorPicker.tsx b/src/shared/ui/color-picker/ColorPicker.tsx new file mode 100644 index 0000000..a1cb245 --- /dev/null +++ b/src/shared/ui/color-picker/ColorPicker.tsx @@ -0,0 +1,77 @@ +'use client'; +import { ComponentProps, CSSProperties, MouseEvent } from 'react'; +import { COLORS } from './const'; +import { cn } from 'shared/lib/utils'; +import { Check } from 'lucide-react'; + +type ColorPickerProps = ComponentProps<'button'> & { + activeColor: string; + setActiveColor: (color: string) => void; + size?: keyof typeof variant.size; + colors?: string[]; +}; + +const variant = { + size: { + default: 'size-8', + sm: 'size-5', + xs: 'size-3', + }, +}; + +export function ColorPicker({ + disabled, + className, + onClick, + setActiveColor, + activeColor, + size = 'default', + colors, + ...props +}: ColorPickerProps) { + const handlePickColor = (e: MouseEvent, color: string) => { + onClick?.(e); + setActiveColor?.(color); + }; + return ( +
+ {(colors ?? COLORS)?.map((item) => { + const isSelected = activeColor === item; + const isVeryLight = item?.toLowerCase() === '#ffffff'; + + return ( + + ); + })} +
+ ); +} diff --git a/src/shared/ui/color-picker/const.ts b/src/shared/ui/color-picker/const.ts new file mode 100644 index 0000000..3ee1d78 --- /dev/null +++ b/src/shared/ui/color-picker/const.ts @@ -0,0 +1,14 @@ +export const COLORS = [ + '#9FA8DA', + '#7E57C2', + '#9575CD', + '#AB47BC', + '#F06292', + '#FF8A65', + '#4FC3F7', + '#4DB6AC', + '#81C784', + '#DCE775', + '#FFF176', + '#FFB74D', +] as const; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 8a9ae79..50ef18f 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -39,3 +39,5 @@ export * from './Select'; export * from './Empty'; export * from './ScrollArea'; export * from './Kanban'; +export * from './checkbox/Checkbox'; +export * from './color-picker/ColorPicker'; diff --git a/src/widgets/app-sidebar/ui/Projects.tsx b/src/widgets/app-sidebar/ui/Projects.tsx index 653a3be..7d544bc 100644 --- a/src/widgets/app-sidebar/ui/Projects.tsx +++ b/src/widgets/app-sidebar/ui/Projects.tsx @@ -48,7 +48,11 @@ export function Projects() { return ( - + {projectIconCodeToEmoji(project.icon)} {project.name} diff --git a/src/widgets/app-sidebar/ui/Team.tsx b/src/widgets/app-sidebar/ui/Team.tsx index 593a39e..ee2b999 100644 --- a/src/widgets/app-sidebar/ui/Team.tsx +++ b/src/widgets/app-sidebar/ui/Team.tsx @@ -60,7 +60,7 @@ export function Team() { - Пригласить участника + Пригласить участника diff --git a/steiger.config.ts b/steiger.config.ts index 536b98e..cd84fef 100644 --- a/steiger.config.ts +++ b/steiger.config.ts @@ -10,4 +10,15 @@ export default defineConfig([ 'fsd/public-api': 'off', }, }, + { + files: [ + './src/features/boards/**', + './src/entities/board/**', + './src/entities/task/**', + './src/features/task/**', + ], + rules: { + 'fsd/insignificant-slice': 'off', + }, + }, ]);