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/auth/api/http.ts b/src/entities/auth/api/http.ts index 170d346..724f39c 100644 --- a/src/entities/auth/api/http.ts +++ b/src/entities/auth/api/http.ts @@ -1,6 +1,6 @@ import { api } from 'shared/api'; import * as SAuth from '../model/schemas'; -import * as TAuth from '../model/types'; +import type * as TAuth from '../model/types'; export class AuthHttp { static signin(data: TAuth.SigninBody) { diff --git a/src/entities/board/api/http.ts b/src/entities/board/api/http.ts new file mode 100644 index 0000000..e320997 --- /dev/null +++ b/src/entities/board/api/http.ts @@ -0,0 +1,130 @@ +import { api } from 'shared/api'; +import * as SBoard from '../model/schemas'; +import type * as TBoard from '../model/types'; + +export class BoardHttp { + static getBoardList(projectSlug: string, signal?: AbortSignal) { + return api({ + url: `/projects/${projectSlug}/area`, + method: 'GET', + contracts: { + response: SBoard.BoardListResponse, + }, + signal, + }); + } + + static getBoard(projectSlug: string, id: string, signal?: AbortSignal) { + return api({ + url: `/projects/${projectSlug}/area/${id}`, + method: 'GET', + contracts: { + response: SBoard.Board, + }, + signal, + }); + } + + static createBoard(projectSlug: string, data: TBoard.CreateBoardBody) { + return api({ + url: `/projects/${projectSlug}/area`, + method: 'POST', + data, + contracts: { + body: SBoard.CreateBoardBody, + response: SBoard.CreateBoardResponse, + }, + }); + } + + static updateBoard(projectSlug: string, boardSlug: string, data: TBoard.UpdateBoardBody) { + return api({ + url: `/projects/${projectSlug}/area/${boardSlug}`, + method: 'PUT', + data, + contracts: { + body: SBoard.UpdateBoardBody, + response: SBoard.ActionResponse, + }, + }); + } + + static removeBoard(projectSlug: string, boardSlug: string) { + return api({ + url: `/projects/${projectSlug}/area/${boardSlug}`, + method: 'DELETE', + contracts: { + response: SBoard.ActionResponse, + }, + }); + } + + static getBoardColumnList(boardSlug: string, signal?: AbortSignal) { + return api({ + url: `/area/${boardSlug}/states`, + method: 'GET', + contracts: { + response: SBoard.BoardColumnListResponse, + }, + signal, + }); + } + + static getBoardColumn(boardSlug: string, columnId: string, signal?: AbortSignal) { + return api({ + url: `/area/${boardSlug}/states/${columnId}`, + method: 'GET', + contracts: { + response: SBoard.BoardColumn, + }, + signal, + }); + } + + static createBoardColumn(boardSlug: string, data: TBoard.CreateBoardColumnBody) { + return api({ + url: `/area/${boardSlug}/states`, + method: 'POST', + data, + contracts: { + body: SBoard.CreateBoardColumnBody, + response: SBoard.CreateBoardColumnResponse, + }, + }); + } + + static updateBoardColumn( + boardSlug: string, + columnId: string, + data: TBoard.UpdateBoardColumnBody + ) { + return api({ + url: `/area/${boardSlug}/states/${columnId}`, + method: 'PATCH', + data, + contracts: { + body: SBoard.UpdateBoardColumnBody, + response: SBoard.ActionResponse, + }, + }); + } + + static removeBoardColumn(boardSlug: string, columnId: string) { + return api({ + url: `/area/${boardSlug}/states/${columnId}`, + method: 'DELETE', + contracts: { + response: SBoard.ActionResponse, + }, + }); + } + static restoreBoardColumn(boardSlug: string, columnId: string) { + return api({ + url: `/area/${boardSlug}/states/${columnId}/restore`, + method: 'POST', + 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..e8e4547 --- /dev/null +++ b/src/entities/board/api/queries.ts @@ -0,0 +1,37 @@ +import { queryOptions } from '@tanstack/react-query'; +import { boardFabricKeys } from '../model/consts'; +import { BoardHttp } from './http'; + +export class BoardQueries { + static getBoardList(slug: string) { + return queryOptions({ + queryKey: boardFabricKeys.list(slug), + queryFn: async ({ signal }) => BoardHttp.getBoardList(slug, signal), + staleTime: 60_000, + }); + } + + static getBoard(slug: string, id: string) { + return queryOptions({ + queryKey: boardFabricKeys.detail(slug, id), + queryFn: async ({ signal }) => BoardHttp.getBoard(slug, id, signal), + staleTime: 60_000, + }); + } + + static getBoardColumnList(slug: string) { + return queryOptions({ + queryKey: boardFabricKeys.columns(slug), + queryFn: async ({ signal }) => BoardHttp.getBoardColumnList(slug, signal), + staleTime: 60_000, + }); + } + + static getBoardColumn(slug: string, id: string) { + return queryOptions({ + queryKey: boardFabricKeys.column(slug, id), + queryFn: async ({ signal }) => BoardHttp.getBoardColumn(slug, id, signal), + staleTime: 60_000, + }); + } +} diff --git a/src/entities/board/config/colors.ts b/src/entities/board/config/colors.ts new file mode 100644 index 0000000..b6bdfab --- /dev/null +++ b/src/entities/board/config/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/index.ts b/src/entities/board/index.ts new file mode 100644 index 0000000..a04fb0b --- /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/consts'; +export { BoardHttp } from './api/http'; +export { BoardQueries } from './api/queries'; +export { BoardMapper, type KanbanBoardData } from './model/mapper'; +export { BOARD_COLUMN_COLORS } from './config/colors'; +export { useBoardStore } from './model/store'; diff --git a/src/entities/board/model/consts.ts b/src/entities/board/model/consts.ts new file mode 100644 index 0000000..901922c --- /dev/null +++ b/src/entities/board/model/consts.ts @@ -0,0 +1,11 @@ +import { createEntityKeys } from 'shared/lib/utils'; + +export const boardFabricKeys = createEntityKeys('board', { + detail: (slug: string, id: string) => ['projects', slug, 'boards', id], + columns: (slug: string) => ['boards', slug, 'columns'], + column: (slug: string, id: string) => ['boards', slug, 'columns', id], + views: (slug: string) => ['boards', slug, 'views'], + view: (slug: string, id: string) => ['boards', slug, 'views', id], + tasks: (boardSlug: string) => ['board', boardSlug, 'tasks'], + task: (columnId: string, taskId: string) => ['columns', columnId, 'tasks', taskId], +}); diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts new file mode 100644 index 0000000..887786f --- /dev/null +++ b/src/entities/board/model/mapper.ts @@ -0,0 +1,51 @@ +import type { BoardColumnResponse, BoardResponse } from './types'; + +// TODO: добавить таски в типы, когда они появятся в API + +type KanbanTaskStub = { + id: string; + columnId: string; +}; + +export type KanbanBoardData = { + board: BoardResponse; + columns: Record; + tasksByColumn: Record; +}; + +export class BoardMapper { + static toKanban( + board: BoardResponse, + columnList: BoardColumnResponse[], + taskList: unknown[] + ): KanbanBoardData { + const sortedColumns = [...columnList].sort((a, b) => a.orderIndex - b.orderIndex); + const tasksByColumn: Record = {}; + const columns: Record = {}; + + sortedColumns.forEach((column) => { + tasksByColumn[column.id] = []; + columns[column.id] = column; + }); + + taskList?.forEach((task) => { + const kanbanTask = task as KanbanTaskStub; + + if (tasksByColumn[kanbanTask.columnId]) { + tasksByColumn[kanbanTask.columnId].push(task); + } else { + console.warn(`Task ${kanbanTask.id} references unknown column ${kanbanTask.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/schemas.ts b/src/entities/board/model/schemas.ts new file mode 100644 index 0000000..952c64a --- /dev/null +++ b/src/entities/board/model/schemas.ts @@ -0,0 +1,202 @@ +import { createSortingSchema, DateTimeString, GlobalSuccess } from 'shared/api'; +import { z } from 'zod/v4'; + +export const ActionResponse = GlobalSuccess; + +export const BoardColumnCategoryEnum = z.enum([ + 'backlog', + 'active', + 'review', + 'completed', + 'archived', +]); +export const ViewTypeEnum = z.enum(['kanban', 'list', 'calendar', 'gantt']); +export const ColumnStatusEnum = z.enum([ + 'backlog', + 'todo', + 'in_progress', + 'review', + 'done', + 'archived', + 'custom', +]); + +export const Board = z.object({ + id: z.string().min(1, 'ID не может быть пустым'), + projectId: z.string().min(1, 'ID проекта обязателен'), + title: z + .string() + .min(1, 'Название области обязательно') + .max(255, 'Название не должно превышать 255 символов'), + slug: z + .string() + .min(1, 'Slug обязателен') + .max(100, 'Slug не должен превышать 100 символов') + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case'), + description: z.string().nullable().optional(), + descriptionHtml: z.string().nullable().optional(), + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)' + ) + .nullable() + .optional(), + icon: z.string().max(20, 'Иконка должна быть не длиннее 20 символов').nullable().optional(), + tasksCount: z + .number() + .int('Количество задач должно быть целым числом') + .min(0, 'Количество задач не может быть отрицательным'), + defaultView: ViewTypeEnum, + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной'), + maxTasksLimit: z + .number() + .int('Лимит задач должен быть целым числом') + .positive('Лимит задач должен быть положительным числом') + .nullable() + .optional(), + isLocked: z.boolean(), + createdAt: DateTimeString, + updatedAt: DateTimeString, + createdBy: z.string().nullable().optional(), + deletedAt: z.string().nullable().optional(), +}); + +export const BoardColumn = z.object({ + id: z.string().min(1, 'ID не может быть пустым'), + title: z + .string() + .min(1, 'Название состояния обязательно') + .max(255, 'Название не должно превышать 255 символов'), + description: z.string().nullable().optional(), + stateType: ColumnStatusEnum, + category: BoardColumnCategoryEnum, + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)' + ) + .nullable() + .optional(), + icon: z.string().max(20, 'Иконка должна быть не длиннее 20 символов').nullable().optional(), + orderIndex: z + .number() + .int('Порядковый номер должен быть целым числом') + .min(0, 'Порядковый номер не может быть отрицательным'), + isVisible: z.boolean(), + maxTasksLimit: z + .number() + .int('Лимит задач должен быть целым числом') + .positive('Лимит задач должен быть положительным числом') + .nullable() + .optional(), + autoTransitionTo: z.string().nullable().optional(), + notifyOnEnter: z.boolean(), + notifyOnExit: z.boolean(), + isLocked: z.boolean(), + createdAt: DateTimeString, + updatedAt: DateTimeString, + createdBy: z.string().nullable().optional(), + deletedAt: z.string().nullable().optional(), +}); + +export const CreateBoardBody = Board.omit({ + id: true, + projectId: true, + tasksCount: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}) + .partial({ + description: true, + descriptionHtml: true, + color: true, + icon: true, + maxTasksLimit: true, + slug: true, + defaultView: true, + position: true, + isLocked: true, + }) + .extend({ + slug: z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case') + .optional(), + }); + +export const UpdateBoardBody = CreateBoardBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const CreateBoardColumnBody = BoardColumn.omit({ + id: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}).partial({ + description: true, + color: true, + icon: true, + maxTasksLimit: true, + autoTransitionTo: true, + stateType: true, + category: true, + orderIndex: true, + isVisible: true, + notifyOnEnter: true, + notifyOnExit: true, + isLocked: true, +}); + +export const UpdateBoardColumnBody = CreateBoardColumnBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const BoardColumnQueryParams = z + .object({ + hidden: z.boolean().optional(), + counts: z.boolean().optional(), + my: z.boolean().optional(), + category: z.string().optional(), + overdue: z.boolean().optional(), + page: z.coerce.number().int().positive().optional(), + offset: z.coerce.number().int().min(0).optional(), + limit: z.coerce.number().int().min(0).max(100).optional(), + }) + .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape) + .transform((data) => { + if (data.page && data.page > 1 && data.offset === 0) { + return { + ...data, + offset: (data.page - 1) * (data.limit || 20), + }; + } + return data; + }); + +export const UpdateBoardColumnResponse = GlobalSuccess; +export const CreateBoardColumnResponse = GlobalSuccess.extend({ + stateId: z.string(), +}); +export const BoardListResponse = Board.array(); +export const BoardColumnListResponse = BoardColumn.array(); + +export const CreateBoardResponse = GlobalSuccess; +export const UpdateBoardResponse = GlobalSuccess; diff --git a/src/entities/board/model/store.ts b/src/entities/board/model/store.ts new file mode 100644 index 0000000..e63042f --- /dev/null +++ b/src/entities/board/model/store.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; + +interface BoardStore { + activeBoardId: string | null; + activeBoardSlug: string | null; + activeColumnId: string | null; + setBoardId: (id: string, slug: string) => void; + setColumnId: (id: string | null) => void; +} + +export const useBoardStore = create((set) => ({ + activeBoardId: null, + activeColumnId: null, + activeBoardSlug: null, + setBoardId(id, slug) { + set({ activeBoardId: id, activeBoardSlug: slug }); + }, + setColumnId(id) { + set({ activeColumnId: id }); + }, +})); diff --git a/src/entities/board/model/types.ts b/src/entities/board/model/types.ts new file mode 100644 index 0000000..4ded86e --- /dev/null +++ b/src/entities/board/model/types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod/v4'; +import * as SBoard from './schemas'; + +export type BoardColumnStatus = z.infer; +export type BoardViewType = z.infer; + +export type BoardColumnResponse = z.infer; +export type BoardColumnListResponse = 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 CreateBoardColumnBody = z.infer; +export type UpdateBoardColumnBody = z.infer; +export type CreateBoardColumnResponse = z.infer; + +export type ActionResponse = z.infer; 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/auth/sign-out/model/useSignOut.ts b/src/features/auth/sign-out/model/useSignOut.ts index e69a88f..d487510 100644 --- a/src/features/auth/sign-out/model/useSignOut.ts +++ b/src/features/auth/sign-out/model/useSignOut.ts @@ -16,8 +16,8 @@ export function useSignOut({ onSuccess, ...rest }: UseSignOutOptions = {}) { return useMutation, DefaultError, void>({ ...rest, mutationFn: AuthHttp.signout, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); await context.client.cancelQueries(); AccessToken.clear(); context.client.clear(); diff --git a/src/features/boards/column/create/config/consts.ts b/src/features/boards/column/create/config/consts.ts new file mode 100644 index 0000000..1723308 --- /dev/null +++ b/src/features/boards/column/create/config/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/config/default-values.ts b/src/features/boards/column/create/config/default-values.ts new file mode 100644 index 0000000..e645aee --- /dev/null +++ b/src/features/boards/column/create/config/default-values.ts @@ -0,0 +1,10 @@ +import { DEFAULT_COLUMN_COLOR } from '../config/consts'; +import { CreateBoardColumnFormValues } from '../model/types'; + +export function getDefaultCreateBoardColumnValues(position = 0): CreateBoardColumnFormValues { + return { + title: '', + color: DEFAULT_COLUMN_COLOR, + orderIndex: position, + }; +} 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/schemas.ts b/src/features/boards/column/create/model/schemas.ts new file mode 100644 index 0000000..520d8a1 --- /dev/null +++ b/src/features/boards/column/create/model/schemas.ts @@ -0,0 +1,3 @@ +import { SBoard } from 'entities/board'; + +export const CreateBoardColumnFormSchema = SBoard.CreateBoardColumnBody; diff --git a/src/features/boards/column/create/model/types.ts b/src/features/boards/column/create/model/types.ts new file mode 100644 index 0000000..576da7c --- /dev/null +++ b/src/features/boards/column/create/model/types.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..5b83f68 --- /dev/null +++ b/src/features/boards/column/create/model/useCreateBoardColumn.ts @@ -0,0 +1,27 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, type TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +type CreateBoardColumnVariables = { + boardSlug: string; + body: TBoard.CreateBoardColumnBody; +}; + +export type UseCreateBoardColumnOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateBoardColumn({ onSuccess, ...rest }: UseCreateBoardColumnOptions = {}) { + return useMutation({ + ...rest, + mutationFn: ({ boardSlug, body }) => BoardHttp.createBoardColumn(boardSlug, body), + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); + toast.success(res.message ?? 'Колонка создана'); + await context.client.invalidateQueries({ + queryKey: boardFabricKeys.columns(variables.boardSlug), + }); + }, + }); +} 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..1a48587 --- /dev/null +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -0,0 +1,52 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { getDefaultCreateBoardColumnValues } from '../config/default-values'; +import { useCreateBoardColumn, UseCreateBoardColumnOptions } from './useCreateBoardColumn'; +import { CreateBoardColumnFormSchema } from './schemas'; +import { setFormErrors } from 'shared/lib/utils'; +import { extractValidationIssues } from 'shared/api'; +import { type CreateBoardColumnFormValues } from './types'; +import { type TBoard } from 'entities/board'; + +type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { + defaultPosition?: number; +}; + +export function useCreateBoardColumnForm( + boardSlug: string, + options: UseCreateBoardColumnFormOptions = {} +) { + const { defaultPosition = 0, ...mutationOptions } = options; + + 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 = { + title: data.title, + orderIndex: data.orderIndex, + ...(data.color ? { color: data.color } : {}), + }; + + createBoardColumn.mutate({ boardSlug, body }); + }; + + return { + form, + 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..e295dce --- /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 { + boardSlug: string; + defaultPosition?: number; + dialog?: ComponentProps; +} + +export function CreateBoardColumnDialog({ + boardSlug, + defaultPosition, + dialog = {}, + ...props +}: CreateBoardColumnDialogProps) { + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + const formId = useId(); + const [pending, setPending] = useState(false); + + return ( + + + + + Новая колонка + + + + { + setPending(true); + }, + onSuccess: () => { + setOpen(false); + }, + onSettled: () => { + setPending(false); + }, + }} + /> + + + + + + + + + + ); +} diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx new file mode 100644 index 0000000..cde3108 --- /dev/null +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -0,0 +1,80 @@ +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, DEFAULT_COLUMN_COLOR } from '../config/consts'; + +interface CreateBoardColumnFormProps extends Omit, 'children' | 'onSubmit'> { + boardSlug: string; + defaultPosition?: number; + mutateOptions?: UseCreateBoardColumnOptions; +} + +export function CreateBoardColumnForm({ + boardSlug, + defaultPosition, + className, + mutateOptions, + ...props +}: CreateBoardColumnFormProps) { + const { form, isPending, handleSubmit } = useCreateBoardColumnForm(boardSlug, { + defaultPosition, + ...mutateOptions, + }); + + return ( + +
+ + ( + + Название + + {fieldState.invalid && } + + )} + /> + { + const { value, ...rest } = field; + return ( + + Цвет + { + form.setValue('color', c); + }} + {...rest} + /> + + {fieldState.invalid && } + + ); + }} + /> + +
+
+ ); +} diff --git a/src/features/boards/column/remove/index.ts b/src/features/boards/column/remove/index.ts new file mode 100644 index 0000000..56e318c --- /dev/null +++ b/src/features/boards/column/remove/index.ts @@ -0,0 +1 @@ +export { RemoveColumnDialog } from './ui/RemoveColumnDialog'; diff --git a/src/features/boards/column/remove/model/useRemoveColumn.ts b/src/features/boards/column/remove/model/useRemoveColumn.ts new file mode 100644 index 0000000..d0fa7fc --- /dev/null +++ b/src/features/boards/column/remove/model/useRemoveColumn.ts @@ -0,0 +1,28 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { boardFabricKeys, BoardHttp, type TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +export type RemoveColunmnVariables = { + columnId: string; + boardSlug: string; +}; + +export type UseDeleteColumnOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useRemoveColumn({ onSuccess, onSettled, ...rest }: UseDeleteColumnOptions = {}) { + return useMutation({ + ...rest, + mutationFn: (args) => BoardHttp.removeBoardColumn(args.boardSlug, 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.boardSlug) }); + }, + }); +} 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..3127859 --- /dev/null +++ b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx @@ -0,0 +1,49 @@ +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, boardSlug, options = {}, ...props }: Props) { + const removeBoard = useRemoveColumn(options); + + const onRemove = () => { + removeBoard.mutate({ columnId, boardSlug }); + }; + + return ( + + + + Удаление доски + + Удалить колонку? + + При удалении колонки будут удалены все задачи в ней + + + + Отмена + + Удалить + + + + + ); +} diff --git a/src/features/boards/create/config/default-values.ts b/src/features/boards/create/config/default-values.ts new file mode 100644 index 0000000..6744c25 --- /dev/null +++ b/src/features/boards/create/config/default-values.ts @@ -0,0 +1,5 @@ +import { CreateBoardFormValues } from '../model/types'; + +export function getDefaultCreateBoardValues(): CreateBoardFormValues { + return { title: '' }; +} 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/schemas.ts b/src/features/boards/create/model/schemas.ts new file mode 100644 index 0000000..d4e5afe --- /dev/null +++ b/src/features/boards/create/model/schemas.ts @@ -0,0 +1,3 @@ +import { SBoard } from 'entities/board'; + +export const CreateBoardFormSchema = SBoard.CreateBoardBody; diff --git a/src/features/boards/create/model/types.ts b/src/features/boards/create/model/types.ts new file mode 100644 index 0000000..529d29f --- /dev/null +++ b/src/features/boards/create/model/types.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..b1489ed --- /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, type TBoard } from 'entities/board'; +import { toast } from 'sonner'; + +type CreateBoardVariables = { + projectSlug: string; + body: TBoard.CreateBoardBody; +}; + +export type UseCreateBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useCreateBoard({ onSuccess, ...rest }: UseCreateBoardOptions = {}) { + return useMutation({ + ...rest, + mutationFn: ({ projectSlug, body }) => BoardHttp.createBoard(projectSlug, body), + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); + toast.success(res.message ?? 'Доска создана'); + + await context.client.invalidateQueries({ + queryKey: boardFabricKeys.list(variables.projectSlug), + }); + }, + }); +} diff --git a/src/features/boards/create/model/useCreateBoardForm.ts b/src/features/boards/create/model/useCreateBoardForm.ts new file mode 100644 index 0000000..7d00602 --- /dev/null +++ b/src/features/boards/create/model/useCreateBoardForm.ts @@ -0,0 +1,46 @@ +import { useParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { getDefaultCreateBoardValues } from '../config/default-values'; +import { useCreateBoard, UseCreateBoardOptions } from './useCreateBoard'; +import { CreateBoardFormSchema } from './schemas'; +import { setFormErrors } from 'shared/lib/utils'; +import { extractValidationIssues } from 'shared/api'; +import { type TBoard } from 'entities/board'; +import { type CreateBoardFormValues } from './types'; + +export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { + const params = useParams<{ slug: string }>(); + const slug = params?.slug; + + 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 = { + title: data.title, + }; + + createBoard.mutate({ projectSlug: slug!, body }); + }; + + return { + form, + projectSlug: slug!, + isPending: createBoard.isPending, + handleSubmit: form.handleSubmit(onSubmit), + }; +} diff --git a/src/features/boards/create/ui/CreateBoardDialog.tsx b/src/features/boards/create/ui/CreateBoardDialog.tsx new file mode 100644 index 0000000..47e76e4 --- /dev/null +++ b/src/features/boards/create/ui/CreateBoardDialog.tsx @@ -0,0 +1,68 @@ +import { ComponentProps, useId, useState } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Spinner, +} from 'shared/ui'; +import { CreateBoardForm } from './CreateBoardForm'; + +interface CreateProjectDialogProps extends ComponentProps { + dialog?: ComponentProps; +} + +export function CreateBoardDialog({ dialog = {}, ...props }: CreateProjectDialogProps) { + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + const formId = useId(); + const [pending, setPending] = useState(false); + + return ( + + + + + Новая доска + + + + { + setPending(true); + }, + onSuccess: () => { + setOpen(false); + }, + onSettled: () => { + setPending(false); + }, + }} + /> + + + + + + + + + + ); +} diff --git a/src/features/boards/create/ui/CreateBoardForm.tsx b/src/features/boards/create/ui/CreateBoardForm.tsx new file mode 100644 index 0000000..6b02e16 --- /dev/null +++ b/src/features/boards/create/ui/CreateBoardForm.tsx @@ -0,0 +1,42 @@ +import { Controller, FormProvider } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { Field, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; +import { useCreateBoardForm } from '../model/useCreateBoardForm'; +import { UseCreateBoardOptions } from '../model/useCreateBoard'; +import { ComponentProps } from 'react'; + +interface CreateBoardFormProps extends Omit, 'children' | 'onSubmit'> { + mutateOptions?: UseCreateBoardOptions; +} + +export function CreateBoardForm({ className, mutateOptions, ...props }: CreateBoardFormProps) { + const { form, isPending, handleSubmit } = useCreateBoardForm(mutateOptions); + + return ( + +
+ + ( + + Название + + {fieldState.invalid && } + + )} + /> + +
+
+ ); +} 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..b5e7220 --- /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 = { + projectSlug: string; + boardSlug: string; +}; + +export type UseDeleteBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useRemoveBoard({ onSuccess, onSettled, ...rest }: UseDeleteBoardOptions = {}) { + return useMutation({ + ...rest, + mutationFn: (args) => BoardHttp.removeBoard(args.projectSlug, args.boardSlug), + 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.projectSlug) }); + }, + }); +} diff --git a/src/features/boards/remove/ui/RemoveBoardDialog.tsx b/src/features/boards/remove/ui/RemoveBoardDialog.tsx new file mode 100644 index 0000000..1d27437 --- /dev/null +++ b/src/features/boards/remove/ui/RemoveBoardDialog.tsx @@ -0,0 +1,43 @@ +import { ComponentProps } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from 'shared/ui'; +import { RemoveBoardVariables, useRemoveBoard } from '../model/useRemoveBoard'; + +type Props = ComponentProps & RemoveBoardVariables; + +export function RemoveBoardDialog({ projectSlug, boardSlug, ...props }: Props) { + const removeBoard = useRemoveBoard(); + + const onRemove = () => { + removeBoard.mutate({ projectSlug, boardSlug }); + }; + + return ( + + + + + Удалить доску? + + + + Отмена + + Удалить + + + + + ); +} diff --git a/src/features/otp-form/index.ts b/src/features/otp-form/index.ts index a2daa32..0a11845 100644 --- a/src/features/otp-form/index.ts +++ b/src/features/otp-form/index.ts @@ -1,4 +1,4 @@ export { OTPForm } from './ui/OTPForm'; export { OTPFormLoader } from './ui/OTPFormLoader'; export { ResendCodeControl } from './ui/ResendCodeControl'; -export { DRAFT_TTL_MS, RESEND_CODE_DELAY_MS } from './model/const'; +export { DRAFT_TTL_MS, RESEND_CODE_DELAY_MS } from './model/consts'; diff --git a/src/features/otp-form/model/const.ts b/src/features/otp-form/model/consts.ts similarity index 100% rename from src/features/otp-form/model/const.ts rename to src/features/otp-form/model/consts.ts diff --git a/src/features/otp-form/ui/ResendCodeControl.tsx b/src/features/otp-form/ui/ResendCodeControl.tsx index 4fb4d11..1e144c1 100644 --- a/src/features/otp-form/ui/ResendCodeControl.tsx +++ b/src/features/otp-form/ui/ResendCodeControl.tsx @@ -4,7 +4,7 @@ import { ComponentProps } from 'react'; import { useTimer } from 'shared/lib/hooks'; import { classNames, formatTime } from 'shared/lib/utils'; import { Button } from 'shared/ui'; -import { RESEND_CODE_DELAY_MS } from '../model/const'; +import { RESEND_CODE_DELAY_MS } from '../model/consts'; import { useResendCode, UseResendOptions } from '../model/useResend'; import { type TAuth } from 'entities/auth'; import { toast } from 'sonner'; diff --git a/src/features/projects/archive/model/useArchiveProject.ts b/src/features/projects/archive/model/useArchiveProject.ts index aefa6fc..08e9e1c 100644 --- a/src/features/projects/archive/model/useArchiveProject.ts +++ b/src/features/projects/archive/model/useArchiveProject.ts @@ -16,8 +16,8 @@ export function useArchiveProject({ onSuccess, ...rest }: UseArchiveProjectOptio return useMutation({ ...rest, mutationFn: ({ teamId, slug }) => ProjectHttp.archiveProject(teamId, slug), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Проект архивирован'); await Promise.all([ diff --git a/src/features/projects/archive/model/useRestoreProject.ts b/src/features/projects/archive/model/useRestoreProject.ts index 3c921e2..3e25a2d 100644 --- a/src/features/projects/archive/model/useRestoreProject.ts +++ b/src/features/projects/archive/model/useRestoreProject.ts @@ -16,8 +16,8 @@ export function useRestoreProject({ onSuccess, ...rest }: UseRestoreProjectOptio return useMutation({ ...rest, mutationFn: ({ teamId, slug }) => ProjectHttp.updateProject(teamId, slug, { status: 'active' }), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Проект восстановлен'); await Promise.all([ diff --git a/src/features/projects/create/model/default-values.ts b/src/features/projects/create/config/default-values.ts similarity index 88% rename from src/features/projects/create/model/default-values.ts rename to src/features/projects/create/config/default-values.ts index bd7e585..a0c553b 100644 --- a/src/features/projects/create/model/default-values.ts +++ b/src/features/projects/create/config/default-values.ts @@ -1,5 +1,5 @@ import { PROJECT_COLORS, PROJECT_ICONS } from 'entities/project'; -import type { CreateProjectFormValues } from './types'; +import type { CreateProjectFormValues } from '../model/types'; function pickRandom(items: readonly T[]): T { return items[Math.floor(Math.random() * items.length)]!; diff --git a/src/features/projects/create/model/useCreateProject.ts b/src/features/projects/create/model/useCreateProject.ts index 94cda91..c054eee 100644 --- a/src/features/projects/create/model/useCreateProject.ts +++ b/src/features/projects/create/model/useCreateProject.ts @@ -16,8 +16,8 @@ export function useCreateProject({ onSuccess, ...rest }: UseCreateProjectOptions return useMutation({ ...rest, mutationFn: ({ teamId, body }) => ProjectHttp.createProject(teamId, body), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Проект создан'); await context.client.invalidateQueries({ diff --git a/src/features/projects/create/model/useCreateProjectForm.ts b/src/features/projects/create/model/useCreateProjectForm.ts index b1048b1..8ae08c6 100644 --- a/src/features/projects/create/model/useCreateProjectForm.ts +++ b/src/features/projects/create/model/useCreateProjectForm.ts @@ -1,13 +1,13 @@ +import { useForm } from 'react-hook-form'; import { useCheckSlug, validateProjectSlugAsync, type TProject } from 'entities/project'; import { useTeamStore } from 'entities/team'; -import { useForm } from 'react-hook-form'; import { extractValidationIssues } from 'shared/api'; import { setFormErrors } from 'shared/lib/utils'; -import { getDefaultCreateProjectValues } from './default-values'; +import { useZodValidationWithAsyncCheck } from 'shared/lib/hooks'; +import { getDefaultCreateProjectValues } from '../config/default-values'; import { CreateProjectFormSchema } from './schemas'; +import { type UseCreateProjectOptions, useCreateProject } from './useCreateProject'; import type { CreateProjectFormValues } from './types'; -import { useCreateProject, type UseCreateProjectOptions } from './useCreateProject'; -import { useZodValidationWithAsyncCheck } from 'shared/lib/hooks'; export function useCreateProjectForm(options: UseCreateProjectOptions = {}) { const teamId = useTeamStore.use.teamId(); diff --git a/src/features/projects/remove/model/useRemoveProject.ts b/src/features/projects/remove/model/useRemoveProject.ts index 0181d27..7f6178a 100644 --- a/src/features/projects/remove/model/useRemoveProject.ts +++ b/src/features/projects/remove/model/useRemoveProject.ts @@ -21,8 +21,8 @@ export function useRemoveProject({ onSuccess, ...rest }: UseRemoveProjectOptions return useMutation({ ...rest, mutationFn: ({ teamId, slug }) => ProjectHttp.removeProject(teamId, slug), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); if (pathname !== routes.team.projects()) { router.replace(routes.team.projects()); diff --git a/src/features/projects/share/model/useShareProject.ts b/src/features/projects/share/model/useShareProject.ts index 123b61c..7257ba8 100644 --- a/src/features/projects/share/model/useShareProject.ts +++ b/src/features/projects/share/model/useShareProject.ts @@ -17,8 +17,8 @@ export function useShareProject({ onSuccess, ...rest }: UseShareProjectOptions = return useMutation({ ...rest, mutationFn: ({ teamId, slug, body = {} }) => ProjectHttp.createShareToken(teamId, slug, body), - onSuccess: async (res, variables, _r, context) => { - onSuccess?.(res, variables, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Ссылка для доступа создана'); await context.client.invalidateQueries({ 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/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/useClickOutside.ts b/src/features/task/create/model/useClickOutside.ts new file mode 100644 index 0000000..6cce89d --- /dev/null +++ b/src/features/task/create/model/useClickOutside.ts @@ -0,0 +1,14 @@ +import { useLayoutEffect, useRef } from 'react'; + +export function useClickOutside(id: string, handler: () => void) { + const handlerRef = useRef(handler); + + useLayoutEffect(() => { + const listener = (e: MouseEvent) => { + const elem = document.getElementById(id); + if (!elem?.contains(e.target as Node)) handlerRef.current?.(); + }; + document.addEventListener('mouseup', listener); + return () => document.removeEventListener('mouseup', listener); + }, [id]); +} 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..c81793f --- /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 '../model/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/features/teams/create/model/useCreateTeam.ts b/src/features/teams/create/model/useCreateTeam.ts index ca80016..2c5f628 100644 --- a/src/features/teams/create/model/useCreateTeam.ts +++ b/src/features/teams/create/model/useCreateTeam.ts @@ -12,8 +12,8 @@ export function useCreateTeam({ onSuccess, ...rest }: UseCreateTeamOptions = {}) return useMutation({ ...rest, mutationFn: TeamHttp.createTeam, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Команда создана'); await context.client.invalidateQueries({ queryKey: userFabricKeys.myTeams() }); }, diff --git a/src/features/teams/invite/model/useInviteTeamMember.ts b/src/features/teams/invite/model/useInviteTeamMember.ts index c5c8f9e..7c2ee6a 100644 --- a/src/features/teams/invite/model/useInviteTeamMember.ts +++ b/src/features/teams/invite/model/useInviteTeamMember.ts @@ -13,12 +13,14 @@ export function useInviteTeamMember({ onSuccess, ...rest }: UseInviteTeamMemberO return useMutation({ ...rest, mutationFn: ({ teamId, body }) => TeamHttp.inviteMember(teamId, body), - onSuccess: async (res, v, _r, context) => { - onSuccess?.(res, v, _r, context); + onSuccess: async (res, variables, r, context) => { + onSuccess?.(res, variables, r, context); toast.success(res.message ?? 'Приглашение отправлено'); - if (v.teamId) { - await context.client.invalidateQueries({ queryKey: teamFabricKeys.invitations(v.teamId) }); + if (variables.teamId) { + await context.client.invalidateQueries({ + queryKey: teamFabricKeys.invitations(variables.teamId), + }); } }, }); diff --git a/src/features/teams/remove/model/useRemoveTeam.ts b/src/features/teams/remove/model/useRemoveTeam.ts index 453b01d..90a1c58 100644 --- a/src/features/teams/remove/model/useRemoveTeam.ts +++ b/src/features/teams/remove/model/useRemoveTeam.ts @@ -16,8 +16,8 @@ export function useRemoveTeam({ onSuccess, onSettled, ...rest }: UseDeleteTeamOp onSuccess?.(res, ...args); toast.success(res.message ?? 'Команда удалена'); }, - onSettled: async (_d, _e, _v, _m, context) => { - onSettled?.(_d, _e, _v, _m, context); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); context.client.invalidateQueries({ queryKey: userFabricKeys.myTeams() }); }, }); diff --git a/src/pages/invitations/api/useAcceptTeamInvitation.ts b/src/pages/invitations/api/useAcceptTeamInvitation.ts index 5e07160..7ae7cb6 100644 --- a/src/pages/invitations/api/useAcceptTeamInvitation.ts +++ b/src/pages/invitations/api/useAcceptTeamInvitation.ts @@ -18,8 +18,8 @@ export function useAcceptTeamInvitation(options: UseAcceptTeamInvitationOptions onSuccess?.(res, ...args); toast.success(res.message ?? 'Приглашение принято'); }, - onSettled: async (_d, _e, _v, _m, context) => { - onSettled?.(_d, _e, _v, _m, context); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); await Promise.all([ context.client.invalidateQueries({ queryKey: userFabricKeys.myTeams() }), context.client.invalidateQueries({ queryKey: userFabricKeys.myInvitations() }), diff --git a/src/pages/profile/api/useUpdateNotifications.ts b/src/pages/profile/api/useUpdateNotifications.ts index 7be5efa..94aa6f6 100644 --- a/src/pages/profile/api/useUpdateNotifications.ts +++ b/src/pages/profile/api/useUpdateNotifications.ts @@ -27,8 +27,8 @@ export function useUpdateNotifications({ onSuccess?.(...args); toast.success('Настройки уведомлений обновлены'); }, - onSettled: async (_d, _e, _v, _m, context) => { - onSettled?.(_d, _e, _v, _m, context); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); context.client.invalidateQueries({ queryKey: userFabricKeys.me() }); }, }); diff --git a/src/pages/profile/api/useUpdateProfile.ts b/src/pages/profile/api/useUpdateProfile.ts index 7ae5a07..d55623f 100644 --- a/src/pages/profile/api/useUpdateProfile.ts +++ b/src/pages/profile/api/useUpdateProfile.ts @@ -15,8 +15,8 @@ export function useUpdateProfile({ onSuccess, onSettled, ...rest }: UseUpdatePro onSuccess?.(res, ...args); toast.success(res.message ?? 'Профиль успешно обновлен'); }, - onSettled: async (_d, _e, _v, _m, context) => { - onSettled?.(_d, _e, _v, _m, context); + onSettled: async (d, e, v, m, context) => { + onSettled?.(d, e, v, m, context); context.client.invalidateQueries({ queryKey: userFabricKeys.me() }); }, }); diff --git a/src/pages/project/api/useUpdateProject.ts b/src/pages/project/api/useUpdateProject.ts index 6e63b13..e706973 100644 --- a/src/pages/project/api/useUpdateProject.ts +++ b/src/pages/project/api/useUpdateProject.ts @@ -22,8 +22,8 @@ export function useUpdateProject({ onSuccess, ...rest }: UseUpdateProjectProps = } return ProjectHttp.updateProject(teamId, slug, data); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Проект обновлён'); if (teamId && slug) { 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/types.ts b/src/pages/project/model/types.ts new file mode 100644 index 0000000..f85f4c3 --- /dev/null +++ b/src/pages/project/model/types.ts @@ -0,0 +1,7 @@ +import { type TBoard } from 'entities/board'; + +export type ProjectBoardViewData = { + board: TBoard.BoardResponse; + columns: TBoard.BoardColumnResponse[]; + tasks: unknown[]; // TODO: заглушка, пока не тасок; +}; diff --git a/src/pages/project/model/useActiveBoard.ts b/src/pages/project/model/useActiveBoard.ts new file mode 100644 index 0000000..379f9af --- /dev/null +++ b/src/pages/project/model/useActiveBoard.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { type TBoard, useBoardStore } from 'entities/board'; + +export const useActiveBoards = (boards: TBoard.BoardResponse[]) => { + const activeBoardId = useBoardStore((s) => s.activeBoardId); + const setActiveBoardId = useBoardStore((s) => s.setBoardId); + const activeBoardSlug = useBoardStore((s) => s.activeBoardSlug); + + const activeBoard: TBoard.BoardResponse | null = + boards?.find((v) => v.id === activeBoardId) ?? (boards.length > 0 ? boards[0] : null); + + useEffect(() => { + if (!activeBoardId && boards.length > 0) { + setActiveBoardId(boards[0].id, boards[0].slug); + } + }, [activeBoardId, boards, setActiveBoardId]); + + return { activeBoardId, activeBoardSlug, setActiveBoardId, activeBoard }; +}; diff --git a/src/pages/project/model/useBoardsPage.ts b/src/pages/project/model/useBoardsPage.ts new file mode 100644 index 0000000..7d43bd7 --- /dev/null +++ b/src/pages/project/model/useBoardsPage.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { BoardQueries } from 'entities/board'; + +export const useBoardsPage = (projectId: string) => { + const { data, isLoading, isError, error, refetch } = useQuery( + BoardQueries.getBoardList(projectId) + ); + + return { data, isLoading, isError, error, refetch }; +}; diff --git a/src/pages/project/model/useUpdateBoard.ts b/src/pages/project/model/useUpdateBoard.ts new file mode 100644 index 0000000..be5ca4f --- /dev/null +++ b/src/pages/project/model/useUpdateBoard.ts @@ -0,0 +1,20 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { BoardHttp, type TBoard } from 'entities/board'; + +type UpdateBoardVariables = { + boardSlug: string; + columnId: string; + body: TBoard.UpdateBoardBody; +}; + +export type UseCreateBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useUpdateBoard({ ...options }: UseCreateBoardOptions = {}) { + return useMutation({ + ...options, + mutationFn: ({ boardSlug, columnId, body }) => BoardHttp.updateBoard(boardSlug, columnId, body), + }); +} diff --git a/src/pages/project/model/useUpdateBoardColumn.ts b/src/pages/project/model/useUpdateBoardColumn.ts new file mode 100644 index 0000000..8c35483 --- /dev/null +++ b/src/pages/project/model/useUpdateBoardColumn.ts @@ -0,0 +1,21 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { BoardHttp, type TBoard } from 'entities/board'; + +type UpdateBoardColumnVariables = { + boardSlug: string; + columnId: string; + body: TBoard.UpdateBoardColumnBody; +}; + +export type UseUpdateBoardOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useUpdateBoardColumn({ ...options }: UseUpdateBoardOptions = {}) { + return useMutation({ + ...options, + mutationFn: ({ boardSlug, columnId, body }) => + BoardHttp.updateBoardColumn(boardSlug, columnId, body), + }); +} 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/ProjectBoardButton.tsx b/src/pages/project/ui/boards/ProjectBoardButton.tsx new file mode 100644 index 0000000..fb351b9 --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoardButton.tsx @@ -0,0 +1,65 @@ +import { VariantProps } from 'class-variance-authority'; +import { type TBoard, useBoardStore } from 'entities/board'; +import { RemoveBoardDialog } from 'features/boards/remove'; +import { EllipsisVertical } from 'lucide-react'; +import { ComponentProps } from 'react'; +import { cn } from 'shared/lib/utils'; +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenu, + buttonVariants, +} from 'shared/ui'; + +type BoardButtonProps = ComponentProps<'div'> & + VariantProps & { + board: TBoard.BoardResponse; + projectSlug: string; + }; + +export function ProjectBoardButton({ + projectSlug, + 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/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..cf0c76d --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoards.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { Button } from 'shared/ui'; +import { CreateBoardDialog } from 'features/boards/create'; +import { PropsWithChildren, useState } from 'react'; +import { BoardQueries, type TBoard } from 'entities/board'; +import { useActiveBoards } from 'pages/project/model/useActiveBoard'; +import { useBoardsPage } from 'pages/project/model/useBoardsPage'; +import { ProjectBoardsSkeleton } from './ProjectBoards.skeleton'; +import { ProjectBoardsError } from './ProjectBoardsError'; +import { useQuery } from '@tanstack/react-query'; +import { ProjectBoardsHeader } from './ProjectBoardsHeader'; +import { ProjectBoardsContent } from './ProjectBoardsContent'; +import { ProjectBoardButton } from './ProjectBoardButton'; + +export function ProjectBoards({ slug }: PropsWithChildren<{ slug: string }>) { + const { data, isLoading, isError, error, refetch } = useBoardsPage(slug); + const { activeBoard, activeBoardSlug } = useActiveBoards(data ?? []); + const [view, setView] = useState(activeBoard?.defaultView ?? 'kanban'); + const columns = useQuery({ + ...BoardQueries.getBoardColumnList(activeBoardSlug!), + enabled: !!activeBoardSlug, + }); + + if (isLoading) return ; + if (isError) { + return refetch()} />; + } + return ( +
+
+ {data?.map((item) => ( + + ))} + + + +
+
+ setView(v)} /> + {activeBoard && columns.data ? ( + + ) : null} +
+
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectBoardsContent.tsx b/src/pages/project/ui/boards/ProjectBoardsContent.tsx new file mode 100644 index 0000000..efc6b5d --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoardsContent.tsx @@ -0,0 +1,29 @@ +import dynamic from 'next/dynamic'; +import React from 'react'; +import { ProjectKanbanSkeleton } from './ProjectKanban.skeleton'; +import { type TBoard } from 'entities/board'; +import { type ProjectBoardViewData } from '../../model/types'; + +type ProjectBoardsContentProps = { + view: TBoard.BoardViewType; + data: ProjectBoardViewData; +}; + +const VIEW_COMPONENTS = { + kanban: dynamic(() => import('./ProjectKanban').then((mod) => mod.ProjectKanban), { + loading: () => , + }), + list: null, + calendar: null, + gantt: null, +} satisfies Record< + TBoard.BoardViewType, + React.ComponentType<{ + data: ProjectBoardViewData; + }> | null +>; + +export function ProjectBoardsContent({ view, data }: ProjectBoardsContentProps) { + const ViewComponent = VIEW_COMPONENTS[view]; + return ViewComponent ? : null; +} 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/ProjectBoardsHeader.tsx b/src/pages/project/ui/boards/ProjectBoardsHeader.tsx new file mode 100644 index 0000000..612dc4e --- /dev/null +++ b/src/pages/project/ui/boards/ProjectBoardsHeader.tsx @@ -0,0 +1,15 @@ +import { type TBoard } from 'entities/board'; +import { SwitchBoardView } from './SwitchBoardView'; + +type ProjectBoardsHeaderProps = { + currentView: TBoard.BoardViewType; + onViewChange: (view: TBoard.BoardViewType) => void; +}; + +export function ProjectBoardsHeader({ currentView, onViewChange }: ProjectBoardsHeaderProps) { + return ( +
+ +
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectBoardsPage.tsx b/src/pages/project/ui/boards/ProjectBoardsPage.tsx index c6f185f..55b3ec4 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<{ slug: string }> }) { + const { slug } = 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.skeleton.tsx b/src/pages/project/ui/boards/ProjectKanban.skeleton.tsx new file mode 100644 index 0000000..01b4bdb --- /dev/null +++ b/src/pages/project/ui/boards/ProjectKanban.skeleton.tsx @@ -0,0 +1,11 @@ +import { TaskColumnSkeleton } from './TaskColumn.skeleton'; + +export function ProjectKanbanSkeleton() { + return ( +
+ + + +
+ ); +} diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx index 0de94d8..1406d32 100644 --- a/src/pages/project/ui/boards/ProjectKanban.tsx +++ b/src/pages/project/ui/boards/ProjectKanban.tsx @@ -1,31 +1,95 @@ '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, useMemo, useState } from 'react'; +import { Kanban, KanbanBoard, KanbanOverlay, Button } from 'shared/ui'; +import { TaskColumn } from './task-column/TaskColumn'; +import { boardFabricKeys, BoardMapper, KanbanBoardData, type TBoard } from 'entities/board'; +import { TTask } from 'entities/task'; +import { CreateBoardColumnDialog } from 'features/boards/column/create'; +import { useQueryClient } from '@tanstack/react-query'; +import { type ProjectBoardViewData } from '../../model/types'; interface ProjectKanbanProps { - board: MockBoard; + data: ProjectBoardViewData; } -export function ProjectKanban({ board }: ProjectKanbanProps) { - const [cards, setCards] = useState(board.cards); +export const ProjectKanban = ({ data }: ProjectKanbanProps) => { + const { columns, tasksByColumn } = useMemo( + () => BoardMapper.toKanban(data.board, data.columns, data.tasks), + [data.board, data.columns, data.tasks] + ); + const [kanbanValue, setKanbanValue] = useState(tasksByColumn); + + useEffect(() => { + setKanbanValue(tasksByColumn); + }, [tasksByColumn]); + + const nextColumnPosition = data.columns.length; + + const queryClient = useQueryClient(); + + const handleValueChange = async (value: KanbanBoardData['tasksByColumn']) => { + setKanbanValue(value); + queryClient.cancelQueries({ queryKey: boardFabricKeys.columns(data.board.slug) }); + + const ids = Object.keys(value); + const idsMap = new Map(ids.map((id, i) => [id, i])); + const columnIdsToUpdate: { columnId: string; orderIndex: number }[] = []; + + const prevColumns = queryClient.getQueryData( + boardFabricKeys.columns(data.board.slug) + ); + + queryClient.setQueryData( + boardFabricKeys.columns(data.board.slug), + (oldColumns) => { + if (!oldColumns) return oldColumns; + + return oldColumns.map((column) => { + const orderIndex = idsMap.get(column.id); + return orderIndex !== undefined ? { ...column, orderIndex } : column; + }); + } + ); + + prevColumns?.forEach((column) => { + const orderIndex = idsMap.get(column.id); + if (orderIndex !== undefined && column.orderIndex !== orderIndex) { + columnIdsToUpdate.push({ columnId: column.id, orderIndex }); + } + }); + + // TODO: тут должны быть логика и запрос на обновление позиций колоночек. + // Если запрос выполнился с ошибкой, вывести тост и вернуть предыдущее состояние из prevColumns + }; return ( - console.log(event)} + getItemValue={(item) => (item as TTask.Task).id} // TODO: as TTask.Task - заглушка, пока нет тасок > - {(column) => ( - - {column.name} - - {(item) => } - - - )} - + + {Object.entries(kanbanValue).map(([id, items]) => { + const column = columns[id]; + // TODO: as TTask.Task - заглушка, пока нет тасок + return ; + })} +
+ + + +
+
+ + ); -} +}; diff --git a/src/pages/project/ui/boards/SwitchBoardView.tsx b/src/pages/project/ui/boards/SwitchBoardView.tsx new file mode 100644 index 0000000..3de8a60 --- /dev/null +++ b/src/pages/project/ui/boards/SwitchBoardView.tsx @@ -0,0 +1,40 @@ +import { type TBoard } from 'entities/board'; +import { SquareKanban, Calendar, GanttChart, List } from 'lucide-react'; +import { type ComponentType } from 'react'; +import { Button } from 'shared/ui'; + +const VIEWS = [ + { view: 'kanban', label: 'Доска', icon: SquareKanban }, + { view: 'calendar', label: 'Календарь', icon: Calendar }, + { view: 'gantt', label: 'Гант', icon: GanttChart }, + { view: 'list', label: 'Список', icon: List }, +] satisfies { + view: TBoard.BoardViewType; + label: string; + icon: ComponentType; +}[]; + +type ProjectBoardViewsProps = { + currentView: TBoard.BoardViewType; + onViewChange: (view: TBoard.BoardViewType) => void; +}; + +export function SwitchBoardView({ onViewChange, currentView }: ProjectBoardViewsProps) { + return ( +
+ {VIEWS.map((item) => { + const isActive = currentView === item.view; + 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..ef9dc83 --- /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 { type TBoard, useBoardStore } 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 boardSlug = useBoardStore((s) => s.activeBoardSlug!); + + const headerColumnData: TaskColumnHeaderProps = { + ...column, + boardSlug, + 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..462e0e2 --- /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; + boardSlug: string; +} + +export function TaskColumnHeader({ data }: { data: TaskColumnHeaderProps }) { + const { tasksLength, title, id, color, boardSlug } = data; + const [activeColor, setActiveColor] = useState(color ?? BOARD_COLUMN_COLORS[0]); + + const existColor = BOARD_COLUMN_COLORS.findIndex( + (v) => v.toLowerCase() === data.color?.toLowerCase() + ); + const isExistColor = existColor !== -1; + const newColors = + color && !isExistColor ? [color, ...BOARD_COLUMN_COLORS] : [...BOARD_COLUMN_COLORS]; + + return ( + +
+
+
+
+

{`${title} (${tasksLength})`}

+
+
+ + + + + + + + + e.preventDefault()} variant="destructive"> + Удалить + + + + + + Цвет колонки + + + + +
+
+
+ + ); +} diff --git a/src/pages/team/api/useRemoveMember.ts b/src/pages/team/api/useRemoveMember.ts index de324a2..1d155f9 100644 --- a/src/pages/team/api/useRemoveMember.ts +++ b/src/pages/team/api/useRemoveMember.ts @@ -18,8 +18,8 @@ export function useRemoveMember({ onSuccess, ...rest }: UseRemoveMemberOptions = } return TeamHttp.removeMember(teamId, userId); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Участник удалён из команды'); if (teamId) { diff --git a/src/pages/team/api/useRemoveMemberInvitation.ts b/src/pages/team/api/useRemoveMemberInvitation.ts index ed9ee8b..64ce1ec 100644 --- a/src/pages/team/api/useRemoveMemberInvitation.ts +++ b/src/pages/team/api/useRemoveMemberInvitation.ts @@ -21,8 +21,8 @@ export function useRemoveMemberInvitation({ } return TeamHttp.removeInvitation(teamId, code); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Приглашение отозвано'); if (teamId) { diff --git a/src/pages/team/api/useUpdateInvitation.ts b/src/pages/team/api/useUpdateInvitation.ts index 6139666..703d9b2 100644 --- a/src/pages/team/api/useUpdateInvitation.ts +++ b/src/pages/team/api/useUpdateInvitation.ts @@ -20,8 +20,8 @@ export function useUpdateInvitation({ onSuccess, ...rest }: UseUpdateInvitationO } return TeamHttp.updateInvitation(teamId, code, data); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success('Роль в приглашении обновлена'); if (teamId) { diff --git a/src/pages/team/api/useUpdateMember.ts b/src/pages/team/api/useUpdateMember.ts index feda5bc..d2eb59a 100644 --- a/src/pages/team/api/useUpdateMember.ts +++ b/src/pages/team/api/useUpdateMember.ts @@ -20,8 +20,8 @@ export function useUpdateMember({ onSuccess, ...rest }: UseUpdateMemberOptions = } return TeamHttp.updateMember(teamId, userId, data); }, - onSuccess: async (res, _v, _r, context) => { - onSuccess?.(res, _v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Данные участника обновлены'); if (teamId) { diff --git a/src/pages/team/api/useUpdateTeam.ts b/src/pages/team/api/useUpdateTeam.ts index e6d8cd6..bab1fac 100644 --- a/src/pages/team/api/useUpdateTeam.ts +++ b/src/pages/team/api/useUpdateTeam.ts @@ -19,8 +19,8 @@ export function useUpdateTeam({ onSuccess, ...rest }: UseUpdateTeamProps = {}) { } return TeamHttp.updateTeam(teamId, data); }, - onSuccess: async (res, v, _r, context) => { - onSuccess?.(res, v, _r, context); + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); toast.success(res.message ?? 'Данные команды обновлены'); await Promise.all([ diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 716a251..ac233f3 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -10,6 +10,7 @@ export { GlobalError, DateTimeString, PaginatedResponseSchema, + createSortingSchema, MetaSchema, } from './schemas'; export { AccessToken } from './token'; diff --git a/src/shared/api/schemas/index.ts b/src/shared/api/schemas/index.ts index e4df2be..b34cc97 100644 --- a/src/shared/api/schemas/index.ts +++ b/src/shared/api/schemas/index.ts @@ -2,3 +2,4 @@ export { GlobalSuccess } from './global-success'; export { GlobalError } from './global-error'; export { DateTimeString } from './date-time-string'; export { PaginatedResponseSchema, MetaSchema } from './pagination'; +export { createSortingSchema } from './sorting'; diff --git a/src/shared/api/schemas/sorting.ts b/src/shared/api/schemas/sorting.ts new file mode 100644 index 0000000..48aebed --- /dev/null +++ b/src/shared/api/schemas/sorting.ts @@ -0,0 +1,28 @@ +import { z } from 'zod/v4'; + +export const createSortingSchema = ( + fields: T, + defaultField?: T[number], + defaultOrder: 'asc' | 'desc' = 'asc' +) => + z.object({ + sortBy: z + .enum(fields) + .optional() + /** + * Приведение as any обусловлено ограничением системы типов TypeScript: + * тип fields[0 выводится как string, а Zod ожидает конкретный литеральный тип + * из объединения T[number]. В рантайме значение гарантированно валидно, + * так как массив fields используется для создания enum. + * as any безопасно подавляет ошибку, не расширяя тип за пределы этой строки. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + .default(defaultField ?? (fields[0] as any)) + .describe(`Поле для сортировки. Доступно: ${fields.join(', ')}`), + + sortOrder: z + .enum(['asc', 'desc']) + .optional() + .default(() => defaultOrder) + .describe('Направление сортировки: asc - по возрастанию, desc - по убыванию'), + }); 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..ca048af --- /dev/null +++ b/src/shared/ui/color-picker/ColorPicker.tsx @@ -0,0 +1,76 @@ +'use client'; +import { ComponentProps, CSSProperties, MouseEvent } from 'react'; +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.map((item) => { + const isSelected = activeColor === item; + const isVeryLight = item?.toLowerCase() === '#ffffff'; + + return ( + + ); + })} +
+ ); +} 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/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', + }, + }, ]);