From e4cff96c64633164ce3b8d25e71ec5ef7da0ae3c Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Tue, 5 May 2026 14:44:29 +0300 Subject: [PATCH 01/20] feat(team): add team entity API client, queries, and validation schemas --- src/entities/team/api/http.ts | 285 +++++++++++++++++++++++++++++ src/entities/team/api/queries.ts | 68 +++++++ src/entities/team/index.ts | 5 + src/entities/team/model/const.ts | 11 ++ src/entities/team/model/schemas.ts | 206 +++++++++++++++++++++ src/entities/team/model/types.ts | 29 +++ 6 files changed, 604 insertions(+) create mode 100644 src/entities/team/api/http.ts create mode 100644 src/entities/team/api/queries.ts create mode 100644 src/entities/team/index.ts create mode 100644 src/entities/team/model/const.ts create mode 100644 src/entities/team/model/schemas.ts create mode 100644 src/entities/team/model/types.ts diff --git a/src/entities/team/api/http.ts b/src/entities/team/api/http.ts new file mode 100644 index 0000000..db90dd8 --- /dev/null +++ b/src/entities/team/api/http.ts @@ -0,0 +1,285 @@ +import { api } from 'shared/api'; +import * as STeam from '../model/schemas'; +import * as TTeam from '../model/types'; + +export class TeamHttp { + static createTeam(data: TTeam.CreateTeamBody) { + return api({ + url: '/teams', + method: 'POST', + data, + contracts: { + body: STeam.CreateTeamBody, + response: STeam.ActionResponse, + }, + }); + } + + static checkSlug(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/check-slug/${slug}`, + method: 'GET', + contracts: { + response: STeam.CheckSlugResponse, + }, + signal, + }); + } + + static getTeam(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}`, + method: 'GET', + contracts: { + response: STeam.TeamDetailsResponse, + }, + signal, + }); + } + + static updateTeam(slug: string, data: TTeam.UpdateTeamBody) { + return api({ + url: `/teams/${slug}`, + method: 'PATCH', + data, + contracts: { + body: STeam.UpdateTeamBody, + response: STeam.ActionResponse, + }, + }); + } + + static removeTeam(slug: string) { + return api({ + url: `/teams/${slug}`, + method: 'DELETE', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static getInvitations(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}/invitations`, + method: 'GET', + contracts: { + response: STeam.TeamInvitationResponse.array(), + }, + signal, + }); + } + + static getInvitation(slug: string, code: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}/invitations/${code}`, + method: 'GET', + contracts: { + response: STeam.TeamInvitationResponse, + }, + signal, + }); + } + + static inviteMember(slug: string, data: TTeam.InviteMemberBody) { + return api({ + url: `/teams/${slug}/invitations`, + method: 'POST', + data, + contracts: { + body: STeam.InviteMemberBody, + response: STeam.ActionResponse, + }, + }); + } + + static acceptInvitation(slug: string, code: string) { + return api({ + url: `/teams/${slug}/invitations/${code}/accept`, + method: 'POST', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static updateInvitation(slug: string, code: string, data: TTeam.UpdateInvitationBody) { + return api({ + url: `/teams/${slug}/invitations/${code}`, + method: 'PATCH', + data, + contracts: { + body: STeam.UpdateInvitationBody, + response: STeam.TeamInvitationResponse, + }, + }); + } + + static removeInvitation(slug: string, code: string) { + return api({ + url: `/teams/${slug}/invitations/${code}`, + method: 'DELETE', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static getMembers(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}/members`, + method: 'GET', + contracts: { + response: STeam.TeamMemberResponse.array(), + }, + signal, + }); + } + + static updateMember(slug: string, userId: string, data: TTeam.UpdateMemberBody) { + return api({ + url: `/teams/${slug}/members/${userId}`, + method: 'PATCH', + data, + contracts: { + body: STeam.UpdateMemberBody, + response: STeam.ActionResponse, + }, + }); + } + + static removeMember(slug: string, userId: string) { + return api({ + url: `/teams/${slug}/members/${userId}`, + method: 'DELETE', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static syncTags(slug: string, data: TTeam.SyncTagsBody) { + return api({ + url: `/teams/${slug}/tags`, + method: 'PUT', + data, + contracts: { + body: STeam.SyncTagsBody, + response: STeam.ActionResponse, + }, + }); + } + + static updateAvatar(slug: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + return api({ + url: `/teams/${slug}/avatar`, + method: 'PATCH', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + contracts: { + response: STeam.FileUploadResponse, + }, + }); + } + + static updateBanner(slug: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + return api({ + url: `/teams/${slug}/banner`, + method: 'PATCH', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + contracts: { + response: STeam.FileUploadResponse, + }, + }); + } + + 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 new file mode 100644 index 0000000..2a5b3f8 --- /dev/null +++ b/src/entities/team/api/queries.ts @@ -0,0 +1,68 @@ +import { queryOptions } from '@tanstack/react-query'; +import { teamFabricKeys } from '../model/const'; +import { TeamHttp } from './http'; + +export class TeamQueries { + static getTeam(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.bySlug(slug), + queryFn: async ({ signal }) => TeamHttp.getTeam(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static checkSlug(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.checkSlug(slug), + queryFn: async ({ signal }) => TeamHttp.checkSlug(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getInvitation(slug: string, code: string) { + return queryOptions({ + queryKey: teamFabricKeys.invitation(slug, code), + queryFn: async ({ signal }) => TeamHttp.getInvitation(slug, code, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getInvitations(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.invitations(slug), + queryFn: async ({ signal }) => TeamHttp.getInvitations(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getMembers(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.members(slug), + queryFn: async ({ signal }) => TeamHttp.getMembers(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getProjects(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.projects(slug), + queryFn: async ({ signal }) => TeamHttp.getProjects(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + 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, + refetchOnMount: false, + }); + } +} diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts new file mode 100644 index 0000000..ca8b6fa --- /dev/null +++ b/src/entities/team/index.ts @@ -0,0 +1,5 @@ +export * as STeam from './model/schemas'; +export * as TTeam from './model/types'; +export { TeamHttp } from './api/http'; +export { TeamQueries } from './api/queries'; +export { teamFabricKeys } from './model/const'; diff --git a/src/entities/team/model/const.ts b/src/entities/team/model/const.ts new file mode 100644 index 0000000..d08a8d8 --- /dev/null +++ b/src/entities/team/model/const.ts @@ -0,0 +1,11 @@ +import { createEntityKeys } from 'shared/lib/utils'; + +export const teamFabricKeys = createEntityKeys('team', { + bySlug: (slug: string) => ['teams', slug], + checkSlug: (slug: string) => ['teams', 'check-slug', slug], + 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 new file mode 100644 index 0000000..f3602e4 --- /dev/null +++ b/src/entities/team/model/schemas.ts @@ -0,0 +1,206 @@ +import { z } from 'zod/v4'; +import { GlobalSuccess } from 'shared/api'; + +export const TeamRole = z.enum([ + 'owner', + 'admin', // управление юзерами, настройками + 'lead', // управление проектами + 'moderator', // чистка контента/сообщений + 'member', // обычный работяга + 'viewer', // просто смотрит +]); +export const MemberStatus = z.enum([ + 'active', // Полноценный участник + 'banned', // Заблокирован не может вернуться по инвайту + 'inactive', // Доступ закрыт, но запись сохранена +]); + +export const CreateTeamBody = z.object({ + name: z.string().min(2).max(100), + description: z.string().min(10).max(500), + slug: z.string().optional(), + tags: z + .array(z.string()) + .optional() + .superRefine((items, ctx) => { + if (!items) return; + const hasDuplicates = new Set(items.map((item) => item.toLowerCase())).size !== items.length; + if (hasDuplicates) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Теги в списке не должны повторяться (регистр не важен)', + }); + } + }), +}); + +export const UpdateTeamBody = CreateTeamBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const CheckSlugResponse = z.object({ + available: z.boolean(), + message: z.string().optional(), +}); + +export const TeamDetailsResponse = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string().nullable(), + avatarUrl: z.string().nullable(), + coverUrl: z.string().nullable(), + ownerId: z.string().nullable(), + createdAt: z.iso.datetime({}), + updatedAt: z.iso.datetime({}), + deletedAt: z.iso.datetime({}).nullable(), +}); + +export const TeamInvitationResponse = z.object({ + code: z.string(), + teamId: z.string(), + teamName: z.string(), + teamAvatar: z.string().nullable(), + email: z.email(), + role: TeamRole, + inviterId: z.string(), + inviterName: z.string(), + createdAt: z.iso.datetime({}), + expiresAt: z.iso.datetime({}), +}); + +export const InviteMemberBody = z.object({ + email: z.email(), + role: TeamRole.default('member'), +}); + +export const UpdateInvitationBody = z.object({ + role: TeamRole, +}); + +export const TeamMemberResponse = z.object({ + id: z.string(), + role: TeamRole, + status: MemberStatus, + fullName: z.string(), + firstName: z.string(), + lastName: z.string(), + avatarUrl: z.url().nullable(), + initials: z.string().max(2), + joinedAt: z.iso.datetime({}), +}); + +export const UpdateMemberBody = z + .object({ + role: TeamRole.optional(), + status: MemberStatus.optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }); + +export const SyncTagsBody = z.object({ + tags: z + .array(z.string()) + .min(1, 'Список тегов не может быть пустым') + .max(15, 'Нельзя добавить более 15 тегов за раз') + .superRefine((items, ctx) => { + const hasDuplicates = new Set(items.map((item) => item.toLowerCase())).size !== items.length; + if (hasDuplicates) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Теги в списке не должны повторяться (регистр не важен)', + }); + } + }), +}); + +export const FileUploadResponse = z.object({ + success: z.boolean(), + url: z.string(), + message: z.string().optional(), +}); + +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 new file mode 100644 index 0000000..55cfafe --- /dev/null +++ b/src/entities/team/model/types.ts @@ -0,0 +1,29 @@ +import { z } from 'zod/v4'; +import * as STeam from './schemas'; + +export type TeamRole = z.infer; +export type MemberStatus = z.infer; + +export type CreateTeamBody = z.infer; +export type UpdateTeamBody = z.infer; +export type CheckSlugResponse = z.infer; +export type TeamDetailsResponse = z.infer; + +export type TeamInvitationResponse = z.infer; +export type TeamMemberResponse = z.infer; + +export type InviteMemberBody = z.infer; +export type UpdateInvitationBody = z.infer; +export type UpdateMemberBody = z.infer; +export type SyncTagsBody = z.infer; +export type FileUploadResponse = 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 52f65b356c7fb09dcc0b76eca476b948bdbee008 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Tue, 5 May 2026 17:55:01 +0300 Subject: [PATCH 02/20] feat(user): update user entity API client, queries, and validation schemas --- src/entities/user/api/http.ts | 22 ++++++++++++++++++++++ src/entities/user/api/queries.ts | 18 ++++++++++++++++++ src/entities/user/model/const.ts | 2 ++ src/entities/user/model/schemas.ts | 28 ++++++++++++++++++++++++++++ src/entities/user/model/types.ts | 2 ++ 5 files changed, 72 insertions(+) diff --git a/src/entities/user/api/http.ts b/src/entities/user/api/http.ts index 30960bd..b3c4682 100644 --- a/src/entities/user/api/http.ts +++ b/src/entities/user/api/http.ts @@ -65,4 +65,26 @@ export class UserHttp { }, }); } + + static getMyTeams(signal?: AbortSignal) { + return api({ + url: '/users/me/teams', + method: 'GET', + contracts: { + response: SUser.UserTeamResponse.array(), + }, + signal, + }); + } + + static getMyInvites(signal?: AbortSignal) { + return api({ + url: '/users/me/invites', + method: 'GET', + contracts: { + response: SUser.UserInviteResponse.array(), + }, + signal, + }); + } } diff --git a/src/entities/user/api/queries.ts b/src/entities/user/api/queries.ts index d6d2b56..82dd123 100644 --- a/src/entities/user/api/queries.ts +++ b/src/entities/user/api/queries.ts @@ -20,4 +20,22 @@ export class UserQueries { refetchOnMount: false, }); } + + static getMyTeams() { + return queryOptions({ + queryKey: userFabricKeys.myTeams(), + queryFn: async ({ signal }) => UserHttp.getMyTeams(signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getMyInvites() { + return queryOptions({ + queryKey: userFabricKeys.myInvites(), + queryFn: async ({ signal }) => UserHttp.getMyInvites(signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } } diff --git a/src/entities/user/model/const.ts b/src/entities/user/model/const.ts index 145fd66..79da620 100644 --- a/src/entities/user/model/const.ts +++ b/src/entities/user/model/const.ts @@ -3,4 +3,6 @@ import { createEntityKeys } from 'shared/lib/utils'; export const userFabricKeys = createEntityKeys('user', { me: () => ['users', 'me'], meActivity: () => ['users', 'me', 'activity'], + myTeams: () => ['users', 'me', 'teams'], + myInvites: () => ['users', 'me', 'invites'], }); diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index 7847f74..a3273b2 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -62,3 +62,31 @@ export const ProfileUpdateBody = z.object({ }); export const ProfileUpdateResponse = GlobalSuccess; + +export const TeamPermissions = z.object({ + canEdit: z.boolean(), + canDelete: z.boolean(), + canManageMembers: z.boolean(), + canInvite: z.boolean(), + isOwner: z.boolean(), +}); + +export const UserTeamResponse = z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + description: z.string().nullable(), + avatarUrl: z.string().nullable(), + role: z.string(), + joinedAt: z.iso.datetime({}), + permissions: TeamPermissions, +}); + +export const UserInviteResponse = z.object({ + code: z.string(), + teamName: z.string(), + teamAvatar: z.string().nullable(), + role: z.string(), + inviterName: z.string(), + expiresAt: z.iso.datetime({}), +}); diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts index b4c7e5e..8393d9b 100644 --- a/src/entities/user/model/types.ts +++ b/src/entities/user/model/types.ts @@ -7,3 +7,5 @@ export type NotificationsUpdateBody = z.infer; export type ProfileUpdateBody = z.infer; export type ProfileUpdateResponse = z.infer; +export type UserTeamResponse = z.infer; +export type UserInviteResponse = z.infer; From e45d33dfcc40858dfff3645df13e0767cce8b47e Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Wed, 6 May 2026 22:17:31 +0300 Subject: [PATCH 03/20] feat(team): team area, PageLayout, shared UI; drop projects/tasks pages --- app/(protected)/profile/layout.tsx | 9 + app/(protected)/projects/page.tsx | 1 - app/(protected)/tasks/page.tsx | 1 - app/(protected)/team/invites/page.tsx | 5 + app/(protected)/team/layout.tsx | 16 ++ app/(protected)/team/members/page.tsx | 5 + app/(protected)/team/page.tsx | 6 + app/(protected)/team/roles/page.tsx | 5 + app/(protected)/team/settings/page.tsx | 5 + eslint.config.mjs | 4 +- proxy.ts | 2 +- src/app/layouts/PageLayout.tsx | 1 + src/app/styles/animations.scss | 12 + src/app/styles/global.css | 106 ++++----- src/pages/profile/ui/ProfileAvatarSection.tsx | 4 +- .../profile/ui/ProfileNotificationsCard.tsx | 4 +- src/pages/projects/index.ts | 1 - src/pages/projects/ui/ProjectsPage.tsx | 15 -- src/pages/signin/ui/SigninForm.tsx | 4 +- src/pages/tasks/index.ts | 1 - src/pages/tasks/ui/TasksPage.tsx | 11 - src/pages/team/index.ts | 5 + src/pages/team/model/config.ts | 41 ++++ src/pages/team/model/mock.ts | 175 +++++++++++++++ src/pages/team/ui/Invites.tsx | 23 ++ src/pages/team/ui/Members.tsx | 73 ++++++ src/pages/team/ui/Roles.tsx | 201 +++++++++++++++++ src/pages/team/ui/Settings.tsx | 211 ++++++++++++++++++ .../ui/components/InviteCard.skeleton.tsx | 27 +++ src/pages/team/ui/components/InviteCard.tsx | 67 ++++++ src/pages/team/ui/components/InviteModal.tsx | 95 ++++++++ .../ui/components/MemberCard.skeleton.tsx | 24 ++ src/pages/team/ui/components/MemberCard.tsx | 91 ++++++++ src/pages/team/ui/components/TabsNav.tsx | 50 +++++ src/shared/config/routes.ts | 9 +- src/shared/lib/hooks/index.ts | 1 - src/shared/lib/hooks/useDebouncedCallback.ts | 46 ---- .../lib/hooks/useQueuedDebouncedMutation.ts | 22 +- src/shared/lib/utils/debounce/debounce.ts | 25 +++ src/shared/lib/utils/index.ts | 1 + src/shared/lib/utils/throttle/throttle.ts | 20 +- src/shared/ui/Avatar.tsx | 31 ++- src/shared/ui/Badge.tsx | 45 ++++ src/shared/ui/Dialog.tsx | 143 ++++++++++++ src/shared/ui/Item.tsx | 182 +++++++++++++++ src/shared/ui/Kbd.tsx | 26 +++ src/shared/ui/Progress.tsx | 29 +++ src/shared/ui/RadioGroup.tsx | 42 ++++ src/shared/ui/button/Button.tsx | 2 + .../ui/floating-save-bar/FloatingSaveBar.tsx | 29 +++ .../floating-save-bar.stories.tsx | 35 +++ src/shared/ui/index.ts | 8 + .../ui/input-password/InputPassword.tsx | 9 +- src/shared/ui/search/Search.tsx | 44 ++++ src/widgets/app-sidebar/ui/AppSidebar.tsx | 61 +++-- src/widgets/app-sidebar/ui/NavUser.tsx | 4 +- src/widgets/page-layout/index.ts | 1 + src/widgets/page-layout/ui/PageLayout.tsx | 39 ++++ 58 files changed, 1974 insertions(+), 181 deletions(-) create mode 100644 app/(protected)/profile/layout.tsx delete mode 100644 app/(protected)/projects/page.tsx delete mode 100644 app/(protected)/tasks/page.tsx create mode 100644 app/(protected)/team/invites/page.tsx create mode 100644 app/(protected)/team/layout.tsx create mode 100644 app/(protected)/team/members/page.tsx create mode 100644 app/(protected)/team/page.tsx create mode 100644 app/(protected)/team/roles/page.tsx create mode 100644 app/(protected)/team/settings/page.tsx create mode 100644 src/app/layouts/PageLayout.tsx delete mode 100644 src/pages/projects/index.ts delete mode 100644 src/pages/projects/ui/ProjectsPage.tsx delete mode 100644 src/pages/tasks/index.ts delete mode 100644 src/pages/tasks/ui/TasksPage.tsx create mode 100644 src/pages/team/index.ts create mode 100644 src/pages/team/model/config.ts create mode 100644 src/pages/team/model/mock.ts create mode 100644 src/pages/team/ui/Invites.tsx create mode 100644 src/pages/team/ui/Members.tsx create mode 100644 src/pages/team/ui/Roles.tsx create mode 100644 src/pages/team/ui/Settings.tsx create mode 100644 src/pages/team/ui/components/InviteCard.skeleton.tsx create mode 100644 src/pages/team/ui/components/InviteCard.tsx create mode 100644 src/pages/team/ui/components/InviteModal.tsx create mode 100644 src/pages/team/ui/components/MemberCard.skeleton.tsx create mode 100644 src/pages/team/ui/components/MemberCard.tsx create mode 100644 src/pages/team/ui/components/TabsNav.tsx delete mode 100644 src/shared/lib/hooks/useDebouncedCallback.ts create mode 100644 src/shared/lib/utils/debounce/debounce.ts create mode 100644 src/shared/ui/Badge.tsx create mode 100644 src/shared/ui/Dialog.tsx create mode 100644 src/shared/ui/Item.tsx create mode 100644 src/shared/ui/Kbd.tsx create mode 100644 src/shared/ui/Progress.tsx create mode 100644 src/shared/ui/RadioGroup.tsx create mode 100644 src/shared/ui/floating-save-bar/FloatingSaveBar.tsx create mode 100644 src/shared/ui/floating-save-bar/floating-save-bar.stories.tsx create mode 100644 src/shared/ui/search/Search.tsx create mode 100644 src/widgets/page-layout/index.ts create mode 100644 src/widgets/page-layout/ui/PageLayout.tsx diff --git a/app/(protected)/profile/layout.tsx b/app/(protected)/profile/layout.tsx new file mode 100644 index 0000000..1bf6e42 --- /dev/null +++ b/app/(protected)/profile/layout.tsx @@ -0,0 +1,9 @@ +import { PageLayout } from 'app/layouts/PageLayout'; + +export default function ProfileLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/(protected)/projects/page.tsx b/app/(protected)/projects/page.tsx deleted file mode 100644 index e50f0df..0000000 --- a/app/(protected)/projects/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsPage as default } from 'pages/projects'; diff --git a/app/(protected)/tasks/page.tsx b/app/(protected)/tasks/page.tsx deleted file mode 100644 index 2af2e5a..0000000 --- a/app/(protected)/tasks/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { TasksPage as default } from 'pages/tasks'; diff --git a/app/(protected)/team/invites/page.tsx b/app/(protected)/team/invites/page.tsx new file mode 100644 index 0000000..bb79120 --- /dev/null +++ b/app/(protected)/team/invites/page.tsx @@ -0,0 +1,5 @@ +import { Invites } from 'pages/team'; + +export default function PendingPage() { + return ; +} diff --git a/app/(protected)/team/layout.tsx b/app/(protected)/team/layout.tsx new file mode 100644 index 0000000..a8ee106 --- /dev/null +++ b/app/(protected)/team/layout.tsx @@ -0,0 +1,16 @@ +import { TabsNav } from 'pages/team'; +import { PageLayout } from 'app/layouts/PageLayout'; +import { Badge } from 'shared/ui'; + +export default function TeamLayout({ children }: { children: React.ReactNode }) { + return ( + 8 участников} + nav={} + > + {children} + + ); +} diff --git a/app/(protected)/team/members/page.tsx b/app/(protected)/team/members/page.tsx new file mode 100644 index 0000000..be0b195 --- /dev/null +++ b/app/(protected)/team/members/page.tsx @@ -0,0 +1,5 @@ +import { Members } from 'pages/team'; + +export default function ActivePage() { + return ; +} diff --git a/app/(protected)/team/page.tsx b/app/(protected)/team/page.tsx new file mode 100644 index 0000000..178826b --- /dev/null +++ b/app/(protected)/team/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; +import { routes } from 'shared/config'; + +export default function TeamPage() { + redirect(routes.team.members()); +} diff --git a/app/(protected)/team/roles/page.tsx b/app/(protected)/team/roles/page.tsx new file mode 100644 index 0000000..6cd3ebb --- /dev/null +++ b/app/(protected)/team/roles/page.tsx @@ -0,0 +1,5 @@ +import { Roles } from 'pages/team'; + +export default function RolesPage() { + return ; +} diff --git a/app/(protected)/team/settings/page.tsx b/app/(protected)/team/settings/page.tsx new file mode 100644 index 0000000..c397581 --- /dev/null +++ b/app/(protected)/team/settings/page.tsx @@ -0,0 +1,5 @@ +import { Settings } from 'pages/team'; + +export default function SettingsPage() { + return ; +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 5b30e07..96e8d1a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,7 +27,7 @@ const eslintConfig = defineConfig([ { '**/{page,layout,loading,error,not-found,template,default,route}.{jsx,tsx}': 'NEXT_JS_PAGE_ROUTER_FILENAME_CASE', - '**/!({page,layout,loading,error,not-found,template,default,route}).{jsx,tsx}': + '**/!({page,layout,loading,error,not-found,template,default,route,index,*.stories}).{jsx,tsx}': 'PASCAL_CASE', '**/*{Error,Type,Types,Interface,Props,Dto,Response,Request,Contract,Contracts}.ts': 'PASCAL_CASE', @@ -35,6 +35,8 @@ const eslintConfig = defineConfig([ 'KEBAB_CASE', '**/*.{js,mjs,cjs,mts,cts}': 'KEBAB_CASE', '**/use*.{ts,tsx}': 'CAMEL_CASE', + '**/*.stories.{jsx,tsx}': 'KEBAB_CASE', + '**/index.tsx': 'KEBAB_CASE', }, { ignoreMiddleExtensions: true, diff --git a/proxy.ts b/proxy.ts index 4ce29c5..df9293d 100644 --- a/proxy.ts +++ b/proxy.ts @@ -6,7 +6,7 @@ import { trace } from '@opentelemetry/api'; const REFRESH_COOKIE = 'refresh'; -const PROTECTED_PREFIXES = [routes.profile(), routes.projects(), routes.tasks()]; +const PROTECTED_PREFIXES = [routes.profile(), routes.team.root()]; const PUBLIC_ONLY_ROUTES = [routes.auth.signin(), routes.auth.signup()]; function startsWithOneOf(pathname: string, prefixes: string[]) { diff --git a/src/app/layouts/PageLayout.tsx b/src/app/layouts/PageLayout.tsx new file mode 100644 index 0000000..28f5750 --- /dev/null +++ b/src/app/layouts/PageLayout.tsx @@ -0,0 +1 @@ +export { PageLayout } from 'widgets/page-layout'; diff --git a/src/app/styles/animations.scss b/src/app/styles/animations.scss index 4966f20..91d2056 100644 --- a/src/app/styles/animations.scss +++ b/src/app/styles/animations.scss @@ -3,6 +3,7 @@ --animate-fade-destructive-input: fadeDestructiveInput 0.7s ease-in-out; --animate-fade-in: fadeIn 0.7s ease-in-out forwards; --animate-fade-out: fadeOut 0.7s ease-in-out forwards; + --animate-slad-in-up: slideInUp 0.2s ease-in-out forwards; @keyframes headShake { 0% { @@ -53,4 +54,15 @@ opacity: 0; } } + + @keyframes slideInUp { + from { + transform: translate3d(0, 100%, 0); + visibility: visible; + } + + to { + transform: translate3d(0, 0, 0); + } + } } diff --git a/src/app/styles/global.css b/src/app/styles/global.css index 5515d55..0ffe416 100644 --- a/src/app/styles/global.css +++ b/src/app/styles/global.css @@ -47,74 +47,74 @@ } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --radius: 0.5rem; + --background: hsl(0 0% 100%); + --foreground: hsl(222 47% 11%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(222 47% 11%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(222 47% 11%); + --primary: hsl(207 100% 52%); + --primary-foreground: hsl(0 0% 100%); + --secondary: hsl(210 40% 96.1%); + --secondary-foreground: hsl(222.2 47.4% 11.2%); + --muted: hsl(210 40% 96.1%); + --muted-foreground: hsl(215.4 16.3% 46.9%); + --accent: hsl(210 40% 96.1%); + --accent-foreground: hsl(222.2 47.4% 11.2%); + --destructive: hsl(0 84.2% 60.2%); + --border: hsl(214.3 31.8% 91.4%); + --input: hsl(214.3 31.8% 91.4%); + --ring: hsl(207 100% 52%); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); --link: oklch(1 0 89.876 / 0); --link-foreground: oklch(54.65% 0.246 262.87); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --background: hsl(222.2 84% 4.9%); + --foreground: hsl(210 40% 98%); + --card: hsl(222.2 84% 4.9%); + --card-foreground: hsl(210 40% 98%); + --popover: hsl(222.2 84% 4.9%); + --popover-foreground: hsl(210 40% 98%); + --primary: hsl(210 40% 98%); + --primary-foreground: hsl(222.2 47.4% 11.2%); + --secondary: hsl(217.2 32.6% 17.5%); + --secondary-foreground: hsl(210 40% 98%); + --muted: hsl(217.2 32.6% 17.5%); + --muted-foreground: hsl(215 20.2% 65.1%); + --accent: hsl(217.2 32.6% 17.5%); + --accent-foreground: hsl(210 40% 98%); + --destructive: hsl(0 62.8% 30.6%); + --border: hsl(217.2 32.6% 17.5%); + --input: hsl(217.2 32.6% 17.5%); + --ring: hsl(212.7 26.8% 83.9%); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --sidebar: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); --link: oklch(1 0 89.876 / 0); --link-foreground: oklch(54.65% 0.246 262.87); } diff --git a/src/pages/profile/ui/ProfileAvatarSection.tsx b/src/pages/profile/ui/ProfileAvatarSection.tsx index abca32e..08cdf33 100644 --- a/src/pages/profile/ui/ProfileAvatarSection.tsx +++ b/src/pages/profile/ui/ProfileAvatarSection.tsx @@ -46,9 +46,7 @@ function ProfileAvatarSection({
- - {`${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase()} - + +
+ +
+

+ Показано {filtered.length} из {members.length} +

+
+ + +
+
+ +
+ {loading + ? Array.from({ length: 8 }).map((_, i) => ) + : filtered.map((m) => )} +
+ + setOpen(false)} /> + + ); +} diff --git a/src/pages/team/ui/Roles.tsx b/src/pages/team/ui/Roles.tsx new file mode 100644 index 0000000..7d79aee --- /dev/null +++ b/src/pages/team/ui/Roles.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { Plus, Shield } from 'lucide-react'; +import { useState } from 'react'; +import { FloatingSaveBar, Switch } from 'shared/ui'; + +type RoleKey = 'Admin' | 'Member' | 'Guest' | 'Viewer'; + +type PermissionKey = + | 'task.create' + | 'task.edit' + | 'task.delete' + | 'project.invite' + | 'project.create' + | 'billing.view'; + +const PERMISSION_GROUPS: { label: string; items: { key: PermissionKey; label: string }[] }[] = [ + { + label: 'Управление задачами', + items: [ + { key: 'task.create', label: 'Создавать задачи' }, + { key: 'task.edit', label: 'Редактировать задачи' }, + { key: 'task.delete', label: 'Удалять задачи' }, + ], + }, + { + label: 'Доступ к проектам', + items: [ + { key: 'project.invite', label: 'Приглашать участников' }, + { key: 'project.create', label: 'Создавать проекты' }, + ], + }, + { + label: 'Биллинг', + items: [{ key: 'billing.view', label: 'Просмотр счетов' }], + }, +]; + +const ROLES: { key: RoleKey; description: string; members: number; locked?: boolean }[] = [ + { + key: 'Admin', + description: 'Полный контроль рабочего пространства и биллинг.', + members: 2, + locked: true, + }, + { key: 'Member', description: 'Стандартный доступ участника.', members: 12 }, + { key: 'Guest', description: 'Внешний, ограниченный доступ к проектам.', members: 4 }, + { key: 'Viewer', description: 'Доступ к проектам только для чтения.', members: 7 }, +]; + +const DEFAULTS: Record> = { + Admin: { + 'task.create': true, + 'task.edit': true, + 'task.delete': true, + 'project.invite': true, + 'project.create': true, + 'billing.view': true, + }, + Member: { + 'task.create': true, + 'task.edit': true, + 'task.delete': false, + 'project.invite': false, + 'project.create': true, + 'billing.view': false, + }, + Guest: { + 'task.create': true, + 'task.edit': false, + 'task.delete': false, + 'project.invite': false, + 'project.create': false, + 'billing.view': false, + }, + Viewer: { + 'task.create': false, + 'task.edit': false, + 'task.delete': false, + 'project.invite': false, + 'project.create': false, + 'billing.view': false, + }, +}; + +export function Roles() { + const [matrix, setMatrix] = useState(DEFAULTS); + const [saved, setSaved] = useState(DEFAULTS); + const [active, setActive] = useState('Member'); + + const dirty = JSON.stringify(matrix) !== JSON.stringify(saved); + + const update = (role: RoleKey, key: PermissionKey, v: boolean) => { + setMatrix((m) => ({ ...m, [role]: { ...m[role], [key]: v } })); + }; + + return ( + <> +
+ {/* Role list */} +
+ {ROLES.map((r) => { + const isActive = active === r.key; + return ( + + ); + })} + +
+ + {/* Matrix */} +
+
+
+

Права роли {active}

+

+ Настройте действия, разрешённые для роли{' '} + {active}. +

+
+ {ROLES.find((r) => r.key === active)?.locked && ( + + Системная роль + + )} +
+ +
+ {PERMISSION_GROUPS.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((p) => { + const isBilling = p.key === 'billing.view'; + const lockedPerm = isBilling && active !== 'Admin'; + return ( +
+
+

{p.label}

+ {lockedPerm && ( +

+ Зарезервировано для роли Admin +

+ )} +
+ update(active, p.key, v)} + /> +
+ ); + })} +
+
+ ))} +
+
+
+ + setSaved(matrix)} + onDiscard={() => setMatrix(saved)} + /> + + ); +} diff --git a/src/pages/team/ui/Settings.tsx b/src/pages/team/ui/Settings.tsx new file mode 100644 index 0000000..786896a --- /dev/null +++ b/src/pages/team/ui/Settings.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useState } from 'react'; +import { AlertTriangle, Upload } from 'lucide-react'; +import { FloatingSaveBar, Switch } from 'shared/ui'; + +type Settings = { + teamName: string; + slug: string; + defaultRole: 'Member' | 'Guest' | 'Viewer'; + autoJoin: boolean; + autoJoinDomain: string; + linkExpiration: '24h' | '7d' | 'never'; + requireApproval: boolean; +}; + +const INITIAL: Settings = { + teamName: 'Acme Inc.', + slug: 'acme', + defaultRole: 'Member', + autoJoin: false, + autoJoinDomain: 'acme.io', + linkExpiration: '7d', + requireApproval: true, +}; + +const inputCls = + 'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-muted-foreground placeholder:text-muted-foreground transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30'; + +export function Settings() { + const [settings, setSettings] = useState(INITIAL); + const [saved, setSaved] = useState(INITIAL); + const dirty = JSON.stringify(settings) !== JSON.stringify(saved); + + const set = (k: K, v: Settings[K]) => + setSettings((s) => ({ ...s, [k]: v })); + + return ( + <> +
+
+
+
+ + +
+
+ + set('teamName', e.target.value)} + /> + + +
+ + app.acme.io/ + + + set('slug', e.target.value.replace(/[^a-z0-9-]/gi, '').toLowerCase()) + } + /> +
+
+
+
+
+ + {/* Membership defaults */} +
+
+ + + + + set('autoJoinDomain', e.target.value)} + placeholder="company.com" + /> + +
+ set('autoJoin', v)} + /> +
+ + {/* Invitation security */} +
+ + + + set('requireApproval', v)} + /> +
+ + {/* Danger zone */} +
+
+
+ +
+
+

