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