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 ( + + + + + Новый проект + + Название и ключ для задач команды. Иконку и цвет можно изменить по желанию. + + + + { + setPending(true); + }, + onSuccess: () => { + setOpen(false); + }, + onSettled: () => { + setPending(false); + }, + }} + /> + + + + + + + + + + ); +} 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 && } + + )} + /> + ( + + Описание +