Опасная зона

+

+ Навсегда удалить это рабочее пространство со всеми проектами, задачами и данными. + Действие необратимо. +

+
+ +
+
+
+ + setSaved(settings)} + onDiscard={() => setSettings(saved)} + /> + + ); +} + +function Section({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} + +function Row({ + title, + description, + checked, + onChange, +}: { + title: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+

{title}

+

{description}

+
+ +
+ ); +} diff --git a/src/pages/team/ui/components/InviteCard.skeleton.tsx b/src/pages/team/ui/components/InviteCard.skeleton.tsx new file mode 100644 index 0000000..367f487 --- /dev/null +++ b/src/pages/team/ui/components/InviteCard.skeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from 'shared/ui'; + +export function InviteCardSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/src/pages/team/ui/components/InviteCard.tsx b/src/pages/team/ui/components/InviteCard.tsx new file mode 100644 index 0000000..8f8f773 --- /dev/null +++ b/src/pages/team/ui/components/InviteCard.tsx @@ -0,0 +1,67 @@ +import { + Badge, + Button, + Item, + ItemActions, + ItemContent, + ItemFooter, + ItemGroup, + ItemHeader, +} from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; +import { TTeam } from 'entities/team'; +import { ComponentProps } from 'react'; +import { Clock, Copy, RotateCw, X } from 'lucide-react'; + +interface InviteCardProps extends Omit, 'children'> { + inv: TTeam.TeamInvitationResponse; +} + +export function InviteCard({ className, inv, ...props }: InviteCardProps) { + return ( + + + +
+
{inv.email}
+
+ {inv.role} + Отправлено {inv.createdAt}{' '} + {/*todo calculate*/} +
+
+ + + +
+ +
+ + + Ссылка истекает через{' '} + {inv.expiresAt} + {/*todo calculate*/} + +
+
+ + + + +
+
+ ); +} diff --git a/src/pages/team/ui/components/InviteModal.tsx b/src/pages/team/ui/components/InviteModal.tsx new file mode 100644 index 0000000..7c31478 --- /dev/null +++ b/src/pages/team/ui/components/InviteModal.tsx @@ -0,0 +1,95 @@ +import { Mail, Send } from 'lucide-react'; +import { useState } from 'react'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldContent, + FieldDescription, + FieldLabel, + FieldTitle, + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, + Label, + RadioGroup, + RadioGroupItem, +} from 'shared/ui'; + +const roleOptions = [ + { value: 'Admin', desc: 'Полный доступ' }, + { value: 'Product', desc: 'Управление проектами' }, + { value: 'Engineer', desc: 'Разработка функций' }, + { value: 'Designer', desc: 'Дизайн функций' }, + { value: 'Marketing', desc: 'Ведение кампаний' }, +] as const; + +export function InviteModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const [role, setRole] = useState('Engineer'); + const [email, setEmail] = useState(''); + + return ( + !nextOpen && onClose()}> + + + Пригласить участника + + Участнику будет отправлена безопасная ссылка для входа в рабочее пространство. + + +
+
+ + + + + + + + setEmail(e.target.value)} + placeholder="name@company.com" + type="email" + /> + +
+ +
+ + + {roleOptions.map(({ value, desc }) => ( + + + + {value} + {desc} + + + + + ))} + +
+
+ + + + + + + +
+
+ ); +} diff --git a/src/pages/team/ui/components/MemberCard.skeleton.tsx b/src/pages/team/ui/components/MemberCard.skeleton.tsx new file mode 100644 index 0000000..342f751 --- /dev/null +++ b/src/pages/team/ui/components/MemberCard.skeleton.tsx @@ -0,0 +1,24 @@ +import { Skeleton } from 'shared/ui'; + +export function MemberCardSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+ +
+ ); +} diff --git a/src/pages/team/ui/components/MemberCard.tsx b/src/pages/team/ui/components/MemberCard.tsx new file mode 100644 index 0000000..07190e9 --- /dev/null +++ b/src/pages/team/ui/components/MemberCard.tsx @@ -0,0 +1,91 @@ +import { + Avatar, + AvatarFallback, + AvatarImage, + Badge, + Item, + ItemContent, + ItemFooter, + ItemGroup, + ItemHeader, + Progress, +} from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; +import { TTeam } from 'entities/team'; +import { memberCardConfig as cfg } from '../../model/config'; +import { ComponentProps } from 'react'; + +const workload = 61; //todo: mock +const skills = ['Design System', 'Sprint Plan']; //todo: mock +const backOn = '2026-05-10'; //todo: mock +const role = 'Admin'; //todo: mock +const email = 'email@example.com'; //todo: mock + +interface MemberCardProps extends Omit, 'children'> { + member: TTeam.TeamMemberResponse; +} + +export function MemberCard({ className, member, ...props }: MemberCardProps) { + const wl = cfg.workloadLabel(workload); + + return ( + + + + + + + +
+

