diff --git a/app/(protected)/team/invitations/page.tsx b/app/(protected)/team/(team)/invitations/page.tsx
similarity index 100%
rename from app/(protected)/team/invitations/page.tsx
rename to app/(protected)/team/(team)/invitations/page.tsx
diff --git a/app/(protected)/team/layout.tsx b/app/(protected)/team/(team)/layout.tsx
similarity index 100%
rename from app/(protected)/team/layout.tsx
rename to app/(protected)/team/(team)/layout.tsx
diff --git a/app/(protected)/team/members/page.tsx b/app/(protected)/team/(team)/members/page.tsx
similarity index 100%
rename from app/(protected)/team/members/page.tsx
rename to app/(protected)/team/(team)/members/page.tsx
diff --git a/app/(protected)/team/page.tsx b/app/(protected)/team/(team)/page.tsx
similarity index 100%
rename from app/(protected)/team/page.tsx
rename to app/(protected)/team/(team)/page.tsx
diff --git a/app/(protected)/team/(team)/projects/page.tsx b/app/(protected)/team/(team)/projects/page.tsx
new file mode 100644
index 0000000..571b8c5
--- /dev/null
+++ b/app/(protected)/team/(team)/projects/page.tsx
@@ -0,0 +1 @@
+export { ProjectsPage as default } from 'pages/team';
diff --git a/app/(protected)/team/roles/page.tsx b/app/(protected)/team/(team)/roles/page.tsx
similarity index 100%
rename from app/(protected)/team/roles/page.tsx
rename to app/(protected)/team/(team)/roles/page.tsx
diff --git a/app/(protected)/team/settings/page.tsx b/app/(protected)/team/(team)/settings/page.tsx
similarity index 100%
rename from app/(protected)/team/settings/page.tsx
rename to app/(protected)/team/(team)/settings/page.tsx
diff --git a/app/(protected)/team/projects/[projectId]/page.tsx b/app/(protected)/team/projects/[projectId]/page.tsx
new file mode 100644
index 0000000..671dc37
--- /dev/null
+++ b/app/(protected)/team/projects/[projectId]/page.tsx
@@ -0,0 +1 @@
+export { ProjectBoardsPage as default } from 'pages/project';
diff --git a/app/(protected)/team/projects/[projectId]/settings/page.tsx b/app/(protected)/team/projects/[projectId]/settings/page.tsx
new file mode 100644
index 0000000..f214d81
--- /dev/null
+++ b/app/(protected)/team/projects/[projectId]/settings/page.tsx
@@ -0,0 +1 @@
+export { ProjectSettingsPage as default } from 'pages/project';
diff --git a/app/projects/[projectId]/page.tsx b/app/projects/[projectId]/page.tsx
new file mode 100644
index 0000000..99a4d50
--- /dev/null
+++ b/app/projects/[projectId]/page.tsx
@@ -0,0 +1,17 @@
+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/components.json b/components.json
index 3252184..0f116f6 100644
--- a/components.json
+++ b/components.json
@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
+ "iconLibrary": "lucide",
"aliases": {
"components": "shared/ui",
"utils": "shared/lib/utils",
@@ -17,5 +18,7 @@
"lib": "shared/lib",
"hooks": "shared/hooks/shadcn"
},
- "iconLibrary": "lucide"
+ "registries": {
+ "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json"
+ }
}
diff --git a/package.json b/package.json
index ecce46c..5b92ae7 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,9 @@
"prepare": "husky"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@grafana/faro-web-sdk": "^2.4.0",
"@grafana/faro-web-tracing": "^2.4.0",
"@hookform/resolvers": "^5.2.2",
@@ -46,6 +49,7 @@
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.1",
+ "tunnel-rat": "^0.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f2f06c6..9abf844 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,15 @@ importers:
.:
dependencies:
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.2.5)
'@grafana/faro-web-sdk':
specifier: ^2.4.0
version: 2.4.0
@@ -80,6 +89,9 @@ importers:
tailwind-merge:
specifier: ^3.4.1
version: 3.5.0
+ tunnel-rat:
+ specifier: ^0.1.2
+ version: 0.1.2(@types/react@19.2.14)(immer@11.1.7)(react@19.2.5)
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -393,6 +405,28 @@ packages:
resolution: {integrity: sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==}
engines: {node: '>=18'}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@dotenvx/dotenvx@1.63.0':
resolution: {integrity: sha512-jjkmzIRu19uH78AjFInqfcALehbDCZZ7M09hurVawyqNxtOXEg2LR73L59y4QnzfYDEzjbhVzGAd2uDHu0D1aQ==}
hasBin: true
@@ -5092,6 +5126,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ tunnel-rat@0.1.2:
+ resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
+
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@@ -5486,6 +5523,21 @@ packages:
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
+ zustand@4.5.7:
+ resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+
zustand@5.0.12:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'}
@@ -5758,6 +5810,31 @@ snapshots:
gonzales-pe: 4.3.0
node-source-walk: 7.0.1
+ '@dnd-kit/accessibility@3.1.1(react@19.2.5)':
+ dependencies:
+ react: 19.2.5
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.5)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.5)
+ react: 19.2.5
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.2.5)':
+ dependencies:
+ react: 19.2.5
+ tslib: 2.8.1
+
'@dotenvx/dotenvx@1.63.0':
dependencies:
commander: 11.1.0
@@ -10679,6 +10756,14 @@ snapshots:
tslib@2.8.1: {}
+ tunnel-rat@0.1.2(@types/react@19.2.14)(immer@11.1.7)(react@19.2.5):
+ dependencies:
+ zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.7)(react@19.2.5)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+ - react
+
tw-animate-css@1.4.0: {}
type-check@0.4.0:
@@ -11050,6 +11135,14 @@ snapshots:
zod@4.3.6: {}
+ zustand@4.5.7(@types/react@19.2.14)(immer@11.1.7)(react@19.2.5):
+ dependencies:
+ use-sync-external-store: 1.6.0(react@19.2.5)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ immer: 11.1.7
+ react: 19.2.5
+
zustand@5.0.12(@types/react@19.2.14)(immer@11.1.7)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
optionalDependencies:
'@types/react': 19.2.14
diff --git a/src/app/layouts/PageLayout.tsx b/src/app/layouts/PageLayout.tsx
index 77cbece..cf44ab8 100644
--- a/src/app/layouts/PageLayout.tsx
+++ b/src/app/layouts/PageLayout.tsx
@@ -3,7 +3,7 @@ import { PageLayout as PageLayoutParent } from 'widgets/page-layout';
import { TabsNav } from 'widgets/tabs-nav';
interface PageLayoutProps extends Omit, 'nav'> {
- tabs: ComponentProps['tabs'];
+ tabs?: ComponentProps['tabs'];
}
export function PageLayout({ children, tabs, ...props }: PageLayoutProps) {
diff --git a/src/app/layouts/SidebarLayout.tsx b/src/app/layouts/SidebarLayout.tsx
index 45ca821..aca4604 100644
--- a/src/app/layouts/SidebarLayout.tsx
+++ b/src/app/layouts/SidebarLayout.tsx
@@ -8,7 +8,7 @@ export function SidebarLayout({ children, ...props }: ComponentProps
-
+
- {children}
+ {children}
);
diff --git a/src/entities/project/api/http.ts b/src/entities/project/api/http.ts
new file mode 100644
index 0000000..bb4c028
--- /dev/null
+++ b/src/entities/project/api/http.ts
@@ -0,0 +1,84 @@
+import { api } from 'shared/api';
+import * as SProject from '../model/schemas';
+import * as TProject from '../model/types';
+
+export class ProjectHttp {
+ static getProjects(teamSlug: string, signal?: AbortSignal) {
+ return api({
+ url: `/teams/${teamSlug}/projects`,
+ method: 'GET',
+ contracts: {
+ response: SProject.ProjectListResponse,
+ },
+ signal,
+ });
+ }
+
+ static getProject(teamSlug: string, id: string, token?: string, signal?: AbortSignal) {
+ return api({
+ url: `/teams/${teamSlug}/projects/${id}`,
+ method: 'GET',
+ params: token ? { token } : undefined,
+ contracts: {
+ response: SProject.ProjectDetailResponse,
+ },
+ signal,
+ });
+ }
+
+ static createProject(teamSlug: string, data: TProject.CreateProjectBody) {
+ return api({
+ url: `/teams/${teamSlug}/projects`,
+ method: 'POST',
+ data,
+ contracts: {
+ body: SProject.CreateProjectBody,
+ response: SProject.CreateProjectResponse,
+ },
+ });
+ }
+
+ static updateProject(teamSlug: string, id: string, data: TProject.UpdateProjectBody) {
+ return api({
+ url: `/teams/${teamSlug}/projects/${id}`,
+ method: 'PATCH',
+ data,
+ contracts: {
+ body: SProject.UpdateProjectBody,
+ response: SProject.ActionResponse,
+ },
+ });
+ }
+
+ static removeProject(teamSlug: string, id: string) {
+ return api({
+ url: `/teams/${teamSlug}/projects/${id}`,
+ method: 'DELETE',
+ contracts: {
+ response: SProject.ActionResponse,
+ },
+ });
+ }
+
+ static archiveProject(teamSlug: string, id: string) {
+ return api({
+ url: `/teams/${teamSlug}/projects/${id}/archive`,
+ method: 'POST',
+ contracts: {
+ response: SProject.ActionResponse,
+ },
+ });
+ }
+
+ static createShareToken(teamSlug: string, id: string, data: TProject.CreateShareTokenBody = {}) {
+ return api({
+ url: `/teams/${teamSlug}/projects/${id}/share`,
+ method: 'POST',
+ data,
+ contracts: {
+ body: SProject.CreateShareTokenBody,
+ response: SProject.CreateShareTokenResponse,
+ },
+ });
+ }
+}
diff --git a/src/entities/project/api/queries.ts b/src/entities/project/api/queries.ts
new file mode 100644
index 0000000..6bfb089
--- /dev/null
+++ b/src/entities/project/api/queries.ts
@@ -0,0 +1,21 @@
+import { queryOptions } from '@tanstack/react-query';
+import { projectFabricKeys } from '../model/const';
+import { ProjectHttp } from './http';
+
+export class ProjectQueries {
+ static getProjects(teamSlug: string) {
+ return queryOptions({
+ queryKey: projectFabricKeys.list(teamSlug),
+ queryFn: async ({ signal }) => ProjectHttp.getProjects(teamSlug, signal),
+ staleTime: 60_000,
+ });
+ }
+
+ static getProject(teamSlug: string, id: string, token?: string) {
+ return queryOptions({
+ queryKey: [...projectFabricKeys.detail(teamSlug, id), token ?? null],
+ queryFn: async ({ signal }) => ProjectHttp.getProject(teamSlug, id, token, signal),
+ staleTime: 60_000,
+ });
+ }
+}
diff --git a/src/entities/project/config/colors.ts b/src/entities/project/config/colors.ts
new file mode 100644
index 0000000..779a537
--- /dev/null
+++ b/src/entities/project/config/colors.ts
@@ -0,0 +1,14 @@
+export const PROJECT_COLORS = [
+ '#9FA8DA',
+ '#7E57C2',
+ '#9575CD',
+ '#AB47BC',
+ '#F06292',
+ '#FF8A65',
+ '#4FC3F7',
+ '#4DB6AC',
+ '#81C784',
+ '#DCE775',
+ '#FFF176',
+ '#FFB74D',
+] as const;
diff --git a/src/entities/project/config/icons.ts b/src/entities/project/config/icons.ts
new file mode 100644
index 0000000..0478daa
--- /dev/null
+++ b/src/entities/project/config/icons.ts
@@ -0,0 +1,42 @@
+export const PROJECT_ICONS = [
+ '1F4C1', // 📁
+ '1F680', // 🚀
+ '1F41B', // 🐛
+ '26A1', // ⚡
+ '1F3AF', // 🎯
+ '1F4A1', // 💡
+ '1F527', // 🔧
+ '1F4CA', // 📊
+ '1F3A8', // 🎨
+ '1F512', // 🔒
+ '1F4F1', // 📱
+ '1F310', // 🌐
+ '2728', // ✨
+ '1F6E0-FE0F', // 🛠️
+ '1F4DD', // 📝
+ '1F3D7-FE0F', // 🏗️
+ '1F3AE', // 🎮
+ '1F4C8', // 📈
+ '1F525', // 🔥
+ '1F48E', // 💎
+ '1F31F', // 🌟
+ '1F4E6', // 📦
+ '1F9EA', // 🧪
+ '1F393', // 🎓
+ '1F3E0', // 🏠
+ '1F6E1-FE0F', // 🛡️
+ '1F916', // 🤖
+ '1F4E3', // 📣
+ '1F5C2-FE0F', // 🗂️
+ '23F1-FE0F', // ⏱️
+ '1F9E9', // 🧩
+ '1F3AA', // 🎪
+ '1F331', // 🌱
+ '1F6A2', // 🚢
+ '1F514', // 🔔
+ '1F4AC', // 💬
+ '1F4CC', // 📌
+ '1F3C6', // 🏆
+ '2699-FE0F', // ⚙️
+ '1F9E0', // 🧠
+] as const;
diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts
new file mode 100644
index 0000000..190e163
--- /dev/null
+++ b/src/entities/project/index.ts
@@ -0,0 +1,9 @@
+export * as SProject from './model/schemas';
+export type * as TProject from './model/types';
+export { ProjectHttp } from './api/http';
+export { ProjectQueries } from './api/queries';
+export { projectFabricKeys } from './model/const';
+export { PROJECT_ICONS } from './config/icons';
+export { PROJECT_COLORS } from './config/colors';
+export { projectIconCodeToEmoji } from './lib/emoji';
+export { buildProjectShareUrl } from './lib/share-url';
diff --git a/src/entities/project/lib/emoji.ts b/src/entities/project/lib/emoji.ts
new file mode 100644
index 0000000..003d700
--- /dev/null
+++ b/src/entities/project/lib/emoji.ts
@@ -0,0 +1,19 @@
+const ICON_CODE_PATTERN = /^[\dA-F]+(?:-[\dA-F]+)*$/i;
+
+export function projectIconCodeToEmoji(code: string | null | undefined): string {
+ if (!code) return '';
+ if (!ICON_CODE_PATTERN.test(code)) return code;
+
+ const codePoints = code
+ .split('-')
+ .map((part) => Number.parseInt(part, 16))
+ .filter((value) => Number.isFinite(value));
+
+ if (!codePoints.length) return '';
+
+ try {
+ return String.fromCodePoint(...codePoints);
+ } catch {
+ return '';
+ }
+}
diff --git a/src/entities/project/lib/share-url.ts b/src/entities/project/lib/share-url.ts
new file mode 100644
index 0000000..0f432f5
--- /dev/null
+++ b/src/entities/project/lib/share-url.ts
@@ -0,0 +1,8 @@
+export function buildProjectShareUrl(projectId: string, token: string) {
+ const origin = typeof window !== 'undefined' ? window.location.origin : '';
+ const url = new URL(`/projects/${projectId}`, origin || 'http://localhost');
+
+ url.searchParams.set('token', token);
+
+ return url.toString();
+}
diff --git a/src/entities/project/model/const.ts b/src/entities/project/model/const.ts
new file mode 100644
index 0000000..0276da1
--- /dev/null
+++ b/src/entities/project/model/const.ts
@@ -0,0 +1,6 @@
+import { createEntityKeys } from 'shared/lib/utils';
+
+export const projectFabricKeys = createEntityKeys('project', {
+ list: (teamSlug: string) => ['teams', teamSlug, 'projects'],
+ detail: (teamSlug: string, id: string) => ['teams', teamSlug, 'projects', id],
+});
diff --git a/src/entities/project/model/schemas.ts b/src/entities/project/model/schemas.ts
new file mode 100644
index 0000000..a226915
--- /dev/null
+++ b/src/entities/project/model/schemas.ts
@@ -0,0 +1,90 @@
+import { DateTimeString, GlobalSuccess } from 'shared/api';
+import { z } from 'zod/v4';
+import { PROJECT_ICONS } from '../config/icons';
+
+export const ActionResponse = GlobalSuccess;
+
+export const CreateProjectBody = z.object({
+ name: z.string().min(1).max(100),
+ key: z
+ .string()
+ .min(2)
+ .max(10)
+ .regex(/^[A-Z0-9]+$/),
+ description: z.string().max(2000).optional().nullable(),
+ icon: z.enum(PROJECT_ICONS).optional().nullable(),
+ color: z
+ .string()
+ .regex(/^#[A-Fa-f0-9]{6}$/)
+ .optional(),
+ visibility: z.enum(['public', 'private']),
+});
+
+export const UpdateProjectBody = CreateProjectBody.extend({
+ status: z.enum(['active', 'archived']).optional(),
+ isPublic: z.boolean().optional(),
+})
+ .partial()
+ .refine((data) => Object.keys(data).length > 0, {
+ error: 'Необходимо передать хотя бы одно поле для обновления',
+ abort: true,
+ });
+
+export const CreateProjectResponse = GlobalSuccess.extend({
+ projectId: z.string(),
+});
+
+export const CreateShareTokenBody = z.object({
+ ttl: DateTimeString.optional().nullable(),
+});
+
+export const CreateShareTokenResponse = GlobalSuccess.extend({
+ payload: z.object({
+ token: z.string(),
+ isYourself: z.boolean(),
+ expiresAt: DateTimeString.nullable(),
+ }),
+});
+
+export const ProjectListItemResponse = z.object({
+ id: z.string(),
+ key: z.string(),
+ name: z.string(),
+ status: z.enum(['active', 'archived', 'template']),
+ color: z.string(),
+ icon: z.string().nullable(),
+ createdAt: DateTimeString,
+ canEdit: z.boolean(),
+});
+
+export const ProjectListResponse = z.object({
+ team: z.object({
+ id: z.string(),
+ name: z.string(),
+ slug: z.string(),
+ role: z.string(),
+ }),
+ items: ProjectListItemResponse.array(),
+ meta: z.object({ total: z.number() }),
+});
+
+export const ProjectDetailResponse = z.object({
+ id: z.string(),
+ key: z.string(),
+ name: z.string(),
+ status: z.enum(['active', 'archived', 'template']),
+ description: z.string().nullable(),
+ visuals: z.object({ color: z.string(), icon: z.string().nullable() }),
+ meta: z.object({
+ taskSequence: z.number(),
+ createdAt: DateTimeString,
+ updatedAt: DateTimeString,
+ }),
+ access: z.object({
+ visibility: z.enum(['public', 'private']),
+ canEdit: z.boolean(),
+ canDelete: z.boolean(),
+ shareUrl: z.string().nullable(),
+ }),
+ settings: z.record(z.string(), z.unknown()),
+});
diff --git a/src/entities/project/model/types.ts b/src/entities/project/model/types.ts
new file mode 100644
index 0000000..835b084
--- /dev/null
+++ b/src/entities/project/model/types.ts
@@ -0,0 +1,13 @@
+import { z } from 'zod/v4';
+import * as SProject from './schemas';
+
+export type CreateProjectBody = z.infer;
+export type UpdateProjectBody = z.infer;
+export type CreateProjectResponse = z.infer;
+export type CreateShareTokenBody = z.infer;
+export type CreateShareTokenResponse = z.infer;
+export type ActionResponse = z.infer;
+
+export type ProjectListItemResponse = z.infer;
+export type ProjectListResponse = z.infer;
+export type ProjectDetailResponse = z.infer;
diff --git a/src/entities/team/api/http.ts b/src/entities/team/api/http.ts
index 704698a..3bbac48 100644
--- a/src/entities/team/api/http.ts
+++ b/src/entities/team/api/http.ts
@@ -169,83 +169,4 @@ export class TeamHttp {
},
});
}
-
- static getProjects(slug: string, signal?: AbortSignal) {
- return api({
- url: `/teams/${slug}/projects`,
- method: 'GET',
- contracts: {
- response: STeam.ProjectListResponse,
- },
- signal,
- });
- }
-
- static getProject(slug: string, id: string, token?: string, signal?: AbortSignal) {
- return api({
- url: `/teams/${slug}/projects/${id}`,
- method: 'GET',
- params: token ? { token } : undefined,
- contracts: {
- response: STeam.ProjectDetailResponse,
- },
- signal,
- });
- }
-
- static createProject(slug: string, data: TTeam.CreateProjectBody) {
- return api({
- url: `/teams/${slug}/projects`,
- method: 'POST',
- data,
- contracts: {
- body: STeam.CreateProjectBody,
- response: STeam.CreateProjectResponse,
- },
- });
- }
-
- static updateProject(slug: string, id: string, data: TTeam.UpdateProjectBody) {
- return api({
- url: `/teams/${slug}/projects/${id}`,
- method: 'PATCH',
- data,
- contracts: {
- body: STeam.UpdateProjectBody,
- response: STeam.ActionResponse,
- },
- });
- }
-
- static removeProject(slug: string, id: string) {
- return api({
- url: `/teams/${slug}/projects/${id}`,
- method: 'DELETE',
- contracts: {
- response: STeam.ActionResponse,
- },
- });
- }
-
- static archiveProject(slug: string, id: string) {
- return api({
- url: `/teams/${slug}/projects/${id}/archive`,
- method: 'POST',
- contracts: {
- response: STeam.ActionResponse,
- },
- });
- }
-
- static createProjectShareToken(slug: string, id: string, data: TTeam.CreateShareTokenBody) {
- return api({
- url: `/teams/${slug}/projects/${id}/share`,
- method: 'POST',
- data,
- contracts: {
- body: STeam.CreateShareTokenBody,
- response: STeam.ActionResponse,
- },
- });
- }
}
diff --git a/src/entities/team/api/queries.ts b/src/entities/team/api/queries.ts
index d2d09e3..566233f 100644
--- a/src/entities/team/api/queries.ts
+++ b/src/entities/team/api/queries.ts
@@ -43,20 +43,4 @@ export class TeamQueries {
staleTime: 60_000,
});
}
-
- static getProjects(slug: string) {
- return queryOptions({
- queryKey: teamFabricKeys.projects(slug),
- queryFn: async ({ signal }) => TeamHttp.getProjects(slug, signal),
- staleTime: 60_000,
- });
- }
-
- static getProject(slug: string, id: string, token?: string) {
- return queryOptions({
- queryKey: [...teamFabricKeys.project(slug, id), token ?? null],
- queryFn: async ({ signal }) => TeamHttp.getProject(slug, id, token, signal),
- staleTime: 60_000,
- });
- }
}
diff --git a/src/entities/team/model/const.ts b/src/entities/team/model/const.ts
index 4ce135e..1e50a15 100644
--- a/src/entities/team/model/const.ts
+++ b/src/entities/team/model/const.ts
@@ -9,6 +9,4 @@ export const teamFabricKeys = createEntityKeys('team', {
invitations: (slug: string) => ['teams', slug, 'invitations'],
invitation: (slug: string, code: string) => ['teams', slug, 'invitations', code],
members: (slug: string) => ['teams', slug, 'members'],
- projects: (slug: string) => ['teams', slug, 'projects'],
- project: (slug: string, id: string) => ['teams', slug, 'projects', id],
});
diff --git a/src/entities/team/model/schemas.ts b/src/entities/team/model/schemas.ts
index 27ecc17..8105ba0 100644
--- a/src/entities/team/model/schemas.ts
+++ b/src/entities/team/model/schemas.ts
@@ -1,4 +1,4 @@
-import { GlobalSuccess } from 'shared/api';
+import { DateTimeString, GlobalSuccess } from 'shared/api';
import { z } from 'zod/v4';
import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH } from './const';
@@ -90,9 +90,9 @@ export const TeamDetailsResponse = z.object({
avatar: TeamAvatarSchema,
coverUrl: z.string().nullable(),
ownerId: z.string().nullable(),
- createdAt: z.iso.datetime({}),
- updatedAt: z.iso.datetime({}),
- deletedAt: z.iso.datetime({}).nullable(),
+ createdAt: DateTimeString,
+ updatedAt: DateTimeString,
+ deletedAt: DateTimeString.nullable(),
});
export const TeamInvitationResponse = z.object({
@@ -104,8 +104,8 @@ export const TeamInvitationResponse = z.object({
role: TeamRole,
inviterId: z.string(),
inviterName: z.string(),
- createdAt: z.iso.datetime({}),
- expiresAt: z.iso.datetime({}),
+ createdAt: DateTimeString,
+ expiresAt: DateTimeString,
});
export const InviteMemberBody = z.object({
@@ -128,7 +128,7 @@ export const TeamMemberResponse = z.object({
lastName: z.string(),
avatar: TeamAvatarSchema,
initials: z.string().max(2),
- joinedAt: z.iso.datetime({}),
+ joinedAt: DateTimeString,
});
export const UpdateMemberBody = z
@@ -158,80 +158,3 @@ export const SyncTagsBody = z.object({
});
export const ActionResponse = GlobalSuccess;
-
-export const CreateProjectBody = z.object({
- name: z.string().min(1).max(100),
- key: z
- .string()
- .min(2)
- .max(10)
- .regex(/^[A-Z0-9]+$/),
- description: z.string().max(2000).optional().nullable(),
- icon: z.string().optional().nullable(),
- color: z
- .string()
- .regex(/^#[A-Fa-f0-9]{6}$/)
- .optional(),
- visibility: z.enum(['public', 'private']).default('public'),
-});
-
-export const UpdateProjectBody = CreateProjectBody.extend({
- status: z.enum(['active', 'archived']).optional(),
- isPublic: z.boolean().optional(),
-})
- .partial()
- .refine((data) => Object.keys(data).length > 0, {
- error: 'Необходимо передать хотя бы одно поле для обновления',
- abort: true,
- });
-
-export const CreateProjectResponse = GlobalSuccess.extend({
- projectId: z.string(),
-});
-
-export const CreateShareTokenBody = z.object({
- ttl: z.iso.datetime({}).optional().nullable(),
-});
-
-export const ProjectListItemResponse = z.object({
- id: z.string(),
- key: z.string(),
- name: z.string(),
- status: z.enum(['active', 'archived', 'template']),
- color: z.string(),
- icon: z.string().nullable(),
- createdAt: z.iso.datetime({}),
- canEdit: z.boolean(),
-});
-
-export const ProjectListResponse = z.object({
- team: z.object({
- id: z.string(),
- name: z.string(),
- slug: z.string(),
- role: z.string(),
- }),
- items: ProjectListItemResponse.array(),
- meta: z.object({ total: z.number() }),
-});
-
-export const ProjectDetailResponse = z.object({
- id: z.string(),
- key: z.string(),
- name: z.string(),
- status: z.enum(['active', 'archived', 'template']),
- description: z.string().nullable(),
- visuals: z.object({ color: z.string(), icon: z.string().nullable() }),
- meta: z.object({
- taskSequence: z.number(),
- createdAt: z.iso.datetime({}),
- updatedAt: z.iso.datetime({}),
- }),
- access: z.object({
- visibility: z.enum(['public', 'private']),
- canEdit: z.boolean(),
- canDelete: z.boolean(),
- shareUrl: z.string().nullable(),
- }),
- settings: z.record(z.string(), z.unknown()),
-});
diff --git a/src/entities/team/model/types.ts b/src/entities/team/model/types.ts
index ddf825d..826761f 100644
--- a/src/entities/team/model/types.ts
+++ b/src/entities/team/model/types.ts
@@ -19,12 +19,3 @@ export type UpdateInvitationBody = z.infer;
export type UpdateMemberBody = z.infer;
export type SyncTagsBody = z.infer;
export type ActionResponse = z.infer;
-
-export type CreateProjectBody = z.infer;
-export type UpdateProjectBody = z.infer;
-export type CreateProjectResponse = z.infer;
-export type CreateShareTokenBody = z.infer;
-
-export type ProjectListItemResponse = z.infer;
-export type ProjectListResponse = z.infer;
-export type ProjectDetailResponse = z.infer;
diff --git a/src/entities/user/api/http.ts b/src/entities/user/api/http.ts
index 62e485e..cf9e8ce 100644
--- a/src/entities/user/api/http.ts
+++ b/src/entities/user/api/http.ts
@@ -49,11 +49,11 @@ export class UserHttp {
}
static getMyTeams(signal?: AbortSignal) {
- return api({
+ return api({
url: '/users/me/teams',
method: 'GET',
contracts: {
- response: SUser.UserTeamResponse.array(),
+ response: SUser.UserTeamsListResponse,
},
signal,
});
diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts
index dab0a6b..274e978 100644
--- a/src/entities/user/model/schemas.ts
+++ b/src/entities/user/model/schemas.ts
@@ -1,5 +1,5 @@
+import { DateTimeString, GlobalSuccess } from 'shared/api';
import { z } from 'zod/v4';
-import { GlobalSuccess } from 'shared/api';
export const UserAvatarSchema = z
.object({
@@ -21,12 +21,12 @@ export const UserResponse = z.object({
avatar: UserAvatarSchema,
timezone: z.string(),
language: z.string(),
- createdAt: z.iso.datetime({}),
- updatedAt: z.iso.datetime({}),
+ createdAt: DateTimeString,
+ updatedAt: DateTimeString,
}),
security: z.object({
is2faEnabled: z.boolean(),
- lastPasswordChange: z.iso.datetime({}),
+ lastPasswordChange: DateTimeString,
}),
notifications: z.object({
email: z.object({
@@ -85,15 +85,29 @@ export const UserTeamResponse = z.object({
description: z.string(),
avatar: UserAvatarSchema,
role: z.string(),
- joinedAt: z.iso.datetime({}),
+ joinedAt: DateTimeString,
permissions: TeamPermissions,
});
+export const UserTeamsListMeta = z.object({
+ hasNextPage: z.boolean(),
+ hasPrevPage: z.boolean(),
+ total: z.number(),
+ totalPages: z.number(),
+ page: z.number(),
+ limit: z.number(),
+});
+
+export const UserTeamsListResponse = z.object({
+ items: UserTeamResponse.array(),
+ meta: UserTeamsListMeta,
+});
+
export const UserInvitationResponse = z.object({
code: z.string(),
teamName: z.string(),
teamAvatar: UserAvatarSchema,
role: z.string(),
inviterName: z.string(),
- expiresAt: z.iso.datetime({}),
+ expiresAt: DateTimeString,
});
diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts
index 6becd41..d56957e 100644
--- a/src/entities/user/model/types.ts
+++ b/src/entities/user/model/types.ts
@@ -7,4 +7,6 @@ export type NotificationsUpdateResponse = z.infer;
export type ProfileUpdateResponse = z.infer;
export type UserTeamResponse = z.infer;
+export type UserTeamsListMeta = z.infer;
+export type UserTeamsListResponse = z.infer;
export type UserInvitationResponse = z.infer;
diff --git a/src/features/projects/archive/index.ts b/src/features/projects/archive/index.ts
new file mode 100644
index 0000000..6f59bd5
--- /dev/null
+++ b/src/features/projects/archive/index.ts
@@ -0,0 +1,2 @@
+export { ArchiveProjectDialog } from './ui/ArchiveProjectDialog';
+export { RestoreProjectDialog } from './ui/RestoreProjectDialog';
diff --git a/src/features/projects/archive/model/useArchiveProject.ts b/src/features/projects/archive/model/useArchiveProject.ts
new file mode 100644
index 0000000..f7d221b
--- /dev/null
+++ b/src/features/projects/archive/model/useArchiveProject.ts
@@ -0,0 +1,33 @@
+import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query';
+import { projectFabricKeys, ProjectHttp, type TProject } from 'entities/project';
+import { toast } from 'sonner';
+
+type ArchiveProjectVariables = {
+ teamSlug: string;
+ id: string;
+};
+
+export type UseArchiveProjectOptions = Omit<
+ UseMutationOptions,
+ 'mutationFn'
+>;
+
+export function useArchiveProject({ onSuccess, ...rest }: UseArchiveProjectOptions = {}) {
+ return useMutation({
+ ...rest,
+ mutationFn: ({ teamSlug, id }) => ProjectHttp.archiveProject(teamSlug, id),
+ onSuccess: async (res, variables, _r, context) => {
+ onSuccess?.(res, variables, _r, context);
+ toast.success(res.message ?? 'Проект архивирован');
+
+ await Promise.all([
+ context.client.invalidateQueries({
+ queryKey: projectFabricKeys.list(variables.teamSlug),
+ }),
+ context.client.invalidateQueries({
+ queryKey: projectFabricKeys.detail(variables.teamSlug, variables.id),
+ }),
+ ]);
+ },
+ });
+}
diff --git a/src/features/projects/archive/model/useRestoreProject.ts b/src/features/projects/archive/model/useRestoreProject.ts
new file mode 100644
index 0000000..44847d5
--- /dev/null
+++ b/src/features/projects/archive/model/useRestoreProject.ts
@@ -0,0 +1,33 @@
+import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query';
+import { projectFabricKeys, ProjectHttp, type TProject } from 'entities/project';
+import { toast } from 'sonner';
+
+type RestoreProjectVariables = {
+ teamSlug: string;
+ id: string;
+};
+
+export type UseRestoreProjectOptions = Omit<
+ UseMutationOptions,
+ 'mutationFn'
+>;
+
+export function useRestoreProject({ onSuccess, ...rest }: UseRestoreProjectOptions = {}) {
+ return useMutation({
+ ...rest,
+ mutationFn: ({ teamSlug, id }) => ProjectHttp.updateProject(teamSlug, id, { status: 'active' }),
+ onSuccess: async (res, variables, _r, context) => {
+ onSuccess?.(res, variables, _r, context);
+ toast.success(res.message ?? 'Проект восстановлен');
+
+ await Promise.all([
+ context.client.invalidateQueries({
+ queryKey: projectFabricKeys.list(variables.teamSlug),
+ }),
+ context.client.invalidateQueries({
+ queryKey: projectFabricKeys.detail(variables.teamSlug, variables.id),
+ }),
+ ]);
+ },
+ });
+}
diff --git a/src/features/projects/archive/ui/ArchiveProjectDialog.tsx b/src/features/projects/archive/ui/ArchiveProjectDialog.tsx
new file mode 100644
index 0000000..c4384f6
--- /dev/null
+++ b/src/features/projects/archive/ui/ArchiveProjectDialog.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import { ComponentProps } from 'react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from 'shared/ui';
+import { useArchiveProject } from '../model/useArchiveProject';
+
+interface ArchiveProjectDialogProps extends ComponentProps {
+ projectName: string;
+ teamSlug: string;
+ projectId: string;
+ onArchived?: () => void;
+}
+
+export function ArchiveProjectDialog({
+ projectName,
+ teamSlug,
+ projectId,
+ onArchived,
+ ...props
+}: ArchiveProjectDialogProps) {
+ const archiveProject = useArchiveProject({
+ onSuccess: () => onArchived?.(),
+ });
+
+ const onArchive = () => {
+ archiveProject.mutate({ teamSlug, id: projectId });
+ };
+
+ return (
+
+
+
+
+ Архивировать проект?
+
+ Проект «{projectName}» будет скрыт из активных. Его можно восстановить позже из архива.
+
+
+
+ Отмена
+
+ Архивировать
+
+
+
+
+ );
+}
diff --git a/src/features/projects/archive/ui/RestoreProjectDialog.tsx b/src/features/projects/archive/ui/RestoreProjectDialog.tsx
new file mode 100644
index 0000000..3c144ff
--- /dev/null
+++ b/src/features/projects/archive/ui/RestoreProjectDialog.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { ComponentProps } from 'react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from 'shared/ui';
+import { useRestoreProject } from '../model/useRestoreProject';
+
+interface RestoreProjectDialogProps extends ComponentProps {
+ projectName: string;
+ teamSlug: string;
+ projectId: string;
+}
+
+export function RestoreProjectDialog({
+ projectName,
+ teamSlug,
+ projectId,
+ ...props
+}: RestoreProjectDialogProps) {
+ const restoreProject = useRestoreProject();
+
+ const onRestore = () => {
+ restoreProject.mutate({ teamSlug, id: projectId });
+ };
+
+ return (
+
+
+
+
+ Восстановить проект?
+
+ Проект «{projectName}» снова станет активным и появится в списке проектов.
+
+
+
+ Отмена
+
+ Восстановить
+
+
+
+
+ );
+}
diff --git a/src/features/projects/create/index.ts b/src/features/projects/create/index.ts
new file mode 100644
index 0000000..1e6d1d6
--- /dev/null
+++ b/src/features/projects/create/index.ts
@@ -0,0 +1,4 @@
+export { CreateProjectForm } from './ui/CreateProjectForm';
+export { CreateProjectDialog } from './ui/CreateProjectDialog';
+export { ProjectIdentityFields } from './ui/ProjectIdentityFields';
+export { VisibilityPicker } from './ui/VisibilityPicker';
diff --git a/src/features/projects/create/model/default-values.ts b/src/features/projects/create/model/default-values.ts
new file mode 100644
index 0000000..0446298
--- /dev/null
+++ b/src/features/projects/create/model/default-values.ts
@@ -0,0 +1,17 @@
+import { PROJECT_COLORS, PROJECT_ICONS } from 'entities/project';
+import type { CreateProjectFormValues } from './types';
+
+function pickRandom(items: readonly T[]): T {
+ return items[Math.floor(Math.random() * items.length)]!;
+}
+
+export function getDefaultCreateProjectValues(): CreateProjectFormValues {
+ return {
+ name: '',
+ key: '',
+ description: '',
+ icon: pickRandom(PROJECT_ICONS),
+ color: pickRandom(PROJECT_COLORS),
+ visibility: 'private',
+ };
+}
diff --git a/src/features/projects/create/model/schemas.ts b/src/features/projects/create/model/schemas.ts
new file mode 100644
index 0000000..2afc271
--- /dev/null
+++ b/src/features/projects/create/model/schemas.ts
@@ -0,0 +1,3 @@
+import { SProject } from 'entities/project';
+
+export const CreateProjectFormSchema = SProject.CreateProjectBody;
diff --git a/src/features/projects/create/model/types.ts b/src/features/projects/create/model/types.ts
new file mode 100644
index 0000000..1d59a67
--- /dev/null
+++ b/src/features/projects/create/model/types.ts
@@ -0,0 +1,10 @@
+import { z } from 'zod/v4';
+import { CreateProjectFormSchema } from './schemas';
+
+export type CreateProjectFormValues = z.input;
+
+//todo исправить(должны быть все поля)
+export type ProjectIdentityFormValues = Pick<
+ CreateProjectFormValues,
+ 'name' | 'key' | 'description' | 'icon' | 'color'
+>;
diff --git a/src/features/projects/create/model/useCreateProject.ts b/src/features/projects/create/model/useCreateProject.ts
new file mode 100644
index 0000000..c3e4ff7
--- /dev/null
+++ b/src/features/projects/create/model/useCreateProject.ts
@@ -0,0 +1,28 @@
+import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query';
+import { projectFabricKeys, ProjectHttp, type TProject } from 'entities/project';
+import { toast } from 'sonner';
+
+type CreateProjectVariables = {
+ teamSlug: string;
+ body: TProject.CreateProjectBody;
+};
+
+export type UseCreateProjectOptions = Omit<
+ UseMutationOptions,
+ 'mutationFn'
+>;
+
+export function useCreateProject({ onSuccess, ...rest }: UseCreateProjectOptions = {}) {
+ return useMutation({
+ ...rest,
+ mutationFn: ({ teamSlug, body }) => ProjectHttp.createProject(teamSlug, body),
+ onSuccess: async (res, variables, _r, context) => {
+ onSuccess?.(res, variables, _r, context);
+ toast.success(res.message ?? 'Проект создан');
+
+ await context.client.invalidateQueries({
+ queryKey: projectFabricKeys.list(variables.teamSlug),
+ });
+ },
+ });
+}
diff --git a/src/features/projects/create/model/useCreateProjectForm.ts b/src/features/projects/create/model/useCreateProjectForm.ts
new file mode 100644
index 0000000..e265595
--- /dev/null
+++ b/src/features/projects/create/model/useCreateProjectForm.ts
@@ -0,0 +1,52 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { 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 { CreateProjectFormSchema } from './schemas';
+import type { CreateProjectFormValues } from './types';
+import { useCreateProject, type UseCreateProjectOptions } from './useCreateProject';
+
+export function useCreateProjectForm(options: UseCreateProjectOptions = {}) {
+ const teamSlug = useTeamStore.use.slug();
+
+ const form = useForm({
+ resolver: zodResolver(CreateProjectFormSchema),
+ defaultValues: getDefaultCreateProjectValues(),
+ });
+
+ const createProject = useCreateProject({
+ ...options,
+ meta: {
+ skipGlobalValidationToast: true,
+ },
+ onError: (err, ...args) => {
+ options.onError?.(err, ...args);
+ setFormErrors(extractValidationIssues(err), form);
+ },
+ });
+
+ const onSubmit = (data: CreateProjectFormValues) => {
+ if (!teamSlug) return;
+
+ const body: TProject.CreateProjectBody = {
+ name: data.name.trim(),
+ key: data.key.trim().toUpperCase(),
+ visibility: data.visibility ?? 'private',
+ ...(data.description?.trim() ? { description: data.description.trim() } : {}),
+ ...(data.icon ? { icon: data.icon } : {}),
+ ...(data.color ? { color: data.color } : {}),
+ };
+
+ createProject.mutate({ teamSlug, body });
+ };
+
+ return {
+ form,
+ teamSlug,
+ isPending: createProject.isPending,
+ handleSubmit: form.handleSubmit(onSubmit),
+ };
+}
diff --git a/src/features/projects/create/ui/CreateProjectDialog.tsx b/src/features/projects/create/ui/CreateProjectDialog.tsx
new file mode 100644
index 0000000..eb88e9a
--- /dev/null
+++ b/src/features/projects/create/ui/CreateProjectDialog.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+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 { CreateProjectForm } from './CreateProjectForm';
+
+interface CreateProjectDialogProps extends ComponentProps {
+ dialog?: ComponentProps;
+}
+
+export function CreateProjectDialog({ dialog = {}, ...props }: CreateProjectDialogProps) {
+ const [open, setOpen] = useControllableState({
+ defaultValue: dialog.defaultOpen,
+ value: dialog.open,
+ onChange: dialog.onOpenChange,
+ });
+ const formId = useId();
+ const [pending, setPending] = useState(false);
+
+ return (
+
+ );
+}
diff --git a/src/features/projects/create/ui/CreateProjectForm.tsx b/src/features/projects/create/ui/CreateProjectForm.tsx
new file mode 100644
index 0000000..dc24312
--- /dev/null
+++ b/src/features/projects/create/ui/CreateProjectForm.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { ComponentProps } from 'react';
+import { FormProvider } from 'react-hook-form';
+import { cn } from 'shared/lib/utils';
+import { FieldGroup, Separator } from 'shared/ui';
+import type { UseCreateProjectOptions } from '../model/useCreateProject';
+import { useCreateProjectForm } from '../model/useCreateProjectForm';
+import { ProjectIdentityFields } from './ProjectIdentityFields';
+import { VisibilityPicker } from './VisibilityPicker';
+
+interface CreateProjectFormProps extends Omit, 'children' | 'onSubmit'> {
+ mutateOptions?: UseCreateProjectOptions;
+}
+
+export function CreateProjectForm({ className, mutateOptions, ...props }: CreateProjectFormProps) {
+ const { form, isPending, handleSubmit } = useCreateProjectForm(mutateOptions);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/features/projects/create/ui/ProjectColorPicker.tsx b/src/features/projects/create/ui/ProjectColorPicker.tsx
new file mode 100644
index 0000000..7d364a6
--- /dev/null
+++ b/src/features/projects/create/ui/ProjectColorPicker.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import { PROJECT_COLORS } from 'entities/project';
+import { Check } from 'lucide-react';
+import { type CSSProperties } from 'react';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
+import { cn } from 'shared/lib/utils';
+import type { ProjectIdentityFormValues } from '../model/types';
+
+interface ProjectColorPickerProps {
+ disabled?: boolean;
+}
+
+export function ProjectColorPicker({ disabled = false }: ProjectColorPickerProps) {
+ const { control } = useFormContext();
+ const selectedColor = useWatch({ control, name: 'color' });
+ const activeColor = selectedColor ?? PROJECT_COLORS[0];
+
+ return (
+ (
+
+ {PROJECT_COLORS.map((item) => {
+ const isSelected = activeColor === item;
+ const isVeryLight = item.toLowerCase() === '#ffffff';
+
+ return (
+
+ );
+ })}
+
+ )}
+ />
+ );
+}
diff --git a/src/features/projects/create/ui/ProjectIconPicker.tsx b/src/features/projects/create/ui/ProjectIconPicker.tsx
new file mode 100644
index 0000000..7ae8da7
--- /dev/null
+++ b/src/features/projects/create/ui/ProjectIconPicker.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import { PROJECT_ICONS, projectIconCodeToEmoji } from 'entities/project';
+import { ImagePlusIcon } from 'lucide-react';
+import { useState } from 'react';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
+import { cn } from 'shared/lib/utils';
+import { Button, Popover, PopoverContent, PopoverTrigger } from 'shared/ui';
+import type { ProjectIdentityFormValues } from '../model/types';
+
+interface ProjectIconPickerProps {
+ disabled?: boolean;
+}
+
+export function ProjectIconPicker({ disabled = false }: ProjectIconPickerProps) {
+ const { control } = useFormContext();
+ const selectedColor = useWatch({ control, name: 'color' });
+ const selectedIconCode = useWatch({ control, name: 'icon' });
+ const [iconOpen, setIconOpen] = useState(false);
+
+ const iconColor = selectedColor ?? '#7C3AED';
+ const iconCode = selectedIconCode ?? PROJECT_ICONS[0];
+
+ return (
+
+
+
+ {projectIconCodeToEmoji(iconCode)}
+
+
+
Иконка
+
Выберите иконку для проекта
+
+
+
+
+
+
+
+
+
+ (
+ <>
+ {PROJECT_ICONS.map((item) => (
+
+ ))}
+ >
+ )}
+ />
+
+
+
+
+ );
+}
diff --git a/src/features/projects/create/ui/ProjectIdentityFields.tsx b/src/features/projects/create/ui/ProjectIdentityFields.tsx
new file mode 100644
index 0000000..82bb54d
--- /dev/null
+++ b/src/features/projects/create/ui/ProjectIdentityFields.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { Controller, useFormContext } from 'react-hook-form';
+import {
+ Field,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+ Input,
+ InputGroup,
+ InputGroupInput,
+ Textarea,
+} from 'shared/ui';
+import type { ProjectIdentityFormValues } from '../model/types';
+import { ProjectColorPicker } from './ProjectColorPicker';
+import { ProjectIconPicker } from './ProjectIconPicker';
+
+interface ProjectIdentityFieldsProps {
+ disabled?: boolean;
+ idPrefix?: string;
+ showPlaceholders?: boolean;
+}
+
+export function ProjectIdentityFields({
+ disabled = false,
+ idPrefix = 'project',
+ showPlaceholders = false,
+}: ProjectIdentityFieldsProps) {
+ const form = useFormContext();
+ const iconError = form.formState.errors.icon;
+ const colorError = form.formState.errors.color;
+ const hasVisualError = Boolean(iconError || colorError);
+
+ return (
+
+ (
+
+ Название
+
+ {fieldState.invalid && }
+
+ )}
+ />
+ (
+
+ Ключ проекта
+
+ {
+ field.onChange(
+ e.target.value
+ .trim()
+ .toUpperCase()
+ .replace(/[^A-Z0-9]/g, '')
+ );
+ }}
+ id={`${idPrefix}-key`}
+ aria-label="Ключ проекта"
+ placeholder={showPlaceholders ? 'PROJ' : undefined}
+ aria-required={showPlaceholders ? true : undefined}
+ aria-invalid={fieldState.invalid}
+ autoComplete="off"
+ disabled={disabled}
+ />
+
+ {fieldState.invalid && }
+
+ )}
+ />
+ (
+
+ Описание
+
+ {fieldState.invalid && }
+
+ )}
+ />
+
+ Иконка и цвет проекта
+
+ {iconError && }
+ {colorError && }
+
+
+ );
+}
diff --git a/src/features/projects/create/ui/VisibilityPicker.tsx b/src/features/projects/create/ui/VisibilityPicker.tsx
new file mode 100644
index 0000000..44d1c95
--- /dev/null
+++ b/src/features/projects/create/ui/VisibilityPicker.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { useId } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+import { Field, FieldContent, FieldDescription, FieldError, FieldLabel, Switch } from 'shared/ui';
+import type { CreateProjectFormValues } from '../model/types';
+
+interface VisibilityPickerProps {
+ disabled?: boolean;
+}
+
+export function VisibilityPicker({ disabled = false }: VisibilityPickerProps) {
+ const { control } = useFormContext();
+ const id = useId();
+
+ return (
+ (
+
+
+
+ Публичный проект
+ Виден всем, у кого есть ссылка
+
+ field.onChange(checked ? 'public' : 'private')}
+ disabled={disabled}
+ aria-label="Публичный проект"
+ />
+
+ {fieldState.invalid && }
+
+ )}
+ />
+ );
+}
diff --git a/src/features/projects/remove/index.ts b/src/features/projects/remove/index.ts
new file mode 100644
index 0000000..5081a2c
--- /dev/null
+++ b/src/features/projects/remove/index.ts
@@ -0,0 +1 @@
+export { RemoveProjectDialog } from './ui/RemoveProjectDialog';
diff --git a/src/features/projects/remove/model/useRemoveProject.ts b/src/features/projects/remove/model/useRemoveProject.ts
new file mode 100644
index 0000000..43c6cb1
--- /dev/null
+++ b/src/features/projects/remove/model/useRemoveProject.ts
@@ -0,0 +1,28 @@
+import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query';
+import { projectFabricKeys, ProjectHttp, type TProject } from 'entities/project';
+import { toast } from 'sonner';
+
+type RemoveProjectVariables = {
+ teamSlug: string;
+ id: string;
+};
+
+export type UseRemoveProjectOptions = Omit<
+ UseMutationOptions,
+ 'mutationFn'
+>;
+
+export function useRemoveProject({ onSuccess, ...rest }: UseRemoveProjectOptions = {}) {
+ return useMutation({
+ ...rest,
+ mutationFn: ({ teamSlug, id }) => ProjectHttp.removeProject(teamSlug, id),
+ onSuccess: async (res, variables, _r, context) => {
+ onSuccess?.(res, variables, _r, context);
+ toast.success(res.message ?? 'Проект удалён');
+
+ await context.client.invalidateQueries({
+ queryKey: projectFabricKeys.list(variables.teamSlug),
+ });
+ },
+ });
+}
diff --git a/src/features/projects/remove/ui/RemoveProjectDialog.tsx b/src/features/projects/remove/ui/RemoveProjectDialog.tsx
new file mode 100644
index 0000000..490f4a2
--- /dev/null
+++ b/src/features/projects/remove/ui/RemoveProjectDialog.tsx
@@ -0,0 +1,58 @@
+import { ComponentProps, useState } from 'react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+ Input,
+} from 'shared/ui';
+import { useRemoveProject } from '../model/useRemoveProject';
+
+interface Props extends ComponentProps {
+ projectName: string;
+ teamSlug: string;
+ projectId: string;
+}
+
+export function RemoveProjectDialog({ projectName, teamSlug, projectId, ...props }: Props) {
+ const [inputValue, setInputValue] = useState('');
+ const removeProject = useRemoveProject();
+
+ const isMatch = inputValue.trim() === projectName.trim();
+
+ const onRemove = () => {
+ removeProject.mutate({ teamSlug, id: projectId });
+ };
+
+ return (
+
+
+
+
+ Удалить проект?
+
+ Это действие необратимо. Для подтверждения введите название проекта:
+ {projectName}
+
+
+ setInputValue(e.target.value)}
+ placeholder={projectName}
+ aria-label="Название проекта для подтверждения удаления"
+ />
+
+ setInputValue('')}>Отмена
+
+ Удалить
+
+
+
+
+ );
+}
diff --git a/src/features/projects/share/config/ttl-options.ts b/src/features/projects/share/config/ttl-options.ts
new file mode 100644
index 0000000..9355c51
--- /dev/null
+++ b/src/features/projects/share/config/ttl-options.ts
@@ -0,0 +1,6 @@
+export const SHARE_TTL_OPTIONS = [
+ { value: '7', label: '7 дней' },
+ { value: '30', label: '30 дней' },
+ { value: '60', label: '60 дней' },
+ { value: '90', label: '90 дней' },
+] as const;
diff --git a/src/features/projects/share/index.ts b/src/features/projects/share/index.ts
new file mode 100644
index 0000000..d8c30d2
--- /dev/null
+++ b/src/features/projects/share/index.ts
@@ -0,0 +1 @@
+export { ShareProjectDialog } from './ui/ShareProjectDialog';
diff --git a/src/features/projects/share/model/copy-share-url.ts b/src/features/projects/share/model/copy-share-url.ts
new file mode 100644
index 0000000..1fd5cb8
--- /dev/null
+++ b/src/features/projects/share/model/copy-share-url.ts
@@ -0,0 +1,14 @@
+import { toast } from 'sonner';
+
+export async function copyShareUrl(shareUrl: string | null) {
+ if (!shareUrl) {
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ toast.success('Ссылка скопирована');
+ } catch {
+ toast.error('Не удалось скопировать ссылку');
+ }
+}
diff --git a/src/features/projects/share/model/ttl-option-to-body.ts b/src/features/projects/share/model/ttl-option-to-body.ts
new file mode 100644
index 0000000..975da1f
--- /dev/null
+++ b/src/features/projects/share/model/ttl-option-to-body.ts
@@ -0,0 +1,8 @@
+import type { TProject } from 'entities/project';
+import type { ShareTtlOption } from './types';
+
+export function ttlOptionToBody(option: ShareTtlOption): TProject.CreateShareTokenBody {
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + Number(option));
+ return { ttl: expiresAt.toISOString() };
+}
diff --git a/src/features/projects/share/model/types.ts b/src/features/projects/share/model/types.ts
new file mode 100644
index 0000000..adebcca
--- /dev/null
+++ b/src/features/projects/share/model/types.ts
@@ -0,0 +1,3 @@
+import type { SHARE_TTL_OPTIONS } from '../config/ttl-options';
+
+export type ShareTtlOption = (typeof SHARE_TTL_OPTIONS)[number]['value'];
diff --git a/src/features/projects/share/model/useShareProject.ts b/src/features/projects/share/model/useShareProject.ts
new file mode 100644
index 0000000..7519043
--- /dev/null
+++ b/src/features/projects/share/model/useShareProject.ts
@@ -0,0 +1,29 @@
+import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query';
+import { projectFabricKeys, ProjectHttp, type TProject } from 'entities/project';
+import { toast } from 'sonner';
+
+type ShareProjectVariables = {
+ teamSlug: string;
+ id: string;
+ body?: TProject.CreateShareTokenBody;
+};
+
+export type UseShareProjectOptions = Omit<
+ UseMutationOptions,
+ 'mutationFn'
+>;
+
+export function useShareProject({ onSuccess, ...rest }: UseShareProjectOptions = {}) {
+ return useMutation({
+ ...rest,
+ mutationFn: ({ teamSlug, id, body = {} }) => ProjectHttp.createShareToken(teamSlug, id, body),
+ onSuccess: async (res, variables, _r, context) => {
+ onSuccess?.(res, variables, _r, context);
+ toast.success(res.message ?? 'Ссылка для доступа создана');
+
+ await context.client.invalidateQueries({
+ queryKey: projectFabricKeys.detail(variables.teamSlug, variables.id),
+ });
+ },
+ });
+}
diff --git a/src/features/projects/share/ui/ShareProjectDialog.tsx b/src/features/projects/share/ui/ShareProjectDialog.tsx
new file mode 100644
index 0000000..2adf5da
--- /dev/null
+++ b/src/features/projects/share/ui/ShareProjectDialog.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { buildProjectShareUrl } from 'entities/project';
+import { Copy } from 'lucide-react';
+import { ComponentProps, useState } from 'react';
+import { formatDate } from 'shared/lib/utils';
+import { useControllableState } from 'shared/lib/hooks';
+import {
+ Button,
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ Field,
+ FieldDescription,
+ FieldLabel,
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Spinner,
+} from 'shared/ui';
+import { SHARE_TTL_OPTIONS } from '../config/ttl-options';
+import type { ShareTtlOption } from '../model/types';
+import { copyShareUrl } from '../model/copy-share-url';
+import { ttlOptionToBody } from '../model/ttl-option-to-body';
+import { useShareProject } from '../model/useShareProject';
+
+interface ShareProjectDialogProps extends ComponentProps {
+ projectName: string;
+ teamSlug: string;
+ projectId: string;
+ dialog?: ComponentProps;
+}
+
+export function ShareProjectDialog({
+ projectName,
+ teamSlug,
+ projectId,
+ dialog = {},
+ ...props
+}: ShareProjectDialogProps) {
+ const [open, setOpen] = useControllableState({
+ defaultValue: dialog.defaultOpen,
+ value: dialog.open,
+ onChange: dialog.onOpenChange,
+ });
+
+ const [ttlOption, setTtlOption] = useState('90');
+ const [shareUrl, setShareUrl] = useState(null);
+ const [expiresAt, setExpiresAt] = useState(null);
+ const shareProject = useShareProject({
+ onSuccess: (res) => {
+ setShareUrl(buildProjectShareUrl(projectId, res.payload.token));
+ setExpiresAt(res.payload.expiresAt);
+ },
+ });
+
+ const resetState = () => {
+ setTtlOption('90');
+ setShareUrl(null);
+ setExpiresAt(null);
+ shareProject.reset();
+ };
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ resetState();
+ }
+ };
+
+ const onCreateLink = () => {
+ shareProject.mutate({
+ teamSlug, //todo не будет
+ id: projectId,
+ body: ttlOptionToBody(ttlOption),
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/src/features/teams/active-team/model/useTeamsQueryWithSlugSync.ts b/src/features/teams/active-team/model/useTeamsQueryWithSlugSync.ts
index 3754b68..c4e84fa 100644
--- a/src/features/teams/active-team/model/useTeamsQueryWithSlugSync.ts
+++ b/src/features/teams/active-team/model/useTeamsQueryWithSlugSync.ts
@@ -11,11 +11,12 @@ export function useTeamsQueryWithSlugSync() {
useEffect(() => {
if (!query.data) return;
- const hasTeamSlug = !!slug && query.data.some((d) => d.slug === slug);
+ const items = query.data.items;
+ const hasTeamSlug = !!slug && items.some((d) => d.slug === slug);
if (hasTeamSlug) return;
- setCurrentTeamSlug(query.data[0]?.slug);
+ setCurrentTeamSlug(items[0]?.slug);
}, [slug, setCurrentTeamSlug, query.data]);
return { query, slug };
diff --git a/src/pages/profile/ui/teams-page/TeamList.tsx b/src/pages/profile/ui/teams-page/TeamList.tsx
index 3b758f0..ea80b12 100644
--- a/src/pages/profile/ui/teams-page/TeamList.tsx
+++ b/src/pages/profile/ui/teams-page/TeamList.tsx
@@ -22,7 +22,7 @@ export function TeamsList() {
const slug = useTeamStore.use.slug();
const { switchTeam } = useSwitchTeam({
- teams: teamsQuery.data,
+ teams: teamsQuery.data?.items,
defaultOptions: { redirect: true },
});
@@ -44,7 +44,7 @@ export function TeamsList() {
);
}
- const teams = teamsQuery.data ?? [];
+ const teams = teamsQuery.data?.items ?? [];
if (teams.length === 0) {
return ;
}
diff --git a/src/pages/project/api/useQueryProject.ts b/src/pages/project/api/useQueryProject.ts
new file mode 100644
index 0000000..fa1dfb1
--- /dev/null
+++ b/src/pages/project/api/useQueryProject.ts
@@ -0,0 +1,15 @@
+import { useQuery } from '@tanstack/react-query';
+import { ProjectQueries } from 'entities/project';
+import { useTeamStore } from 'entities/team';
+import { useParams } from 'next/navigation';
+
+export function useQueryProject() {
+ const teamSlug = useTeamStore.use.slug();
+ const params = useParams();
+ const projectId = typeof params?.projectId === 'string' ? params.projectId : undefined;
+
+ return useQuery({
+ ...ProjectQueries.getProject(teamSlug!, projectId!),
+ enabled: Boolean(teamSlug && projectId),
+ });
+}
diff --git a/src/pages/project/api/useUpdateProject.ts b/src/pages/project/api/useUpdateProject.ts
new file mode 100644
index 0000000..d73ee0e
--- /dev/null
+++ b/src/pages/project/api/useUpdateProject.ts
@@ -0,0 +1,41 @@
+import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query';
+import { projectFabricKeys, ProjectHttp, type TProject } from 'entities/project';
+import { useTeamStore } from 'entities/team';
+import { useParams } from 'next/navigation';
+import { toast } from 'sonner';
+
+type UseUpdateProjectProps = Omit<
+ UseMutationOptions,
+ 'mutationFn'
+>;
+
+export function useUpdateProject({ onSuccess, ...rest }: UseUpdateProjectProps = {}) {
+ const teamSlug = useTeamStore.use.slug();
+ const params = useParams();
+ const projectId = typeof params?.projectId === 'string' ? params.projectId : undefined;
+
+ return useMutation({
+ ...rest,
+ mutationFn: (data) => {
+ if (!teamSlug || !projectId) {
+ throw new Error('Не выбран проект');
+ }
+ return ProjectHttp.updateProject(teamSlug, projectId, data);
+ },
+ onSuccess: async (res, _v, _r, context) => {
+ onSuccess?.(res, _v, _r, context);
+ toast.success(res.message ?? 'Проект обновлён');
+
+ if (teamSlug && projectId) {
+ await Promise.all([
+ context.client.invalidateQueries({
+ queryKey: projectFabricKeys.detail(teamSlug, projectId),
+ }),
+ context.client.invalidateQueries({
+ queryKey: projectFabricKeys.list(teamSlug),
+ }),
+ ]);
+ }
+ },
+ });
+}
diff --git a/src/pages/project/index.ts b/src/pages/project/index.ts
new file mode 100644
index 0000000..cc85b6b
--- /dev/null
+++ b/src/pages/project/index.ts
@@ -0,0 +1,2 @@
+export { ProjectBoardsPage } from './ui/boards/ProjectBoardsPage';
+export { ProjectSettingsPage } from './ui/settings/ProjectSettingsPage';
diff --git a/src/pages/project/model/boards-mock.ts b/src/pages/project/model/boards-mock.ts
new file mode 100644
index 0000000..ae15903
--- /dev/null
+++ b/src/pages/project/model/boards-mock.ts
@@ -0,0 +1,69 @@
+export type MockBoardColumn = {
+ id: string;
+ name: string;
+};
+
+export type MockBoardCard = {
+ id: string;
+ name: string;
+ column: string;
+};
+
+export type MockBoard = {
+ id: string;
+ name: string;
+ columns: MockBoardColumn[];
+ cards: MockBoardCard[];
+};
+
+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' },
+ ],
+ },
+ {
+ 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' },
+ ],
+ },
+ {
+ 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' },
+ ],
+ },
+];
diff --git a/src/pages/project/model/settings.ts b/src/pages/project/model/settings.ts
new file mode 100644
index 0000000..63c6a2a
--- /dev/null
+++ b/src/pages/project/model/settings.ts
@@ -0,0 +1,8 @@
+import { SProject } from 'entities/project';
+import { z } from 'zod/v4';
+
+export const ProjectSettingsFormSchema = SProject.CreateProjectBody.extend({
+ status: z.enum(['active', 'archived']),
+});
+
+export type ProjectSettingsFormValues = z.infer;
diff --git a/src/pages/project/ui/boards/ProjectBoardsPage.tsx b/src/pages/project/ui/boards/ProjectBoardsPage.tsx
new file mode 100644
index 0000000..c6f185f
--- /dev/null
+++ b/src/pages/project/ui/boards/ProjectBoardsPage.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from 'shared/ui';
+import { MOCK_BOARDS } from '../../model/boards-mock';
+import { ProjectKanban } from './ProjectKanban';
+
+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) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/project/ui/boards/ProjectKanban.tsx b/src/pages/project/ui/boards/ProjectKanban.tsx
new file mode 100644
index 0000000..0de94d8
--- /dev/null
+++ b/src/pages/project/ui/boards/ProjectKanban.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { useState } from 'react';
+import { KanbanBoard, KanbanCard, KanbanCards, KanbanHeader, KanbanProvider } from 'shared/ui';
+import type { MockBoard, MockBoardCard } from '../../model/boards-mock';
+
+interface ProjectKanbanProps {
+ board: MockBoard;
+}
+
+export function ProjectKanban({ board }: ProjectKanbanProps) {
+ const [cards, setCards] = useState(board.cards);
+
+ return (
+
+ {(column) => (
+
+ {column.name}
+
+ {(item) => }
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/project/ui/settings/ProjectDangerZone.tsx b/src/pages/project/ui/settings/ProjectDangerZone.tsx
new file mode 100644
index 0000000..f7bb975
--- /dev/null
+++ b/src/pages/project/ui/settings/ProjectDangerZone.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { RemoveProjectDialog } from 'features/projects/remove';
+import { AlertTriangle } from 'lucide-react';
+import {
+ Button,
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemMedia,
+ ItemTitle,
+} from 'shared/ui';
+
+interface ProjectDangerZoneProps {
+ projectName: string;
+ teamSlug: string;
+ projectId: string;
+}
+
+export function ProjectDangerZone({ projectName, teamSlug, projectId }: ProjectDangerZoneProps) {
+ return (
+ -
+
+
+
+
+ Опасная зона
+
+ Безвозвратно удалить проект со всеми задачами и данными.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/project/ui/settings/ProjectSettingsPage.tsx b/src/pages/project/ui/settings/ProjectSettingsPage.tsx
new file mode 100644
index 0000000..52c0781
--- /dev/null
+++ b/src/pages/project/ui/settings/ProjectSettingsPage.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { ProjectIdentityFields, VisibilityPicker } from 'features/projects/create';
+import { useEffect } from 'react';
+import { Controller, FormProvider, useForm } from 'react-hook-form';
+import {
+ CardSection,
+ Field,
+ FieldError,
+ FieldLabel,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Skeleton,
+} from 'shared/ui';
+import { useTeamStore } from 'entities/team';
+import { useQueryProject } from '../../api/useQueryProject';
+import { ProjectSettingsFormSchema, type ProjectSettingsFormValues } from '../../model/settings';
+import { ProjectDangerZone } from './ProjectDangerZone';
+import { ProjectSettingsSaveBar } from './ProjectSettingsSaveBar';
+
+export function ProjectSettingsPage() {
+ const teamSlug = useTeamStore.use.slug();
+ const projectQuery = useQueryProject();
+ const project = projectQuery.data;
+
+ const form = useForm({
+ resolver: zodResolver(ProjectSettingsFormSchema),
+ mode: 'onChange',
+ defaultValues: {
+ name: '',
+ key: '',
+ description: '',
+ visibility: 'private',
+ status: 'active',
+ },
+ });
+
+ const { reset } = form;
+
+ useEffect(() => {
+ if (project) {
+ reset({
+ name: project.name,
+ key: project.key,
+ description: project.description ?? '',
+ icon: (project.visuals.icon ?? undefined) as ProjectSettingsFormValues['icon'],
+ color: project.visuals.color,
+ visibility: project.access.visibility,
+ status: project.status === 'archived' ? 'archived' : 'active',
+ });
+ }
+ }, [reset, project]);
+
+ if (projectQuery.isError) {
+ return (
+
+ Не удалось загрузить настройки проекта. Попробуйте обновить страницу.
+
+ );
+ }
+
+ if (projectQuery.isPending || !project) {
+ return (
+
+
+
+
+ );
+ }
+
+ const isTemplate = project.status === 'template';
+
+ return (
+
+
+ {project.access.canEdit ? : null}
+
+ );
+}
diff --git a/src/pages/project/ui/settings/ProjectSettingsSaveBar.tsx b/src/pages/project/ui/settings/ProjectSettingsSaveBar.tsx
new file mode 100644
index 0000000..04986ff
--- /dev/null
+++ b/src/pages/project/ui/settings/ProjectSettingsSaveBar.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { type TProject } from 'entities/project';
+import { useFormContext, useFormState } from 'react-hook-form';
+import { FloatingSaveBar } from 'shared/ui';
+import { useUpdateProject } from '../../api/useUpdateProject';
+import type { ProjectSettingsFormValues } from '../../model/settings';
+
+interface ProjectSettingsSaveBarProps {
+ project: TProject.ProjectDetailResponse;
+}
+
+export function ProjectSettingsSaveBar({ project }: ProjectSettingsSaveBarProps) {
+ const form = useFormContext();
+ const { isDirty, dirtyFields, isValidating, isValid } = useFormState({ control: form.control });
+
+ const updateProject = useUpdateProject({
+ onSuccess: () => {
+ form.reset(form.getValues());
+ },
+ });
+
+ const onSubmit = (data: ProjectSettingsFormValues) => {
+ const body: TProject.UpdateProjectBody = {
+ ...(dirtyFields.name && { name: data.name.trim() }),
+ ...(dirtyFields.key && { key: data.key.trim().toUpperCase() }),
+ ...(dirtyFields.description && {
+ description: data.description?.trim() ? data.description.trim() : null,
+ }),
+ ...(dirtyFields.icon && { icon: data.icon ?? null }),
+ ...(dirtyFields.color && { color: data.color }),
+ ...(dirtyFields.visibility && { visibility: data.visibility }),
+ ...(dirtyFields.status && { status: data.status }),
+ };
+
+ if (Object.keys(body).length === 0) {
+ return;
+ }
+
+ updateProject.mutate(body);
+ };
+
+ return (
+
+ form.reset({
+ name: project.name,
+ key: project.key,
+ description: project.description ?? '',
+ icon: (project.visuals.icon ?? undefined) as ProjectSettingsFormValues['icon'],
+ color: project.visuals.color,
+ visibility: project.access.visibility,
+ status: project.status === 'archived' ? 'archived' : 'active',
+ })
+ }
+ pending={updateProject.isPending || isValidating}
+ disabledSave={!isValid}
+ />
+ );
+}
diff --git a/src/pages/team/config/tabs.ts b/src/pages/team/config/tabs.ts
index 5e182f9..e2c1187 100644
--- a/src/pages/team/config/tabs.ts
+++ b/src/pages/team/config/tabs.ts
@@ -3,6 +3,7 @@ import { TabNavItem } from 'widgets/tabs-nav';
export const teamTabs: TabNavItem[] = [
{ key: routes.team.members(), label: 'Участники', badge: { value: '8', variant: 'default' } },
+ { key: routes.team.projects(), label: 'Проекты' },
{
key: routes.team.invitations(),
label: 'Приглашения',
diff --git a/src/pages/team/index.ts b/src/pages/team/index.ts
index 7eb16ac..df92b2b 100644
--- a/src/pages/team/index.ts
+++ b/src/pages/team/index.ts
@@ -1,4 +1,5 @@
export { MembersPage } from './ui/members/MembersPage';
+export { ProjectsPage } from './ui/projects/ProjectsPage';
export { InvitationsPage } from './ui/invitations/InvitationsPage';
export { RolesPage } from './ui/roles/RolesPage';
export { Settings } from './ui/settings/SettingsPage';
diff --git a/src/pages/team/ui/projects/ProjectCard.skeleton.tsx b/src/pages/team/ui/projects/ProjectCard.skeleton.tsx
new file mode 100644
index 0000000..90e5b61
--- /dev/null
+++ b/src/pages/team/ui/projects/ProjectCard.skeleton.tsx
@@ -0,0 +1,49 @@
+import { Skeleton } from 'shared/ui';
+
+export function ProjectCardSkeleton() {
+ return (
+
+ );
+}
diff --git a/src/pages/team/ui/projects/ProjectCard.tsx b/src/pages/team/ui/projects/ProjectCard.tsx
new file mode 100644
index 0000000..202582c
--- /dev/null
+++ b/src/pages/team/ui/projects/ProjectCard.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { projectIconCodeToEmoji, TProject } from 'entities/project';
+import { useTeamStore } from 'entities/team';
+import { ArchiveProjectDialog, RestoreProjectDialog } from 'features/projects/archive';
+import { RemoveProjectDialog } from 'features/projects/remove';
+import { ShareProjectDialog } from 'features/projects/share';
+import { CalendarDays, KeyRound, MoreHorizontal, ShieldCheck, ShieldX, Users } from 'lucide-react';
+import Link from 'next/link';
+import { ComponentProps } from 'react';
+import { routes } from 'shared/config';
+import { cn, formatDate } from 'shared/lib/utils';
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Progress,
+} from 'shared/ui';
+
+export type ProjectCardProps = ComponentProps & {
+ project?: TProject.ProjectListItemResponse;
+ name?: string;
+ description?: string;
+ statusLabel?: string;
+};
+
+const statusLabels: Record = {
+ active: 'Активен',
+ archived: 'В архиве',
+ template: 'Шаблон',
+};
+
+export function ProjectCard({
+ className,
+ project,
+ name: nameProp,
+ description: descriptionProp,
+ statusLabel: statusLabelProp,
+ ...props
+}: ProjectCardProps) {
+ const teamSlug = useTeamStore.use.slug();
+ const name = nameProp ?? project?.name ?? 'Atlas Platform';
+ const description =
+ descriptionProp ?? (project ? `Ключ проекта: ${project.key}` : 'Core team workspace.');
+ const statusLabel = statusLabelProp ?? (project ? statusLabels[project.status] : 'On Track');
+ const iconEmoji = project ? projectIconCodeToEmoji(project.icon) : null;
+ const iconColor = project?.color;
+ const createdAtLabel = project ? formatDate(project.createdAt).split(',')[0] : null;
+
+ // Deterministic mock data for visual flair
+ const mockProgress = project ? (project.id.charCodeAt(0) % 60) + 30 : 75;
+ const mockMembersCount = project ? (project.id.charCodeAt(1) % 3) + 2 : 3;
+ const mockMembers = Array.from({ length: mockMembersCount }).map((_, i) => i + 1);
+
+ const projectHref = project && teamSlug ? routes.team.project.root(project.id) : null;
+
+ const card = (
+
+ {projectHref && (
+
+ )}
+
+
+
+
+
+ {iconEmoji ? (
+ {iconEmoji}
+ ) : (
+ {name.slice(0, 1).toUpperCase()}
+ )}
+
+
+
+ {name}
+
+
+
+ {project?.status !== 'archived' && (
+
+ )}
+ {statusLabel}
+
+ {project?.key && (
+
+
+ {project.key}
+
+ )}
+
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+
+ e.preventDefault()}>Поделиться
+
+ {project?.status === 'archived' ? (
+
+ e.preventDefault()}>
+ Восстановить
+
+
+ ) : (
+ project?.status !== 'template' && (
+
+ e.preventDefault()}>
+ Архивировать
+
+
+ )
+ )}
+
+ e.preventDefault()}>
+ Удалить
+
+
+
+
+
+
+
+
+
+ {description}
+
+
+
+
+ Прогресс
+ {mockProgress}%
+
+
+
+
+
+
+
+
+ {mockMembers.map((i) => (
+
+
+
+ U{i}
+
+
+ ))}
+
+
+
+
+
+
+
+ {project?.canEdit ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ {createdAtLabel && (
+
+
+ {createdAtLabel}
+
+ )}
+
+
+
+ );
+
+ return card;
+}
diff --git a/src/pages/team/ui/projects/ProjectsEmpty.tsx b/src/pages/team/ui/projects/ProjectsEmpty.tsx
new file mode 100644
index 0000000..d5647d4
--- /dev/null
+++ b/src/pages/team/ui/projects/ProjectsEmpty.tsx
@@ -0,0 +1,32 @@
+import { CreateProjectDialog } from 'features/projects/create';
+import { FolderKanban } from 'lucide-react';
+import {
+ Button,
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from 'shared/ui';
+
+export function ProjectsEmpty() {
+ return (
+
+
+
+
+
+ Нет проектов
+
+ Создайте первый проект команды, чтобы он появился в этом списке.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/team/ui/projects/ProjectsPage.tsx b/src/pages/team/ui/projects/ProjectsPage.tsx
new file mode 100644
index 0000000..75799f9
--- /dev/null
+++ b/src/pages/team/ui/projects/ProjectsPage.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { useQuery } from '@tanstack/react-query';
+import { ProjectQueries } from 'entities/project';
+import { useTeamStore } from 'entities/team';
+import { CreateProjectDialog } from 'features/projects/create';
+import { Plus } from 'lucide-react';
+import { Button } from 'shared/ui';
+import { ProjectCard } from './ProjectCard';
+import { ProjectCardSkeleton } from './ProjectCard.skeleton';
+import { ProjectsEmpty } from './ProjectsEmpty';
+
+export function ProjectsPage() {
+ const slug = useTeamStore.use.slug();
+ const { data, isPending } = useQuery({
+ ...ProjectQueries.getProjects(slug!),
+ enabled: !!slug,
+ });
+
+ if (!isPending && !data?.items.length) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {isPending
+ ? Array.from({ length: 8 }).map((_, i) =>
)
+ : data?.items.map((project) => (
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/pages/team/ui/roles/RolesPage.tsx b/src/pages/team/ui/roles/RolesPage.tsx
index f7856f1..7fbbeab 100644
--- a/src/pages/team/ui/roles/RolesPage.tsx
+++ b/src/pages/team/ui/roles/RolesPage.tsx
@@ -3,8 +3,8 @@
import { useState } from 'react';
import { FloatingSaveBar } from 'shared/ui';
import { DEFAULTS, RoleKey } from '../../model/roles-mock';
-import { RolesList } from 'pages/team/ui/roles/RolesList';
-import { Permissions } from 'pages/team/ui/roles/Permissions';
+import { RolesList } from './RolesList';
+import { Permissions } from './Permissions';
export function RolesPage() {
const [matrix, setMatrix] = useState(DEFAULTS);
diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts
index 39ec289..acd41c5 100644
--- a/src/shared/api/index.ts
+++ b/src/shared/api/index.ts
@@ -5,6 +5,6 @@ export {
extractValidationIssues,
type ValidationIssue,
} from './validation';
-export { GlobalSuccess, GlobalError } from './schemas';
+export { GlobalSuccess, GlobalError, DateTimeString } from './schemas';
export { AccessToken } from './token';
export { queryClient } from './query-client';
diff --git a/src/shared/api/schemas/date-time-string.ts b/src/shared/api/schemas/date-time-string.ts
new file mode 100644
index 0000000..c941d73
--- /dev/null
+++ b/src/shared/api/schemas/date-time-string.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod/v4';
+
+const INVALID_DATE_MESSAGE = 'Строка не является валидной датой';
+
+export const DateTimeString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), {
+ message: INVALID_DATE_MESSAGE,
+});
diff --git a/src/shared/api/schemas/index.ts b/src/shared/api/schemas/index.ts
index 8c9726b..ace9de0 100644
--- a/src/shared/api/schemas/index.ts
+++ b/src/shared/api/schemas/index.ts
@@ -1,2 +1,3 @@
export { GlobalSuccess } from './global-success';
export { GlobalError } from './global-error';
+export { DateTimeString } from './date-time-string';
diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts
index f816f78..f4fb8e8 100644
--- a/src/shared/config/routes.ts
+++ b/src/shared/config/routes.ts
@@ -15,6 +15,11 @@ export const routes = {
invitations: (): Route => '/team/invitations',
roles: (): Route => '/team/roles',
settings: (): Route => '/team/settings',
+ projects: (): Route => '/team/projects',
+ project: {
+ root: (projectId: string): Route => `/team/projects/${projectId}` as Route,
+ settings: (projectId: string): Route => `/team/projects/${projectId}/settings` as Route,
+ },
},
auth: {
signin: (): Route => '/signin',
diff --git a/src/shared/ui/Kanban.tsx b/src/shared/ui/Kanban.tsx
new file mode 100644
index 0000000..59dcd0d
--- /dev/null
+++ b/src/shared/ui/Kanban.tsx
@@ -0,0 +1,322 @@
+'use client';
+
+import type {
+ Announcements,
+ DndContextProps,
+ DragEndEvent,
+ DragOverEvent,
+ DragStartEvent,
+} from '@dnd-kit/core';
+import {
+ closestCenter,
+ DndContext,
+ DragOverlay,
+ KeyboardSensor,
+ MouseSensor,
+ TouchSensor,
+ useDroppable,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { createContext, type HTMLAttributes, type ReactNode, useContext, useState } from 'react';
+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,
+});
+
+export type KanbanBoardProps = {
+ id: string;
+ children: ReactNode;
+ className?: string;
+};
+
+export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
+ const { isOver, setNodeRef } = useDroppable({
+ id,
+ });
+
+ return (
+
+ {children}
+
+ );
+};
+
+export type KanbanCardProps = T & {
+ children?: ReactNode;
+ className?: string;
+};
+
+export const KanbanCard = ({
+ id,
+ name,
+ children,
+ className,
+}: KanbanCardProps) => {
+ const { attributes, listeners, setNodeRef, transition, transform, isDragging } = useSortable({
+ id,
+ });
+ const { activeCardId } = useContext(KanbanContext) as KanbanContextProps;
+
+ const style = {
+ transition,
+ transform: CSS.Transform.toString(transform),
+ };
+
+ return (
+ <>
+
+
+ {children ?? {name}
}
+
+
+ {activeCardId === id && (
+
+
+ {children ?? {name}
}
+
+
+ )}
+ >
+ );
+};
+
+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);
+
+ return (
+
+
+
+ {filteredData.map(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 const KanbanProvider = <
+ T extends KanbanItemProps = KanbanItemProps,
+ C extends KanbanColumnProps = KanbanColumnProps,
+>({
+ children,
+ onDragStart,
+ onDragEnd,
+ onDragOver,
+ className,
+ columns,
+ data,
+ onDataChange,
+ ...props
+}: KanbanProviderProps) => {
+ const [activeCardId, setActiveCardId] = useState(null);
+
+ 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 handleDragOver = (event: DragOverEvent) => {
+ const { active, over } = event;
+
+ if (!over) {
+ return;
+ }
+
+ const activeItem = data.find((item) => item.id === active.id);
+ const overItem = data.find((item) => item.id === over.id);
+
+ if (!activeItem) {
+ return;
+ }
+
+ const activeColumn = activeItem.column;
+ const overColumn =
+ overItem?.column || columns.find((col) => col.id === over.id)?.id || columns[0]?.id;
+
+ if (activeColumn !== overColumn) {
+ let newData = [...data];
+ const activeIndex = newData.findIndex((item) => item.id === active.id);
+ const overIndex = newData.findIndex((item) => item.id === over.id);
+
+ newData[activeIndex].column = overColumn;
+ newData = arrayMove(newData, activeIndex, overIndex);
+
+ onDataChange?.(newData);
+ }
+
+ onDragOver?.(event);
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ setActiveCardId(null);
+
+ onDragEnd?.(event);
+
+ const { active, over } = event;
+
+ if (!over || active.id === over.id) {
+ return;
+ }
+
+ let newData = [...data];
+
+ const oldIndex = newData.findIndex((item) => item.id === active.id);
+ const newIndex = newData.findIndex((item) => item.id === over.id);
+
+ newData = arrayMove(newData, oldIndex, newIndex);
+
+ onDataChange?.(newData);
+ };
+
+ const announcements: Announcements = {
+ onDragStart({ active }) {
+ const { name, column } = data.find((item) => item.id === active.id) ?? {};
+
+ 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;
+
+ 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;
+
+ return `Dropped the card "${name}" into the "${newColumn}" column`;
+ },
+ onDragCancel({ active }) {
+ const { name } = data.find((item) => item.id === active.id) ?? {};
+
+ return `Cancelled dragging the card "${name}"`;
+ },
+ };
+
+ return (
+
+
+
+ {columns.map((column) => children(column))}
+
+ {typeof window !== 'undefined' &&
+ createPortal(
+
+
+ ,
+ document.body
+ )}
+
+
+ );
+};
diff --git a/src/shared/ui/Popover.tsx b/src/shared/ui/Popover.tsx
new file mode 100644
index 0000000..b030631
--- /dev/null
+++ b/src/shared/ui/Popover.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import * as React from 'react';
+import { Popover as PopoverPrimitive } from 'radix-ui';
+
+import { cn } from 'shared/lib/utils';
+
+function Popover({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function PopoverTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function PopoverAnchor({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function PopoverContent({
+ className,
+ align = 'center',
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
diff --git a/src/shared/ui/ScrollArea.tsx b/src/shared/ui/ScrollArea.tsx
new file mode 100644
index 0000000..f5cd887
--- /dev/null
+++ b/src/shared/ui/ScrollArea.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
+
+import { cn } from 'shared/lib/utils';
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 761f1d8..8a9ae79 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -30,9 +30,12 @@ export * from './Kbd';
export * from './Progress';
export * from './floating-save-bar/FloatingSaveBar';
export * from './Dialog';
+export * from './Popover';
export * from './RadioGroup';
export * from './search/Search';
export * from './option-group/OptionGroup';
export * from './card-section/CardSection';
export * from './Select';
export * from './Empty';
+export * from './ScrollArea';
+export * from './Kanban';
diff --git a/src/shared/ui/sidebar/Sidebar.tsx b/src/shared/ui/sidebar/Sidebar.tsx
index 8ee6b5c..3069e61 100644
--- a/src/shared/ui/sidebar/Sidebar.tsx
+++ b/src/shared/ui/sidebar/Sidebar.tsx
@@ -1,8 +1,9 @@
'use client';
-import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
+import { PanelLeftIcon } from 'lucide-react';
import { Slot } from 'radix-ui';
+import * as React from 'react';
import { useIsMobile } from 'shared/lib/hooks';
import { cn } from 'shared/lib/utils';
import { Button } from '../button/Button';
@@ -19,7 +20,6 @@ import {
SIDEBAR_WIDTH_ICON,
SIDEBAR_WIDTH_MOBILE,
} from './const';
-import { PanelLeftIcon } from 'lucide-react';
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
@@ -289,7 +289,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
query.data ?? [], [query.data]);
+ const teams = useMemo(() => query.data?.items ?? [], [query.data]);
const { switchTeam } = useSwitchTeam({ teams });
diff --git a/src/widgets/app-sidebar/ui/AppSidebar.tsx b/src/widgets/app-sidebar/ui/AppSidebar.tsx
index 69d6f79..5d0c81a 100644
--- a/src/widgets/app-sidebar/ui/AppSidebar.tsx
+++ b/src/widgets/app-sidebar/ui/AppSidebar.tsx
@@ -8,6 +8,7 @@ import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
+ Separator,
Sidebar,
SidebarContent,
SidebarFooter,
@@ -24,6 +25,7 @@ import {
} from 'shared/ui';
import { NavUser } from './NavUser';
import { TeamsDropdown } from './teams/TeamsDropdown';
+import { Projects } from './Projects';
const team = [
{
@@ -46,7 +48,7 @@ export function AppSidebar({ ...props }: Omit
-
+
@@ -86,6 +88,8 @@ export function AppSidebar({ ...props }: Omit
+
+
diff --git a/src/widgets/app-sidebar/ui/Projects.tsx b/src/widgets/app-sidebar/ui/Projects.tsx
new file mode 100644
index 0000000..2a13373
--- /dev/null
+++ b/src/widgets/app-sidebar/ui/Projects.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import { useQuery } from '@tanstack/react-query';
+import { projectIconCodeToEmoji, ProjectQueries } from 'entities/project';
+import { useTeamStore } from 'entities/team';
+import { ArchiveProjectDialog, RestoreProjectDialog } from 'features/projects/archive';
+import { CreateProjectDialog } from 'features/projects/create';
+import { ShareProjectDialog } from 'features/projects/share';
+import { Archive, Link2, MoreHorizontal, Plus } from 'lucide-react';
+import Link from 'next/link';
+import { useState } from 'react';
+import { routes } from 'shared/config';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from 'shared/ui';
+
+export function Projects() {
+ const slug = useTeamStore.use.slug();
+ const { isMobile } = useSidebar();
+ const [createProjectOpen, setCreateProjectOpen] = useState(false);
+ const projects = useQuery({ ...ProjectQueries.getProjects(slug!), enabled: !!slug });
+
+ if (!projects.data) {
+ return null;
+ }
+
+ return (
+ <>
+
+ Проекты
+ setCreateProjectOpen(true)}
+ >
+
+
+
+ {projects.data.items.map((project) => {
+ const canManage = Boolean(slug && project.canEdit);
+
+ return (
+
+
+
+ {projectIconCodeToEmoji(project.icon)}
+ {project.name}
+
+
+
+
+
+
+ Действия с проектом
+
+
+
+
+ e.preventDefault()}>
+
+ Создать публичную ссылку
+
+
+ {project.status === 'archived' ? (
+
+ e.preventDefault()}>
+
+ Восстановить
+
+
+ ) : (
+ project.status !== 'template' && (
+
+ e.preventDefault()}>
+
+ Архивировать
+
+
+ )
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+ Больше
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx b/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx
index 6fced57..44ed0fa 100644
--- a/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx
+++ b/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx
@@ -1,13 +1,13 @@
-import { TUser } from 'entities/user';
import { UseQueryResult } from '@tanstack/react-query';
-import { TeamItem } from './TeamItem';
-import { TeamItemSkeleton } from './TeamItem.skeleton';
+import { useTeamStore } from 'entities/team';
+import { TUser } from 'entities/user';
import { ChevronsUpDown } from 'lucide-react';
import { useMemo } from 'react';
-import { useTeamStore } from 'entities/team';
+import { TeamItem } from './TeamItem';
+import { TeamItemSkeleton } from './TeamItem.skeleton';
interface TeamTriggerProps {
- query: UseQueryResult;
+ query: UseQueryResult;
}
export function TeamTrigger({ query }: TeamTriggerProps) {
@@ -15,7 +15,7 @@ export function TeamTrigger({ query }: TeamTriggerProps) {
const activeTeam = useMemo(() => {
if (query.data) {
- return query.data.find((d) => d.slug === slug);
+ return query.data.items.find((d) => d.slug === slug);
}
}, [slug, query.data]);
diff --git a/src/widgets/page-layout/ui/PageLayout.tsx b/src/widgets/page-layout/ui/PageLayout.tsx
index 5b9726d..cd86624 100644
--- a/src/widgets/page-layout/ui/PageLayout.tsx
+++ b/src/widgets/page-layout/ui/PageLayout.tsx
@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
interface PageLayoutProps {
- title: string;
+ title: string | ReactNode;
description?: string;
badge?: ReactNode;
headerSlot?: ReactNode;
@@ -32,7 +32,7 @@ export function PageLayout({
{nav && {nav}
}
-
+
);