From 5e28211e11f05fa4381b138b8ef4ca76ef39aa48 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Sun, 28 Jun 2026 12:08:26 +0300 Subject: [PATCH] Remove unused environment variables and refactor project structure - Deleted .env.production file as it contained local development configurations. - Refactored project structure by moving ProjectsPage and related components to a new directory. - Introduced new pages for project and board management, enhancing the Kanban functionality. - Added task creation and removal features with corresponding hooks and UI components. - Cleaned up unused files and components to streamline the codebase. --- .env.production | 25 -- app/(protected)/team/(team)/layout.tsx | 4 +- app/(protected)/team/(team)/projects/page.tsx | 1 - .../[projectSlug]/[boardSlug]/page.tsx | 16 ++ .../[boardSlug]/settings/page.tsx | 19 ++ .../team/projects/[projectSlug]/page.tsx | 12 + .../projects/[projectSlug]/settings/page.tsx | 11 + app/(protected)/team/projects/[slug]/page.tsx | 1 - .../team/projects/[slug]/settings/page.tsx | 1 - app/(protected)/team/projects/page.tsx | 1 + app/projects/[projectId]/page.tsx | 17 -- package.json | 2 + pnpm-lock.yaml | 51 ++++ src/entities/board/api/queries.ts | 6 + src/entities/board/index.ts | 8 +- src/entities/board/model/mapper.ts | 51 ---- src/entities/board/model/schemas.ts | 15 +- src/entities/board/model/store.ts | 21 -- src/entities/project/config/colors.ts | 2 +- src/entities/project/model/schemas.ts | 7 +- src/entities/task/api/http.ts | 91 ++++++- src/entities/task/api/queries.ts | 33 +++ src/entities/task/index.ts | 1 + src/entities/task/model/const.ts | 18 +- src/entities/task/model/schemas.ts | 145 ++++++++-- src/entities/task/model/types.ts | 13 +- .../boards/column/create/config/consts.ts | 5 - .../column/create/config/default-values.ts | 4 +- .../create/model/useCreateBoardColumn.ts | 2 +- .../create/model/useCreateBoardColumnForm.ts | 12 +- .../create/ui/CreateBoardColumnDialog.tsx | 9 +- .../create/ui/CreateBoardColumnForm.tsx | 17 +- .../column/remove/model/useRemoveColumn.ts | 2 +- .../column/remove/ui/RemoveColumnDialog.tsx | 8 +- .../boards/create/model/useCreateBoardForm.ts | 14 +- .../boards/create/ui/CreateBoardDialog.tsx | 2 +- .../boards/remove/ui/RemoveBoardDialog.tsx | 11 +- .../projects/remove/model/useRemoveProject.ts | 4 +- src/features/task/create/index.ts | 3 +- .../task/create/model/useActiveFieldStore.ts | 13 - .../task/create/model/useCreateTask.ts | 93 +++++++ .../task/create/model/useCreateTask.tsx | 25 -- .../task/create/ui/CreateTaskButton.tsx | 12 - .../task/create/ui/CreateTaskDialog.tsx | 3 - .../task/create/ui/CreateTaskField.tsx | 72 ----- src/features/task/remove/index.ts | 1 + .../task/remove/model/useRemoveTask.ts | 64 +++++ .../task/remove/ui/RemoveTaskDialog.tsx | 46 ++++ src/features/task/update/index.ts | 1 + .../task/update/model/useUpdateTask.ts | 67 +++++ .../teams/remove/ui/RemoveTeamDialog.tsx | 4 +- src/pages/boards/api/useBoardDataQuery.ts | 24 ++ src/pages/boards/index.ts | 1 + .../boards/model/tasks-by-column-to-list.ts | 11 + src/pages/boards/model/useBoardParams.ts | 15 + .../boards/model/useInlineFieldKeyDown.ts | 29 ++ .../model/useInlineFieldOutsidePointerDown.ts | 33 +++ src/pages/boards/model/useMoveTask.ts | 33 +++ src/pages/boards/model/useSetBoardTasks.ts | 19 ++ src/pages/boards/ui/BoardsPage.tsx | 39 +++ src/pages/boards/ui/BoardsPageContent.tsx | 32 +++ src/pages/boards/ui/board/Board.tsx | 67 +++++ src/pages/boards/ui/column/Column.tsx | 66 +++++ src/pages/boards/ui/column/ColumnHeader.tsx | 57 ++++ .../boards/ui/column/ColumnHeaderActions.tsx | 60 ++++ .../boards/ui/column/CreateTaskInline.tsx | 82 ++++++ src/pages/boards/ui/stages/Stages.tsx | 78 ++++++ src/pages/boards/ui/task/Task.tsx | 44 +++ .../ui/boards => boards/ui/task}/TaskCard.tsx | 35 ++- src/pages/boards/ui/task/TaskCardActions.tsx | 53 ++++ src/pages/boards/ui/task/TaskCardTitle.tsx | 103 +++++++ src/pages/boards/ui/task/TaskWidget.tsx | 53 ++++ src/pages/main/ui/MainPage.tsx | 4 +- src/pages/project/api/useQueryProject.ts | 15 - src/pages/project/api/useUpdateProject.ts | 41 --- src/pages/project/index.ts | 2 - src/pages/project/model/boards-mock.ts | 204 -------------- src/pages/project/model/settings.ts | 6 - src/pages/project/model/types.ts | 7 - src/pages/project/model/useActiveBoard.ts | 19 -- src/pages/project/model/useBoardsPage.ts | 10 - src/pages/project/model/useUpdateBoard.ts | 20 -- .../project/model/useUpdateBoardColumn.ts | 21 -- .../ui/boards/BoardButton.skeleton.tsx | 19 -- .../project/ui/boards/ProjectBoardButton.tsx | 65 ----- .../ui/boards/ProjectBoards.skeleton.tsx | 24 -- src/pages/project/ui/boards/ProjectBoards.tsx | 50 ---- .../ui/boards/ProjectBoardsContent.tsx | 29 -- .../project/ui/boards/ProjectBoardsError.tsx | 39 --- .../project/ui/boards/ProjectBoardsHeader.tsx | 15 - .../project/ui/boards/ProjectBoardsPage.tsx | 7 - .../ui/boards/ProjectKanban.skeleton.tsx | 11 - src/pages/project/ui/boards/ProjectKanban.tsx | 95 ------- .../project/ui/boards/SwitchBoardView.tsx | 40 --- src/pages/project/ui/boards/Task.tsx | 24 -- .../project/ui/boards/TaskColumn.skeleton.tsx | 43 --- .../ui/boards/task-column/TaskColumn.tsx | 49 ---- .../boards/task-column/TaskColumnHeader.tsx | 77 ------ .../project/ui/settings/ProjectDangerZone.tsx | 42 --- .../ui/settings/ProjectSettingsPage.tsx | 148 ---------- .../ui/settings/ProjectSettingsSaveBar.tsx | 75 ----- .../projects/config/project-status-map.ts | 14 + src/pages/projects/index.ts | 1 + .../projects/model/get-projects-count-text.ts | 14 + src/pages/projects/ui/ProjectCard.tsx | 61 +++++ src/pages/projects/ui/ProjectsPage.tsx | 23 ++ src/pages/projects/ui/ProjectsPageContent.tsx | 36 +++ .../projects/ui/ProjectsPageFallback.tsx | 17 ++ src/pages/team/config/tabs.ts | 2 +- src/pages/team/index.ts | 5 +- src/pages/team/model/roles-mock.ts | 2 +- .../team/ui/projects/ProjectCard.skeleton.tsx | 49 ---- src/pages/team/ui/projects/ProjectCard.tsx | 257 ------------------ src/pages/team/ui/projects/ProjectsEmpty.tsx | 32 --- src/pages/team/ui/projects/ProjectsPage.tsx | 43 --- src/pages/team/ui/settings/DangerZone.tsx | 7 +- src/pages/team/ui/settings/TeamIdentity.tsx | 8 +- .../skeletons/TeamIdentity.skeleton.tsx | 5 +- src/pages/teams/ui/TeamCard.tsx | 23 +- src/shared/api/token/access-token.ts | 18 +- src/shared/config/routes.ts | 13 +- src/shared/lib/hooks/index.ts | 5 +- src/shared/lib/hooks/useFixedHeightWithMax.ts | 55 ++++ src/shared/lib/utils/index.ts | 1 + .../utils/is-hex-color/is-hex-color.test.ts | 15 + .../lib/utils/is-hex-color/is-hex-color.ts | 5 + src/shared/ui/ButtonGroup.tsx | 78 ++++++ src/shared/ui/Drawer.tsx | 118 ++++++++ src/shared/ui/Kanban.tsx | 130 +++++++-- src/shared/ui/checkbox/Checkbox.tsx | 9 +- src/shared/ui/index.ts | 63 +++-- src/shared/ui/owner-wrap/OwnerWrap.tsx | 22 ++ .../ui/projects/ProjectsContent.tsx | 12 +- .../config/route-definitions.ts | 12 +- src/widgets/task/api/useTaskQuery.ts | 21 ++ src/widgets/task/config/task-meta-map.ts | 14 + src/widgets/task/index.ts | 1 + src/widgets/task/ui/TaskWidgetContent.tsx | 197 ++++++++++++++ src/widgets/task/ui/TaskWidgetFallback.tsx | 18 ++ steiger.config.ts | 7 +- 140 files changed, 2564 insertions(+), 2050 deletions(-) delete mode 100644 .env.production delete mode 100644 app/(protected)/team/(team)/projects/page.tsx create mode 100644 app/(protected)/team/projects/[projectSlug]/[boardSlug]/page.tsx create mode 100644 app/(protected)/team/projects/[projectSlug]/[boardSlug]/settings/page.tsx create mode 100644 app/(protected)/team/projects/[projectSlug]/page.tsx create mode 100644 app/(protected)/team/projects/[projectSlug]/settings/page.tsx delete mode 100644 app/(protected)/team/projects/[slug]/page.tsx delete mode 100644 app/(protected)/team/projects/[slug]/settings/page.tsx create mode 100644 app/(protected)/team/projects/page.tsx delete mode 100644 app/projects/[projectId]/page.tsx delete mode 100644 src/entities/board/model/mapper.ts delete mode 100644 src/entities/board/model/store.ts create mode 100644 src/entities/task/api/queries.ts delete mode 100644 src/features/boards/column/create/config/consts.ts delete mode 100644 src/features/task/create/model/useActiveFieldStore.ts create mode 100644 src/features/task/create/model/useCreateTask.ts delete mode 100644 src/features/task/create/model/useCreateTask.tsx delete mode 100644 src/features/task/create/ui/CreateTaskButton.tsx delete mode 100644 src/features/task/create/ui/CreateTaskDialog.tsx delete mode 100644 src/features/task/create/ui/CreateTaskField.tsx create mode 100644 src/features/task/remove/index.ts create mode 100644 src/features/task/remove/model/useRemoveTask.ts create mode 100644 src/features/task/remove/ui/RemoveTaskDialog.tsx create mode 100644 src/features/task/update/index.ts create mode 100644 src/features/task/update/model/useUpdateTask.ts create mode 100644 src/pages/boards/api/useBoardDataQuery.ts create mode 100644 src/pages/boards/index.ts create mode 100644 src/pages/boards/model/tasks-by-column-to-list.ts create mode 100644 src/pages/boards/model/useBoardParams.ts create mode 100644 src/pages/boards/model/useInlineFieldKeyDown.ts create mode 100644 src/pages/boards/model/useInlineFieldOutsidePointerDown.ts create mode 100644 src/pages/boards/model/useMoveTask.ts create mode 100644 src/pages/boards/model/useSetBoardTasks.ts create mode 100644 src/pages/boards/ui/BoardsPage.tsx create mode 100644 src/pages/boards/ui/BoardsPageContent.tsx create mode 100644 src/pages/boards/ui/board/Board.tsx create mode 100644 src/pages/boards/ui/column/Column.tsx create mode 100644 src/pages/boards/ui/column/ColumnHeader.tsx create mode 100644 src/pages/boards/ui/column/ColumnHeaderActions.tsx create mode 100644 src/pages/boards/ui/column/CreateTaskInline.tsx create mode 100644 src/pages/boards/ui/stages/Stages.tsx create mode 100644 src/pages/boards/ui/task/Task.tsx rename src/pages/{project/ui/boards => boards/ui/task}/TaskCard.tsx (59%) create mode 100644 src/pages/boards/ui/task/TaskCardActions.tsx create mode 100644 src/pages/boards/ui/task/TaskCardTitle.tsx create mode 100644 src/pages/boards/ui/task/TaskWidget.tsx delete mode 100644 src/pages/project/api/useQueryProject.ts delete mode 100644 src/pages/project/api/useUpdateProject.ts delete mode 100644 src/pages/project/index.ts delete mode 100644 src/pages/project/model/boards-mock.ts delete mode 100644 src/pages/project/model/settings.ts delete mode 100644 src/pages/project/model/types.ts delete mode 100644 src/pages/project/model/useActiveBoard.ts delete mode 100644 src/pages/project/model/useBoardsPage.ts delete mode 100644 src/pages/project/model/useUpdateBoard.ts delete mode 100644 src/pages/project/model/useUpdateBoardColumn.ts delete mode 100644 src/pages/project/ui/boards/BoardButton.skeleton.tsx delete mode 100644 src/pages/project/ui/boards/ProjectBoardButton.tsx delete mode 100644 src/pages/project/ui/boards/ProjectBoards.skeleton.tsx delete mode 100644 src/pages/project/ui/boards/ProjectBoards.tsx delete mode 100644 src/pages/project/ui/boards/ProjectBoardsContent.tsx delete mode 100644 src/pages/project/ui/boards/ProjectBoardsError.tsx delete mode 100644 src/pages/project/ui/boards/ProjectBoardsHeader.tsx delete mode 100644 src/pages/project/ui/boards/ProjectBoardsPage.tsx delete mode 100644 src/pages/project/ui/boards/ProjectKanban.skeleton.tsx delete mode 100644 src/pages/project/ui/boards/ProjectKanban.tsx delete mode 100644 src/pages/project/ui/boards/SwitchBoardView.tsx delete mode 100644 src/pages/project/ui/boards/Task.tsx delete mode 100644 src/pages/project/ui/boards/TaskColumn.skeleton.tsx delete mode 100644 src/pages/project/ui/boards/task-column/TaskColumn.tsx delete mode 100644 src/pages/project/ui/boards/task-column/TaskColumnHeader.tsx delete mode 100644 src/pages/project/ui/settings/ProjectDangerZone.tsx delete mode 100644 src/pages/project/ui/settings/ProjectSettingsPage.tsx delete mode 100644 src/pages/project/ui/settings/ProjectSettingsSaveBar.tsx create mode 100644 src/pages/projects/config/project-status-map.ts create mode 100644 src/pages/projects/index.ts create mode 100644 src/pages/projects/model/get-projects-count-text.ts create mode 100644 src/pages/projects/ui/ProjectCard.tsx create mode 100644 src/pages/projects/ui/ProjectsPage.tsx create mode 100644 src/pages/projects/ui/ProjectsPageContent.tsx create mode 100644 src/pages/projects/ui/ProjectsPageFallback.tsx delete mode 100644 src/pages/team/ui/projects/ProjectCard.skeleton.tsx delete mode 100644 src/pages/team/ui/projects/ProjectCard.tsx delete mode 100644 src/pages/team/ui/projects/ProjectsEmpty.tsx delete mode 100644 src/pages/team/ui/projects/ProjectsPage.tsx create mode 100644 src/shared/lib/hooks/useFixedHeightWithMax.ts create mode 100644 src/shared/lib/utils/is-hex-color/is-hex-color.test.ts create mode 100644 src/shared/lib/utils/is-hex-color/is-hex-color.ts create mode 100644 src/shared/ui/ButtonGroup.tsx create mode 100644 src/shared/ui/Drawer.tsx create mode 100644 src/shared/ui/owner-wrap/OwnerWrap.tsx create mode 100644 src/widgets/task/api/useTaskQuery.ts create mode 100644 src/widgets/task/config/task-meta-map.ts create mode 100644 src/widgets/task/index.ts create mode 100644 src/widgets/task/ui/TaskWidgetContent.tsx create mode 100644 src/widgets/task/ui/TaskWidgetFallback.tsx diff --git a/.env.production b/.env.production deleted file mode 100644 index 9e1156c..0000000 --- a/.env.production +++ /dev/null @@ -1,25 +0,0 @@ -NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/v1 -NEXT_PUBLIC_APP_URL=http://localhost:3000 -PORT=3000 -# Next App FRONTEND Instrumentation -NEXT_PUBLIC_FARO_URL=http://localhost:12347/collect -NEXT_PUBLIC_FARO_APP_NAME=ttopen-frontend -NEXT_PUBLIC_FARO_APP_NAMESPACE=ttopen-front -NEXT_PUBLIC_FARO_APP_VERSION=1.0.0 -NEXT_PUBLIC_APP_ENV=development -NEXT_PUBLIC_METRICS_ENABLED=true - -# Next App BACKEND Instrumentation -## Example assumes that the collector is running on the same machine -OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 -## Force protobuf -OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf -## Set Backend service name -OTEL_SERVICE_NAME=next-backend -## Customize resource attributes, namespace is a recommended attribute -OTEL_RESOURCE_ATTRIBUTES=service.namespace=nextjs-ttopen - -# OTel collector -GRAFANA_CLOUD_USERNAME= -GRAFANA_CLOUD_API_KEY= -GRAFANA_CLOUD_ENDPOINT= diff --git a/app/(protected)/team/(team)/layout.tsx b/app/(protected)/team/(team)/layout.tsx index a89dc4b..3ca574e 100644 --- a/app/(protected)/team/(team)/layout.tsx +++ b/app/(protected)/team/(team)/layout.tsx @@ -1,12 +1,12 @@ import { PageLayout } from 'app/layouts/PageLayout'; -import { Badge } from 'shared/ui'; import { teamTabs } from 'pages/team'; +import { Badge } from 'shared/ui'; export default function TeamLayout({ children }: { children: React.ReactNode }) { return ( 8 участников} tabs={teamTabs} > diff --git a/app/(protected)/team/(team)/projects/page.tsx b/app/(protected)/team/(team)/projects/page.tsx deleted file mode 100644 index 571b8c5..0000000 --- a/app/(protected)/team/(team)/projects/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsPage as default } from 'pages/team'; diff --git a/app/(protected)/team/projects/[projectSlug]/[boardSlug]/page.tsx b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/page.tsx new file mode 100644 index 0000000..d347ef0 --- /dev/null +++ b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/page.tsx @@ -0,0 +1,16 @@ +import { notFound } from 'next/navigation'; +import { BoardsPage } from 'pages/boards'; + +export default async function Page({ + params, +}: { + params: Promise<{ boardSlug: string; projectSlug: string }>; +}) { + const { boardSlug, projectSlug } = await params; + + if (!boardSlug || !projectSlug) { + return notFound(); + } + + return ; +} diff --git a/app/(protected)/team/projects/[projectSlug]/[boardSlug]/settings/page.tsx b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/settings/page.tsx new file mode 100644 index 0000000..0b0ff8f --- /dev/null +++ b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/settings/page.tsx @@ -0,0 +1,19 @@ +import { notFound } from 'next/navigation'; + +export default async function Page({ + params, +}: { + params: Promise<{ boardSlug: string; projectSlug: string }>; +}) { + const { boardSlug, projectSlug } = await params; + + if (!boardSlug || !projectSlug) { + return notFound(); + } + + return ( +
+ настройки доски {boardSlug} проекта {projectSlug} +
+ ); +} diff --git a/app/(protected)/team/projects/[projectSlug]/page.tsx b/app/(protected)/team/projects/[projectSlug]/page.tsx new file mode 100644 index 0000000..89fe1a1 --- /dev/null +++ b/app/(protected)/team/projects/[projectSlug]/page.tsx @@ -0,0 +1,12 @@ +import { notFound } from 'next/navigation'; +import { BoardsPage } from 'pages/boards'; + +export default async function Page({ params }: { params: Promise<{ projectSlug: string }> }) { + const { projectSlug } = await params; + + if (!projectSlug) { + return notFound(); + } + + return ; +} diff --git a/app/(protected)/team/projects/[projectSlug]/settings/page.tsx b/app/(protected)/team/projects/[projectSlug]/settings/page.tsx new file mode 100644 index 0000000..2239b10 --- /dev/null +++ b/app/(protected)/team/projects/[projectSlug]/settings/page.tsx @@ -0,0 +1,11 @@ +import { notFound } from 'next/navigation'; + +export default async function Page({ params }: { params: Promise<{ projectSlug: string }> }) { + const { projectSlug } = await params; + + if (!projectSlug) { + return notFound(); + } + + return
конфиг проекта {projectSlug}
; +} diff --git a/app/(protected)/team/projects/[slug]/page.tsx b/app/(protected)/team/projects/[slug]/page.tsx deleted file mode 100644 index 671dc37..0000000 --- a/app/(protected)/team/projects/[slug]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectBoardsPage as default } from 'pages/project'; diff --git a/app/(protected)/team/projects/[slug]/settings/page.tsx b/app/(protected)/team/projects/[slug]/settings/page.tsx deleted file mode 100644 index f214d81..0000000 --- a/app/(protected)/team/projects/[slug]/settings/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectSettingsPage as default } from 'pages/project'; diff --git a/app/(protected)/team/projects/page.tsx b/app/(protected)/team/projects/page.tsx new file mode 100644 index 0000000..e50f0df --- /dev/null +++ b/app/(protected)/team/projects/page.tsx @@ -0,0 +1 @@ +export { ProjectsPage as default } from 'pages/projects'; diff --git a/app/projects/[projectId]/page.tsx b/app/projects/[projectId]/page.tsx deleted file mode 100644 index 99a4d50..0000000 --- a/app/projects/[projectId]/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -export default async function Page({ - params, - searchParams, -}: { - params: Promise<{ projectId: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}) { - const { projectId } = await params; - const { token } = await searchParams; - - return ( -
-
Проект: {projectId}
-
Токен: {token}
-
- ); -} diff --git a/package.json b/package.json index 5b92ae7..65a7b7c 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,12 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.71.2", + "react-indiana-drag-scroll": "^2.2.1", "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.1", "tunnel-rat": "^0.1.2", + "vaul": "^1.1.2", "zod": "^4.3.6", "zustand": "^5.0.11" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9abf844..f8549f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: react-hook-form: specifier: ^7.71.2 version: 7.72.1(react@19.2.5) + react-indiana-drag-scroll: + specifier: ^2.2.1 + version: 2.2.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) socket.io-client: specifier: ^4.8.3 version: 4.8.3 @@ -92,6 +95,9 @@ importers: tunnel-rat: specifier: ^0.1.2 version: 0.1.2(@types/react@19.2.14)(immer@11.1.7)(react@19.2.5) + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) zod: specifier: ^4.3.6 version: 4.3.6 @@ -2775,6 +2781,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -2912,6 +2921,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -3059,6 +3071,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + easy-bem@1.1.1: + resolution: {integrity: sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==} + eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -4639,6 +4654,13 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-indiana-drag-scroll@2.2.1: + resolution: {integrity: sha512-aGNJt7fxWzZGVkd0xRF+fPDt5RFuYouQffwOFx3m+CiOlvLZDQtV+4NyPnJqXCRlfbbwP26LI4LclNymTOgDiQ==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5254,6 +5276,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vite-plugin-storybook-nextjs@3.2.4: resolution: {integrity: sha512-shFOJpGQsWDS1FLm8BR8b6FIQC65pFZ5a0IUFGLiBHAX1eRz0N8TOhUJN4p708zfPBLDXqWzj++ocECe8gSoMg==} peerDependencies: @@ -8134,6 +8162,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -8251,6 +8281,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + debounce@1.2.1: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -8378,6 +8410,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + easy-bem@1.1.1: {} + eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) @@ -10120,6 +10154,14 @@ snapshots: dependencies: react: 19.2.5 + react-indiana-drag-scroll@2.2.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + classnames: 2.5.1 + debounce: 1.2.1 + easy-bem: 1.1.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-is@16.13.1: {} react-is@17.0.2: {} @@ -10913,6 +10955,15 @@ snapshots: vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vite-plugin-storybook-nextjs@3.2.4(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(yaml@2.8.3)): dependencies: '@next/env': 16.0.0 diff --git a/src/entities/board/api/queries.ts b/src/entities/board/api/queries.ts index e8e4547..6715b43 100644 --- a/src/entities/board/api/queries.ts +++ b/src/entities/board/api/queries.ts @@ -1,5 +1,6 @@ import { queryOptions } from '@tanstack/react-query'; import { boardFabricKeys } from '../model/consts'; +import { BoardColumnResponse } from '../model/types'; import { BoardHttp } from './http'; export class BoardQueries { @@ -24,6 +25,11 @@ export class BoardQueries { queryKey: boardFabricKeys.columns(slug), queryFn: async ({ signal }) => BoardHttp.getBoardColumnList(slug, signal), staleTime: 60_000, + select: (data) => + data.reduce>((acc, el) => { + acc[el.id] = el; + return acc; + }, {}), }); } diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts index a04fb0b..5082726 100644 --- a/src/entities/board/index.ts +++ b/src/entities/board/index.ts @@ -1,8 +1,6 @@ -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'; +export { boardFabricKeys } from './model/consts'; +export * as SBoard from './model/schemas'; +export type * as TBoard from './model/types'; diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts deleted file mode 100644 index 931fd3f..0000000 --- a/src/entities/board/model/mapper.ts +++ /dev/null @@ -1,51 +0,0 @@ -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.position - b.position); - 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 index 216e644..586fe4d 100644 --- a/src/entities/board/model/schemas.ts +++ b/src/entities/board/model/schemas.ts @@ -1,5 +1,6 @@ import { createSortingSchema, CursorQuerySchema, DateTimeString, GlobalSuccess } from 'shared/api'; import { z } from 'zod/v4'; +import { HEX_COLOR_REGEX } from 'shared/lib/utils'; export const ActionResponse = GlobalSuccess; @@ -37,10 +38,7 @@ export const Board = z.object({ descriptionHtml: z.string().nullable().optional(), color: z .string() - .regex( - /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, - 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)' - ) + .regex(HEX_COLOR_REGEX, 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)') .nullable() .optional(), icon: z.string().max(20, 'Иконка должна быть не длиннее 20 символов').nullable().optional(), @@ -77,17 +75,14 @@ export const BoardColumn = z.object({ category: BoardColumnCategoryEnum, color: z .string() - .regex( - /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, - 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)' - ) + .regex(HEX_COLOR_REGEX, 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)') .nullable() .optional(), icon: z.string().max(20, 'Иконка должна быть не длиннее 20 символов').nullable().optional(), position: z .number() - .int('Порядковый номер должен быть целым числом') - .min(0, 'Порядковый номер не может быть отрицательным'), + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной'), isVisible: z.boolean(), maxTasksLimit: z .number() diff --git a/src/entities/board/model/store.ts b/src/entities/board/model/store.ts deleted file mode 100644 index e63042f..0000000 --- a/src/entities/board/model/store.ts +++ /dev/null @@ -1,21 +0,0 @@ -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/project/config/colors.ts b/src/entities/project/config/colors.ts index 779a537..e6550ea 100644 --- a/src/entities/project/config/colors.ts +++ b/src/entities/project/config/colors.ts @@ -1,4 +1,4 @@ -export const PROJECT_COLORS = [ +export const PROJECT_COLORS: string[] = [ '#9FA8DA', '#7E57C2', '#9575CD', diff --git a/src/entities/project/model/schemas.ts b/src/entities/project/model/schemas.ts index e6ee8e5..979ed3a 100644 --- a/src/entities/project/model/schemas.ts +++ b/src/entities/project/model/schemas.ts @@ -1,4 +1,5 @@ import { DateTimeString, GlobalSuccess, PaginatedResponseSchema } from 'shared/api'; +import { HEX_COLOR_REGEX } from 'shared/lib/utils'; import { z } from 'zod/v4'; import { PROJECT_ICONS } from '../config/icons'; import { MEMBER_ROLE, PROJECT_STATUSES, PROJECT_VISIBILITIES } from './const'; @@ -69,10 +70,7 @@ export const ProjectSchema = z.object({ icon: z.enum(PROJECT_ICONS).nullish(), color: z .string() - .regex( - /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, - 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)' - ) + .regex(HEX_COLOR_REGEX, 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)') .nullish(), status: ProjectStatusSchema, visibility: ProjectVisibilitySchema, @@ -143,6 +141,7 @@ export const ProjectListItemResponse = z .object({ id: z.string(), slug: z.string(), + description: z.string(), name: z.string(), status: ProjectStatusSchema, color: z.string(), diff --git a/src/entities/task/api/http.ts b/src/entities/task/api/http.ts index 0319bb7..e655db9 100644 --- a/src/entities/task/api/http.ts +++ b/src/entities/task/api/http.ts @@ -3,14 +3,99 @@ import * as STask from '../model/schemas'; import * as TTask from '../model/types'; export class TaskHttp { - static createTask(data: TTask.CreateTaskBody) { + static getTasks(query: TTask.TaskListQuery, signal?: AbortSignal) { + return api({ + url: '/issues', + method: 'GET', + params: query, + contracts: { + params: STask.TaskListQuery, + response: STask.TaskListResponse, + }, + signal, + }); + } + + static getTask(id: string, query: TTask.TaskContextQuery, signal?: AbortSignal) { + return api({ + url: `/issues/${id}`, + method: 'GET', + params: query, + contracts: { + response: STask.TaskResponse, + }, + signal, + }); + } + + static createTask(query: TTask.TaskContextQuery, data: TTask.CreateTaskBody) { return api({ - url: `/teams/projects/`, + url: '/issues', method: 'POST', + params: query, data, contracts: { - response: STask.CreateTaskResponse, body: STask.CreateTaskBody, + response: STask.CreateTaskResponse, + }, + }); + } + + static updateTask(id: string, query: TTask.TaskContextQuery, data: TTask.UpdateTaskBody) { + return api({ + url: `/issues/${id}`, + method: 'PATCH', + params: query, + data, + contracts: { + body: STask.UpdateTaskBody, + response: STask.ActionResponse, + }, + }); + } + + static removeTask(id: string, query: TTask.TaskContextQuery) { + return api({ + url: `/issues/${id}`, + method: 'DELETE', + params: query, + }); + } + + static moveTask(query: TTask.TaskByIdContextQuery, data: TTask.MoveTaskBody) { + return api({ + url: `/issues/${query.id}/move`, + method: 'POST', + params: query, + data, + contracts: { + params: STask.TaskByIdContextQuery, + body: STask.MoveTaskBody, + response: STask.ActionResponse, + }, + }); + } + + static assignTask(id: string, query: TTask.TaskContextQuery, data: TTask.AssignTaskBody) { + return api({ + url: `/issues/${id}/assignee`, + method: 'PUT', + params: query, + data, + contracts: { + body: STask.AssignTaskBody, + response: STask.ActionResponse, + }, + }); + } + + static restoreTask(id: string, query: TTask.TaskContextQuery) { + return api({ + url: `/issues/${id}/restore`, + method: 'POST', + params: query, + contracts: { + response: STask.ActionResponse, }, }); } diff --git a/src/entities/task/api/queries.ts b/src/entities/task/api/queries.ts new file mode 100644 index 0000000..e1bd5d0 --- /dev/null +++ b/src/entities/task/api/queries.ts @@ -0,0 +1,33 @@ +import { queryOptions } from '@tanstack/react-query'; +import { taskFabricKeys } from '../model/const'; +import type { Task, TaskContextQuery, TaskListQuery } from '../model/types'; +import { TaskHttp } from './http'; + +export class TaskQueries { + static getTasks(query: TaskListQuery) { + return queryOptions({ + queryKey: taskFabricKeys.list(query.slug, query.key, query), + queryFn: async ({ signal }) => TaskHttp.getTasks(query, signal), + staleTime: 60_000, + select: (data) => { + return data.reduce>((acc, task) => { + if (!task.stateId) { + return acc; + } + + acc[task.stateId] = acc[task.stateId] ? [...acc[task.stateId], task] : [task]; + + return acc; + }, {}); + }, + }); + } + + static getTask(id: string, query: TaskContextQuery) { + return queryOptions({ + queryKey: [...taskFabricKeys.detail(query.slug, query.key, id), query], + queryFn: async ({ signal }) => TaskHttp.getTask(id, query, signal), + staleTime: 60_000, + }); + } +} diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts index d810ae8..253e8e8 100644 --- a/src/entities/task/index.ts +++ b/src/entities/task/index.ts @@ -1,4 +1,5 @@ export type * as TTask from './model/types'; export * as STask from './model/schemas'; export { TaskHttp } from './api/http'; +export { TaskQueries } from './api/queries'; export { taskFabricKeys } from './model/const'; diff --git a/src/entities/task/model/const.ts b/src/entities/task/model/const.ts index 90e87ad..2483c3f 100644 --- a/src/entities/task/model/const.ts +++ b/src/entities/task/model/const.ts @@ -1,6 +1,20 @@ import { createEntityKeys } from 'shared/lib/utils'; export const taskFabricKeys = createEntityKeys('task', { - list: () => ['teams', 'tasks'], - detail: (taskId: string) => ['teams', 'projects', taskId], + list: (projectSlug: string, boardSlug: string, params?: Record) => [ + 'projects', + projectSlug, + 'boards', + boardSlug, + 'issues', + params ?? {}, + ], + detail: (projectSlug: string, boardSlug: string, taskId: string) => [ + 'projects', + projectSlug, + 'boards', + boardSlug, + 'issues', + taskId, + ], }); diff --git a/src/entities/task/model/schemas.ts b/src/entities/task/model/schemas.ts index 5726f31..da8983b 100644 --- a/src/entities/task/model/schemas.ts +++ b/src/entities/task/model/schemas.ts @@ -1,27 +1,130 @@ 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({ +export const IssuePriority = z.enum(['critical', 'low', 'medium', 'high']); +export const IssueType = z.enum(['bug', 'task', 'epic']); + +export const IssueFilterPriority = z.enum(['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']); +export const IssueFilterType = z.enum(['TASK', 'BUG', 'EPIC']); +export const IssueSortBy = z.enum(['position']); +export const IssueSortOrder = z.enum(['asc', 'desc']); + +export const IssueMember = z + .object({ + id: z.string(), name: z.string(), - avatarUrl: z.string().nullable(), - }), - dueDate: z.string().optional(), - position: z.number(), - createdAt: DateTimeString, - updatedAt: DateTimeString, -}); + email: z.email().optional(), + avatarUrl: z.url().nullable().optional(), + }) + .strict(); + +export const IssueParent = z + .object({ + id: z.string().nullable().optional(), + title: z.string().nullable().optional(), + }) + .strict(); + +export const Task = z + .object({ + id: z.string().min(1), + title: z.string().min(1).max(255), + description: z.string().nullable().optional(), + descriptionHtml: z.string().nullable().optional(), + priority: IssuePriority, + type: IssueType, + areaId: z.string().min(1), + stateId: z.string().nullable().optional(), + position: z.number().int().nonnegative(), + assigneeId: z.string().nullable().optional(), + assignee: IssueMember.nullable(), + reporterId: z.string().nullable().optional(), + reporter: IssueMember.nullable().optional(), + parentId: z.string().nullable().optional(), + parent: IssueParent.nullable().optional(), + labels: z.array(z.string().max(50)), + storyPoints: z.number().int().min(0).max(10000).nullable().optional(), + dueDate: DateTimeString.nullable().optional(), + createdAt: DateTimeString, + updatedAt: DateTimeString, + createdBy: z.string().nullable().optional(), + deletedAt: DateTimeString.nullable().optional(), + }) + .strict(); + +export const TaskListResponse = Task.array(); +export const TaskResponse = Task; + +export const CreateTaskBody = z + .object({ + title: z.string().min(1).max(255), + description: z.string().nullable().optional(), + descriptionHtml: z.string().nullable().optional(), + priority: IssuePriority.optional(), + type: IssueType.optional(), + stateId: z.string().nullable().optional(), + position: z.number().int().nonnegative().optional(), + assigneeId: z.string().nullable().optional(), + reporterId: z.string().nullable().optional(), + parentId: z.string().nullable().optional(), + labels: z.array(z.string().max(50)).optional(), + storyPoints: z.number().int().min(0).max(10000).nullable().optional(), + dueDate: DateTimeString.nullable().optional(), + }) + .strict(); -export const CreateTaskBody = z.object({ - title: z.string().min(1).max(300), - boardId: z.string(), - columnId: z.string(), +export const CreateTaskResponse = GlobalSuccess.extend({ + id: z.string(), }); -export const CreateTaskResponse = GlobalSuccess; + +export const UpdateTaskBody = CreateTaskBody.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .strict(); + +export const MoveTaskBody = z + .object({ + targetAreaId: z.string().optional(), + targetStateId: z.string().nullable().optional(), + position: z.number().int().nonnegative(), + }) + .strict(); + +export const AssignTaskBody = z + .object({ + assigneeId: z.string().nullable(), + }) + .strict(); + +export const TaskContextQuery = z + .object({ + slug: z.string(), + key: z.string(), + }) + .strict(); + +export const TaskByIdContextQuery = TaskContextQuery.extend({ + id: z.string().min(1), +}).strict(); + +export const TaskListQuery = TaskContextQuery.extend({ + stateId: z.string().optional(), + assigneeId: z.string().optional(), + reporterId: z.string().optional(), + priority: IssueFilterPriority.optional(), + type: IssueFilterType.optional(), + parentId: z.string().optional(), + labels: z.string().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().positive().optional(), + sortBy: IssueSortBy.optional(), + sortOrder: IssueSortOrder.optional(), + areaId: z.string().optional(), + page: z.coerce.number().int().positive().optional(), + offset: z.coerce.number().int().min(0).optional(), + search: z.string().optional(), +}).strict(); + +export const ActionResponse = GlobalSuccess; diff --git a/src/entities/task/model/types.ts b/src/entities/task/model/types.ts index 9e3eaa9..d6a44e1 100644 --- a/src/entities/task/model/types.ts +++ b/src/entities/task/model/types.ts @@ -2,7 +2,18 @@ import { z } from 'zod/v4'; import * as STask from './schemas'; export type Task = z.infer; +export type TaskResponse = z.infer; +export type TaskListResponse = z.infer; export type CreateTaskBody = z.infer; +export type UpdateTaskBody = z.infer; +export type MoveTaskBody = z.infer; +export type AssignTaskBody = z.infer; +export type TaskContextQuery = z.infer; +export type TaskByIdContextQuery = z.infer; +export type TaskListQuery = z.infer; -export type CreateTaskResponse = Task; +export type CreateTaskResponse = z.infer; +export type ActionResponse = z.infer; +export type IssuePriority = z.infer; +export type IssueType = z.infer; diff --git a/src/features/boards/column/create/config/consts.ts b/src/features/boards/column/create/config/consts.ts deleted file mode 100644 index 1723308..0000000 --- a/src/features/boards/column/create/config/consts.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 index d11fc3a..e8e1ded 100644 --- a/src/features/boards/column/create/config/default-values.ts +++ b/src/features/boards/column/create/config/default-values.ts @@ -1,10 +1,10 @@ -import { DEFAULT_COLUMN_COLOR } from '../config/consts'; +import { PROJECT_COLORS } from 'entities/project'; import { CreateBoardColumnFormValues } from '../model/types'; export function getDefaultCreateBoardColumnValues(position = 0): CreateBoardColumnFormValues { return { title: '', - color: DEFAULT_COLUMN_COLOR, + color: PROJECT_COLORS[0], position, }; } diff --git a/src/features/boards/column/create/model/useCreateBoardColumn.ts b/src/features/boards/column/create/model/useCreateBoardColumn.ts index 5b83f68..4d21825 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumn.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumn.ts @@ -18,7 +18,7 @@ export function useCreateBoardColumn({ onSuccess, ...rest }: UseCreateBoardColum mutationFn: ({ boardSlug, body }) => BoardHttp.createBoardColumn(boardSlug, body), onSuccess: async (res, variables, r, context) => { onSuccess?.(res, variables, r, context); - toast.success(res.message ?? 'Колонка создана'); + 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 index 0a4e5e0..317b612 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -1,12 +1,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { type TBoard } from 'entities/board'; import { useForm } from 'react-hook-form'; +import { extractValidationIssues } from 'shared/api'; +import { setFormErrors } from 'shared/lib/utils'; 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'; +import { useCreateBoardColumn, UseCreateBoardColumnOptions } from './useCreateBoardColumn'; type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { defaultPosition?: number; @@ -16,7 +16,7 @@ export function useCreateBoardColumnForm( boardSlug: string, options: UseCreateBoardColumnFormOptions = {} ) { - const { defaultPosition = 0, ...mutationOptions } = options; + const { defaultPosition = 100, ...mutationOptions } = options; const form = useForm({ resolver: zodResolver(CreateBoardColumnFormSchema), @@ -37,7 +37,7 @@ export function useCreateBoardColumnForm( const onSubmit = (data: CreateBoardColumnFormValues) => { const body: TBoard.CreateBoardColumnBody = { title: data.title, - position: data.position, + position: data.position, //todo: пока будет 100, потом будет динамический position ...(data.color ? { color: data.color } : {}), }; diff --git a/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx b/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx index e295dce..9cd7ca5 100644 --- a/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx +++ b/src/features/boards/column/create/ui/CreateBoardColumnDialog.tsx @@ -16,13 +16,11 @@ import { CreateBoardColumnForm } from './CreateBoardColumnForm'; interface CreateBoardColumnDialogProps extends ComponentProps { boardSlug: string; - defaultPosition?: number; dialog?: ComponentProps; } export function CreateBoardColumnDialog({ boardSlug, - defaultPosition, dialog = {}, ...props }: CreateBoardColumnDialogProps) { @@ -36,17 +34,16 @@ export function CreateBoardColumnDialog({ return ( - + {props.children ? : null} - Новая колонка + Новый этап { setPending(true); @@ -68,7 +65,7 @@ export function CreateBoardColumnDialog({ diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx index cde3108..f3bce41 100644 --- a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -1,26 +1,23 @@ +import { PROJECT_COLORS } from 'entities/project'; +import { ComponentProps } from 'react'; 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'; +import { useCreateBoardColumnForm } from '../model/useCreateBoardColumnForm'; 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, }); @@ -37,7 +34,7 @@ export function CreateBoardColumnForm({ Цвет { form.setValue('color', c); }} diff --git a/src/features/boards/column/remove/model/useRemoveColumn.ts b/src/features/boards/column/remove/model/useRemoveColumn.ts index d0fa7fc..e221767 100644 --- a/src/features/boards/column/remove/model/useRemoveColumn.ts +++ b/src/features/boards/column/remove/model/useRemoveColumn.ts @@ -18,7 +18,7 @@ export function useRemoveColumn({ onSuccess, onSettled, ...rest }: UseDeleteColu mutationFn: (args) => BoardHttp.removeBoardColumn(args.boardSlug, args.columnId), onSuccess: async (res, ...args) => { onSuccess?.(res, ...args); - toast.success(res.message ?? 'Колонка удалена'); + toast.success(res.message ?? 'Этап удален'); }, onSettled: async (d, e, v, m, context) => { onSettled?.(d, e, v, m, context); diff --git a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx index 3127859..4f46505 100644 --- a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx +++ b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx @@ -28,13 +28,13 @@ export function RemoveColumnDialog({ columnId, boardSlug, options = {}, ...props return ( - + {props.children ? : null} - Удаление доски + Удаление этапа - Удалить колонку? + Удалить этап? - При удалении колонки будут удалены все задачи в ней + При удалении этапа будут удалены все задачи в нем diff --git a/src/features/boards/create/model/useCreateBoardForm.ts b/src/features/boards/create/model/useCreateBoardForm.ts index 7d00602..f9d245f 100644 --- a/src/features/boards/create/model/useCreateBoardForm.ts +++ b/src/features/boards/create/model/useCreateBoardForm.ts @@ -1,17 +1,17 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { type TBoard } from 'entities/board'; import { useParams } from 'next/navigation'; import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; +import { extractValidationIssues } from 'shared/api'; +import { setFormErrors } from 'shared/lib/utils'; 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'; +import { useCreateBoard, UseCreateBoardOptions } from './useCreateBoard'; export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { - const params = useParams<{ slug: string }>(); - const slug = params?.slug; + const params = useParams<{ projectSlug: string }>(); + const slug = params?.projectSlug; const form = useForm({ resolver: zodResolver(CreateBoardFormSchema), diff --git a/src/features/boards/create/ui/CreateBoardDialog.tsx b/src/features/boards/create/ui/CreateBoardDialog.tsx index 47e76e4..258b542 100644 --- a/src/features/boards/create/ui/CreateBoardDialog.tsx +++ b/src/features/boards/create/ui/CreateBoardDialog.tsx @@ -29,7 +29,7 @@ export function CreateBoardDialog({ dialog = {}, ...props }: CreateProjectDialog return ( - + {props.children ? : null} Новая доска diff --git a/src/features/boards/remove/ui/RemoveBoardDialog.tsx b/src/features/boards/remove/ui/RemoveBoardDialog.tsx index 1d27437..3ee0dd5 100644 --- a/src/features/boards/remove/ui/RemoveBoardDialog.tsx +++ b/src/features/boards/remove/ui/RemoveBoardDialog.tsx @@ -12,9 +12,10 @@ import { } from 'shared/ui'; import { RemoveBoardVariables, useRemoveBoard } from '../model/useRemoveBoard'; -type Props = ComponentProps & RemoveBoardVariables; +type Props = ComponentProps & + RemoveBoardVariables & { boardName: string }; -export function RemoveBoardDialog({ projectSlug, boardSlug, ...props }: Props) { +export function RemoveBoardDialog({ projectSlug, boardSlug, boardName, ...props }: Props) { const removeBoard = useRemoveBoard(); const onRemove = () => { @@ -23,12 +24,12 @@ export function RemoveBoardDialog({ projectSlug, boardSlug, ...props }: Props) { return ( - + {props.children ? : null} Удалить доску? - diff --git a/src/features/projects/remove/model/useRemoveProject.ts b/src/features/projects/remove/model/useRemoveProject.ts index 7f6178a..e2dfff8 100644 --- a/src/features/projects/remove/model/useRemoveProject.ts +++ b/src/features/projects/remove/model/useRemoveProject.ts @@ -24,8 +24,8 @@ export function useRemoveProject({ onSuccess, ...rest }: UseRemoveProjectOptions onSuccess: async (res, variables, r, context) => { onSuccess?.(res, variables, r, context); - if (pathname !== routes.team.projects()) { - router.replace(routes.team.projects()); + if (pathname !== routes.team.projects.all()) { + router.replace(routes.team.projects.all()); } toast.success(res.message ?? 'Проект удалён'); diff --git a/src/features/task/create/index.ts b/src/features/task/create/index.ts index d01684f..05a74e7 100644 --- a/src/features/task/create/index.ts +++ b/src/features/task/create/index.ts @@ -1,2 +1 @@ -export { CreateTaskField } from './ui/CreateTaskField'; -export { CreateTaskButton } from './ui/CreateTaskButton'; +export { useCreateTask } from './model/useCreateTask'; diff --git a/src/features/task/create/model/useActiveFieldStore.ts b/src/features/task/create/model/useActiveFieldStore.ts deleted file mode 100644 index e2b1ccd..0000000 --- a/src/features/task/create/model/useActiveFieldStore.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { create } from 'zustand'; - -interface ActiveFieldStore { - activeId: string | null; - open: (id: string) => void; - close: () => void; -} - -export const useActiveFieldStore = create((set) => ({ - activeId: null, - open: (id) => set({ activeId: id }), - close: () => set({ activeId: null }), -})); diff --git a/src/features/task/create/model/useCreateTask.ts b/src/features/task/create/model/useCreateTask.ts new file mode 100644 index 0000000..3ac61c8 --- /dev/null +++ b/src/features/task/create/model/useCreateTask.ts @@ -0,0 +1,93 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { taskFabricKeys, TaskHttp, type TTask } from 'entities/task'; + +type CreateTaskVariables = { + slug: string; + key: string; + body: TTask.CreateTaskBody; +}; + +type OptimisticContext = { + previous: Array<[readonly unknown[], TTask.TaskListResponse | undefined]>; +}; + +export type UseCreateProjectOptions = Omit< + UseMutationOptions< + TTask.CreateTaskResponse, + DefaultError, + CreateTaskVariables, + OptimisticContext + >, + 'mutationFn' | 'onMutate' +>; + +export function useCreateTask({ onSettled, onError, ...options }: UseCreateProjectOptions = {}) { + return useMutation< + TTask.CreateTaskResponse, + DefaultError, + CreateTaskVariables, + OptimisticContext + >({ + ...options, + mutationFn: ({ slug, key, body }) => TaskHttp.createTask({ slug, key }, body), + onMutate: async (variables, context) => { + const queryKey = taskFabricKeys.list(variables.slug, variables.key); + const now = new Date().toISOString(); + const optimisticTask: TTask.Task = { + id: `tmp-${crypto.randomUUID()}`, + title: variables.body.title, + description: variables.body.description ?? null, + descriptionHtml: variables.body.descriptionHtml ?? null, + priority: variables.body.priority ?? 'medium', + type: variables.body.type ?? 'task', + areaId: '', + stateId: variables.body.stateId ?? null, + position: variables.body.position ?? 0, + assigneeId: variables.body.assigneeId ?? null, + assignee: null, + reporterId: variables.body.reporterId ?? null, + reporter: null, + parentId: variables.body.parentId ?? null, + parent: null, + labels: variables.body.labels ?? [], + storyPoints: variables.body.storyPoints ?? null, + dueDate: variables.body.dueDate ?? null, + createdAt: now, + updatedAt: now, + createdBy: null, + deletedAt: null, + }; + + await context.client.cancelQueries({ queryKey }); + + const previous = context.client.getQueriesData({ queryKey }); + + if (previous) { + context.client.setQueriesData({ queryKey }, (old = []) => [ + optimisticTask, + ...old, + ]); + } + + return { previous }; + }, + onError: (error, variables, onMutateResult, context) => { + onError?.(error, variables, onMutateResult, context); + if (!onMutateResult?.previous?.length) { + return; + } + + onMutateResult.previous.forEach( + ([key, data]: [readonly unknown[], TTask.TaskListResponse | undefined]) => { + context.client.setQueryData(key, data); + } + ); + }, + onSettled: (data, error, variables, onMutateResult, context) => { + onSettled?.(data, error, variables, onMutateResult, context); + context?.client.invalidateQueries({ + queryKey: taskFabricKeys.list(variables.slug, variables.key), + }); + }, + }); +} diff --git a/src/features/task/create/model/useCreateTask.tsx b/src/features/task/create/model/useCreateTask.tsx deleted file mode 100644 index 257654c..0000000 --- a/src/features/task/create/model/useCreateTask.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* 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 deleted file mode 100644 index 7b6ddd7..0000000 --- a/src/features/task/create/ui/CreateTaskButton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 3198d56..0000000 --- a/src/features/task/create/ui/CreateTaskDialog.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function CreateTaskDialog() { - return 'dialog'; -} diff --git a/src/features/task/create/ui/CreateTaskField.tsx b/src/features/task/create/ui/CreateTaskField.tsx deleted file mode 100644 index c81793f..0000000 --- a/src/features/task/create/ui/CreateTaskField.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'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/task/remove/index.ts b/src/features/task/remove/index.ts new file mode 100644 index 0000000..0922ed9 --- /dev/null +++ b/src/features/task/remove/index.ts @@ -0,0 +1 @@ +export { RemoveTaskDialog } from './ui/RemoveTaskDialog'; diff --git a/src/features/task/remove/model/useRemoveTask.ts b/src/features/task/remove/model/useRemoveTask.ts new file mode 100644 index 0000000..038d491 --- /dev/null +++ b/src/features/task/remove/model/useRemoveTask.ts @@ -0,0 +1,64 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { taskFabricKeys, TaskHttp, type TTask } from 'entities/task'; +import { toast } from 'sonner'; + +export type RemoveTaskVariables = { + slug: string; + boardSlug: string; + taskId: string; +}; + +type OptimisticContext = { + previous: Array<[readonly unknown[], TTask.TaskListResponse | undefined]>; +}; + +export type UseRemoveTaskOptions = Omit< + UseMutationOptions, + 'mutationFn' | 'onMutate' +>; + +export function useRemoveTask({ + onSuccess, + onSettled, + onError, + ...rest +}: UseRemoveTaskOptions = {}) { + return useMutation({ + ...rest, + mutationFn: ({ slug, boardSlug, taskId }) => + TaskHttp.removeTask(taskId, { slug, key: boardSlug }), + onMutate: async (variables, context) => { + const queryKey = taskFabricKeys.list(variables.slug, variables.boardSlug); + + await context.client.cancelQueries({ queryKey }); + + const previous = context.client.getQueriesData({ queryKey }); + + context.client.setQueriesData({ queryKey }, (old = []) => + old.filter((task) => task.id !== variables.taskId) + ); + + return { previous }; + }, + onSuccess: async (res, ...args) => { + onSuccess?.(res, ...args); + toast.success('Задача удалена'); + }, + onError: (error, variables, onMutateResult, context) => { + onError?.(error, variables, onMutateResult, context); + + onMutateResult?.previous?.forEach(([key, data]) => { + context.client.setQueryData(key, data); + }); + + toast.error(error.message ?? 'Не удалось удалить задачу'); + }, + onSettled: (data, error, variables, onMutateResult, context) => { + onSettled?.(data, error, variables, onMutateResult, context); + + context?.client.invalidateQueries({ + queryKey: taskFabricKeys.list(variables.slug, variables.boardSlug), + }); + }, + }); +} diff --git a/src/features/task/remove/ui/RemoveTaskDialog.tsx b/src/features/task/remove/ui/RemoveTaskDialog.tsx new file mode 100644 index 0000000..8dd6a6e --- /dev/null +++ b/src/features/task/remove/ui/RemoveTaskDialog.tsx @@ -0,0 +1,46 @@ +import { ComponentProps } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from 'shared/ui'; +import { RemoveTaskVariables, useRemoveTask } from '../model/useRemoveTask'; + +type Props = ComponentProps & + RemoveTaskVariables & { + taskTitle: string; + }; + +export function RemoveTaskDialog({ slug, boardSlug, taskId, taskTitle, ...props }: Props) { + const removeTask = useRemoveTask(); + + const onRemove = () => { + removeTask.mutate({ slug, boardSlug, taskId }); + }; + + return ( + + {props.children ? : null} + + + Удалить задачу? + + Вы действительно хотите удалить задачу {taskTitle}? + + + + Отмена + + Удалить + + + + + ); +} diff --git a/src/features/task/update/index.ts b/src/features/task/update/index.ts new file mode 100644 index 0000000..2160830 --- /dev/null +++ b/src/features/task/update/index.ts @@ -0,0 +1 @@ +export { useUpdateTask } from './model/useUpdateTask'; diff --git a/src/features/task/update/model/useUpdateTask.ts b/src/features/task/update/model/useUpdateTask.ts new file mode 100644 index 0000000..04b1bef --- /dev/null +++ b/src/features/task/update/model/useUpdateTask.ts @@ -0,0 +1,67 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { taskFabricKeys, TaskHttp, type TTask } from 'entities/task'; +import { toast } from 'sonner'; + +export type UpdateTaskVariables = { + slug: string; + boardSlug: string; + taskId: string; + body: TTask.UpdateTaskBody; +}; + +type OptimisticContext = { + previous: Array<[readonly unknown[], TTask.TaskListResponse | undefined]>; +}; + +export type UseUpdateTaskOptions = Omit< + UseMutationOptions, + 'mutationFn' | 'onMutate' +>; + +export function useUpdateTask({ onSettled, onError, ...rest }: UseUpdateTaskOptions = {}) { + return useMutation({ + ...rest, + mutationFn: ({ slug, boardSlug, taskId, body }) => + TaskHttp.updateTask(taskId, { slug, key: boardSlug }, body), + onMutate: async (variables, context) => { + const queryKey = taskFabricKeys.list(variables.slug, variables.boardSlug); + + await context.client.cancelQueries({ queryKey }); + + const previous = context.client.getQueriesData({ queryKey }); + + context.client.setQueriesData({ queryKey }, (old = []) => + old.map((task) => + task.id === variables.taskId + ? { + ...task, + ...variables.body, + updatedAt: new Date().toISOString(), + } + : task + ) + ); + + return { previous }; + }, + onError: (error, variables, onMutateResult, context) => { + onError?.(error, variables, onMutateResult, context); + + onMutateResult?.previous?.forEach(([key, data]) => { + context.client.setQueryData(key, data); + }); + + toast.error(error.message ?? 'Не удалось обновить задачу'); + }, + onSettled: (data, error, variables, onMutateResult, context) => { + onSettled?.(data, error, variables, onMutateResult, context); + + context?.client.invalidateQueries({ + queryKey: taskFabricKeys.list(variables.slug, variables.boardSlug), + }); + context?.client.invalidateQueries({ + queryKey: taskFabricKeys.detail(variables.slug, variables.boardSlug, variables.taskId), + }); + }, + }); +} diff --git a/src/features/teams/remove/ui/RemoveTeamDialog.tsx b/src/features/teams/remove/ui/RemoveTeamDialog.tsx index ccb6c07..b49724d 100644 --- a/src/features/teams/remove/ui/RemoveTeamDialog.tsx +++ b/src/features/teams/remove/ui/RemoveTeamDialog.tsx @@ -42,9 +42,9 @@ export function RemoveTeamDialog({ teamName, teamId, dialog = {}, ...props }: Pr {props.children ? : null} - Удалить рабочее пространство? + Удалить команду? - Это действие необратимо. Для подтверждения введите название рабочего пространства: + Это действие необратимо. Для подтверждения введите название команды: {teamName} diff --git a/src/pages/boards/api/useBoardDataQuery.ts b/src/pages/boards/api/useBoardDataQuery.ts new file mode 100644 index 0000000..7147fa9 --- /dev/null +++ b/src/pages/boards/api/useBoardDataQuery.ts @@ -0,0 +1,24 @@ +import { useSuspenseQueries } from '@tanstack/react-query'; +import { BoardQueries } from 'entities/board'; +import { TaskQueries, TTask } from 'entities/task'; + +export function useBoardDataQuery(projectSlug: string, boardSlug: string) { + return useSuspenseQueries({ + queries: [ + BoardQueries.getBoardColumnList(boardSlug), + TaskQueries.getTasks({ slug: projectSlug, key: boardSlug }), + ], + combine: (results) => { + const columns = results[0].data; + const tasksByColumn = results[1].data; + + return Object.values(columns).reduce>((acc, column) => { + acc[column.id] = tasksByColumn[column.id]?.sort((a, b) => a.position - b.position) ?? []; + + return acc; + }, {}); + }, + }); +} + +export { useBoardDataQuery as useBoardData }; diff --git a/src/pages/boards/index.ts b/src/pages/boards/index.ts new file mode 100644 index 0000000..32abbe5 --- /dev/null +++ b/src/pages/boards/index.ts @@ -0,0 +1 @@ +export { BoardsPage } from './ui/BoardsPage'; diff --git a/src/pages/boards/model/tasks-by-column-to-list.ts b/src/pages/boards/model/tasks-by-column-to-list.ts new file mode 100644 index 0000000..2a3c4fb --- /dev/null +++ b/src/pages/boards/model/tasks-by-column-to-list.ts @@ -0,0 +1,11 @@ +import { TTask } from 'entities/task'; + +export function tasksByColumnToList(value: Record): TTask.TaskListResponse { + return Object.entries(value).flatMap(([stateId, tasks]) => + tasks.map((task, position) => ({ + ...task, + stateId, + position, + })) + ); +} diff --git a/src/pages/boards/model/useBoardParams.ts b/src/pages/boards/model/useBoardParams.ts new file mode 100644 index 0000000..f671864 --- /dev/null +++ b/src/pages/boards/model/useBoardParams.ts @@ -0,0 +1,15 @@ +import { useParams } from 'next/navigation'; + +type BoardRouteParams = { + projectSlug: string; + boardSlug: string; +}; + +export function useBoardParams(): BoardRouteParams { + const params = useParams(); + + return { + projectSlug: params?.projectSlug ?? '', + boardSlug: params?.boardSlug ?? '', + }; +} diff --git a/src/pages/boards/model/useInlineFieldKeyDown.ts b/src/pages/boards/model/useInlineFieldKeyDown.ts new file mode 100644 index 0000000..c508240 --- /dev/null +++ b/src/pages/boards/model/useInlineFieldKeyDown.ts @@ -0,0 +1,29 @@ +import { KeyboardEvent, useCallback } from 'react'; + +type UseInlineFieldKeyDownOptions = { + onCancel: () => void; + onSubmit: () => void | Promise; +}; + +export function useInlineFieldKeyDown({ onCancel, onSubmit }: UseInlineFieldKeyDownOptions) { + return useCallback( + async (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onCancel(); + return; + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + await onSubmit(); + return; + } + + if (event.key === ' ' && !event.shiftKey) { + event.stopPropagation(); + } + }, + [onCancel, onSubmit] + ); +} diff --git a/src/pages/boards/model/useInlineFieldOutsidePointerDown.ts b/src/pages/boards/model/useInlineFieldOutsidePointerDown.ts new file mode 100644 index 0000000..0b072a4 --- /dev/null +++ b/src/pages/boards/model/useInlineFieldOutsidePointerDown.ts @@ -0,0 +1,33 @@ +import { RefObject, useEffect } from 'react'; + +type UseInlineFieldOutsidePointerDownOptions = { + enabled: boolean; + containerRef: RefObject; + onOutsidePointerDown: () => void | Promise; +}; + +export function useInlineFieldOutsidePointerDown({ + enabled, + containerRef, + onOutsidePointerDown, +}: UseInlineFieldOutsidePointerDownOptions) { + useEffect(() => { + if (!enabled) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + + if (!(target instanceof Node) || containerRef.current?.contains(target)) { + return; + } + + onOutsidePointerDown(); + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + + return () => document.removeEventListener('pointerdown', handlePointerDown, true); + }, [containerRef, enabled, onOutsidePointerDown]); +} diff --git a/src/pages/boards/model/useMoveTask.ts b/src/pages/boards/model/useMoveTask.ts new file mode 100644 index 0000000..de189ca --- /dev/null +++ b/src/pages/boards/model/useMoveTask.ts @@ -0,0 +1,33 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { taskFabricKeys, TaskHttp, type TTask } from 'entities/task'; +import { toast } from 'sonner'; + +type MoveTaskVariables = { + slug: string; + key: string; + taskId: string; + body: TTask.MoveTaskBody; +}; + +export type UseMoveTaskOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useMoveTask({ onSettled, onError, ...options }: UseMoveTaskOptions = {}) { + return useMutation({ + ...options, + mutationFn: ({ slug, key, taskId, body }) => TaskHttp.moveTask({ id: taskId, slug, key }, body), + onSettled: (data, error, variables, onMutateResult, context) => { + onSettled?.(data, error, variables, onMutateResult, context); + + context?.client.invalidateQueries({ + queryKey: taskFabricKeys.list(variables.slug, variables.key), + }); + }, + onError: (error, variables, onMutateResult, context) => { + onError?.(error, variables, onMutateResult, context); + toast.error(error.message ?? 'Не удалось переместить задачу'); + }, + }); +} diff --git a/src/pages/boards/model/useSetBoardTasks.ts b/src/pages/boards/model/useSetBoardTasks.ts new file mode 100644 index 0000000..763e3ae --- /dev/null +++ b/src/pages/boards/model/useSetBoardTasks.ts @@ -0,0 +1,19 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { taskFabricKeys, TTask } from 'entities/task'; +import { useCallback } from 'react'; +import { tasksByColumnToList } from './tasks-by-column-to-list'; + +export function useSetBoardTasks(projectSlug: string, boardSlug: string) { + const queryClient = useQueryClient(); + + return useCallback( + (value: Record) => { + const queryKey = taskFabricKeys.list(projectSlug, boardSlug); + + queryClient.setQueriesData({ queryKey }, () => + tasksByColumnToList(value) + ); + }, + [boardSlug, projectSlug, queryClient] + ); +} diff --git a/src/pages/boards/ui/BoardsPage.tsx b/src/pages/boards/ui/BoardsPage.tsx new file mode 100644 index 0000000..2ba2b3c --- /dev/null +++ b/src/pages/boards/ui/BoardsPage.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { BoardQueries } from 'entities/board'; +import dynamic from 'next/dynamic'; +import { useRouter } from 'next/navigation'; +import { useLayoutEffect } from 'react'; +import { routes } from 'shared/config'; + +const BoardsPageContent = dynamic( + () => import('./BoardsPageContent').then((mod) => mod.BoardsPageContent), + { + ssr: false, + loading: () =>
Loading...
, //todo позже + } +); + +interface BoardsPageProps extends Partial> { + projectSlug: string; +} + +export function BoardsPage(props: BoardsPageProps) { + const router = useRouter(); + const { data } = useQuery(BoardQueries.getBoardList(props.projectSlug)); + + useLayoutEffect(() => { + if (!data || data.length === 0 || data.some((item) => item.slug === props.boardSlug)) { + return; + } + + router.replace(routes.team.projects.board(props.projectSlug, data[0].slug)); + }, [props.boardSlug, props.projectSlug, data, router]); + + if (!props.boardSlug) { + return null; + } + + return ; +} diff --git a/src/pages/boards/ui/BoardsPageContent.tsx b/src/pages/boards/ui/BoardsPageContent.tsx new file mode 100644 index 0000000..e435191 --- /dev/null +++ b/src/pages/boards/ui/BoardsPageContent.tsx @@ -0,0 +1,32 @@ +'use client'; + +import ScrollContainer from 'react-indiana-drag-scroll'; +import { classNames } from 'shared/lib/utils'; +import { Board } from './board/Board'; +import { BoardList } from './stages/Stages'; + +interface BoardsPageContentProps extends Omit, 'children'> { + projectSlug: string; + boardSlug: string; +} + +export function BoardsPageContent({ + projectSlug, + boardSlug, + className, + ...props +}: BoardsPageContentProps) { + return ( +
+ + + + +
+ ); +} diff --git a/src/pages/boards/ui/board/Board.tsx b/src/pages/boards/ui/board/Board.tsx new file mode 100644 index 0000000..a5065ae --- /dev/null +++ b/src/pages/boards/ui/board/Board.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { BoardQueries } from 'entities/board'; +import { TTask } from 'entities/task'; +import { useCallback } from 'react'; +import { classNames } from 'shared/lib/utils'; +import { Kanban, KanbanBoard, KanbanItemMoveEvent, KanbanOverlay } from 'shared/ui'; +import { useBoardDataQuery } from '../../api/useBoardDataQuery'; +import { useMoveTask } from '../../model/useMoveTask'; +import { useSetBoardTasks } from '../../model/useSetBoardTasks'; +import { Column } from '../column/Column'; + +interface BoardProps extends Omit, 'children'> { + projectSlug: string; + boardSlug: string; +} + +export const Board = ({ projectSlug, boardSlug, className, ...props }: BoardProps) => { + const value = useBoardDataQuery(projectSlug, boardSlug); + const columns = useSuspenseQuery(BoardQueries.getBoardColumnList(boardSlug)); + const setValue = useSetBoardTasks(projectSlug, boardSlug); + const { mutate: moveTask } = useMoveTask(); + + const handleItemMove = useCallback( + ({ taskId, item, toContainer, toIndex }: KanbanItemMoveEvent) => { + moveTask({ + slug: projectSlug, + key: boardSlug, + taskId, + body: { + targetStateId: toContainer, + targetAreaId: item.areaId, + position: toIndex, + }, + }); + }, + [boardSlug, moveTask, projectSlug] + ); + + return ( + item.id} + > + + {Object.entries(value).map(([columnId, tasks]) => { + return ( + + ); + })} + + + + ); +}; diff --git a/src/pages/boards/ui/column/Column.tsx b/src/pages/boards/ui/column/Column.tsx new file mode 100644 index 0000000..af68694 --- /dev/null +++ b/src/pages/boards/ui/column/Column.tsx @@ -0,0 +1,66 @@ +import { type TTask } from 'entities/task'; +import { ComponentProps } from 'react'; +import { useFixedHeightWithMax } from 'shared/lib/hooks'; +import { classNames } from 'shared/lib/utils'; +import { KanbanColumn, KanbanColumnContent, ScrollArea, ScrollBar } from 'shared/ui'; +import { Task } from '../task/Task'; +import { TaskColumnHeader } from './ColumnHeader'; + +interface ColumnProps extends Omit, 'children'> { + projectSlug: string; + boardSlug: string; + title: string; + id: string; + color: string; + tasks: TTask.Task[]; +} + +export function Column({ + value, + className, + projectSlug, + boardSlug, + title, + id, + color, + tasks, + ...props +}: ColumnProps) { + const { ref: contentRef, style: scrollAreaStyle } = useFixedHeightWithMax({ + maxHeight: 'calc(100dvh - 16rem)', + }); + + return ( + + + +
+ + {tasks.map((task) => ( + + ))} + + +
+
+
+ ); +} diff --git a/src/pages/boards/ui/column/ColumnHeader.tsx b/src/pages/boards/ui/column/ColumnHeader.tsx new file mode 100644 index 0000000..c755755 --- /dev/null +++ b/src/pages/boards/ui/column/ColumnHeader.tsx @@ -0,0 +1,57 @@ +import { BOARD_COLUMN_COLORS } from 'entities/board'; +import { GripVerticalIcon } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { classNames } from 'shared/lib/utils'; +import { Button, KanbanColumnHandle } from 'shared/ui'; +import { ColumnHeaderActions } from './ColumnHeaderActions'; +import { CreateTaskInline } from './CreateTaskInline'; + +export interface TaskColumnHeaderProps { + value: string; + tasksLength: number; + title: string; + color: string; + boardSlug: string; + projectSlug: string; + className?: string; +} + +export function TaskColumnHeader(props: TaskColumnHeaderProps) { + const { tasksLength, title, color, boardSlug, projectSlug, className, value } = props; + const [activeColor, setActiveColor] = useState(color ?? BOARD_COLUMN_COLORS[0]); + const columnId = value; + + const colors = useMemo(() => { + return [...new Set([color, ...BOARD_COLUMN_COLORS])].filter(Boolean) as string[]; + }, [color]); + + return ( +
+
+
+
+
+

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

+
+
+ + + + +
+
+
+ +
+
+
+ ); +} diff --git a/src/pages/boards/ui/column/ColumnHeaderActions.tsx b/src/pages/boards/ui/column/ColumnHeaderActions.tsx new file mode 100644 index 0000000..e080aef --- /dev/null +++ b/src/pages/boards/ui/column/ColumnHeaderActions.tsx @@ -0,0 +1,60 @@ +import { RemoveColumnDialog } from 'features/boards/column/remove'; +import { Ellipsis, Trash2 } from 'lucide-react'; +import { + Button, + ColorPicker, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'shared/ui'; + +interface ColumnHeaderActionsProps { + columnId: string; + boardSlug: string; + colors: string[]; + activeColor: string; + setActiveColor: (color: string) => void; +} + +export function ColumnHeaderActions({ + columnId, + boardSlug, + colors, + activeColor, + setActiveColor, +}: ColumnHeaderActionsProps) { + return ( + + + + + + + + e.preventDefault()} variant="destructive"> + + Удалить этап + + + + + + Цвет этапа + + + + + ); +} diff --git a/src/pages/boards/ui/column/CreateTaskInline.tsx b/src/pages/boards/ui/column/CreateTaskInline.tsx new file mode 100644 index 0000000..8256941 --- /dev/null +++ b/src/pages/boards/ui/column/CreateTaskInline.tsx @@ -0,0 +1,82 @@ +import { useCreateTask } from 'features/task/create'; +import { PlusIcon } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; +import { Button, Card, CardContent, Checkbox, Textarea } from 'shared/ui'; +import { useInlineFieldKeyDown } from '../../model/useInlineFieldKeyDown'; +import { useInlineFieldOutsidePointerDown } from '../../model/useInlineFieldOutsidePointerDown'; + +interface CreateTaskInlineProps { + projectSlug: string; + boardSlug: string; + columnId: string; +} + +export function CreateTaskInline({ projectSlug, boardSlug, columnId }: CreateTaskInlineProps) { + const [isCreating, setIsCreating] = useState(false); + const [taskTitle, setTaskTitle] = useState(''); + const { mutateAsync: createTask } = useCreateTask(); + const containerRef = useRef(null); + const resetDraft = (createMore: boolean = false) => { + setTaskTitle(''); + setIsCreating(createMore); + }; + + const handleCreateTask = useCallback( + async (createMore: boolean = false): Promise => { + const titleValue = taskTitle.trim(); + + resetDraft(createMore); + + if (!titleValue) { + return; + } + + await createTask({ + slug: projectSlug, + key: boardSlug, + body: { + title: titleValue, + stateId: columnId, + }, + }); + }, + [projectSlug, boardSlug, columnId, taskTitle, createTask] + ); + + const onKeyDown = useInlineFieldKeyDown({ + onCancel: () => resetDraft(), + onSubmit: () => handleCreateTask(true), + }); + + useInlineFieldOutsidePointerDown({ + enabled: isCreating, + containerRef, + onOutsidePointerDown: handleCreateTask, + }); + + if (!isCreating) { + return ( + + ); + } + + return ( + + + +