{member.fullName}

+

{email}

+
+
+ + {role} + {member.status} + {(skills ?? []).map((s) => ( + + {s} + + ))} + + +
+ + Загруженность + +
+ {wl.text} + {workload}% +
+
+ [data-slot=progress-indicator]]: h-1.5' + cfg.workloadColor(workload)} + value={workload} + /> + {backOn && ( +
+ В отпуске + Вернётся {backOn} +
+ )} +
+
+
+ ); +} diff --git a/src/pages/team/ui/components/TabsNav.tsx b/src/pages/team/ui/components/TabsNav.tsx new file mode 100644 index 0000000..ba46a90 --- /dev/null +++ b/src/pages/team/ui/components/TabsNav.tsx @@ -0,0 +1,50 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { routes } from 'shared/config'; +import type { Route } from 'next'; +import { Badge } from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; +import { ComponentProps } from 'react'; + +//todo principle of a single source of truth +const tabs: { key: Route; label: string; badge?: number }[] = [ + { key: routes.team.members(), label: 'Участники', badge: 8 }, + { key: routes.team.invites(), label: 'Приглашения', badge: 3 }, + { key: routes.team.roles(), label: 'Роли и права' }, + { key: routes.team.settings(), label: 'Настройки' }, +]; + +export function TabsNav({ className, ...props }: ComponentProps<'div'>) { + const pathname = usePathname(); + + return ( +
+ {tabs.map((t) => { + const active = pathname === t.key; + + return ( + + {t.label} + {t.badge !== undefined && {t.badge}} + {active && } + + ); + })} +
+ ); +} diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index 73b00d7..c51d2c9 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -3,8 +3,13 @@ import type { Route } from 'next'; export const routes = { home: (): Route => '/', profile: (): Route => '/profile', - projects: (): Route => '/projects', - tasks: (): Route => '/tasks', + team: { + root: (): Route => '/team', + members: (): Route => '/team/members', + invites: (): Route => '/team/invites', + roles: (): Route => '/team/roles', + settings: (): Route => '/team/settings', + }, auth: { signin: (): Route => '/signin', signup: (): Route => '/signup', diff --git a/src/shared/lib/hooks/index.ts b/src/shared/lib/hooks/index.ts index ae32932..697f6fb 100644 --- a/src/shared/lib/hooks/index.ts +++ b/src/shared/lib/hooks/index.ts @@ -1,6 +1,5 @@ export { useControllableState, type UseControllableStateProps } from './useControllableState'; export { useIsMobile } from './useMobile'; -export { useDebouncedCallback } from './useDebouncedCallback'; export { useQueuedDebouncedMutation } from './useQueuedDebouncedMutation'; export { useLocalStorageDraft, type DraftWithTTL } from './useLocalStorageDraft'; export { useTimer, type UseTimerOptions, type UseTimerReturn } from './use-timer/useTimer'; diff --git a/src/shared/lib/hooks/useDebouncedCallback.ts b/src/shared/lib/hooks/useDebouncedCallback.ts deleted file mode 100644 index ecfdab9..0000000 --- a/src/shared/lib/hooks/useDebouncedCallback.ts +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useRef } from 'react'; - -interface UseDebouncedCallbackReturn { - debouncedCallback: (...args: TArgs) => void; - cancelDebouncedCallback: () => void; -} - -export function useDebouncedCallback( - callback: (...args: TArgs) => void, - delayMs: number -): UseDebouncedCallbackReturn { - const callbackRef = useRef(callback); - const timeoutRef = useRef | null>(null); - - useEffect(() => { - callbackRef.current = callback; - }, [callback]); - - const cancelDebouncedCallback = useCallback(() => { - if (!timeoutRef.current) { - return; - } - - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - }, []); - - const debouncedCallback = useCallback( - (...args: TArgs) => { - cancelDebouncedCallback(); - timeoutRef.current = setTimeout(() => { - callbackRef.current(...args); - }, delayMs); - }, - [cancelDebouncedCallback, delayMs] - ); - - useEffect(() => cancelDebouncedCallback, [cancelDebouncedCallback]); - - return { - debouncedCallback, - cancelDebouncedCallback, - }; -} diff --git a/src/shared/lib/hooks/useQueuedDebouncedMutation.ts b/src/shared/lib/hooks/useQueuedDebouncedMutation.ts index 20c683e..4863ca5 100644 --- a/src/shared/lib/hooks/useQueuedDebouncedMutation.ts +++ b/src/shared/lib/hooks/useQueuedDebouncedMutation.ts @@ -1,7 +1,7 @@ 'use client'; -import { useCallback, useRef } from 'react'; -import { useDebouncedCallback } from './useDebouncedCallback'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { debounce } from '../utils'; interface UseQueuedDebouncedMutationOptions { delayMs: number; @@ -62,16 +62,24 @@ export function useQueuedDebouncedMutation({ } }, [isEqual, mutationFn, onError, onSuccess]); - const { debouncedCallback } = useDebouncedCallback(() => { - void flushQueue(); - }, delayMs); + const flushQueueRef = useRef(flushQueue); + useEffect(() => { + flushQueueRef.current = flushQueue; + }, [flushQueue]); + + const { debouncedCallback: debouncedFlush, cancelDebouncedCallback } = useMemo( + () => debounce(() => void flushQueueRef.current(), delayMs), + [delayMs] + ); + + useEffect(() => cancelDebouncedCallback, [cancelDebouncedCallback]); const enqueueMutation = useCallback( (value: TValue) => { queuedValueRef.current = value; - debouncedCallback(); + debouncedFlush(); }, - [debouncedCallback] + [debouncedFlush] ); const syncPersistedValue = useCallback((value: TValue | null) => { diff --git a/src/shared/lib/utils/debounce/debounce.ts b/src/shared/lib/utils/debounce/debounce.ts new file mode 100644 index 0000000..972be49 --- /dev/null +++ b/src/shared/lib/utils/debounce/debounce.ts @@ -0,0 +1,25 @@ +interface DebouncedCallbackReturn { + debouncedCallback: (...args: TArgs) => void; + cancelDebouncedCallback: () => void; +} + +export function debounce( + callback: (...args: TArgs) => void, + delayMs: number +): DebouncedCallbackReturn { + let timeoutId: ReturnType | null = null; + + const cancelDebouncedCallback = () => { + if (timeoutId === null) return; + clearTimeout(timeoutId); + timeoutId = null; + }; + + return { + cancelDebouncedCallback, + debouncedCallback: (...args: TArgs) => { + cancelDebouncedCallback(); + timeoutId = setTimeout(() => callback(...args), delayMs); + }, + }; +} diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index 7c1612a..eaffba0 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -6,3 +6,4 @@ export { setFormErrors } from './set-form-errors'; export { createEntityKeys } from './create-entity-keys'; export { classNames } from './class-names/class-names'; export { throttle } from './throttle/throttle'; +export { debounce } from './debounce/debounce'; diff --git a/src/shared/lib/utils/throttle/throttle.ts b/src/shared/lib/utils/throttle/throttle.ts index b0d6847..46bf7c3 100644 --- a/src/shared/lib/utils/throttle/throttle.ts +++ b/src/shared/lib/utils/throttle/throttle.ts @@ -3,12 +3,18 @@ interface SavedCall { thisArg: TThis; } +interface ThrottledFunction { + (this: TThis, ...args: TArgs): void; + cancel: () => void; +} + export function throttle( func: (this: TThis, ...args: TArgs) => void, ms: number -): (this: TThis, ...args: TArgs) => void { +): ThrottledFunction { let isThrottled = false; let savedCall: SavedCall | null = null; + let timeoutId: ReturnType | null = null; function wrapper(this: TThis, ...args: TArgs) { if (isThrottled) { @@ -23,7 +29,8 @@ export function throttle( isThrottled = true; - setTimeout(() => { + timeoutId = setTimeout(() => { + timeoutId = null; isThrottled = false; if (savedCall) { const { args: savedArgs, thisArg } = savedCall; @@ -33,5 +40,14 @@ export function throttle( }, ms); } + wrapper.cancel = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + isThrottled = false; + savedCall = null; + }; + return wrapper; } diff --git a/src/shared/ui/Avatar.tsx b/src/shared/ui/Avatar.tsx index 9576600..dc07e8e 100644 --- a/src/shared/ui/Avatar.tsx +++ b/src/shared/ui/Avatar.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { UserIcon } from 'lucide-react'; import { Avatar as AvatarPrimitive } from 'radix-ui'; import { cn } from 'shared/lib/utils'; @@ -15,7 +16,7 @@ function Avatar({ data-slot="avatar" data-size={size} className={cn( - 'group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten', + 'group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-12 data-[size=sm]:size-6 dark:after:mix-blend-lighten', className )} {...props} @@ -35,20 +36,42 @@ function AvatarImage({ className, ...props }: React.ComponentProps) { +}: React.ComponentProps & { + firstName?: string; + lastName?: string; +}) { + const content = children ?? getInitials(firstName, lastName); + return ( + > + {content ?? ( + + )} + ); } +function getInitials(firstName?: string, lastName?: string): string | undefined { + const first = firstName?.trim()[0]?.toUpperCase(); + const last = lastName?.trim()[0]?.toUpperCase(); + + if (first && last) return `${first}${last}`; + if (first) return first; + if (last) return last; + return undefined; +} + function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { return ( svg]:pointer-events-none [&>svg]:size-3!', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + destructive: + 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', + outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function Badge({ + className, + variant = 'default', + asChild = false, + ...props +}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'span'; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/src/shared/ui/Dialog.tsx b/src/shared/ui/Dialog.tsx new file mode 100644 index 0000000..19a9b54 --- /dev/null +++ b/src/shared/ui/Dialog.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { Dialog as DialogPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib/utils'; +import { XIcon } from 'lucide-react'; +import { Button } from 'shared/ui/button/Button'; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/shared/ui/Item.tsx b/src/shared/ui/Item.tsx new file mode 100644 index 0000000..f07f237 --- /dev/null +++ b/src/shared/ui/Item.tsx @@ -0,0 +1,182 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Slot } from 'radix-ui'; + +import { cn } from 'shared/lib/utils'; +import { Separator } from 'shared/ui/Separator'; + +function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function ItemSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +const itemVariants = cva( + 'group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted', + { + variants: { + variant: { + default: 'border-transparent', + outline: 'border-border', + muted: 'border-transparent bg-muted/50', + }, + size: { + default: 'gap-2.5 px-3 py-2.5', + sm: 'gap-2.5 px-3 py-2.5', + xs: 'gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +function Item({ + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'div'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'div'; + return ( + + ); +} + +const itemMediaVariants = cva( + 'flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none', + { + variants: { + variant: { + default: 'bg-transparent', + icon: "[&_svg:not([class*='size-'])]:size-4", + image: + 'size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function ItemMedia({ + className, + variant = 'default', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function ItemContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +

a:hover]:text-primary line-clamp-2 text-left text-sm leading-normal font-normal group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4', + className + )} + {...props} + /> + ); +} + +function ItemActions({ className, ...props }: React.ComponentProps<'div'>) { + return ( +

+ ); +} + +function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +}; diff --git a/src/shared/ui/Kbd.tsx b/src/shared/ui/Kbd.tsx new file mode 100644 index 0000000..81c148c --- /dev/null +++ b/src/shared/ui/Kbd.tsx @@ -0,0 +1,26 @@ +import { cn } from 'shared/lib/utils'; + +function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ); +} + +function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +export { Kbd, KbdGroup }; diff --git a/src/shared/ui/Progress.tsx b/src/shared/ui/Progress.tsx new file mode 100644 index 0000000..f5429f3 --- /dev/null +++ b/src/shared/ui/Progress.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Progress as ProgressPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib/utils'; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/src/shared/ui/RadioGroup.tsx b/src/shared/ui/RadioGroup.tsx new file mode 100644 index 0000000..697a3a0 --- /dev/null +++ b/src/shared/ui/RadioGroup.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { RadioGroup as RadioGroupPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib/utils'; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/src/shared/ui/button/Button.tsx b/src/shared/ui/button/Button.tsx index e68ea80..be7cbf6 100644 --- a/src/shared/ui/button/Button.tsx +++ b/src/shared/ui/button/Button.tsx @@ -12,6 +12,8 @@ const buttonVariants = cva( default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground', + 'outline-primary': + 'border-primary/50 text-primary bg-background hover:bg-muted dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', ghost: diff --git a/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx new file mode 100644 index 0000000..773e80d --- /dev/null +++ b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx @@ -0,0 +1,29 @@ +import { Button } from 'shared/ui'; + +export function FloatingSaveBar({ + visible, + onSave, + onDiscard, +}: { + visible: boolean; + onSave: () => void; + onDiscard: () => void; +}) { + if (visible) { + return ( +
+
+ +

Есть несохранённые изменения

+
+
+ + +
+
+ ); + } + return null; +} diff --git a/src/shared/ui/floating-save-bar/floating-save-bar.stories.tsx b/src/shared/ui/floating-save-bar/floating-save-bar.stories.tsx new file mode 100644 index 0000000..485992f --- /dev/null +++ b/src/shared/ui/floating-save-bar/floating-save-bar.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { FloatingSaveBar } from './FloatingSaveBar'; + +const meta = { + title: 'Shared/FloatingSaveBar', + component: FloatingSaveBar, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + render: (args) => ( +
+ +
+ ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Visible: Story = { + args: { + visible: true, + onSave: () => undefined, + onDiscard: () => undefined, + }, +}; + +export const Hidden: Story = { + args: { + visible: false, + onSave: () => undefined, + onDiscard: () => undefined, + }, +}; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index e623cd5..2e1cbc3 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -24,3 +24,11 @@ export * from './DropdownMenu'; export * from './Collapsible'; export * from './Avatar'; export * from './Switch'; +export * from './Badge'; +export * from './Item'; +export * from './Kbd'; +export * from './Progress'; +export * from './floating-save-bar/FloatingSaveBar'; +export * from './Dialog'; +export * from './RadioGroup'; +export * from './search/Search'; diff --git a/src/shared/ui/input-password/InputPassword.tsx b/src/shared/ui/input-password/InputPassword.tsx index ab11f9e..1acfeb8 100644 --- a/src/shared/ui/input-password/InputPassword.tsx +++ b/src/shared/ui/input-password/InputPassword.tsx @@ -1,13 +1,10 @@ import * as React from 'react'; -import { EyeOffIcon, EyeIcon } from 'lucide-react'; +import { HTMLInputTypeAttribute } from 'react'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; import { Button, InputGroup, InputGroupAddon, InputGroupInput } from 'shared/ui'; import { useControllableState } from 'shared/lib/hooks'; -import { HTMLInputTypeAttribute } from 'react'; -interface InputPasswordProps extends Omit< - React.ComponentProps, - 'children' -> { +interface InputPasswordProps extends React.ComponentProps { showEyeIcon?: boolean; visible?: boolean; defaultVisibleValue?: boolean; diff --git a/src/shared/ui/search/Search.tsx b/src/shared/ui/search/Search.tsx new file mode 100644 index 0000000..c22485d --- /dev/null +++ b/src/shared/ui/search/Search.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { SearchIcon } from 'lucide-react'; + +import { InputGroup, InputGroupAddon, InputGroupInput } from '../InputGroup'; +import { Kbd } from '../Kbd'; +import { ComponentProps, useEffect, useRef } from 'react'; + +type SearchProps = ComponentProps; + +export function Search(props: SearchProps) { + const input = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + input.current?.focus(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + return ( +
+ + { + input.current = ref; + }} + /> + + + + + ⌘K + + +
+ ); +} diff --git a/src/widgets/app-sidebar/ui/AppSidebar.tsx b/src/widgets/app-sidebar/ui/AppSidebar.tsx index 193eee4..4489332 100644 --- a/src/widgets/app-sidebar/ui/AppSidebar.tsx +++ b/src/widgets/app-sidebar/ui/AppSidebar.tsx @@ -2,14 +2,16 @@ import { AudioWaveform, + ChevronRight, Command, - FolderKanban, GalleryVerticalEnd, - ListTodo, UserRound, + UsersRound, } from 'lucide-react'; import { - Link, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, Sidebar, SidebarContent, SidebarFooter, @@ -18,11 +20,15 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, SidebarRail, } from 'shared/ui'; import { TeamSwitcher } from './TeamSwitcher'; import { NavUser } from './NavUser'; import { routes } from 'shared/config'; +import Link from 'next/link'; const data = { user: { @@ -49,6 +55,13 @@ const data = { ], }; +const team = [ + { url: routes.team.members(), title: 'Участники' }, + { url: routes.team.invites(), title: 'Приглашения' }, + { url: routes.team.roles(), title: 'Роли' }, + { url: routes.team.settings(), title: 'Настройки' }, +]; + export function AppSidebar({ ...props }: React.ComponentProps) { return ( @@ -62,26 +75,34 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - Мой профиль - - - - - - - - Мои задачи - - - - - - - - Мои Проекты + Профиль + + + + + + Команда + + + + + + {team.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + diff --git a/src/widgets/app-sidebar/ui/NavUser.tsx b/src/widgets/app-sidebar/ui/NavUser.tsx index 347d9c7..cac1349 100644 --- a/src/widgets/app-sidebar/ui/NavUser.tsx +++ b/src/widgets/app-sidebar/ui/NavUser.tsx @@ -43,7 +43,7 @@ export function NavUser({ > - CN +
{user.name} @@ -62,7 +62,7 @@ export function NavUser({
- CN +
{user.name} diff --git a/src/widgets/page-layout/index.ts b/src/widgets/page-layout/index.ts new file mode 100644 index 0000000..32121a9 --- /dev/null +++ b/src/widgets/page-layout/index.ts @@ -0,0 +1 @@ +export { PageLayout } from './ui/PageLayout'; diff --git a/src/widgets/page-layout/ui/PageLayout.tsx b/src/widgets/page-layout/ui/PageLayout.tsx new file mode 100644 index 0000000..f6dedc7 --- /dev/null +++ b/src/widgets/page-layout/ui/PageLayout.tsx @@ -0,0 +1,39 @@ +import { ReactNode } from 'react'; + +interface PageLayoutProps { + title: string; + description?: string; + badge?: ReactNode; + headerSlot?: ReactNode; + nav?: ReactNode; + children: ReactNode; +} + +export function PageLayout({ + title, + description, + badge, + headerSlot, + nav, + children, +}: PageLayoutProps) { + return ( +
+
+
+
+

{title}

+ {badge} +
+ {description &&

{description}

} +
+ + {headerSlot &&
{headerSlot}
} + + {nav &&
{nav}
} + +
{children}
+
+
+ ); +} From d97b8a0c5c309ddec1c6dde464fe4176e19a835e Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Wed, 6 May 2026 22:43:54 +0300 Subject: [PATCH 04/20] style(page-layout): drop max-w-7xl --- src/widgets/page-layout/ui/PageLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/page-layout/ui/PageLayout.tsx b/src/widgets/page-layout/ui/PageLayout.tsx index f6dedc7..5b9726d 100644 --- a/src/widgets/page-layout/ui/PageLayout.tsx +++ b/src/widgets/page-layout/ui/PageLayout.tsx @@ -19,7 +19,7 @@ export function PageLayout({ }: PageLayoutProps) { return (
-
+

{title}

From 6a054f6327fd97c73080f6f3d78c18f86c4edb75 Mon Sep 17 00:00:00 2001 From: soorq Date: Wed, 6 May 2026 23:35:42 +0300 Subject: [PATCH 05/20] refactor: decompose profile and auth logic into features and entities --- .env.example | 2 +- src/entities/user/api/http.ts | 5 +- src/entities/user/model/schemas.ts | 11 ++++- src/features/auth/sign-out/index.ts | 1 + .../auth/sign-out}/model/useSignOut.ts | 16 ++++++- src/features/auth/sign-out/ui/SignOut.tsx | 23 ++++++++++ src/pages/profile/model/useUpdateAvatar.ts | 4 +- src/pages/profile/model/useUpdateProfile.ts | 8 +++- src/pages/profile/ui/ProfileAvatarSection.tsx | 16 ++----- src/pages/profile/ui/ProfileIdentityCard.tsx | 13 +----- src/pages/profile/ui/ProfilePage.tsx | 15 ++---- src/pages/profile/ui/SignOut.tsx | 38 --------------- src/shared/api/instance.ts | 2 +- src/widgets/app-sidebar/ui/AppSidebar.tsx | 7 +-- src/widgets/app-sidebar/ui/NavUser.tsx | 46 +++++++++---------- 15 files changed, 98 insertions(+), 109 deletions(-) create mode 100644 src/features/auth/sign-out/index.ts rename src/{pages/profile => features/auth/sign-out}/model/useSignOut.ts (53%) create mode 100644 src/features/auth/sign-out/ui/SignOut.tsx delete mode 100644 src/pages/profile/ui/SignOut.tsx diff --git a/.env.example b/.env.example index dcc9002..16afb62 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/api/ +NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/v1 PORT=3000 # Next App FRONTEND Instrumentation NEXT_PUBLIC_FARO_URL=http://localhost:12347/collect diff --git a/src/entities/user/api/http.ts b/src/entities/user/api/http.ts index b3c4682..e33f831 100644 --- a/src/entities/user/api/http.ts +++ b/src/entities/user/api/http.ts @@ -24,13 +24,16 @@ export class UserHttp { }); } + // TODO: add to global point, to reusable include static updateAvatar(file: File) { const formData = new FormData(); formData.append('file', file); + // INCLUDED: SEE AT SWAGGER DOCS TO CONTEXT AND PROPS TOO + formData.append('context', 'user.avatar'); return api({ - url: '/users/me/avatar', + url: '/upload', method: 'POST', data: formData, headers: { diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index a3273b2..2f7bd1a 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -1,6 +1,15 @@ import { z } from 'zod/v4'; import { GlobalSuccess } from 'shared/api'; +export const UserAvatarSchema = z + .object({ + small: z.string().url(), + medium: z.string().url(), + large: z.string().url(), + original: z.string().url(), + }) + .nullish(); + export const UserResponse = z.object({ id: z.string(), email: z.email(), @@ -9,7 +18,7 @@ export const UserResponse = z.object({ lastName: z.string(), middleName: z.string().nullable(), bio: z.string().nullable(), - avatarUrl: z.url().nullable(), + avatar: UserAvatarSchema, timezone: z.string(), language: z.string(), createdAt: z.iso.datetime({}), diff --git a/src/features/auth/sign-out/index.ts b/src/features/auth/sign-out/index.ts new file mode 100644 index 0000000..28e2f31 --- /dev/null +++ b/src/features/auth/sign-out/index.ts @@ -0,0 +1 @@ +export { SignOut } from './ui/SignOut'; diff --git a/src/pages/profile/model/useSignOut.ts b/src/features/auth/sign-out/model/useSignOut.ts similarity index 53% rename from src/pages/profile/model/useSignOut.ts rename to src/features/auth/sign-out/model/useSignOut.ts index e149ee3..8b2699a 100644 --- a/src/pages/profile/model/useSignOut.ts +++ b/src/features/auth/sign-out/model/useSignOut.ts @@ -1,5 +1,9 @@ import { type DefaultError, useMutation } from '@tanstack/react-query'; import { AuthHttp, TAuth } from 'entities/auth'; +import { useRouter } from 'next/navigation'; +import { AccessToken } from 'shared/api'; +import { routes } from 'shared/config'; +import { toast } from 'sonner'; interface UseSignOutProps { onSuccess?: (res: TAuth.SignoutResponse) => void; @@ -7,13 +11,23 @@ interface UseSignOutProps { } export function useSignOut({ onSuccess, onError }: UseSignOutProps = {}) { + const router = useRouter(); + return useMutation, DefaultError, void>({ mutationFn: AuthHttp.signout, onError: (err) => { onError?.(err); }, - onSuccess: (res) => { + onSuccess: async (res, _v, _m, { client }) => { onSuccess?.(res); + + await client.cancelQueries(); + client.clear(); + + AccessToken.clear(); + + toast.success(res.message || 'Вы вышли из аккаунта'); + router.replace(routes.auth.signin()); }, }); } diff --git a/src/features/auth/sign-out/ui/SignOut.tsx b/src/features/auth/sign-out/ui/SignOut.tsx new file mode 100644 index 0000000..0aa2c48 --- /dev/null +++ b/src/features/auth/sign-out/ui/SignOut.tsx @@ -0,0 +1,23 @@ +import { LogOut } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { Button } from 'shared/ui'; +import { useSignOut } from '../model/useSignOut'; + +function SignOut(props: Omit, 'children'>) { + const signoutMutation = useSignOut(); + + return ( + + ); +} + +export { SignOut }; diff --git a/src/pages/profile/model/useUpdateAvatar.ts b/src/pages/profile/model/useUpdateAvatar.ts index a16897d..deab267 100644 --- a/src/pages/profile/model/useUpdateAvatar.ts +++ b/src/pages/profile/model/useUpdateAvatar.ts @@ -1,5 +1,6 @@ import { type DefaultError, useMutation } from '@tanstack/react-query'; import { TUser, UserHttp } from 'entities/user'; +import { toast } from 'sonner'; interface UseUpdateAvatarProps { onSuccess?: (file: File, res: TUser.AvatarUpdateResponse) => void; @@ -12,8 +13,9 @@ export function useUpdateAvatar({ onSuccess, onError }: UseUpdateAvatarProps = { onError: (err) => { onError?.(err); }, - onSuccess: (res, file) => { + onSuccess: async (res, file) => { onSuccess?.(file, res); + toast.success(res.message ?? 'Профиль успешно обновлен'); }, }); } diff --git a/src/pages/profile/model/useUpdateProfile.ts b/src/pages/profile/model/useUpdateProfile.ts index e88f1f4..f0282c0 100644 --- a/src/pages/profile/model/useUpdateProfile.ts +++ b/src/pages/profile/model/useUpdateProfile.ts @@ -1,5 +1,6 @@ import { type DefaultError, useMutation } from '@tanstack/react-query'; -import { TUser, UserHttp } from 'entities/user'; +import { type TUser, userFabricKeys, UserHttp } from 'entities/user'; +import { toast } from 'sonner'; interface UseUpdateProfileProps { onSuccess?: (body: TUser.ProfileUpdateBody, res: TUser.ProfileUpdateResponse) => void; @@ -12,8 +13,11 @@ export function useUpdateProfile({ onSuccess, onError }: UseUpdateProfileProps = onError: (err) => { onError?.(err); }, - onSuccess: (res, body) => { + onSuccess: async (res, body) => { onSuccess?.(body, res); + toast.success(res.message ?? 'Профиль успешно обновлен'); }, + onSettled: async (_d, _e, _v, _m, { client }) => + client.invalidateQueries({ queryKey: userFabricKeys.me() }), }); } diff --git a/src/pages/profile/ui/ProfileAvatarSection.tsx b/src/pages/profile/ui/ProfileAvatarSection.tsx index 08cdf33..9d98cde 100644 --- a/src/pages/profile/ui/ProfileAvatarSection.tsx +++ b/src/pages/profile/ui/ProfileAvatarSection.tsx @@ -1,32 +1,24 @@ import { Pencil } from 'lucide-react'; import { type ChangeEvent, useRef } from 'react'; import { Avatar, AvatarFallback, AvatarImage, Button } from 'shared/ui'; -import { toast } from 'sonner'; import { useUpdateAvatar } from '../model/useUpdateAvatar'; interface ProfileAvatarSectionProps { - avatarUrl: string | null; + avatar: string | null; fullName: string; firstName: string; lastName: string; - onUploaded: () => Promise; } function ProfileAvatarSection({ - avatarUrl, + avatar, fullName, firstName, lastName, - onUploaded, }: ProfileAvatarSectionProps) { const fileInputRef = useRef(null); - const uploadAvatarMutation = useUpdateAvatar({ - onSuccess: async () => { - toast.success('Аватар обновлён'); - await onUploaded(); - }, - }); + const uploadAvatarMutation = useUpdateAvatar(); const handleAvatarPick = () => { fileInputRef.current?.click(); @@ -45,7 +37,7 @@ function ProfileAvatarSection({ return (
- + + ); } return ( -
- +
diff --git a/src/pages/profile/ui/SignOut.tsx b/src/pages/profile/ui/SignOut.tsx deleted file mode 100644 index ec9fe51..0000000 --- a/src/pages/profile/ui/SignOut.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { LogOut } from 'lucide-react'; -import { ComponentProps } from 'react'; -import { Button } from 'shared/ui'; -import { AccessToken } from 'shared/api'; -import { routes } from 'shared/config'; -import { useRouter } from 'next/navigation'; -import { toast } from 'sonner'; -import { useQueryClient } from '@tanstack/react-query'; -import { useSignOut } from '../model/useSignOut'; - -function SignOut(props: Omit, 'children'>) { - const router = useRouter(); - const queryClient = useQueryClient(); - - const signoutMutation = useSignOut({ - onSuccess: (response) => { - AccessToken.clear(); - queryClient.clear(); - router.replace(routes.auth.signin()); - toast.success(response.message || 'Вы вышли из аккаунта'); - }, - }); - - return ( - - ); -} - -export { SignOut }; diff --git a/src/shared/api/instance.ts b/src/shared/api/instance.ts index e191ab7..76191d8 100644 --- a/src/shared/api/instance.ts +++ b/src/shared/api/instance.ts @@ -8,6 +8,6 @@ const AXIOS_INSTANCE = Axios.create({ applyInterceptors(AXIOS_INSTANCE); -export const instance = (config: AxiosRequestConfig): Promise => { +export const instance = async (config: AxiosRequestConfig): Promise => { return AXIOS_INSTANCE(config).then(({ data }) => data); }; diff --git a/src/widgets/app-sidebar/ui/AppSidebar.tsx b/src/widgets/app-sidebar/ui/AppSidebar.tsx index 4489332..436375d 100644 --- a/src/widgets/app-sidebar/ui/AppSidebar.tsx +++ b/src/widgets/app-sidebar/ui/AppSidebar.tsx @@ -31,11 +31,6 @@ import { routes } from 'shared/config'; import Link from 'next/link'; const data = { - user: { - name: 'whoami', - email: 'mail@example.com', - avatar: 'https://cdn.ttopen.ru/test.jpeg', - }, teams: [ { name: 'Task Tracker Frontend', @@ -107,7 +102,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + diff --git a/src/widgets/app-sidebar/ui/NavUser.tsx b/src/widgets/app-sidebar/ui/NavUser.tsx index cac1349..85b09a8 100644 --- a/src/widgets/app-sidebar/ui/NavUser.tsx +++ b/src/widgets/app-sidebar/ui/NavUser.tsx @@ -1,7 +1,6 @@ 'use client'; -import { BadgeCheck, Bell, ChevronsUpDown, LogOut } from 'lucide-react'; - +import { BadgeCheck, Bell, ChevronsUpDown } from 'lucide-react'; import { Avatar, AvatarFallback, @@ -20,17 +19,17 @@ import { useSidebar, } from 'shared/ui'; import { routes } from 'shared/config'; +import { UserQueries } from 'entities/user'; +import { SignOut } from 'features/auth/sign-out'; +import { useQuery } from '@tanstack/react-query'; -export function NavUser({ - user, -}: { - user: { - name: string; - email: string; - avatar: string; - }; -}) { +export function NavUser() { const { isMobile } = useSidebar(); + const query = useQuery(UserQueries.getMe()); + + if (!query.data) return null; + + const { email, profile } = query.data; return ( @@ -41,13 +40,13 @@ export function NavUser({ size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - - - + + +
- {user.name} - {user.email} + {profile.firstName} + {email}
@@ -60,13 +59,13 @@ export function NavUser({ >
- - - + + +
- {user.name} - {user.email} + {profile.firstName} + {email}
@@ -82,9 +81,8 @@ export function NavUser({ - - - Log out + + From f9760a518b487c411a50ffe51f7921c7b54fc2d6 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Thu, 7 May 2026 09:58:16 +0300 Subject: [PATCH 06/20] refactor(ui): rename animation --- src/app/styles/animations.scss | 2 +- src/shared/ui/floating-save-bar/FloatingSaveBar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/styles/animations.scss b/src/app/styles/animations.scss index 91d2056..0c64494 100644 --- a/src/app/styles/animations.scss +++ b/src/app/styles/animations.scss @@ -3,7 +3,7 @@ --animate-fade-destructive-input: fadeDestructiveInput 0.7s ease-in-out; --animate-fade-in: fadeIn 0.7s ease-in-out forwards; --animate-fade-out: fadeOut 0.7s ease-in-out forwards; - --animate-slad-in-up: slideInUp 0.2s ease-in-out forwards; + --animate-slide-in-up: slideInUp 0.2s ease-in-out forwards; @keyframes headShake { 0% { diff --git a/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx index 773e80d..cbb50c4 100644 --- a/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx +++ b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx @@ -11,7 +11,7 @@ export function FloatingSaveBar({ }) { if (visible) { return ( -
+

Есть несохранённые изменения

From f67488e3b7e41c939f3cc4195e3b6f1adadae513 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Thu, 7 May 2026 10:03:30 +0300 Subject: [PATCH 07/20] fix(team): debounce member search by input value --- src/pages/team/ui/Members.tsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/pages/team/ui/Members.tsx b/src/pages/team/ui/Members.tsx index 0763440..3ce1259 100644 --- a/src/pages/team/ui/Members.tsx +++ b/src/pages/team/ui/Members.tsx @@ -18,7 +18,19 @@ export function Members() { UserHttp.getMyInvites; //todo временно для fsd - const onFilter = useMemo(() => debounce(setFiltered, 300), []); + const onFilter = useMemo( + () => + debounce((value: string) => { + setFiltered( + members.filter( + (m) => + m.fullName.toLowerCase().includes(value.trim().toLowerCase()) || + m.role.toLowerCase().includes(value.trim().toLowerCase()) + ) + ); + }, 300), + [] + ); useEffect(() => { const t = setTimeout(() => setLoading(false), 700); @@ -29,13 +41,7 @@ export function Members() { const value = e.target.value; setSearch(value); - onFilter.debouncedCallback( - members.filter( - (m) => - m.fullName.toLowerCase().includes(value.trim().toLowerCase()) || - m.role.toLowerCase().includes(value.trim().toLowerCase()) - ) - ); + onFilter.debouncedCallback(value); }; return ( From f23f4489eee7e0f81f83369823087d1bfc43a26e Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Thu, 7 May 2026 20:51:26 +0300 Subject: [PATCH 08/20] feat(shared/ui): add CardSection, Select, and improve option/item variants --- src/shared/ui/Field.tsx | 2 +- src/shared/ui/Item.tsx | 2 + src/shared/ui/Select.tsx | 183 +++++++++++++++++++++ src/shared/ui/card-section/CardSection.tsx | 22 +++ src/shared/ui/index.ts | 3 + src/shared/ui/option-group/OptionGroup.tsx | 62 +++++++ 6 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/shared/ui/Select.tsx create mode 100644 src/shared/ui/card-section/CardSection.tsx create mode 100644 src/shared/ui/option-group/OptionGroup.tsx diff --git a/src/shared/ui/Field.tsx b/src/shared/ui/Field.tsx index 4d8b9ba..f4af555 100644 --- a/src/shared/ui/Field.tsx +++ b/src/shared/ui/Field.tsx @@ -109,7 +109,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
) { + return ; +} + +function SelectGroup({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default'; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = 'item-aligned', + align = 'center', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/src/shared/ui/card-section/CardSection.tsx b/src/shared/ui/card-section/CardSection.tsx new file mode 100644 index 0000000..3f6e778 --- /dev/null +++ b/src/shared/ui/card-section/CardSection.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { ComponentProps, ReactNode } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'shared/ui'; + +interface ICardSectionProps extends Omit, 'title'> { + title: string | ReactNode; + description: string | ReactNode; +} + +function CardSection({ title, description, ...props }: ICardSectionProps) { + return ( + + + {title} + {description} + + + + ); +} + +export { CardSection }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 2e1cbc3..9d44cd3 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -32,3 +32,6 @@ export * from './floating-save-bar/FloatingSaveBar'; export * from './Dialog'; export * from './RadioGroup'; export * from './search/Search'; +export * from './option-group/OptionGroup'; +export * from './card-section/CardSection'; +export * from './Select'; diff --git a/src/shared/ui/option-group/OptionGroup.tsx b/src/shared/ui/option-group/OptionGroup.tsx new file mode 100644 index 0000000..42c492c --- /dev/null +++ b/src/shared/ui/option-group/OptionGroup.tsx @@ -0,0 +1,62 @@ +import { ComponentProps, ReactNode, useId } from 'react'; +import { Label } from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; + +export interface OptionGroupItemProps extends ComponentProps<'div'> { + label: string; + input: (props: { id: string; 'aria-describedby': string }) => ReactNode; + hint?: string; +} + +export interface OptionGroupProps { + name: string; + items: (OptionGroupItemProps & { key: string })[]; +} + +export function OptionItem({ label, hint, input, className, ...props }: OptionGroupItemProps) { + const inputId = useId(); + const hintId = useId(); + + return ( +
+
+ + {hint && ( +

+ {hint} +

+ )} +
+ +
{input({ id: inputId, 'aria-describedby': hintId })}
+
+ ); +} + +export function OptionGroup({ name, items }: OptionGroupProps) { + return ( +
+

{name}

+
+ {items.map((item) => ( + + ))} +
+
+ ); +} From 99747b9ade068f803dc3491f02041757d347745a Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Thu, 7 May 2026 21:14:53 +0300 Subject: [PATCH 09/20] refactor(entities): add file upload entity and drop team/user avatar APIs --- src/entities/file/api/http.ts | 25 +++++++++++++++++++++++++ src/entities/file/index.ts | 3 +++ src/entities/file/model/schemas.ts | 3 +++ src/entities/file/model/types.ts | 9 +++++++++ src/entities/team/api/http.ts | 17 ----------------- src/entities/user/api/http.ts | 21 --------------------- src/entities/user/model/schemas.ts | 2 -- src/entities/user/model/types.ts | 1 - 8 files changed, 40 insertions(+), 41 deletions(-) create mode 100644 src/entities/file/api/http.ts create mode 100644 src/entities/file/index.ts create mode 100644 src/entities/file/model/schemas.ts create mode 100644 src/entities/file/model/types.ts diff --git a/src/entities/file/api/http.ts b/src/entities/file/api/http.ts new file mode 100644 index 0000000..d38b4ae --- /dev/null +++ b/src/entities/file/api/http.ts @@ -0,0 +1,25 @@ +import { api } from 'shared/api'; +import { UploadFileData, UploadResponse } from '../model/types'; +import { UploadResponse as UploadResponseSchema } from '../model/schemas'; + +export class UploadHttp { + static uploadFile(data: UploadFileData): Promise { + const formData = new FormData(); + + formData.append('file', data.file); + // INCLUDED: SEE AT SWAGGER DOCS TO CONTEXT AND PROPS TOO + formData.append('context', data.context); + + return api({ + url: '/upload', + method: 'POST', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + contracts: { + response: UploadResponseSchema, + }, + }); + } +} diff --git a/src/entities/file/index.ts b/src/entities/file/index.ts new file mode 100644 index 0000000..6965323 --- /dev/null +++ b/src/entities/file/index.ts @@ -0,0 +1,3 @@ +export * as SFile from './model/schemas'; +export * as TFile from './model/types'; +export { UploadHttp } from './api/http'; diff --git a/src/entities/file/model/schemas.ts b/src/entities/file/model/schemas.ts new file mode 100644 index 0000000..c782b4b --- /dev/null +++ b/src/entities/file/model/schemas.ts @@ -0,0 +1,3 @@ +import { GlobalSuccess } from 'shared/api'; + +export const UploadResponse = GlobalSuccess; \ No newline at end of file diff --git a/src/entities/file/model/types.ts b/src/entities/file/model/types.ts new file mode 100644 index 0000000..efeb18a --- /dev/null +++ b/src/entities/file/model/types.ts @@ -0,0 +1,9 @@ +import { z } from 'zod/v4'; +import { UploadResponse } from './schemas'; + +export type UploadResponse = z.infer; + +export type UploadFileData = { + file: File; + context: string; //TODO: typify +}; \ No newline at end of file diff --git a/src/entities/team/api/http.ts b/src/entities/team/api/http.ts index db90dd8..de37977 100644 --- a/src/entities/team/api/http.ts +++ b/src/entities/team/api/http.ts @@ -170,23 +170,6 @@ export class TeamHttp { }); } - static updateAvatar(slug: string, file: File) { - const formData = new FormData(); - formData.append('file', file); - - return api({ - url: `/teams/${slug}/avatar`, - method: 'PATCH', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data', - }, - contracts: { - response: STeam.FileUploadResponse, - }, - }); - } - static updateBanner(slug: string, file: File) { const formData = new FormData(); formData.append('file', file); diff --git a/src/entities/user/api/http.ts b/src/entities/user/api/http.ts index e33f831..4a78460 100644 --- a/src/entities/user/api/http.ts +++ b/src/entities/user/api/http.ts @@ -24,27 +24,6 @@ export class UserHttp { }); } - // TODO: add to global point, to reusable include - static updateAvatar(file: File) { - const formData = new FormData(); - - formData.append('file', file); - // INCLUDED: SEE AT SWAGGER DOCS TO CONTEXT AND PROPS TOO - formData.append('context', 'user.avatar'); - - return api({ - url: '/upload', - method: 'POST', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data', - }, - contracts: { - response: SUser.AvatarUpdateResponse, - }, - }); - } - static updateNotificationsConfig(data: TUser.NotificationsUpdateBody) { return api({ url: '/users/me/notifications', diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index 2f7bd1a..d4504da 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -41,8 +41,6 @@ export const UserResponse = z.object({ }), }); -export const AvatarUpdateResponse = GlobalSuccess; - export const NotificationsUpdateBody = z.object({ email: z .object({ diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts index 8393d9b..e798783 100644 --- a/src/entities/user/model/types.ts +++ b/src/entities/user/model/types.ts @@ -2,7 +2,6 @@ import { z } from 'zod/v4'; import * as SUser from './schemas'; export type UserResponse = z.infer; -export type AvatarUpdateResponse = z.infer; export type NotificationsUpdateBody = z.infer; export type NotificationsUpdateResponse = z.infer; export type ProfileUpdateBody = z.infer; From 9a98802970aaf9bf8701f5c588eb891da02004bc Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Thu, 7 May 2026 21:16:44 +0300 Subject: [PATCH 10/20] feat(upload-avatar): add UploadAvatar UI and useUploadAvatar mutation --- src/features/upload-avatar/index.ts | 1 + .../upload-avatar/model/useUploadAvatar.ts | 22 ++++++ .../upload-avatar/ui/UploadAvatar.tsx | 71 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 src/features/upload-avatar/index.ts create mode 100644 src/features/upload-avatar/model/useUploadAvatar.ts create mode 100644 src/features/upload-avatar/ui/UploadAvatar.tsx diff --git a/src/features/upload-avatar/index.ts b/src/features/upload-avatar/index.ts new file mode 100644 index 0000000..4ae2813 --- /dev/null +++ b/src/features/upload-avatar/index.ts @@ -0,0 +1 @@ +export {UploadAvatar} from './ui/UploadAvatar' \ No newline at end of file diff --git a/src/features/upload-avatar/model/useUploadAvatar.ts b/src/features/upload-avatar/model/useUploadAvatar.ts new file mode 100644 index 0000000..58030f1 --- /dev/null +++ b/src/features/upload-avatar/model/useUploadAvatar.ts @@ -0,0 +1,22 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { TFile, UploadHttp } from 'entities/file'; + +export type UseUploadFileOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useUploadAvatar({ onSuccess, onError, ...props }: UseUploadFileOptions = {}) { + return useMutation({ + mutationFn: UploadHttp.uploadFile, + onError: (...args) => { + onError?.(...args); + }, + onSuccess: async (res, ...args) => { + onSuccess?.(res, ...args); + toast.success(res.message ?? 'Аватар успешно загружен'); + }, + ...props, + }); +} diff --git a/src/features/upload-avatar/ui/UploadAvatar.tsx b/src/features/upload-avatar/ui/UploadAvatar.tsx new file mode 100644 index 0000000..f3abb7a --- /dev/null +++ b/src/features/upload-avatar/ui/UploadAvatar.tsx @@ -0,0 +1,71 @@ +import { Pencil } from 'lucide-react'; +import { type ChangeEvent, ComponentProps, useRef } from 'react'; +import { Avatar, AvatarFallback, AvatarImage, Button } from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; +import { useUploadAvatar, UseUploadFileOptions } from '../model/useUploadAvatar'; +import { TFile } from 'entities/file'; + +interface UploadAvatarProps { + className?: string; + avatar: string | null; + alt: string; + fallback?: ComponentProps; + context: TFile.UploadFileData['context']; + mutationOptions?: UseUploadFileOptions; +} + +function UploadAvatar({ + className, + avatar, + alt, + context, + fallback = {}, + mutationOptions, +}: UploadAvatarProps) { + const fileInputRef = useRef(null); + + const uploadAvatarMutation = useUploadAvatar(mutationOptions); + + const handleAvatarPick = () => { + fileInputRef.current?.click(); + }; + + const handleAvatarChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + uploadAvatarMutation.mutate({ file, context }); + event.target.value = ''; + }; + + return ( +
+ + + + + + +
+ ); +} + +export { UploadAvatar }; From 984437c33634cabed74564c424fce4739cd47f94 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Thu, 7 May 2026 21:19:27 +0300 Subject: [PATCH 11/20] refactor(profile): use UploadAvatar feature and remove useUpdateAvatar --- src/pages/profile/model/useUpdateAvatar.ts | 21 ------- src/pages/profile/ui/ProfileAvatarSection.tsx | 56 ++++--------------- 2 files changed, 10 insertions(+), 67 deletions(-) delete mode 100644 src/pages/profile/model/useUpdateAvatar.ts diff --git a/src/pages/profile/model/useUpdateAvatar.ts b/src/pages/profile/model/useUpdateAvatar.ts deleted file mode 100644 index deab267..0000000 --- a/src/pages/profile/model/useUpdateAvatar.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type DefaultError, useMutation } from '@tanstack/react-query'; -import { TUser, UserHttp } from 'entities/user'; -import { toast } from 'sonner'; - -interface UseUpdateAvatarProps { - onSuccess?: (file: File, res: TUser.AvatarUpdateResponse) => void; - onError?: (err: Error) => void; -} - -export function useUpdateAvatar({ onSuccess, onError }: UseUpdateAvatarProps = {}) { - return useMutation, DefaultError, File>({ - mutationFn: UserHttp.updateAvatar, - onError: (err) => { - onError?.(err); - }, - onSuccess: async (res, file) => { - onSuccess?.(file, res); - toast.success(res.message ?? 'Профиль успешно обновлен'); - }, - }); -} diff --git a/src/pages/profile/ui/ProfileAvatarSection.tsx b/src/pages/profile/ui/ProfileAvatarSection.tsx index 9d98cde..4492070 100644 --- a/src/pages/profile/ui/ProfileAvatarSection.tsx +++ b/src/pages/profile/ui/ProfileAvatarSection.tsx @@ -1,7 +1,4 @@ -import { Pencil } from 'lucide-react'; -import { type ChangeEvent, useRef } from 'react'; -import { Avatar, AvatarFallback, AvatarImage, Button } from 'shared/ui'; -import { useUpdateAvatar } from '../model/useUpdateAvatar'; +import { UploadAvatar } from 'features/upload-avatar'; interface ProfileAvatarSectionProps { avatar: string | null; @@ -16,49 +13,16 @@ function ProfileAvatarSection({ firstName, lastName, }: ProfileAvatarSectionProps) { - const fileInputRef = useRef(null); - - const uploadAvatarMutation = useUpdateAvatar(); - - const handleAvatarPick = () => { - fileInputRef.current?.click(); - }; - - const handleAvatarChange = (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) { - return; - } - - uploadAvatarMutation.mutate(file); - event.target.value = ''; - }; - return ( -
- - - - - - -
+ ); } From 9f6fe8339649c2d6d8c7e8cbbef511e9df0d034e Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Thu, 7 May 2026 21:22:49 +0300 Subject: [PATCH 12/20] refactor(team): reorganize members/invites/roles/settings and add roles mock data --- src/pages/team/index.ts | 10 +- src/pages/team/model/roles-mock.ts | 78 +++++++ src/pages/team/model/types.ts | 11 + src/pages/team/ui/Roles.tsx | 201 ----------------- src/pages/team/ui/Settings.tsx | 211 ------------------ .../team/ui/{components => }/TabsNav.tsx | 10 +- .../InviteCard.skeleton.tsx | 0 .../ui/{components => invites}/InviteCard.tsx | 0 .../{components => invites}/InviteModal.tsx | 0 .../{Invites.tsx => invites/InvitesPage.tsx} | 8 +- .../MemberCard.skeleton.tsx | 0 .../ui/{components => members}/MemberCard.tsx | 4 +- .../{Members.tsx => members/MembersPage.tsx} | 10 +- src/pages/team/ui/roles/Permissions.tsx | 56 +++++ src/pages/team/ui/roles/RolesList.tsx | 52 +++++ src/pages/team/ui/roles/RolesPage.tsx | 30 +++ src/pages/team/ui/settings/DangerZone.tsx | 34 +++ .../team/ui/settings/DefaultSettings.tsx | 74 ++++++ .../ui/settings/DeleteWorkspaceDialog.tsx | 55 +++++ src/pages/team/ui/settings/InviteSecurity.tsx | 64 ++++++ src/pages/team/ui/settings/SettingsPage.tsx | 45 ++++ .../team/ui/settings/WorkspaceIdentity.tsx | 66 ++++++ 22 files changed, 588 insertions(+), 431 deletions(-) create mode 100644 src/pages/team/model/roles-mock.ts create mode 100644 src/pages/team/model/types.ts delete mode 100644 src/pages/team/ui/Roles.tsx delete mode 100644 src/pages/team/ui/Settings.tsx rename src/pages/team/ui/{components => }/TabsNav.tsx (87%) rename src/pages/team/ui/{components => invites}/InviteCard.skeleton.tsx (100%) rename src/pages/team/ui/{components => invites}/InviteCard.tsx (100%) rename src/pages/team/ui/{components => invites}/InviteModal.tsx (100%) rename src/pages/team/ui/{Invites.tsx => invites/InvitesPage.tsx} (72%) rename src/pages/team/ui/{components => members}/MemberCard.skeleton.tsx (100%) rename src/pages/team/ui/{components => members}/MemberCard.tsx (95%) rename src/pages/team/ui/{Members.tsx => members/MembersPage.tsx} (90%) create mode 100644 src/pages/team/ui/roles/Permissions.tsx create mode 100644 src/pages/team/ui/roles/RolesList.tsx create mode 100644 src/pages/team/ui/roles/RolesPage.tsx create mode 100644 src/pages/team/ui/settings/DangerZone.tsx create mode 100644 src/pages/team/ui/settings/DefaultSettings.tsx create mode 100644 src/pages/team/ui/settings/DeleteWorkspaceDialog.tsx create mode 100644 src/pages/team/ui/settings/InviteSecurity.tsx create mode 100644 src/pages/team/ui/settings/SettingsPage.tsx create mode 100644 src/pages/team/ui/settings/WorkspaceIdentity.tsx diff --git a/src/pages/team/index.ts b/src/pages/team/index.ts index fa8a343..2d48cd9 100644 --- a/src/pages/team/index.ts +++ b/src/pages/team/index.ts @@ -1,5 +1,5 @@ -export { TabsNav } from './ui/components/TabsNav'; -export { Members } from './ui/Members'; -export { Invites } from './ui/Invites'; -export { Roles } from './ui/Roles'; -export { Settings } from './ui/Settings'; +export { TabsNav } from './ui/TabsNav'; +export { MembersPage } from './ui/members/MembersPage'; +export { InvitesPage } from './ui/invites/InvitesPage'; +export { RolesPage } from './ui/roles/RolesPage'; +export { Settings } from './ui/settings/SettingsPage'; diff --git a/src/pages/team/model/roles-mock.ts b/src/pages/team/model/roles-mock.ts new file mode 100644 index 0000000..f27067b --- /dev/null +++ b/src/pages/team/model/roles-mock.ts @@ -0,0 +1,78 @@ +export type RoleKey = 'Admin' | 'Member' | 'Guest' | 'Viewer'; + +export type PermissionKey = + | 'task.create' + | 'task.edit' + | 'task.delete' + | 'project.invite' + | 'project.create' + | 'billing.view'; + +export const PERMISSION_GROUPS: { label: string; items: { key: PermissionKey; label: string }[] }[] = [ + { + label: 'Управление задачами', + items: [ + { key: 'task.create', label: 'Создавать задачи' }, + { key: 'task.edit', label: 'Редактировать задачи' }, + { key: 'task.delete', label: 'Удалять задачи' }, + ], + }, + { + label: 'Доступ к проектам', + items: [ + { key: 'project.invite', label: 'Приглашать участников' }, + { key: 'project.create', label: 'Создавать проекты' }, + ], + }, + { + label: 'Биллинг', + items: [{ key: 'billing.view', label: 'Просмотр счетов' }], + }, +]; + +export const ROLES: { key: RoleKey; description: string; members: number; locked?: boolean }[] = [ + { + key: 'Admin', + description: 'Полный контроль рабочего пространства и биллинг.', + members: 2, + locked: true, + }, + { key: 'Member', description: 'Стандартный доступ участника.', members: 12 }, + { key: 'Guest', description: 'Внешний, ограниченный доступ к проектам.', members: 4 }, + { key: 'Viewer', description: 'Доступ к проектам только для чтения.', members: 7 }, +]; + +export const DEFAULTS: Record> = { + Admin: { + 'task.create': true, + 'task.edit': true, + 'task.delete': true, + 'project.invite': true, + 'project.create': true, + 'billing.view': true, + }, + Member: { + 'task.create': true, + 'task.edit': true, + 'task.delete': false, + 'project.invite': false, + 'project.create': true, + 'billing.view': false, + }, + Guest: { + 'task.create': true, + 'task.edit': false, + 'task.delete': false, + 'project.invite': false, + 'project.create': false, + 'billing.view': false, + }, + Viewer: { + 'task.create': false, + 'task.edit': false, + 'task.delete': false, + 'project.invite': false, + 'project.create': false, + 'billing.view': false, + }, +}; \ No newline at end of file diff --git a/src/pages/team/model/types.ts b/src/pages/team/model/types.ts new file mode 100644 index 0000000..337e234 --- /dev/null +++ b/src/pages/team/model/types.ts @@ -0,0 +1,11 @@ +export type SettingsValues = { + teamName: string; + slug: string; + defaultRole: 'Member' | 'Guest' | 'Viewer'; + autoJoin: boolean; + autoJoinDomain: string; + linkExpiration: '24h' | '7d' | 'never'; + requireApproval: boolean; +}; + +export type SettingsSetter = (k: K, v: SettingsValues[K]) => void; diff --git a/src/pages/team/ui/Roles.tsx b/src/pages/team/ui/Roles.tsx deleted file mode 100644 index 7d79aee..0000000 --- a/src/pages/team/ui/Roles.tsx +++ /dev/null @@ -1,201 +0,0 @@ -'use client'; - -import { Plus, Shield } from 'lucide-react'; -import { useState } from 'react'; -import { FloatingSaveBar, Switch } from 'shared/ui'; - -type RoleKey = 'Admin' | 'Member' | 'Guest' | 'Viewer'; - -type PermissionKey = - | 'task.create' - | 'task.edit' - | 'task.delete' - | 'project.invite' - | 'project.create' - | 'billing.view'; - -const PERMISSION_GROUPS: { label: string; items: { key: PermissionKey; label: string }[] }[] = [ - { - label: 'Управление задачами', - items: [ - { key: 'task.create', label: 'Создавать задачи' }, - { key: 'task.edit', label: 'Редактировать задачи' }, - { key: 'task.delete', label: 'Удалять задачи' }, - ], - }, - { - label: 'Доступ к проектам', - items: [ - { key: 'project.invite', label: 'Приглашать участников' }, - { key: 'project.create', label: 'Создавать проекты' }, - ], - }, - { - label: 'Биллинг', - items: [{ key: 'billing.view', label: 'Просмотр счетов' }], - }, -]; - -const ROLES: { key: RoleKey; description: string; members: number; locked?: boolean }[] = [ - { - key: 'Admin', - description: 'Полный контроль рабочего пространства и биллинг.', - members: 2, - locked: true, - }, - { key: 'Member', description: 'Стандартный доступ участника.', members: 12 }, - { key: 'Guest', description: 'Внешний, ограниченный доступ к проектам.', members: 4 }, - { key: 'Viewer', description: 'Доступ к проектам только для чтения.', members: 7 }, -]; - -const DEFAULTS: Record> = { - Admin: { - 'task.create': true, - 'task.edit': true, - 'task.delete': true, - 'project.invite': true, - 'project.create': true, - 'billing.view': true, - }, - Member: { - 'task.create': true, - 'task.edit': true, - 'task.delete': false, - 'project.invite': false, - 'project.create': true, - 'billing.view': false, - }, - Guest: { - 'task.create': true, - 'task.edit': false, - 'task.delete': false, - 'project.invite': false, - 'project.create': false, - 'billing.view': false, - }, - Viewer: { - 'task.create': false, - 'task.edit': false, - 'task.delete': false, - 'project.invite': false, - 'project.create': false, - 'billing.view': false, - }, -}; - -export function Roles() { - const [matrix, setMatrix] = useState(DEFAULTS); - const [saved, setSaved] = useState(DEFAULTS); - const [active, setActive] = useState('Member'); - - const dirty = JSON.stringify(matrix) !== JSON.stringify(saved); - - const update = (role: RoleKey, key: PermissionKey, v: boolean) => { - setMatrix((m) => ({ ...m, [role]: { ...m[role], [key]: v } })); - }; - - return ( - <> -
- {/* Role list */} -
- {ROLES.map((r) => { - const isActive = active === r.key; - return ( - - ); - })} - -
- - {/* Matrix */} -
-
-
-

Права роли {active}

-

- Настройте действия, разрешённые для роли{' '} - {active}. -

-
- {ROLES.find((r) => r.key === active)?.locked && ( - - Системная роль - - )} -
- -
- {PERMISSION_GROUPS.map((group) => ( -
-

- {group.label} -

-
- {group.items.map((p) => { - const isBilling = p.key === 'billing.view'; - const lockedPerm = isBilling && active !== 'Admin'; - return ( -
-
-

{p.label}

- {lockedPerm && ( -

- Зарезервировано для роли Admin -

- )} -
- update(active, p.key, v)} - /> -
- ); - })} -
-
- ))} -
-
-
- - setSaved(matrix)} - onDiscard={() => setMatrix(saved)} - /> - - ); -} diff --git a/src/pages/team/ui/Settings.tsx b/src/pages/team/ui/Settings.tsx deleted file mode 100644 index 786896a..0000000 --- a/src/pages/team/ui/Settings.tsx +++ /dev/null @@ -1,211 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { AlertTriangle, Upload } from 'lucide-react'; -import { FloatingSaveBar, Switch } from 'shared/ui'; - -type Settings = { - teamName: string; - slug: string; - defaultRole: 'Member' | 'Guest' | 'Viewer'; - autoJoin: boolean; - autoJoinDomain: string; - linkExpiration: '24h' | '7d' | 'never'; - requireApproval: boolean; -}; - -const INITIAL: Settings = { - teamName: 'Acme Inc.', - slug: 'acme', - defaultRole: 'Member', - autoJoin: false, - autoJoinDomain: 'acme.io', - linkExpiration: '7d', - requireApproval: true, -}; - -const inputCls = - 'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-muted-foreground placeholder:text-muted-foreground transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30'; - -export function Settings() { - const [settings, setSettings] = useState(INITIAL); - const [saved, setSaved] = useState(INITIAL); - const dirty = JSON.stringify(settings) !== JSON.stringify(saved); - - const set = (k: K, v: Settings[K]) => - setSettings((s) => ({ ...s, [k]: v })); - - return ( - <> -
-
-
-
- - -
-
- - set('teamName', e.target.value)} - /> - - -
- - app.acme.io/ - - - set('slug', e.target.value.replace(/[^a-z0-9-]/gi, '').toLowerCase()) - } - /> -
-
-
-
-
- - {/* Membership defaults */} -
-
- - - - - set('autoJoinDomain', e.target.value)} - placeholder="company.com" - /> - -
- set('autoJoin', v)} - /> -
- - {/* Invitation security */} -
- - - - set('requireApproval', v)} - /> -
- - {/* Danger zone */} -
-
-
- -
-
-

Опасная зона

-

- Навсегда удалить это рабочее пространство со всеми проектами, задачами и данными. - Действие необратимо. -

-
- -
-
-
- - setSaved(settings)} - onDiscard={() => setSettings(saved)} - /> - - ); -} - -function Section({ - title, - description, - children, -}: { - title: string; - description: string; - children: React.ReactNode; -}) { - return ( -
-
-

{title}

-

{description}

-
-
{children}
-
- ); -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( - - ); -} - -function Row({ - title, - description, - checked, - onChange, -}: { - title: string; - description: string; - checked: boolean; - onChange: (v: boolean) => void; -}) { - return ( -
-
-

{title}

-

{description}

-
- -
- ); -} diff --git a/src/pages/team/ui/components/TabsNav.tsx b/src/pages/team/ui/TabsNav.tsx similarity index 87% rename from src/pages/team/ui/components/TabsNav.tsx rename to src/pages/team/ui/TabsNav.tsx index ba46a90..e571b30 100644 --- a/src/pages/team/ui/components/TabsNav.tsx +++ b/src/pages/team/ui/TabsNav.tsx @@ -21,9 +21,11 @@ export function TabsNav({ className, ...props }: ComponentProps<'div'>) { return (
{tabs.map((t) => { @@ -34,7 +36,7 @@ export function TabsNav({ className, ...props }: ComponentProps<'div'>) { key={t.key} href={t.key} className={classNames( - 'relative flex items-center gap-2 p-3 text-sm font-medium transition-colors duration-200', + 'relative flex items-center gap-2 p-3 text-sm font-medium whitespace-nowrap transition-colors duration-200', {}, [active ? 'hover:cursor-default' : 'hover:text-muted-foreground'] )} diff --git a/src/pages/team/ui/components/InviteCard.skeleton.tsx b/src/pages/team/ui/invites/InviteCard.skeleton.tsx similarity index 100% rename from src/pages/team/ui/components/InviteCard.skeleton.tsx rename to src/pages/team/ui/invites/InviteCard.skeleton.tsx diff --git a/src/pages/team/ui/components/InviteCard.tsx b/src/pages/team/ui/invites/InviteCard.tsx similarity index 100% rename from src/pages/team/ui/components/InviteCard.tsx rename to src/pages/team/ui/invites/InviteCard.tsx diff --git a/src/pages/team/ui/components/InviteModal.tsx b/src/pages/team/ui/invites/InviteModal.tsx similarity index 100% rename from src/pages/team/ui/components/InviteModal.tsx rename to src/pages/team/ui/invites/InviteModal.tsx diff --git a/src/pages/team/ui/Invites.tsx b/src/pages/team/ui/invites/InvitesPage.tsx similarity index 72% rename from src/pages/team/ui/Invites.tsx rename to src/pages/team/ui/invites/InvitesPage.tsx index 4bb853d..a983165 100644 --- a/src/pages/team/ui/Invites.tsx +++ b/src/pages/team/ui/invites/InvitesPage.tsx @@ -1,11 +1,11 @@ 'use client'; import { useEffect, useState } from 'react'; -import { invites } from '../model/mock'; -import { InviteCard } from './components/InviteCard'; -import { InviteCardSkeleton } from './components/InviteCard.skeleton'; +import { invites } from '../../model/mock'; +import { InviteCard } from './InviteCard'; +import { InviteCardSkeleton } from './InviteCard.skeleton'; -export function Invites() { +export function InvitesPage() { const [loading, setLoading] = useState(true); useEffect(() => { diff --git a/src/pages/team/ui/components/MemberCard.skeleton.tsx b/src/pages/team/ui/members/MemberCard.skeleton.tsx similarity index 100% rename from src/pages/team/ui/components/MemberCard.skeleton.tsx rename to src/pages/team/ui/members/MemberCard.skeleton.tsx diff --git a/src/pages/team/ui/components/MemberCard.tsx b/src/pages/team/ui/members/MemberCard.tsx similarity index 95% rename from src/pages/team/ui/components/MemberCard.tsx rename to src/pages/team/ui/members/MemberCard.tsx index 07190e9..fc04381 100644 --- a/src/pages/team/ui/components/MemberCard.tsx +++ b/src/pages/team/ui/members/MemberCard.tsx @@ -75,7 +75,9 @@ export function MemberCard({ className, member, ...props }: MemberCardProps) {
[data-slot=progress-indicator]]: h-1.5' + cfg.workloadColor(workload)} + className={classNames('h-1.5', {}, [ + '[&>[data-slot=progress-indicator]]:' + cfg.workloadColor(workload), + ])} value={workload} /> {backOn && ( diff --git a/src/pages/team/ui/Members.tsx b/src/pages/team/ui/members/MembersPage.tsx similarity index 90% rename from src/pages/team/ui/Members.tsx rename to src/pages/team/ui/members/MembersPage.tsx index 3ce1259..1e600d8 100644 --- a/src/pages/team/ui/Members.tsx +++ b/src/pages/team/ui/members/MembersPage.tsx @@ -2,15 +2,15 @@ import { ChangeEvent, useEffect, useMemo, useState } from 'react'; import { Filter, Plus, SlidersHorizontal } from 'lucide-react'; -import { MemberCardSkeleton } from './components/MemberCard.skeleton'; -import { MemberCard } from './components/MemberCard'; -import { InviteModal } from './components/InviteModal'; -import { members } from '../model/mock'; +import { MemberCardSkeleton } from './MemberCard.skeleton'; +import { MemberCard } from './MemberCard'; +import { InviteModal } from '../invites/InviteModal'; +import { members } from '../../model/mock'; import { Button, Search } from 'shared/ui'; import { debounce } from 'shared/lib/utils'; import { UserHttp } from 'entities/user'; -export function Members() { +export function MembersPage() { const [search, setSearch] = useState(''); const [filtered, setFiltered] = useState(members); const [open, setOpen] = useState(false); diff --git a/src/pages/team/ui/roles/Permissions.tsx b/src/pages/team/ui/roles/Permissions.tsx new file mode 100644 index 0000000..564c85c --- /dev/null +++ b/src/pages/team/ui/roles/Permissions.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState } from 'react'; +import { Badge, CardSection, OptionGroup, Switch } from 'shared/ui'; +import { DEFAULTS, PERMISSION_GROUPS, PermissionKey, RoleKey, ROLES } from '../../model/roles-mock'; + +interface PermissionsProps { + className?: string; + active: RoleKey; +} + +export function Permissions({ className, active }: PermissionsProps) { + const [matrix, setMatrix] = useState(DEFAULTS); + + const update = (role: RoleKey, key: PermissionKey, v: boolean) => { + setMatrix((m) => ({ ...m, [role]: { ...m[role], [key]: v } })); + }; + + return ( + + Права роли {active} + {ROLES.find((r) => r.key === active)?.locked && ( + Системная роль + )} + + } + description={`Настройте действия, разрешённые для роли ${active}.`} + > + {PERMISSION_GROUPS.map((group) => ( + { + const lockedPerm = p.key === 'billing.view' && active !== 'Admin'; + return { + key: p.key, + label: p.label, + hint: lockedPerm ? 'Зарезервировано для роли Admin' : undefined, + input: (props) => ( + update(active, p.key, v)} + disabled={lockedPerm} + {...props} + /> + ), + }; + })} + /> + ))} + + ); +} diff --git a/src/pages/team/ui/roles/RolesList.tsx b/src/pages/team/ui/roles/RolesList.tsx new file mode 100644 index 0000000..cc95c58 --- /dev/null +++ b/src/pages/team/ui/roles/RolesList.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Plus, Shield } from 'lucide-react'; +import { Dispatch, SetStateAction } from 'react'; +import { + Button, + Field, + FieldContent, + FieldDescription, + FieldLabel, + FieldTitle, + RadioGroup, + RadioGroupItem, +} from 'shared/ui'; +import { RoleKey, ROLES } from '../../model/roles-mock'; + +interface RolesListProps { + className?: string; + active: RoleKey; + setActive: Dispatch>; +} + +export function RolesList({ className, active, setActive }: RolesListProps) { + return ( +
+ setActive(v)}> + {ROLES.map(({ key, description, members }) => ( + + + + + ))} + + +
+ ); +} diff --git a/src/pages/team/ui/roles/RolesPage.tsx b/src/pages/team/ui/roles/RolesPage.tsx new file mode 100644 index 0000000..f7856f1 --- /dev/null +++ b/src/pages/team/ui/roles/RolesPage.tsx @@ -0,0 +1,30 @@ +'use client'; + +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'; + +export function RolesPage() { + const [matrix, setMatrix] = useState(DEFAULTS); + const [saved, setSaved] = useState(DEFAULTS); + const [active, setActive] = useState('Member'); + + const dirty = JSON.stringify(matrix) !== JSON.stringify(saved); + + return ( + <> +
+ + +
+ + setSaved(matrix)} + onDiscard={() => setMatrix(saved)} + /> + + ); +} diff --git a/src/pages/team/ui/settings/DangerZone.tsx b/src/pages/team/ui/settings/DangerZone.tsx new file mode 100644 index 0000000..c7c2b68 --- /dev/null +++ b/src/pages/team/ui/settings/DangerZone.tsx @@ -0,0 +1,34 @@ +import { AlertTriangle } from 'lucide-react'; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from 'shared/ui'; +import { DeleteWorkspaceDialog } from './DeleteWorkspaceDialog'; + +interface Props { + workspaceName: string; +} + +export function DangerZone({ workspaceName }: Props) { + return ( + + + + + + Опасная зона + + Навсегда удалить это рабочее пространство со всеми проектами, задачами и данными. Действие + необратимо. + + + + + + + ); +} diff --git a/src/pages/team/ui/settings/DefaultSettings.tsx b/src/pages/team/ui/settings/DefaultSettings.tsx new file mode 100644 index 0000000..6a7049f --- /dev/null +++ b/src/pages/team/ui/settings/DefaultSettings.tsx @@ -0,0 +1,74 @@ +import { + CardSection, + Field, + FieldLabel, + Input, + OptionItem, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from 'shared/ui'; +import { type SettingsSetter, type SettingsValues } from '../../model/types'; +import { useId } from 'react'; + +type Props = { + settings: SettingsValues; + set: SettingsSetter; +}; + +export function DefaultSettings({ settings, set }: Props) { + const roleId = useId(); + const autoJoinId = useId(); + + return ( + + + Роль по умолчанию для новых участников + + + + Домен для автоматического входа + set('autoJoinDomain', e.target.value)} + placeholder="company.com" + /> + + ( + set('autoJoin', v)} + {...props} + /> + )} + /> + + ); +} diff --git a/src/pages/team/ui/settings/DeleteWorkspaceDialog.tsx b/src/pages/team/ui/settings/DeleteWorkspaceDialog.tsx new file mode 100644 index 0000000..0a65e60 --- /dev/null +++ b/src/pages/team/ui/settings/DeleteWorkspaceDialog.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Button, + Input, +} from 'shared/ui'; + +interface Props { + workspaceName: string; +} + +export function DeleteWorkspaceDialog({ workspaceName }: Props) { + const [inputValue, setInputValue] = useState(''); + + const isMatch = inputValue.trim() === workspaceName.trim(); + + return ( + + + + + + + Удалить рабочее пространство? + + Это действие необратимо. Для подтверждения введите название рабочего пространства: + {workspaceName} + + + setInputValue(e.target.value)} + placeholder={workspaceName} + aria-label="Название рабочего пространства для подтверждения удаления" + /> + + setInputValue('')}>Отмена + + Удалить + + + + + ); +} diff --git a/src/pages/team/ui/settings/InviteSecurity.tsx b/src/pages/team/ui/settings/InviteSecurity.tsx new file mode 100644 index 0000000..ea12482 --- /dev/null +++ b/src/pages/team/ui/settings/InviteSecurity.tsx @@ -0,0 +1,64 @@ +import { + CardSection, + Field, + FieldLabel, + OptionItem, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from 'shared/ui'; +import { type SettingsSetter, type SettingsValues } from '../../model/types'; +import { useId } from 'react'; + +type Props = { + settings: SettingsValues; + set: SettingsSetter; +}; + +export function InviteSecurity({ settings, set }: Props) { + const id = useId(); + + return ( + + + Срок действия ссылки приглашения + + + ( + set('requireApproval', v)} + {...props} + /> + )} + /> + + ); +} diff --git a/src/pages/team/ui/settings/SettingsPage.tsx b/src/pages/team/ui/settings/SettingsPage.tsx new file mode 100644 index 0000000..2f07655 --- /dev/null +++ b/src/pages/team/ui/settings/SettingsPage.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; +import { FloatingSaveBar } from 'shared/ui'; +import { type SettingsValues } from '../../model/types'; +import { WorkspaceIdentity } from './WorkspaceIdentity'; +import { DefaultSettings } from './DefaultSettings'; +import { InviteSecurity } from './InviteSecurity'; +import { DangerZone } from './DangerZone'; + +const INITIAL: SettingsValues = { + teamName: 'Acme Inc.', + slug: 'acme', + defaultRole: 'Member', + autoJoin: false, + autoJoinDomain: 'acme.io', + linkExpiration: '7d', + requireApproval: true, +}; + +export function Settings() { + const [settings, setSettings] = useState(INITIAL); + const [saved, setSaved] = useState(INITIAL); + const dirty = JSON.stringify(settings) !== JSON.stringify(saved); + + const set = (k: K, v: SettingsValues[K]) => + setSettings((s) => ({ ...s, [k]: v })); + + return ( + <> +
+ + + + +
+ + setSaved(settings)} + onDiscard={() => setSettings(saved)} + /> + + ); +} diff --git a/src/pages/team/ui/settings/WorkspaceIdentity.tsx b/src/pages/team/ui/settings/WorkspaceIdentity.tsx new file mode 100644 index 0000000..6af72fc --- /dev/null +++ b/src/pages/team/ui/settings/WorkspaceIdentity.tsx @@ -0,0 +1,66 @@ +import { + CardSection, + Field, + FieldLabel, + Input, + InputGroup, + InputGroupAddon, + InputGroupInput, +} from 'shared/ui'; +import { type SettingsSetter, type SettingsValues } from '../../model/types'; +import { UploadAvatar } from 'features/upload-avatar'; +import { UsersIcon } from 'lucide-react'; +import { useId } from 'react'; + +interface Props { + settings: SettingsValues; + set: SettingsSetter; +} + +export function WorkspaceIdentity({ settings, set }: Props) { + const idName = useId(); + const idSlug = useId(); + + return ( + +
+
+ , + }} + /> +
+
+ + Название команды + set('teamName', e.target.value)} + /> + + + URL рабочего пространства + + app.acme.io/ + + set('slug', e.target.value.replace(/[^a-z0-9-]/gi, '').toLowerCase()) + } + /> + + +
+
+
+ ); +} From b2909fecc38ec60781dd34ff647464d20784968e Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Thu, 7 May 2026 21:24:08 +0300 Subject: [PATCH 13/20] chore(team): align app routes with renamed page exports --- app/(protected)/team/invites/page.tsx | 6 +++--- app/(protected)/team/members/page.tsx | 6 +++--- app/(protected)/team/roles/page.tsx | 6 +++--- app/(protected)/team/settings/page.tsx | 2 +- next-env.d.ts | 2 +- src/app/styles/global.css | 3 +++ 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/(protected)/team/invites/page.tsx b/app/(protected)/team/invites/page.tsx index bb79120..490bfec 100644 --- a/app/(protected)/team/invites/page.tsx +++ b/app/(protected)/team/invites/page.tsx @@ -1,5 +1,5 @@ -import { Invites } from 'pages/team'; +import { InvitesPage } from 'pages/team'; -export default function PendingPage() { - return ; +export default function Page() { + return ; } diff --git a/app/(protected)/team/members/page.tsx b/app/(protected)/team/members/page.tsx index be0b195..fcfeec0 100644 --- a/app/(protected)/team/members/page.tsx +++ b/app/(protected)/team/members/page.tsx @@ -1,5 +1,5 @@ -import { Members } from 'pages/team'; +import { MembersPage } from 'pages/team'; -export default function ActivePage() { - return ; +export default function Page() { + return ; } diff --git a/app/(protected)/team/roles/page.tsx b/app/(protected)/team/roles/page.tsx index 6cd3ebb..0250db8 100644 --- a/app/(protected)/team/roles/page.tsx +++ b/app/(protected)/team/roles/page.tsx @@ -1,5 +1,5 @@ -import { Roles } from 'pages/team'; +import { RolesPage } from 'pages/team'; -export default function RolesPage() { - return ; +export default function Page() { + return ; } diff --git a/app/(protected)/team/settings/page.tsx b/app/(protected)/team/settings/page.tsx index c397581..c496ccc 100644 --- a/app/(protected)/team/settings/page.tsx +++ b/app/(protected)/team/settings/page.tsx @@ -1,5 +1,5 @@ import { Settings } from 'pages/team'; -export default function SettingsPage() { +export default function Page() { return ; } diff --git a/next-env.d.ts b/next-env.d.ts index cdb6b7b..0c7fad7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import './.next/dev/types/routes.d.ts'; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/styles/global.css b/src/app/styles/global.css index 0ffe416..ae7823f 100644 --- a/src/app/styles/global.css +++ b/src/app/styles/global.css @@ -126,4 +126,7 @@ body { @apply bg-background text-foreground; } + .input-max-w { + @apply md:max-w-[380px]; + } } From b4ff9ce280450ce0a6a7fb11a70893ccb8273679 Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Fri, 8 May 2026 00:20:19 +0300 Subject: [PATCH 14/20] feat(ui): add pending state to FloatingSaveBar --- .../ui/floating-save-bar/FloatingSaveBar.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx index cbb50c4..b8dc90a 100644 --- a/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx +++ b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx @@ -1,26 +1,38 @@ -import { Button } from 'shared/ui'; +import { Button, Spinner } from 'shared/ui'; export function FloatingSaveBar({ visible, onSave, onDiscard, + pending = false, }: { visible: boolean; onSave: () => void; onDiscard: () => void; + pending?: boolean; }) { if (visible) { return (
- -

Есть несохранённые изменения

+
+ {pending ? ( + + ) : ( + + )} +
+

+ {pending ? 'Сохранение...' : 'Есть несохранённые изменения.'} +

- - +
); From e8fc7fb028894d5869e883aee66ca0938a947e6d Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Fri, 8 May 2026 00:23:03 +0300 Subject: [PATCH 15/20] feat(routing): nested profile routes and sidebar sub-navigation --- proxy.ts | 4 +- .../upload-avatar/ui/UploadAvatar.tsx | 2 +- src/pages/signin/ui/SigninPage.tsx | 2 +- src/pages/signup/ui/SignupPage.tsx | 2 +- .../team/ui/settings/WorkspaceIdentity.tsx | 18 ++++----- src/shared/config/routes.ts | 7 +++- src/widgets/app-sidebar/ui/AppSidebar.tsx | 38 +++++++++++++++---- src/widgets/app-sidebar/ui/NavUser.tsx | 12 ++---- 8 files changed, 53 insertions(+), 32 deletions(-) diff --git a/proxy.ts b/proxy.ts index df9293d..1d245a2 100644 --- a/proxy.ts +++ b/proxy.ts @@ -6,7 +6,7 @@ import { trace } from '@opentelemetry/api'; const REFRESH_COOKIE = 'refresh'; -const PROTECTED_PREFIXES = [routes.profile(), routes.team.root()]; +const PROTECTED_PREFIXES = [routes.profile.root(), routes.team.root()]; const PUBLIC_ONLY_ROUTES = [routes.auth.signin(), routes.auth.signup()]; function startsWithOneOf(pathname: string, prefixes: string[]) { @@ -26,7 +26,7 @@ export function proxy(req: NextRequest) { } if (isPublicOnly && hasRefreshCookie) { - return NextResponse.redirect(new URL(routes.profile(), req.url)); + return NextResponse.redirect(new URL(routes.profile.root(), req.url)); } const response = NextResponse.next(); diff --git a/src/features/upload-avatar/ui/UploadAvatar.tsx b/src/features/upload-avatar/ui/UploadAvatar.tsx index f3abb7a..7614bda 100644 --- a/src/features/upload-avatar/ui/UploadAvatar.tsx +++ b/src/features/upload-avatar/ui/UploadAvatar.tsx @@ -41,7 +41,7 @@ function UploadAvatar({ }; return ( -
+
diff --git a/src/pages/signin/ui/SigninPage.tsx b/src/pages/signin/ui/SigninPage.tsx index 6be7340..81b8566 100644 --- a/src/pages/signin/ui/SigninPage.tsx +++ b/src/pages/signin/ui/SigninPage.tsx @@ -21,7 +21,7 @@ function SigninPage() { onSuccess={(_, res) => { if (res.success) { AccessToken.token = res.token; - router.replace(routes.profile()); + router.replace(routes.profile.root()); if (res.message) { toast.success(res.message); } diff --git a/src/pages/signup/ui/SignupPage.tsx b/src/pages/signup/ui/SignupPage.tsx index 17d51d9..6bb9043 100644 --- a/src/pages/signup/ui/SignupPage.tsx +++ b/src/pages/signup/ui/SignupPage.tsx @@ -59,7 +59,7 @@ function SignupPage() { if (res.success) { clearDraft(); AccessToken.token = res.token; - router.replace(routes.profile()); + router.replace(routes.profile.root()); if (res.message) { toast.success(res.message); } diff --git a/src/pages/team/ui/settings/WorkspaceIdentity.tsx b/src/pages/team/ui/settings/WorkspaceIdentity.tsx index 6af72fc..cd150ed 100644 --- a/src/pages/team/ui/settings/WorkspaceIdentity.tsx +++ b/src/pages/team/ui/settings/WorkspaceIdentity.tsx @@ -27,16 +27,14 @@ export function WorkspaceIdentity({ settings, set }: Props) { description="Публичная информация о команде." >
-
- , - }} - /> -
+ , + }} + />
Название команды diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index c51d2c9..8a9e90a 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -2,7 +2,12 @@ import type { Route } from 'next'; export const routes = { home: (): Route => '/', - profile: (): Route => '/profile', + profile: { + root: (): Route => '/profile', + me: (): Route => '/profile/me', + security: (): Route => '/profile/security', + notifications: (): Route => '/profile/notifications', + }, team: { root: (): Route => '/team', members: (): Route => '/team/members', diff --git a/src/widgets/app-sidebar/ui/AppSidebar.tsx b/src/widgets/app-sidebar/ui/AppSidebar.tsx index 436375d..9c6a591 100644 --- a/src/widgets/app-sidebar/ui/AppSidebar.tsx +++ b/src/widgets/app-sidebar/ui/AppSidebar.tsx @@ -57,6 +57,12 @@ const team = [ { url: routes.team.settings(), title: 'Настройки' }, ]; +const profile = [ + { url: routes.profile.me(), title: 'Пользователь' }, + { url: routes.profile.security(), title: 'Безопасность' }, + { url: routes.profile.notifications(), title: 'Уведомления' }, +]; + export function AppSidebar({ ...props }: React.ComponentProps) { return ( @@ -66,14 +72,30 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - - - - - Профиль - - - + + + + + + Профиль + + + + + + {profile.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + diff --git a/src/widgets/app-sidebar/ui/NavUser.tsx b/src/widgets/app-sidebar/ui/NavUser.tsx index 85b09a8..9960ce5 100644 --- a/src/widgets/app-sidebar/ui/NavUser.tsx +++ b/src/widgets/app-sidebar/ui/NavUser.tsx @@ -1,6 +1,6 @@ 'use client'; -import { BadgeCheck, Bell, ChevronsUpDown } from 'lucide-react'; +import { BadgeCheck, ChevronsUpDown } from 'lucide-react'; import { Avatar, AvatarFallback, @@ -12,7 +12,6 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, - Link, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -20,8 +19,9 @@ import { } from 'shared/ui'; import { routes } from 'shared/config'; import { UserQueries } from 'entities/user'; -import { SignOut } from 'features/auth/sign-out'; import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { SignOut } from 'features/auth/sign-out'; export function NavUser() { const { isMobile } = useSidebar(); @@ -73,11 +73,7 @@ export function NavUser() { - Account - - - - Notifications + Account From 74fb9f8fa325011b7258babe94618c4b7fa0bdfd Mon Sep 17 00:00:00 2001 From: kapitulin24 Date: Fri, 8 May 2026 00:26:16 +0300 Subject: [PATCH 16/20] feat(profile): split settings across me, security, and notifications routes --- app/(protected)/page.tsx | 2 +- app/(protected)/profile/layout.tsx | 7 +- app/(protected)/profile/me/page.tsx | 5 + .../profile/notifications/page.tsx | 5 + app/(protected)/profile/page.tsx | 7 +- app/(protected)/profile/security/page.tsx | 5 + src/pages/profile/index.ts | 5 +- src/pages/profile/model/config.ts | 36 ++++ src/pages/profile/model/types.ts | 10 + .../profile/model/useUpdateNotifications.ts | 6 +- src/pages/profile/ui/ProfileAvatarSection.tsx | 29 --- src/pages/profile/ui/ProfileIdentityCard.tsx | 203 ------------------ .../profile/ui/ProfileNotificationsCard.tsx | 171 --------------- src/pages/profile/ui/ProfilePage.skeleton.tsx | 41 ---- src/pages/profile/ui/ProfilePage.tsx | 48 ----- src/pages/profile/ui/ProfileSecurityCard.tsx | 45 ---- src/pages/profile/ui/TabsNav.tsx | 48 +++++ src/pages/profile/ui/me/IdentityItem.tsx | 45 ++++ src/pages/profile/ui/me/MePage.tsx | 107 +++++++++ src/pages/profile/ui/me/ProfileForm.tsx | 76 +++++++ .../ui/notifications/NotificationsPage.tsx | 109 ++++++++++ .../profile/ui/security/SecurityPage.tsx | 39 ++++ 22 files changed, 507 insertions(+), 542 deletions(-) create mode 100644 app/(protected)/profile/me/page.tsx create mode 100644 app/(protected)/profile/notifications/page.tsx create mode 100644 app/(protected)/profile/security/page.tsx create mode 100644 src/pages/profile/model/config.ts delete mode 100644 src/pages/profile/ui/ProfileAvatarSection.tsx delete mode 100644 src/pages/profile/ui/ProfileIdentityCard.tsx delete mode 100644 src/pages/profile/ui/ProfileNotificationsCard.tsx delete mode 100644 src/pages/profile/ui/ProfilePage.skeleton.tsx delete mode 100644 src/pages/profile/ui/ProfilePage.tsx delete mode 100644 src/pages/profile/ui/ProfileSecurityCard.tsx create mode 100644 src/pages/profile/ui/TabsNav.tsx create mode 100644 src/pages/profile/ui/me/IdentityItem.tsx create mode 100644 src/pages/profile/ui/me/MePage.tsx create mode 100644 src/pages/profile/ui/me/ProfileForm.tsx create mode 100644 src/pages/profile/ui/notifications/NotificationsPage.tsx create mode 100644 src/pages/profile/ui/security/SecurityPage.tsx diff --git a/app/(protected)/page.tsx b/app/(protected)/page.tsx index f2d5f3f..284e44f 100644 --- a/app/(protected)/page.tsx +++ b/app/(protected)/page.tsx @@ -1 +1 @@ -export { ProfilePage as default } from 'pages/profile'; +export { MePage as default } from 'pages/profile'; diff --git a/app/(protected)/profile/layout.tsx b/app/(protected)/profile/layout.tsx index 1bf6e42..8f89916 100644 --- a/app/(protected)/profile/layout.tsx +++ b/app/(protected)/profile/layout.tsx @@ -1,8 +1,13 @@ import { PageLayout } from 'app/layouts/PageLayout'; +import { TabsNav } from 'pages/profile'; export default function ProfileLayout({ children }: { children: React.ReactNode }) { return ( - + } + > {children} ); diff --git a/app/(protected)/profile/me/page.tsx b/app/(protected)/profile/me/page.tsx new file mode 100644 index 0000000..cbeaa19 --- /dev/null +++ b/app/(protected)/profile/me/page.tsx @@ -0,0 +1,5 @@ +import { MePage } from 'pages/profile'; + +export default function Page() { + return ; +} diff --git a/app/(protected)/profile/notifications/page.tsx b/app/(protected)/profile/notifications/page.tsx new file mode 100644 index 0000000..39d1471 --- /dev/null +++ b/app/(protected)/profile/notifications/page.tsx @@ -0,0 +1,5 @@ +import { NotificationsPage } from 'pages/profile'; + +export default function Page() { + return ; +} diff --git a/app/(protected)/profile/page.tsx b/app/(protected)/profile/page.tsx index f2d5f3f..dda1fcf 100644 --- a/app/(protected)/profile/page.tsx +++ b/app/(protected)/profile/page.tsx @@ -1 +1,6 @@ -export { ProfilePage as default } from 'pages/profile'; +import { redirect } from 'next/navigation'; +import { routes } from 'shared/config'; + +export default function ProfilePage() { + redirect(routes.profile.me()); +} diff --git a/app/(protected)/profile/security/page.tsx b/app/(protected)/profile/security/page.tsx new file mode 100644 index 0000000..1f96043 --- /dev/null +++ b/app/(protected)/profile/security/page.tsx @@ -0,0 +1,5 @@ +import { SecurityPage } from 'pages/profile'; + +export default function Page() { + return ; +} diff --git a/src/pages/profile/index.ts b/src/pages/profile/index.ts index 7d9c888..0c042f3 100644 --- a/src/pages/profile/index.ts +++ b/src/pages/profile/index.ts @@ -1 +1,4 @@ -export { ProfilePage } from './ui/ProfilePage'; +export { TabsNav } from './ui/TabsNav'; +export { MePage } from 'pages/profile/ui/me/MePage'; +export { SecurityPage } from './ui/security/SecurityPage'; +export { NotificationsPage } from './ui/notifications/NotificationsPage'; diff --git a/src/pages/profile/model/config.ts b/src/pages/profile/model/config.ts new file mode 100644 index 0000000..68a0386 --- /dev/null +++ b/src/pages/profile/model/config.ts @@ -0,0 +1,36 @@ +import { NotificationItem } from './types'; + +export const notificationItems: { + email: NotificationItem<'email'>[]; + push: NotificationItem<'push'>[]; +} = { + email: [ + { + key: 'mentions', + label: 'Упоминания', + ariaLabel: 'Email уведомления об упоминаниях', + }, + { + key: 'daily_summary', + label: 'Ежедневная сводка', + ariaLabel: 'Email ежедневная сводка', + }, + { + key: 'task_assigned', + label: 'Назначение задач', + ariaLabel: 'Email уведомления о назначении задач', + }, + ], + push: [ + { + key: 'reminders', + label: 'Напоминания', + ariaLabel: 'Push напоминания', + }, + { + key: 'task_assigned', + label: 'Назначение задач', + ariaLabel: 'Push уведомления о назначении задач', + }, + ], +}; diff --git a/src/pages/profile/model/types.ts b/src/pages/profile/model/types.ts index f74058f..c394947 100644 --- a/src/pages/profile/model/types.ts +++ b/src/pages/profile/model/types.ts @@ -1,4 +1,14 @@ import { z } from 'zod/v4'; import * as SProfile from './schemas'; +import { TUser } from 'entities/user'; + +export type Notifications = TUser.UserResponse['notifications']; +export type NotificationChannel = keyof Notifications; + +export type NotificationItem = { + key: keyof Notifications[TChannel]; + label: string; + ariaLabel: string; +}; export type ProfileFormValues = z.infer; diff --git a/src/pages/profile/model/useUpdateNotifications.ts b/src/pages/profile/model/useUpdateNotifications.ts index 7a01b92..72443d4 100644 --- a/src/pages/profile/model/useUpdateNotifications.ts +++ b/src/pages/profile/model/useUpdateNotifications.ts @@ -1,5 +1,6 @@ import { type DefaultError, useMutation } from '@tanstack/react-query'; -import { TUser, UserHttp } from 'entities/user'; +import { TUser, userFabricKeys, UserHttp } from 'entities/user'; +import { toast } from 'sonner'; interface UseUpdateNotificationsProps { onSuccess?: (body: TUser.NotificationsUpdateBody, res: TUser.NotificationsUpdateResponse) => void; @@ -18,6 +19,9 @@ export function useUpdateNotifications({ onSuccess, onError }: UseUpdateNotifica }, onSuccess: (res, body) => { onSuccess?.(body, res); + toast.success('Настройки уведомлений обновлены'); }, + onSettled: async (_d, _e, _v, _m, { client }) => + client.invalidateQueries({ queryKey: userFabricKeys.me() }), }); } diff --git a/src/pages/profile/ui/ProfileAvatarSection.tsx b/src/pages/profile/ui/ProfileAvatarSection.tsx deleted file mode 100644 index 4492070..0000000 --- a/src/pages/profile/ui/ProfileAvatarSection.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { UploadAvatar } from 'features/upload-avatar'; - -interface ProfileAvatarSectionProps { - avatar: string | null; - fullName: string; - firstName: string; - lastName: string; -} - -function ProfileAvatarSection({ - avatar, - fullName, - firstName, - lastName, -}: ProfileAvatarSectionProps) { - return ( - - ); -} - -export { ProfileAvatarSection }; diff --git a/src/pages/profile/ui/ProfileIdentityCard.tsx b/src/pages/profile/ui/ProfileIdentityCard.tsx deleted file mode 100644 index 7f4ef90..0000000 --- a/src/pages/profile/ui/ProfileIdentityCard.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { ComponentProps, useEffect } from 'react'; -import { - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Field, - FieldError, - FieldGroup, - Input, - Textarea, -} from 'shared/ui'; -import { Controller, useForm, useWatch } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { ProfileAvatarSection } from './ProfileAvatarSection'; -import { TUser, UserQueries } from 'entities/user'; -import { ProfileForm as ProfileFormSchema } from '../model/schemas'; -import type { ProfileFormValues } from '../model/types'; -import { useUpdateProfile } from '../model/useUpdateProfile'; -import { useQuery } from '@tanstack/react-query'; - -function ProfileIdentityCard(props: Omit, 'children'>) { - const query = useQuery(UserQueries.getMe()); - const profile = query.data?.profile; - const email = query.data?.email; - - const form = useForm({ - resolver: zodResolver(ProfileFormSchema), - defaultValues: { - firstName: '', - lastName: '', - bio: '', - }, - }); - const formValues = useWatch({ control: form.control }); - - const updateProfileMutation = useUpdateProfile(); - - useEffect(() => { - if (!profile) { - return; - } - - form.reset({ - firstName: profile.firstName, - lastName: profile.lastName, - bio: profile.bio || '', - }); - }, [form, profile]); - - if (!profile || !email) { - return ( - - - Профиль - Данные профиля пока недоступны. - - - ); - } - - const fullName = `${profile.firstName} ${profile.lastName}`; - const profileFormKeys: Array = ['firstName', 'lastName', 'bio']; - const hasProfileChanges = profileFormKeys.some( - (key) => (formValues[key] ?? '').trim() !== (profile[key] ?? '').trim() - ); - - const onSubmit = (data: ProfileFormValues) => { - const body: TUser.ProfileUpdateBody = { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - bio: data.bio ? data.bio.trim() : '', - }; - - updateProfileMutation.mutate(body); - }; - - return ( - - - Профиль - Основная информация аккаунта и настройки языка. - - -
-
- -
-

{fullName}

-

{email}

-

- {profile.bio?.trim() || 'Добавьте информацию о себе в профиле'} -

-
-
-
-
-
-

- Имя и фамилия -

- - ( - - - {fieldState.invalid && } - - )} - /> - ( - - - {fieldState.invalid && } - - )} - /> -
-

- О себе -

- ( - -