From 3b48960dec534dbc92b7c5c4bf1c98bfe6dcf073 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Sun, 24 May 2026 21:43:04 +0300 Subject: [PATCH 01/14] refactor(entities): extract project entity from team --- src/entities/project/api/http.ts | 84 +++++++++++++++++++++++++++ src/entities/project/api/queries.ts | 21 +++++++ src/entities/project/config/colors.ts | 14 +++++ src/entities/project/config/icons.ts | 42 ++++++++++++++ src/entities/project/index.ts | 8 +++ src/entities/project/lib/emoji.ts | 19 ++++++ src/entities/project/model/const.ts | 6 ++ src/entities/project/model/schemas.ts | 82 ++++++++++++++++++++++++++ src/entities/project/model/types.ts | 12 ++++ src/entities/team/api/http.ts | 79 ------------------------- src/entities/team/api/queries.ts | 16 ----- src/entities/team/model/const.ts | 2 - src/entities/team/model/schemas.ts | 77 ------------------------ src/entities/team/model/types.ts | 9 --- 14 files changed, 288 insertions(+), 183 deletions(-) create mode 100644 src/entities/project/api/http.ts create mode 100644 src/entities/project/api/queries.ts create mode 100644 src/entities/project/config/colors.ts create mode 100644 src/entities/project/config/icons.ts create mode 100644 src/entities/project/index.ts create mode 100644 src/entities/project/lib/emoji.ts create mode 100644 src/entities/project/model/const.ts create mode 100644 src/entities/project/model/schemas.ts create mode 100644 src/entities/project/model/types.ts diff --git a/src/entities/project/api/http.ts b/src/entities/project/api/http.ts new file mode 100644 index 0000000..1e2053c --- /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.ActionResponse, + }, + }); + } +} 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..ad05ad2 --- /dev/null +++ b/src/entities/project/index.ts @@ -0,0 +1,8 @@ +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'; 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/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..f99d278 --- /dev/null +++ b/src/entities/project/model/schemas.ts @@ -0,0 +1,82 @@ +import { 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: 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/project/model/types.ts b/src/entities/project/model/types.ts new file mode 100644 index 0000000..04e6109 --- /dev/null +++ b/src/entities/project/model/types.ts @@ -0,0 +1,12 @@ +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 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..d60f749 100644 --- a/src/entities/team/model/schemas.ts +++ b/src/entities/team/model/schemas.ts @@ -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; From 6cef0c63032a1f7709572de85e60b7400e8770fe Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Sun, 24 May 2026 21:46:04 +0300 Subject: [PATCH 02/14] feat(projects): add create project feature --- src/features/projects/create/index.ts | 2 + .../projects/create/model/default-values.ts | 17 +++ src/features/projects/create/model/schemas.ts | 3 + src/features/projects/create/model/types.ts | 4 + .../projects/create/model/useCreateProject.ts | 28 ++++ .../create/model/useCreateProjectForm.ts | 52 ++++++++ .../create/ui/CreateProjectDialog.tsx | 72 +++++++++++ .../projects/create/ui/CreateProjectForm.tsx | 121 ++++++++++++++++++ .../projects/create/ui/ProjectColorPicker.tsx | 64 +++++++++ .../projects/create/ui/ProjectIconPicker.tsx | 94 ++++++++++++++ .../projects/create/ui/VisibilityPicker.tsx | 48 +++++++ 11 files changed, 505 insertions(+) create mode 100644 src/features/projects/create/index.ts create mode 100644 src/features/projects/create/model/default-values.ts create mode 100644 src/features/projects/create/model/schemas.ts create mode 100644 src/features/projects/create/model/types.ts create mode 100644 src/features/projects/create/model/useCreateProject.ts create mode 100644 src/features/projects/create/model/useCreateProjectForm.ts create mode 100644 src/features/projects/create/ui/CreateProjectDialog.tsx create mode 100644 src/features/projects/create/ui/CreateProjectForm.tsx create mode 100644 src/features/projects/create/ui/ProjectColorPicker.tsx create mode 100644 src/features/projects/create/ui/ProjectIconPicker.tsx create mode 100644 src/features/projects/create/ui/VisibilityPicker.tsx diff --git a/src/features/projects/create/index.ts b/src/features/projects/create/index.ts new file mode 100644 index 0000000..1de2b53 --- /dev/null +++ b/src/features/projects/create/index.ts @@ -0,0 +1,2 @@ +export { CreateProjectForm } from './ui/CreateProjectForm'; +export { CreateProjectDialog } from './ui/CreateProjectDialog'; 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..5976acc --- /dev/null +++ b/src/features/projects/create/model/types.ts @@ -0,0 +1,4 @@ +import { z } from 'zod/v4'; +import { CreateProjectFormSchema } from './schemas'; + +export type CreateProjectFormValues = z.input; 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..cf5d632 --- /dev/null +++ b/src/features/projects/create/ui/CreateProjectForm.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { ComponentProps } from 'react'; +import { Controller, FormProvider } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { + Field, + FieldError, + FieldGroup, + FieldLabel, + Input, + InputGroup, + InputGroupInput, + Textarea, +} from 'shared/ui'; +import type { UseCreateProjectOptions } from '../model/useCreateProject'; +import { useCreateProjectForm } from '../model/useCreateProjectForm'; +import { ProjectColorPicker } from './ProjectColorPicker'; +import { ProjectIconPicker } from './ProjectIconPicker'; +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); + 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="create-project-key" + aria-label="Ключ проекта" + placeholder="PROJ" + aria-required="true" + aria-invalid={fieldState.invalid} + autoComplete="off" + disabled={isPending} + /> + + {fieldState.invalid && } + + )} + /> + ( + + Описание +