diff --git a/projects/core/src/consts/lists/actiion-type-list.const.ts b/projects/core/src/consts/lists/actiion-type-list.const.ts new file mode 100644 index 000000000..399e0d35c --- /dev/null +++ b/projects/core/src/consts/lists/actiion-type-list.const.ts @@ -0,0 +1,22 @@ +/** @format */ + +export const actionTypeList = [ + { + id: 1, + value: "action", + label: "действие", + additionalInfo: "task", + }, + { + id: 2, + value: "call", + label: "звонок", + additionalInfo: "phone", + }, + { + id: 3, + value: "meet", + label: "встреча", + additionalInfo: "people-bold", + }, +]; diff --git a/projects/core/src/consts/lists/ldirection-project-list.const.ts b/projects/core/src/consts/lists/direction-project-list.const.ts similarity index 100% rename from projects/core/src/consts/lists/ldirection-project-list.const.ts rename to projects/core/src/consts/lists/direction-project-list.const.ts diff --git a/projects/core/src/consts/lists/priority-info-list.const.ts b/projects/core/src/consts/lists/priority-info-list.const.ts new file mode 100644 index 000000000..e1fd7a158 --- /dev/null +++ b/projects/core/src/consts/lists/priority-info-list.const.ts @@ -0,0 +1,47 @@ +/** + * Информация для приоритетов + * + * @format + * @field name - выбор в выпадающем списке + * @field color - цвет для определенного типа приоритета + * @field priorityType - значение (от 0 до 5), которое соотносится на бэке + */ + +export const priorityInfoList = [ + { + id: 1, + label: "бэклог", + color: "#322299", + priorityType: 1, + }, + { + id: 2, + label: "в ближайшие часы", + color: "#A63838", + priorityType: 2, + }, + { + id: 3, + label: "высокий", + color: "#D48A9E", + priorityType: 3, + }, + { + id: 4, + label: "средний", + color: "#E5B25D", + priorityType: 4, + }, + { + id: 5, + label: "низкий", + color: "#297373", + priorityType: 5, + }, + { + id: 6, + label: "улучшение", + color: "#88C9A1", + priorityType: 6, + }, +]; diff --git a/projects/core/src/consts/navigation/nav-project-items.const.ts b/projects/core/src/consts/navigation/nav-project-items.const.ts index 21f82b8d4..0f6ada4b9 100644 --- a/projects/core/src/consts/navigation/nav-project-items.const.ts +++ b/projects/core/src/consts/navigation/nav-project-items.const.ts @@ -1,6 +1,6 @@ /** @format */ -import { EditStep } from "@office/projects/edit/services/project-step.service"; +import { EditStep } from "projects/social_platform/src/app/api/project/project-step.service"; /** * Элементы навигации для редактирования проекта diff --git a/projects/core/src/consts/other/kanban-column-info.const.ts b/projects/core/src/consts/other/kanban-column-info.const.ts new file mode 100644 index 000000000..592ddec3c --- /dev/null +++ b/projects/core/src/consts/other/kanban-column-info.const.ts @@ -0,0 +1,14 @@ +/** @format */ + +export const kanbanColumnInfo = [ + { + id: 1, + label: "редактировать", + value: "edit", + }, + { + id: 2, + label: "удалить", + value: "delete", + }, +]; diff --git a/projects/core/src/consts/other/kanban-icons.const.ts b/projects/core/src/consts/other/kanban-icons.const.ts new file mode 100644 index 000000000..428ecfb00 --- /dev/null +++ b/projects/core/src/consts/other/kanban-icons.const.ts @@ -0,0 +1,19 @@ +/** @format */ + +export const KanbanIcons = [ + { id: 0, name: "task", value: "task" }, + { id: 1, name: "key", value: "key" }, + { id: 2, name: "command", value: "command" }, + { id: 3, name: "anchor", value: "anchor" }, + { id: 4, name: "in-search", value: "in-search" }, + { id: 5, name: "suitcase", value: "suitcase" }, + { id: 6, name: "person", value: "person" }, + { id: 7, name: "deadline", value: "deadline" }, + { id: 8, name: "main", value: "main" }, + { id: 9, name: "attach", value: "attach" }, + { id: 10, name: "send", value: "send" }, + { id: 11, name: "contacts", value: "contacts" }, + { id: 12, name: "graph", value: "graph" }, + { id: 13, name: "phone", value: "phone" }, + { id: 14, name: "people-bold", value: "people-bold" }, +]; diff --git a/projects/core/src/consts/other/quick-answers.const.ts b/projects/core/src/consts/other/quick-answers.const.ts new file mode 100644 index 000000000..6416df812 --- /dev/null +++ b/projects/core/src/consts/other/quick-answers.const.ts @@ -0,0 +1,24 @@ +/** @format */ + +export const QuickAnswers = [ + { + id: 1, + title: "у работы нет результата", + }, + { + id: 2, + title: "задача была нереальной", + }, + { + id: 3, + title: "нет ссылок", + }, + { + id: 4, + title: "результат плохо выполнен", + }, + { + id: 5, + title: "результат требует доработок", + }, +]; diff --git a/projects/core/src/consts/other/tag-colors.const.ts b/projects/core/src/consts/other/tag-colors.const.ts new file mode 100644 index 000000000..00d37c308 --- /dev/null +++ b/projects/core/src/consts/other/tag-colors.const.ts @@ -0,0 +1,44 @@ +/** @format */ + +export const tagColors = [ + { + id: 1, + name: "accent", + color: "#8A63E6", + }, + { + id: 2, + name: "accent-medium", + color: "#9764BA", + }, + { + id: 3, + name: "blue-dark", + color: "#2F36AA", + }, + { + id: 4, + name: "cyan", + color: "#4CD9F1", + }, + { + id: 5, + name: "complete", + color: "#88C9A1", + }, + { + id: 6, + name: "red", + color: "#D48A9E", + }, + { + id: 7, + name: "gold", + color: "#E5B25D", + }, + { + id: 8, + name: "green-dark", + color: "#297373", + }, +]; diff --git a/projects/core/src/consts/lists/trajectory-more-list.const.ts b/projects/core/src/consts/other/trajectory-more.const.ts similarity index 88% rename from projects/core/src/consts/lists/trajectory-more-list.const.ts rename to projects/core/src/consts/other/trajectory-more.const.ts index 7a8861cbd..63f222cfa 100644 --- a/projects/core/src/consts/lists/trajectory-more-list.const.ts +++ b/projects/core/src/consts/other/trajectory-more.const.ts @@ -1,6 +1,6 @@ /** @format */ -export const trajectoryMoreList = [ +export const trajectoryMore = [ { label: "Работа с наставником", }, diff --git a/projects/social_platform/src/app/auth/guards/auth-required.guard.spec.ts b/projects/core/src/lib/guards/auth/auth-required.guard.spec.ts similarity index 91% rename from projects/social_platform/src/app/auth/guards/auth-required.guard.spec.ts rename to projects/core/src/lib/guards/auth/auth-required.guard.spec.ts index 1e85d53f2..c5d59ed3f 100644 --- a/projects/social_platform/src/app/auth/guards/auth-required.guard.spec.ts +++ b/projects/core/src/lib/guards/auth/auth-required.guard.spec.ts @@ -4,9 +4,9 @@ import { TestBed } from "@angular/core/testing"; import { AuthRequiredGuard } from "./auth-required.guard"; import { RouterTestingModule } from "@angular/router/testing"; -import { AuthService } from "../services"; import { of } from "rxjs"; import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; describe("AuthRequiredGuard", () => { beforeEach(() => { diff --git a/projects/social_platform/src/app/auth/guards/auth-required.guard.ts b/projects/core/src/lib/guards/auth/auth-required.guard.ts similarity index 95% rename from projects/social_platform/src/app/auth/guards/auth-required.guard.ts rename to projects/core/src/lib/guards/auth/auth-required.guard.ts index 74b3e87b9..bd956eddd 100644 --- a/projects/social_platform/src/app/auth/guards/auth-required.guard.ts +++ b/projects/core/src/lib/guards/auth/auth-required.guard.ts @@ -2,9 +2,9 @@ import { inject } from "@angular/core"; import { CanActivateFn, Router } from "@angular/router"; -import { AuthService } from "../services"; import { catchError, map } from "rxjs"; import { TokenService } from "@corelib"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; /** * Guard для проверки аутентификации пользователя diff --git a/projects/core/src/lib/guards/kanban/kanban.guard.ts b/projects/core/src/lib/guards/kanban/kanban.guard.ts new file mode 100644 index 000000000..b2ccbd9cd --- /dev/null +++ b/projects/core/src/lib/guards/kanban/kanban.guard.ts @@ -0,0 +1,36 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { Collaborator } from "projects/social_platform/src/app/domain/project/collaborator.model"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { catchError, map, Observable, of, switchMap } from "rxjs"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; + +export const KanbanBoardGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot +): Observable => { + const router = inject(Router); + const projectService = inject(ProjectService); + const authService = inject(AuthService); + + const projectId = Number(route.parent?.params["projectId"]); + + if (!projectId) return of(router.createUrlTree([`/office/projects/${projectId}/`])); + + return authService.profile.pipe( + switchMap((user: User) => + projectService.getOne(projectId).pipe( + map(project => { + const isInProject = project.collaborators.some( + (collaborator: Collaborator) => collaborator.userId === user.id + ); + + return isInProject ? true : router.createUrlTree([`/office/projects/${projectId}`]); + }), + catchError(() => of(router.createUrlTree(["/office/projects"]))) + ) + ) + ); +}; diff --git a/projects/core/src/lib/guards/profile-edit/profile-edit.guard.ts b/projects/core/src/lib/guards/profile-edit/profile-edit.guard.ts new file mode 100644 index 000000000..98d62addb --- /dev/null +++ b/projects/core/src/lib/guards/profile-edit/profile-edit.guard.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { inject, signal } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; +import { Observable, of } from "rxjs"; + +export const ProfileEditRequiredGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot +): Observable => { + const router = inject(Router); + const authService = inject(AuthService); + + const loggedUserId = signal(undefined); + + authService.profile.subscribe({ + next: profile => { + loggedUserId.set(profile.id); + }, + }); + + const profileId = Number(route.paramMap.get("id")); + if (profileId !== loggedUserId()) { + return of(router.createUrlTree([`/office/profile/${profileId}/`])); + } + + return of(router.createUrlTree([`/office/profel/${profileId}/`])); +}; diff --git a/projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts b/projects/core/src/lib/guards/projects-edit/projects-edit.guard.ts similarity index 90% rename from projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts rename to projects/core/src/lib/guards/projects-edit/projects-edit.guard.ts index 6f800c121..6f1e52b65 100644 --- a/projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts +++ b/projects/core/src/lib/guards/projects-edit/projects-edit.guard.ts @@ -4,7 +4,7 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; import { Observable, of } from "rxjs"; import { catchError, map } from "rxjs/operators"; -import { ProjectService } from "@office/services/project.service"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; export const ProjectEditRequiredGuard: CanActivateFn = ( route: ActivatedRouteSnapshot diff --git a/projects/core/src/lib/interceptors/bearer-token.interceptor.spec.ts b/projects/core/src/lib/interceptors/bearer-token.interceptor.spec.ts index 64d449306..e98b62c72 100644 --- a/projects/core/src/lib/interceptors/bearer-token.interceptor.spec.ts +++ b/projects/core/src/lib/interceptors/bearer-token.interceptor.spec.ts @@ -3,9 +3,9 @@ import { TestBed } from "@angular/core/testing"; import { BearerTokenInterceptor } from "./bearer-token.interceptor"; -import { AuthService } from "@auth/services"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { RouterTestingModule } from "@angular/router/testing"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; describe("BearerTokenInterceptor", () => { beforeEach(() => { diff --git a/projects/social_platform/src/app/error/models/error-code.ts b/projects/core/src/lib/models/error/error-code.ts similarity index 100% rename from projects/social_platform/src/app/error/models/error-code.ts rename to projects/core/src/lib/models/error/error-code.ts diff --git a/projects/social_platform/src/app/error/models/error-message.ts b/projects/core/src/lib/models/error/error-message.ts similarity index 100% rename from projects/social_platform/src/app/error/models/error-message.ts rename to projects/core/src/lib/models/error/error-message.ts diff --git a/projects/social_platform/src/app/core/models/http.model.ts b/projects/core/src/lib/models/http.model.ts similarity index 100% rename from projects/social_platform/src/app/core/models/http.model.ts rename to projects/core/src/lib/models/http.model.ts diff --git a/projects/core/src/lib/pipes/control-error.pipe.spec.ts b/projects/core/src/lib/pipes/controls/control-error.pipe.spec.ts similarity index 100% rename from projects/core/src/lib/pipes/control-error.pipe.spec.ts rename to projects/core/src/lib/pipes/controls/control-error.pipe.spec.ts diff --git a/projects/core/src/lib/pipes/control-error.pipe.ts b/projects/core/src/lib/pipes/controls/control-error.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/control-error.pipe.ts rename to projects/core/src/lib/pipes/controls/control-error.pipe.ts diff --git a/projects/core/src/lib/pipes/form-control.pipe.spec.ts b/projects/core/src/lib/pipes/controls/form-control.pipe.spec.ts similarity index 75% rename from projects/core/src/lib/pipes/form-control.pipe.spec.ts rename to projects/core/src/lib/pipes/controls/form-control.pipe.spec.ts index 2331b5d62..4b5b7dba1 100644 --- a/projects/core/src/lib/pipes/form-control.pipe.spec.ts +++ b/projects/core/src/lib/pipes/controls/form-control.pipe.spec.ts @@ -1,6 +1,6 @@ /** @format */ -import { FormControlPipe } from "./form-control.pipe"; +import { FormControlPipe } from "../form-control.pipe"; describe("FormControlPipe", () => { it("create an instance", () => { diff --git a/projects/core/src/lib/pipes/form-control.pipe.ts b/projects/core/src/lib/pipes/controls/form-control.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/form-control.pipe.ts rename to projects/core/src/lib/pipes/controls/form-control.pipe.ts diff --git a/projects/core/src/lib/pipes/capitalize.pipe.ts b/projects/core/src/lib/pipes/formatters/capitalize.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/capitalize.pipe.ts rename to projects/core/src/lib/pipes/formatters/capitalize.pipe.ts diff --git a/projects/core/src/lib/pipes/dayjs.pipe.spec.ts b/projects/core/src/lib/pipes/formatters/dayjs.pipe.spec.ts similarity index 100% rename from projects/core/src/lib/pipes/dayjs.pipe.spec.ts rename to projects/core/src/lib/pipes/formatters/dayjs.pipe.spec.ts diff --git a/projects/core/src/lib/pipes/dayjs.pipe.ts b/projects/core/src/lib/pipes/formatters/dayjs.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/dayjs.pipe.ts rename to projects/core/src/lib/pipes/formatters/dayjs.pipe.ts diff --git a/projects/core/src/lib/pipes/parse-breaks.pipe.spec.ts b/projects/core/src/lib/pipes/formatters/parse-breaks.pipe.spec.ts similarity index 100% rename from projects/core/src/lib/pipes/parse-breaks.pipe.spec.ts rename to projects/core/src/lib/pipes/formatters/parse-breaks.pipe.spec.ts diff --git a/projects/core/src/lib/pipes/parse-breaks.pipe.ts b/projects/core/src/lib/pipes/formatters/parse-breaks.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/parse-breaks.pipe.ts rename to projects/core/src/lib/pipes/formatters/parse-breaks.pipe.ts diff --git a/projects/core/src/lib/pipes/parse-links.pipe.spec.ts b/projects/core/src/lib/pipes/formatters/parse-links.pipe.spec.ts similarity index 100% rename from projects/core/src/lib/pipes/parse-links.pipe.spec.ts rename to projects/core/src/lib/pipes/formatters/parse-links.pipe.spec.ts diff --git a/projects/core/src/lib/pipes/parse-links.pipe.ts b/projects/core/src/lib/pipes/formatters/parse-links.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/parse-links.pipe.ts rename to projects/core/src/lib/pipes/formatters/parse-links.pipe.ts diff --git a/projects/core/src/lib/pipes/pluralize.pipe.spec.ts b/projects/core/src/lib/pipes/formatters/pluralize.pipe.spec.ts similarity index 100% rename from projects/core/src/lib/pipes/pluralize.pipe.spec.ts rename to projects/core/src/lib/pipes/formatters/pluralize.pipe.spec.ts diff --git a/projects/core/src/lib/pipes/pluralize.pipe.ts b/projects/core/src/lib/pipes/formatters/pluralize.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/pluralize.pipe.ts rename to projects/core/src/lib/pipes/formatters/pluralize.pipe.ts diff --git a/projects/core/src/lib/pipes/truncate.pipe.ts b/projects/core/src/lib/pipes/formatters/truncate.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/truncate.pipe.ts rename to projects/core/src/lib/pipes/formatters/truncate.pipe.ts diff --git a/projects/core/src/lib/pipes/years-from-birthday.pipe.spec.ts b/projects/core/src/lib/pipes/formatters/years-from-birthday.pipe.spec.ts similarity index 100% rename from projects/core/src/lib/pipes/years-from-birthday.pipe.spec.ts rename to projects/core/src/lib/pipes/formatters/years-from-birthday.pipe.spec.ts diff --git a/projects/core/src/lib/pipes/years-from-birthday.pipe.ts b/projects/core/src/lib/pipes/formatters/years-from-birthday.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/years-from-birthday.pipe.ts rename to projects/core/src/lib/pipes/formatters/years-from-birthday.pipe.ts diff --git a/projects/core/src/lib/pipes/index.ts b/projects/core/src/lib/pipes/index.ts index ce32a7254..2b8879192 100644 --- a/projects/core/src/lib/pipes/index.ts +++ b/projects/core/src/lib/pipes/index.ts @@ -1,9 +1,9 @@ /** @format */ -export * from "./control-error.pipe"; -export * from "./dayjs.pipe"; -export * from "./form-control.pipe"; -export * from "./parse-breaks.pipe"; -export * from "./parse-links.pipe"; -export * from "./pluralize.pipe"; -export * from "./years-from-birthday.pipe"; +export * from "./controls/control-error.pipe"; +export * from "./formatters/dayjs.pipe"; +export * from "./controls/form-control.pipe"; +export * from "./formatters/parse-breaks.pipe"; +export * from "./formatters/parse-links.pipe"; +export * from "./formatters/pluralize.pipe"; +export * from "./formatters/years-from-birthday.pipe"; diff --git a/projects/social_platform/src/app/core/pipes/formatted-file-size.pipe.ts b/projects/core/src/lib/pipes/transformers/formatted-file-size.pipe.ts similarity index 100% rename from projects/social_platform/src/app/core/pipes/formatted-file-size.pipe.ts rename to projects/core/src/lib/pipes/transformers/formatted-file-size.pipe.ts diff --git a/projects/core/src/lib/pipes/link-transform.pipe.spec.ts b/projects/core/src/lib/pipes/transformers/link-transform.pipe.spec.ts similarity index 100% rename from projects/core/src/lib/pipes/link-transform.pipe.spec.ts rename to projects/core/src/lib/pipes/transformers/link-transform.pipe.spec.ts diff --git a/projects/core/src/lib/pipes/link-transform.pipe.ts b/projects/core/src/lib/pipes/transformers/link-transform.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/link-transform.pipe.ts rename to projects/core/src/lib/pipes/transformers/link-transform.pipe.ts diff --git a/projects/core/src/lib/pipes/options-transform.pipe.ts b/projects/core/src/lib/pipes/transformers/options-transform.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/options-transform.pipe.ts rename to projects/core/src/lib/pipes/transformers/options-transform.pipe.ts diff --git a/projects/core/src/lib/pipes/salary-transform.pipe.ts b/projects/core/src/lib/pipes/transformers/salary-transform.pipe.ts similarity index 100% rename from projects/core/src/lib/pipes/salary-transform.pipe.ts rename to projects/core/src/lib/pipes/transformers/salary-transform.pipe.ts diff --git a/projects/core/src/lib/pipes/salary-trasform.pipe.spec.ts b/projects/core/src/lib/pipes/transformers/salary-trasform.pipe.spec.ts similarity index 100% rename from projects/core/src/lib/pipes/salary-trasform.pipe.spec.ts rename to projects/core/src/lib/pipes/transformers/salary-trasform.pipe.spec.ts diff --git a/projects/social_platform/src/app/core/pipes/user-links.pipe.spec.ts b/projects/core/src/lib/pipes/user/user-links.pipe.spec.ts similarity index 76% rename from projects/social_platform/src/app/core/pipes/user-links.pipe.spec.ts rename to projects/core/src/lib/pipes/user/user-links.pipe.spec.ts index 7c485f563..423e07036 100644 --- a/projects/social_platform/src/app/core/pipes/user-links.pipe.spec.ts +++ b/projects/core/src/lib/pipes/user/user-links.pipe.spec.ts @@ -1,6 +1,6 @@ /** @format */ -import { UserLinksPipe } from "./user-links.pipe"; +import { UserLinksPipe } from "../user-links.pipe"; describe("UserLinksPipe", () => { it("create an instance", () => { diff --git a/projects/social_platform/src/app/core/pipes/user-links.pipe.ts b/projects/core/src/lib/pipes/user/user-links.pipe.ts similarity index 100% rename from projects/social_platform/src/app/core/pipes/user-links.pipe.ts rename to projects/core/src/lib/pipes/user/user-links.pipe.ts diff --git a/projects/social_platform/src/app/core/pipes/user-role.pipe.spec.ts b/projects/core/src/lib/pipes/user/user-role.pipe.spec.ts similarity index 68% rename from projects/social_platform/src/app/core/pipes/user-role.pipe.spec.ts rename to projects/core/src/lib/pipes/user/user-role.pipe.spec.ts index 1bda7006a..aa9989c75 100644 --- a/projects/social_platform/src/app/core/pipes/user-role.pipe.spec.ts +++ b/projects/core/src/lib/pipes/user/user-role.pipe.spec.ts @@ -1,7 +1,7 @@ /** @format */ -import { UserRolePipe } from "./user-role.pipe"; -import { AuthService } from "@auth/services"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; +import { UserRolePipe } from "../user-role.pipe"; import { of } from "rxjs"; describe("UserRolePipe", () => { diff --git a/projects/social_platform/src/app/core/pipes/user-role.pipe.ts b/projects/core/src/lib/pipes/user/user-role.pipe.ts similarity index 93% rename from projects/social_platform/src/app/core/pipes/user-role.pipe.ts rename to projects/core/src/lib/pipes/user/user-role.pipe.ts index 6e0111b84..8d08e40cc 100644 --- a/projects/social_platform/src/app/core/pipes/user-role.pipe.ts +++ b/projects/core/src/lib/pipes/user/user-role.pipe.ts @@ -1,7 +1,7 @@ /** @format */ import { Pipe, PipeTransform } from "@angular/core"; -import { AuthService } from "@auth/services"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; import { map, Observable } from "rxjs"; /** diff --git a/projects/core/src/lib/services/api.service.spec.ts b/projects/core/src/lib/services/api/api.service.spec.ts similarity index 91% rename from projects/core/src/lib/services/api.service.spec.ts rename to projects/core/src/lib/services/api/api.service.spec.ts index a81f5e22a..4404d8503 100644 --- a/projects/core/src/lib/services/api.service.spec.ts +++ b/projects/core/src/lib/services/api/api.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { ApiService } from "./api.service"; +import { ApiService } from "../api.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; describe("ApiService", () => { diff --git a/projects/core/src/lib/services/api.service.ts b/projects/core/src/lib/services/api/api.service.ts similarity index 99% rename from projects/core/src/lib/services/api.service.ts rename to projects/core/src/lib/services/api/api.service.ts index 02df1d320..d9c9c7d81 100644 --- a/projects/core/src/lib/services/api.service.ts +++ b/projects/core/src/lib/services/api/api.service.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from "@angular/core"; import { HttpClient, HttpParams } from "@angular/common/http"; import { first, Observable } from "rxjs"; -import { API_URL } from "../providers"; +import { API_URL } from "../../providers"; /** * Базовый сервис для работы с REST API diff --git a/projects/core/src/lib/services/skillsApi.service.ts b/projects/core/src/lib/services/api/skillsApi.service.ts similarity index 96% rename from projects/core/src/lib/services/skillsApi.service.ts rename to projects/core/src/lib/services/api/skillsApi.service.ts index 00cf8e2bc..b1f533646 100644 --- a/projects/core/src/lib/services/skillsApi.service.ts +++ b/projects/core/src/lib/services/api/skillsApi.service.ts @@ -2,8 +2,7 @@ import { Inject, Injectable } from "@angular/core"; import { HttpClient } from "@angular/common/http"; -import { ApiService } from "./api.service"; -import { SKILLS_API_URL } from "@corelib"; +import { ApiService, SKILLS_API_URL } from "@corelib"; /** * Специализированный API сервис для работы с Skills API diff --git a/projects/social_platform/src/app/error/services/error.service.spec.ts b/projects/core/src/lib/services/error/error.service.spec.ts similarity index 89% rename from projects/social_platform/src/app/error/services/error.service.spec.ts rename to projects/core/src/lib/services/error/error.service.spec.ts index 800e55b8d..d1647d4ea 100644 --- a/projects/social_platform/src/app/error/services/error.service.spec.ts +++ b/projects/core/src/lib/services/error/error.service.spec.ts @@ -2,8 +2,8 @@ import { TestBed } from "@angular/core/testing"; -import { ErrorService } from "./error.service"; import { RouterTestingModule } from "@angular/router/testing"; +import { ErrorService } from "../error.service"; describe("ErrorService", () => { let service: ErrorService; diff --git a/projects/social_platform/src/app/error/services/error.service.ts b/projects/core/src/lib/services/error/error.service.ts similarity index 97% rename from projects/social_platform/src/app/error/services/error.service.ts rename to projects/core/src/lib/services/error/error.service.ts index be5e7df7d..482725bc2 100644 --- a/projects/social_platform/src/app/error/services/error.service.ts +++ b/projects/core/src/lib/services/error/error.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { ErrorCode } from "../models/error-code"; +import { ErrorCode } from "../../models/error/error-code"; /** * Сервис для обработки и навигации к страницам ошибок diff --git a/projects/social_platform/src/app/error/services/global-error-handler.service.spec.ts b/projects/core/src/lib/services/error/global-error-handler.service.spec.ts similarity index 86% rename from projects/social_platform/src/app/error/services/global-error-handler.service.spec.ts rename to projects/core/src/lib/services/error/global-error-handler.service.spec.ts index 2cccdb02b..512de6453 100644 --- a/projects/social_platform/src/app/error/services/global-error-handler.service.spec.ts +++ b/projects/core/src/lib/services/error/global-error-handler.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { GlobalErrorHandlerService } from "./global-error-handler.service"; +import { GlobalErrorHandlerService } from "../global-error-handler.service"; import { RouterTestingModule } from "@angular/router/testing"; describe("GlobalErrorHandlerService", () => { diff --git a/projects/social_platform/src/app/error/services/global-error-handler.service.ts b/projects/core/src/lib/services/error/global-error-handler.service.ts similarity index 100% rename from projects/social_platform/src/app/error/services/global-error-handler.service.ts rename to projects/core/src/lib/services/error/global-error-handler.service.ts diff --git a/projects/social_platform/src/app/core/services/file.service.spec.ts b/projects/core/src/lib/services/file/file.service.spec.ts similarity index 82% rename from projects/social_platform/src/app/core/services/file.service.spec.ts rename to projects/core/src/lib/services/file/file.service.spec.ts index 61c3ef1ce..eaa29c478 100644 --- a/projects/social_platform/src/app/core/services/file.service.spec.ts +++ b/projects/core/src/lib/services/file/file.service.spec.ts @@ -2,9 +2,9 @@ import { TestBed } from "@angular/core/testing"; -import { FileService } from "./file.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { AuthService } from "@auth/services"; +import { FileService } from "../file.service"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; describe("FileService", () => { let service: FileService; diff --git a/projects/social_platform/src/app/core/services/file.service.ts b/projects/core/src/lib/services/file/file.service.ts similarity index 100% rename from projects/social_platform/src/app/core/services/file.service.ts rename to projects/core/src/lib/services/file/file.service.ts diff --git a/projects/core/src/lib/services/index.ts b/projects/core/src/lib/services/index.ts index 538be53ff..7975c4f11 100644 --- a/projects/core/src/lib/services/index.ts +++ b/projects/core/src/lib/services/index.ts @@ -1,8 +1,8 @@ /** @format */ -export * from "./api.service"; -export * from "./skillsApi.service"; -export * from "./subscription-plans.service"; -export * from "./token.service"; +export * from "./api/api.service"; +export * from "./api/skillsApi.service"; +export * from "./subscriptions/subscription-plans.service"; +export * from "./tokens/token.service"; export * from "./yt-extract.service"; -export * from "./validation.service"; +export * from "./validation/validation.service"; diff --git a/projects/core/src/lib/services/subscription-plans.service.ts b/projects/core/src/lib/services/subscriptions/subscription-plans.service.ts similarity index 96% rename from projects/core/src/lib/services/subscription-plans.service.ts rename to projects/core/src/lib/services/subscriptions/subscription-plans.service.ts index 632901baf..6ef87a435 100644 --- a/projects/core/src/lib/services/subscription-plans.service.ts +++ b/projects/core/src/lib/services/subscriptions/subscription-plans.service.ts @@ -1,8 +1,8 @@ /** @format */ import { Injectable, inject } from "@angular/core"; -import { SkillsApiService } from "@corelib"; -import { PaymentStatus, SubscriptionPlan } from "../models"; +import { PaymentStatus, SubscriptionPlan } from "../../models"; +import { SkillsApiService } from "../api/skillsApi.service"; /** * Сервис для управления планами подписок и платежами diff --git a/projects/core/src/lib/services/token.service.spec.ts b/projects/core/src/lib/services/tokens/token.service.spec.ts similarity index 86% rename from projects/core/src/lib/services/token.service.spec.ts rename to projects/core/src/lib/services/tokens/token.service.spec.ts index 976f00815..0b3001c02 100644 --- a/projects/core/src/lib/services/token.service.spec.ts +++ b/projects/core/src/lib/services/tokens/token.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { TokenService } from "./token.service"; +import { TokenService } from "../token.service"; describe("TokenService", () => { let service: TokenService; diff --git a/projects/core/src/lib/services/token.service.ts b/projects/core/src/lib/services/tokens/token.service.ts similarity index 96% rename from projects/core/src/lib/services/token.service.ts rename to projects/core/src/lib/services/tokens/token.service.ts index 06f74b0e0..e2d9e72f9 100644 --- a/projects/core/src/lib/services/token.service.ts +++ b/projects/core/src/lib/services/tokens/token.service.ts @@ -2,9 +2,9 @@ import { Inject, Injectable } from "@angular/core"; import { map, Observable } from "rxjs"; -import { RefreshResponse } from "@auth/models/http.model"; +import { RefreshResponse } from "projects/social_platform/src/app/domain/auth/http.model"; import { plainToInstance } from "class-transformer"; -import { Tokens } from "@auth/models/tokens.model"; +import { Tokens } from "projects/social_platform/src/app/domain/auth/tokens.model"; import Cookies, { CookieAttributes } from "js-cookie"; import { ApiService, PRODUCTION } from "@corelib"; diff --git a/projects/core/src/lib/services/validation.service.spec.ts b/projects/core/src/lib/services/validation/validation.service.spec.ts similarity index 85% rename from projects/core/src/lib/services/validation.service.spec.ts rename to projects/core/src/lib/services/validation/validation.service.spec.ts index 9a6fa19d6..615a127ad 100644 --- a/projects/core/src/lib/services/validation.service.spec.ts +++ b/projects/core/src/lib/services/validation/validation.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { ValidationService } from "./validation.service"; +import { ValidationService } from "../validation.service"; describe("ValidationService", () => { let service: ValidationService; diff --git a/projects/core/src/lib/services/validation.service.ts b/projects/core/src/lib/services/validation/validation.service.ts similarity index 99% rename from projects/core/src/lib/services/validation.service.ts rename to projects/core/src/lib/services/validation/validation.service.ts index ceff06c13..80d8f80c0 100644 --- a/projects/core/src/lib/services/validation.service.ts +++ b/projects/core/src/lib/services/validation/validation.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { AbstractControl, FormGroup, ValidationErrors, ValidatorFn } from "@angular/forms"; -import { PasswordValidationErrors } from "@auth/models/password-errors.model"; +import { PasswordValidationErrors } from "projects/social_platform/src/app/domain/auth/password-errors.model"; import * as dayjs from "dayjs"; import * as cpf from "dayjs/plugin/customParseFormat"; import * as relativeTime from "dayjs/plugin/relativeTime"; diff --git a/projects/social_platform/src/app/core/services/websocket.service.spec.ts b/projects/core/src/lib/services/websockets/websocket.service.spec.ts similarity index 85% rename from projects/social_platform/src/app/core/services/websocket.service.spec.ts rename to projects/core/src/lib/services/websockets/websocket.service.spec.ts index 934582bce..a4b9355f3 100644 --- a/projects/social_platform/src/app/core/services/websocket.service.spec.ts +++ b/projects/core/src/lib/services/websockets/websocket.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { WebsocketService } from "./websocket.service"; +import { WebsocketService } from "../websocket.service"; describe("WebsocketService", () => { let service: WebsocketService; diff --git a/projects/social_platform/src/app/core/services/websocket.service.ts b/projects/core/src/lib/services/websockets/websocket.service.ts similarity index 100% rename from projects/social_platform/src/app/core/services/websocket.service.ts rename to projects/core/src/lib/services/websockets/websocket.service.ts diff --git a/projects/skills/src/app/app.component.ts b/projects/skills/src/app/app.component.ts index 3c70b6778..1945bd373 100644 --- a/projects/skills/src/app/app.component.ts +++ b/projects/skills/src/app/app.component.ts @@ -7,7 +7,7 @@ import { IconComponent, SidebarComponent } from "@uilib"; import { SidebarProfileComponent } from "./shared/sidebar-profile/sidebar-profile.component"; import { ProfileService } from "./profile/services/profile.service"; import type { UserData } from "../models/profile.model"; -import { AuthService } from "@auth/services"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; /** * Корневой компонент приложения, который служит основным контейнером макета diff --git a/projects/skills/src/app/profile/services/profile.service.ts b/projects/skills/src/app/profile/services/profile.service.ts index 8d8fefe94..8c9176b18 100644 --- a/projects/skills/src/app/profile/services/profile.service.ts +++ b/projects/skills/src/app/profile/services/profile.service.ts @@ -1,8 +1,9 @@ /** @format */ import { inject, Injectable } from "@angular/core"; -import { SkillsApiService, SubscriptionData } from "@corelib"; import { Profile, UserData } from "../../../models/profile.model"; +import { SkillsApiService } from "projects/core/src/lib/services/api/skillsApi.service"; +import { SubscriptionData } from "@corelib"; /** * Служба профилей diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts index e31625742..56327dd90 100644 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts +++ b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts @@ -24,7 +24,7 @@ import { Trajectory } from "projects/skills/src/models/trajectory.model"; import { HttpErrorResponse } from "@angular/common/http"; import { BreakpointObserver } from "@angular/cdk/layout"; import { map, Observable } from "rxjs"; -import { trajectoryMoreList } from "projects/core/src/consts/lists/trajectory-more-list.const"; +import { trajectoryMore } from "projects/core/src/consts/other/trajectory-more.const"; /** * Компонент отображения карточки траектории @@ -53,7 +53,7 @@ import { trajectoryMoreList } from "projects/core/src/consts/lists/trajectory-mo export class TrajectoryComponent implements AfterViewInit, OnInit { @Input() trajectory!: Trajectory; protected readonly dotsArray = Array; - protected readonly trajectoryMore = trajectoryMoreList; + protected readonly trajectoryMore = trajectoryMore; router = inject(Router); trajectoryService = inject(TrajectoriesService); diff --git a/projects/social_platform/src/app/office/services/advert.service.spec.ts b/projects/social_platform/src/app/api/advert/advert.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/services/advert.service.spec.ts rename to projects/social_platform/src/app/api/advert/advert.service.spec.ts index 6f848541c..d2813f521 100644 --- a/projects/social_platform/src/app/office/services/advert.service.spec.ts +++ b/projects/social_platform/src/app/api/advert/advert.service.spec.ts @@ -2,8 +2,8 @@ import { TestBed } from "@angular/core/testing"; -import { AdvertService } from "./advert.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AdvertService } from "./advert.service"; describe("ArticleService", () => { let service: AdvertService; diff --git a/projects/social_platform/src/app/office/services/advert.service.ts b/projects/social_platform/src/app/api/advert/advert.service.ts similarity index 96% rename from projects/social_platform/src/app/office/services/advert.service.ts rename to projects/social_platform/src/app/api/advert/advert.service.ts index 75e7fdfe0..ebe9d3d5d 100644 --- a/projects/social_platform/src/app/office/services/advert.service.ts +++ b/projects/social_platform/src/app/api/advert/advert.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { map, Observable } from "rxjs"; -import { New } from "@models/article.model"; +import { New } from "projects/social_platform/src/app/domain/news/article.model"; import { ApiService } from "projects/core"; import { plainToInstance } from "class-transformer"; diff --git a/projects/social_platform/src/app/auth/services/auth.service.spec.ts b/projects/social_platform/src/app/api/auth/auth.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/auth/services/auth.service.spec.ts rename to projects/social_platform/src/app/api/auth/auth.service.spec.ts diff --git a/projects/social_platform/src/app/auth/services/auth.service.ts b/projects/social_platform/src/app/api/auth/auth.service.ts similarity index 97% rename from projects/social_platform/src/app/auth/services/auth.service.ts rename to projects/social_platform/src/app/api/auth/auth.service.ts index e93a13b9b..99d4a6e01 100644 --- a/projects/social_platform/src/app/auth/services/auth.service.ts +++ b/projects/social_platform/src/app/api/auth/auth.service.ts @@ -9,10 +9,10 @@ import { LoginResponse, RegisterRequest, RegisterResponse, -} from "../models/http.model"; -import { User, UserRole } from "../models/user.model"; -import { Project } from "@office/models/project.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; +} from "../../domain/auth/http.model"; +import { User, UserRole } from "../../domain/auth/user.model"; +import { Project } from "../../domain/project/project.model"; +import { ApiPagination } from "../../domain/other/api-pagination.model"; /** * Сервис аутентификации и управления пользователями diff --git a/projects/social_platform/src/app/api/auth/facades/auth-email.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-email.service.ts new file mode 100644 index 000000000..0c178223d --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-email.service.ts @@ -0,0 +1,80 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { TokenService } from "@corelib"; +import { filter, interval, map, Subject, takeUntil } from "rxjs"; +import { AuthService } from "../auth.service"; + +@Injectable() +export class AuthEmailService { + private readonly tokenService = inject(TokenService); + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + + private readonly destroy$ = new Subject(); + + // ConfirmEmail Component + private readonly userEmail = signal(undefined); + + // VerificationEmail Component + readonly counter = signal(0); + private timerStarted = false; + + // ConfirmEmail Component + + initializationTokens(): void { + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe(queries => { + const { access_token: accessToken, refresh_token: refreshToken } = queries; + this.tokenService.memTokens({ access: accessToken, refresh: refreshToken }); + + if (this.tokenService.getTokens() !== null) { + this.router + .navigateByUrl("/office") + .then(() => console.debug("Route changed from ConfirmEmailComponent")); + } + }); + } + + // VerificationEmail Component + + initializationEmail(): void { + this.route.queryParams + .pipe( + map(r => r["adress"]), + takeUntil(this.destroy$) + ) + .subscribe(address => { + this.userEmail.set(address); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onResend(): void { + if (!this.userEmail()) return; + + this.authService + .resendEmail(this.userEmail()!) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.counter.set(60); + }); + } + + initializationTimer(): void { + if (this.timerStarted) return; + this.timerStarted = true; + this.timer$.subscribe(); + } + + timer$ = interval(1000).pipe( + filter(() => this.counter() > 0), + map(() => this.counter.update(c => c - 1)), + takeUntil(this.destroy$) + ); +} diff --git a/projects/social_platform/src/app/api/auth/facades/auth-login.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-login.service.ts new file mode 100644 index 000000000..bfefd1750 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-login.service.ts @@ -0,0 +1,62 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { TokenService, ValidationService } from "@corelib"; +import { Subject, takeUntil } from "rxjs"; +import { AuthService } from "../auth.service"; +import { AuthUIInfoService } from "./ui/auth-ui-info.service"; +import { LoginRequest } from "../../../domain/auth/http.model"; + +@Injectable() +export class AuthLoginService { + private readonly tokenService = inject(TokenService); + private readonly authService = inject(AuthService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly authUIInfoService = inject(AuthUIInfoService); + + private readonly destroy$ = new Subject(); + + // Login Component + readonly showPassword = this.authUIInfoService.showPassword; + readonly loginIsSubmitting = this.authUIInfoService.loginIsSubmitting; + readonly errorWrongAuth = this.authUIInfoService.errorWrongAuth; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSubmit(loginForm: LoginRequest) { + const redirectType = this.route.snapshot.queryParams["redirect"]; + + this.loginIsSubmitting.set(true); + + this.authService + .login(loginForm) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: res => { + this.tokenService.memTokens(res); + this.loginIsSubmitting.set(false); + + if (!redirectType) + this.router + .navigateByUrl("/office") + .then(() => console.debug("Route changed from LoginComponent")); + else if (redirectType === "program") + this.router + .navigateByUrl("/office/program/list") + .then(() => console.debug("Route changed from LoginComponent")); + }, + error: error => { + if (error.status === 401) { + this.errorWrongAuth.set(true); + } + + this.loginIsSubmitting.set(false); + }, + }); + } +} diff --git a/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts new file mode 100644 index 000000000..e2343352f --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts @@ -0,0 +1,82 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormGroup } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { ValidationService } from "@corelib"; +import { map, Subject, takeUntil } from "rxjs"; +import { AuthService } from "../auth.service"; +import { AuthUIInfoService } from "./ui/auth-ui-info.service"; + +@Injectable() +export class AuthPasswordService { + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + private readonly validationService = inject(ValidationService); + private readonly authUIInfoService = inject(AuthUIInfoService); + + private readonly destroy$ = new Subject(); + + // ResetPassword-Confirmation Component + readonly email = this.route.queryParams.pipe( + map(r => r["email"]), + takeUntil(this.destroy$) + ); + + // ResetPassword Component + readonly isSubmitting = this.authUIInfoService.isSubmitting; + readonly errorRequest = this.authUIInfoService.errorRequest; + readonly credsSubmitInitiated = this.authUIInfoService.credsSubmitInitiated; + private readonly errorServer = this.authUIInfoService.errorServer; + + // ResetPassword Component + onSubmitResetPassword(resetForm: FormGroup): void { + if (!this.validationService.getFormValidation(resetForm)) return; + + this.errorServer.set(false); + this.isSubmitting.set(true); + + this.authService.resetPassword(resetForm.value.email).subscribe({ + next: () => { + this.router + .navigate(["/auth/reset_password/confirm"], { + queryParams: { email: resetForm.value.email }, + }) + .then(() => console.debug("ResetPasswordComponent")); + }, + error: () => { + this.errorServer.set(true); + this.isSubmitting.set(false); + + resetForm.reset(); + }, + complete: () => { + this.isSubmitting.set(false); + }, + }); + } + + // SetPassword Component + onSubmitSetPassword(passwordForm: FormGroup): void { + this.credsSubmitInitiated.set(true); + const token = this.route.snapshot.queryParamMap.get("token"); + + if (!token || !this.validationService.getFormValidation(passwordForm)) return; + + this.authService.setPassword(passwordForm.value.password, token).subscribe({ + next: () => { + this.router.navigateByUrl("/auth/login").then(() => console.debug("SetPasswordComponent")); + }, + error: error => { + console.error("Error setting password:", error); + this.errorRequest.set(true); + }, + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/social_platform/src/app/api/auth/facades/auth-register.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-register.service.ts new file mode 100644 index 000000000..8e61c8d10 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-register.service.ts @@ -0,0 +1,67 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormGroup } from "@angular/forms"; +import { Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; +import { AuthService } from "../auth.service"; +import { ValidationService } from "@corelib"; +import { AuthUIInfoService } from "./ui/auth-ui-info.service"; +import dayjs from "dayjs"; + +@Injectable() +export class AuthRegisterService { + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + private readonly validationService = inject(ValidationService); + private readonly authUIInfoService = inject(AuthUIInfoService); + + readonly registerAgreement = this.authUIInfoService.registerAgreement; + readonly ageAgreement = this.authUIInfoService.ageAgreement; + readonly registerIsSubmitting = this.authUIInfoService.registerIsSubmitting; + readonly credsSubmitInitiated = this.authUIInfoService.credsSubmitInitiated; + readonly infoSubmitInitiated = this.authUIInfoService.infoSubmitInitiated; + + readonly showPassword = this.authUIInfoService.showPassword; + readonly showPasswordRepeat = this.authUIInfoService.showPasswordRepeat; + + readonly isUserCreationModalError = this.authUIInfoService.isUserCreationModalError; + + readonly step = this.authUIInfoService.step; + + readonly serverErrors = signal([]); + + private readonly destroy$ = new Subject(); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSendForm(registerForm: FormGroup): void { + const form = this.authUIInfoService.prepareFormValues(registerForm); + + this.registerIsSubmitting.set(true); + + this.authService + .register(form) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.registerIsSubmitting.set(false); + this.router.navigateByUrl("/auth/verification/email?adress=" + form.email); + }, + error: error => { + const emailErrors = error?.error?.email; + + if (error.status === 400 && Array.isArray(emailErrors)) { + this.serverErrors.set(Object.values(error.error).flat() as string[]); + } else if (error.status === 500) { + this.isUserCreationModalError.set(true); + } + + this.registerIsSubmitting.set(false); + }, + }); + } +} diff --git a/projects/social_platform/src/app/api/auth/facades/ui/auth-ui-info.service.ts b/projects/social_platform/src/app/api/auth/facades/ui/auth-ui-info.service.ts new file mode 100644 index 000000000..e4a8a4faa --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/ui/auth-ui-info.service.ts @@ -0,0 +1,143 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, FormGroup, Validators } from "@angular/forms"; +import { ValidationService } from "@corelib"; +import dayjs from "dayjs"; + +@Injectable() +export class AuthUIInfoService { + private readonly fb = inject(FormBuilder); + private readonly validationService = inject(ValidationService); + + // login + readonly showPasswordRepeat = signal(false); + readonly showPassword = signal(false); + + readonly loginIsSubmitting = signal(false); + readonly errorWrongAuth = signal(false); + + // password + readonly isSubmitting = signal(false); + readonly credsSubmitInitiated = signal(false); + + readonly errorServer = signal(false); + readonly errorRequest = signal(false); + + // register + readonly registerAgreement = signal(false); + readonly ageAgreement = signal(false); + + readonly registerIsSubmitting = signal(false); + readonly infoSubmitInitiated = signal(false); + + readonly isUserCreationModalError = signal(false); + readonly step = signal<"credentials" | "info">("credentials"); + + // login + readonly loginForm = this.fb.group({ + email: ["", [Validators.required, Validators.email]], + password: ["", [Validators.required]], + }); + + // register + readonly registerForm = this.fb.group( + { + firstName: ["", [Validators.required, this.validationService.useLanguageValidator()]], + lastName: ["", [Validators.required, this.validationService.useLanguageValidator()]], + birthday: [ + "", + [ + Validators.required, + this.validationService.useDateFormatValidator, + this.validationService.useAgeValidator(), + ], + ], + email: [ + "", + [Validators.required, Validators.email, this.validationService.useEmailValidator()], + ], + password: ["", [Validators.required, this.validationService.usePasswordValidator(8)]], + repeatedPassword: ["", [Validators.required]], + }, + { validators: [this.validationService.useMatchValidator("password", "repeatedPassword")] } + ); + + // reset password + readonly resetForm = this.fb.group({ + email: ["", [Validators.required, Validators.email]], + }); + + // set password + readonly passwordForm = this.fb.group( + { + password: ["", [Validators.required, this.validationService.usePasswordValidator(8)]], + passwordRepeated: ["", [Validators.required]], + }, + { validators: [this.validationService.useMatchValidator("password", "passwordRepeated")] } + ); + + // Login Component + toggleShowPassword(section: "login" | "register", type?: "repeat" | "first") { + if (section === "login") { + this.showPassword.set(!this.showPassword()); + } else { + if (type === "repeat") { + this.showPasswordRepeat.set(!this.showPasswordRepeat()); + } else { + this.showPassword.set(!this.showPassword()); + } + } + } + + // register + onInfoStep(registerForm: FormGroup) { + const fields = [ + registerForm.get("email"), + registerForm.get("password"), + registerForm.get("repeatedPassword"), + ]; + + const errors = fields.map(field => { + field?.markAsTouched(); + return !!field?.valid; + }); + + if (errors.every(Boolean) && this.registerAgreement() && this.ageAgreement()) { + this.step.set("info"); + } + } + + prepareFormValues(registerForm: FormGroup) { + const form = { + ...registerForm.value, + birthday: registerForm.value.birthday + ? dayjs(registerForm.value.birthday, "DD.MM.YYYY").format("YYYY-MM-DD") + : undefined, + }; + delete form.repeatedPassword; + + return form; + } + + onSubmit(registerForm: FormGroup): FormGroup | null { + if (this.step() === "credentials") { + this.credsSubmitInitiated.set(true); + this.onInfoStep(registerForm); + return null; + } + + if (this.step() === "info") { + this.infoSubmitInitiated.set(true); + + if (registerForm.invalid) { + registerForm.markAllAsTouched(); + return null; + } + + return registerForm; + } + + return null; + } +} diff --git a/projects/social_platform/src/app/auth/services/index.ts b/projects/social_platform/src/app/api/auth/index.ts similarity index 100% rename from projects/social_platform/src/app/auth/services/index.ts rename to projects/social_platform/src/app/api/auth/index.ts diff --git a/projects/social_platform/src/app/auth/services/profile.service.spec.ts b/projects/social_platform/src/app/api/auth/profile.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/auth/services/profile.service.spec.ts rename to projects/social_platform/src/app/api/auth/profile.service.spec.ts diff --git a/projects/social_platform/src/app/auth/services/profile.service.ts b/projects/social_platform/src/app/api/auth/profile.service.ts similarity index 95% rename from projects/social_platform/src/app/auth/services/profile.service.ts rename to projects/social_platform/src/app/api/auth/profile.service.ts index 984a0e909..ed9471b6d 100644 --- a/projects/social_platform/src/app/auth/services/profile.service.ts +++ b/projects/social_platform/src/app/api/auth/profile.service.ts @@ -2,10 +2,10 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; -import { Achievement } from "../models/user.model"; +import { Achievement } from "../../domain/auth/user.model"; import { map, Observable } from "rxjs"; import { plainToInstance } from "class-transformer"; -import { Approve } from "@office/models/skill"; +import { Approve } from "../../domain/skills/skill"; /** * Сервис управления профилем пользователя diff --git a/projects/social_platform/src/app/office/chat/services/chat-direct.service.spec.ts b/projects/social_platform/src/app/api/chat/chat-direct/chat-direct.service.spec.ts similarity index 84% rename from projects/social_platform/src/app/office/chat/services/chat-direct.service.spec.ts rename to projects/social_platform/src/app/api/chat/chat-direct/chat-direct.service.spec.ts index b7349437b..2a25d4586 100644 --- a/projects/social_platform/src/app/office/chat/services/chat-direct.service.spec.ts +++ b/projects/social_platform/src/app/api/chat/chat-direct/chat-direct.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { ChatDirectService } from "./chat-direct.service"; +import { ChatDirectService } from "../ui/pages/chat/services/chat-direct.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; describe("ChatDirectService", () => { diff --git a/projects/social_platform/src/app/office/chat/services/chat-direct.service.ts b/projects/social_platform/src/app/api/chat/chat-direct/chat-direct.service.ts similarity index 90% rename from projects/social_platform/src/app/office/chat/services/chat-direct.service.ts rename to projects/social_platform/src/app/api/chat/chat-direct/chat-direct.service.ts index caa7f180a..9ba7b9803 100644 --- a/projects/social_platform/src/app/office/chat/services/chat-direct.service.ts +++ b/projects/social_platform/src/app/api/chat/chat-direct/chat-direct.service.ts @@ -3,10 +3,10 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { Observable } from "rxjs"; -import { ChatItem, ChatListItem } from "@office/chat/models/chat-item.model"; import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { ChatMessage } from "@models/chat-message.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { ChatMessage } from "projects/social_platform/src/app/domain/chat/chat-message.model"; +import { ChatItem, ChatListItem } from "../../../domain/chat/chat-item.model"; /** * Сервис для работы с прямыми чатами (личными сообщениями) diff --git a/projects/social_platform/src/app/office/chat/services/chat-project.service.spec.ts b/projects/social_platform/src/app/api/chat/chat-projects/chat-project.service.spec.ts similarity index 84% rename from projects/social_platform/src/app/office/chat/services/chat-project.service.spec.ts rename to projects/social_platform/src/app/api/chat/chat-projects/chat-project.service.spec.ts index 73de1886e..dd724c298 100644 --- a/projects/social_platform/src/app/office/chat/services/chat-project.service.spec.ts +++ b/projects/social_platform/src/app/api/chat/chat-projects/chat-project.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { ChatProjectService } from "./chat-project.service"; +import { ChatProjectService } from "../ui/pages/chat/services/chat-project.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; describe("ChatProjectService", () => { diff --git a/projects/social_platform/src/app/office/chat/services/chat-project.service.ts b/projects/social_platform/src/app/api/chat/chat-projects/chat-project.service.ts similarity index 93% rename from projects/social_platform/src/app/office/chat/services/chat-project.service.ts rename to projects/social_platform/src/app/api/chat/chat-projects/chat-project.service.ts index 3dce31d6d..9546ee992 100644 --- a/projects/social_platform/src/app/office/chat/services/chat-project.service.ts +++ b/projects/social_platform/src/app/api/chat/chat-projects/chat-project.service.ts @@ -3,7 +3,7 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { Observable } from "rxjs"; -import { ChatListItem } from "@office/chat/models/chat-item.model"; +import { ChatListItem } from "../../../domain/chat/chat-item.model"; /** * Сервис для работы с чатами проектов (групповыми чатами) diff --git a/projects/social_platform/src/app/office/services/chat.service.spec.ts b/projects/social_platform/src/app/api/chat/chat.service.spec.ts similarity index 90% rename from projects/social_platform/src/app/office/services/chat.service.spec.ts rename to projects/social_platform/src/app/api/chat/chat.service.spec.ts index 53af8d191..8d74e76c3 100644 --- a/projects/social_platform/src/app/office/services/chat.service.spec.ts +++ b/projects/social_platform/src/app/api/chat/chat.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { ChatService } from "./chat.service"; +import { ChatService } from "../chat.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; describe("ChatService", () => { diff --git a/projects/social_platform/src/app/office/services/chat.service.ts b/projects/social_platform/src/app/api/chat/chat.service.ts similarity index 96% rename from projects/social_platform/src/app/office/services/chat.service.ts rename to projects/social_platform/src/app/api/chat/chat.service.ts index dee4ed65d..b195efa23 100644 --- a/projects/social_platform/src/app/office/services/chat.service.ts +++ b/projects/social_platform/src/app/api/chat/chat.service.ts @@ -2,9 +2,12 @@ import { HttpParams } from "@angular/common/http"; import { Injectable } from "@angular/core"; -import { WebsocketService } from "@core/services/websocket.service"; -import { ApiPagination } from "@models/api-pagination.model"; -import { ChatFile, ChatMessage } from "@models/chat-message.model"; +import { WebsocketService } from "projects/core/src/lib/services/websockets/websocket.service"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { + ChatFile, + ChatMessage, +} from "projects/social_platform/src/app/domain/chat/chat-message.model"; import { ChatEventType, DeleteChatMessageDto, @@ -18,7 +21,7 @@ import { SendChatMessageDto, TypingInChatDto, TypingInChatEventDto, -} from "@models/chat.model"; +} from "projects/social_platform/src/app/domain/chat/chat.model"; import { plainToInstance } from "class-transformer"; import { ApiService, TokenService } from "projects/core"; import { BehaviorSubject, map, Observable } from "rxjs"; diff --git a/projects/social_platform/src/app/api/chat/facedes/chat-direct-info.service.ts b/projects/social_platform/src/app/api/chat/facedes/chat-direct-info.service.ts new file mode 100644 index 000000000..010927c91 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/facedes/chat-direct-info.service.ts @@ -0,0 +1,273 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { AuthService } from "../../auth"; +import { ChatService } from "../chat.service"; +import { ChatDirectService } from "../chat-direct/chat-direct.service"; +import { ChatMessage } from "../../../domain/chat/chat-message.model"; +import { map, Observable, Subject, switchMap, takeUntil, tap } from "rxjs"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { ChatDirectUIInfoService } from "./ui/chat-direct-ui-info.service"; + +@Injectable() +export class ChatDirectInfoService { + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly chatService = inject(ChatService); + private readonly chatDirectService = inject(ChatDirectService); + private readonly chatDirectUIInfoService = inject(ChatDirectUIInfoService); + + private readonly destroy$ = new Subject(); + + // Сохраняем тип чата для использования в методах + private chatType: "direct" | "project" = "direct"; + + /** Список пользователей, которые сейчас печатают */ + readonly typingPersons = this.chatDirectUIInfoService.typingPersons; + + /** Данные текущего чата */ + readonly chat = this.chatDirectUIInfoService.chat; + + /** Массив сообщений чата */ + readonly messages = this.chatDirectUIInfoService.messages; + + /** Все файлы, загруженные в чат */ + readonly chatFiles = this.chatDirectUIInfoService.chatFiles; + + /** ID текущего пользователя */ + readonly currentUserId = this.chatDirectUIInfoService.currentUserId; + + /** Флаг процесса загрузки сообщений */ + readonly fetching = this.chatDirectUIInfoService.fetching; + + initializationChatDirect(type: "direct" | "project"): void { + this.chatType = type; // Сохраняем тип чата + + this.route.data + .pipe( + map(r => r["data"]), + tap(chat => this.chat.set(chat)), + switchMap(() => this.fetchMessages(type)), + takeUntil(this.destroy$) + ) + .subscribe(); + + // Инициализация обработчиков WebSocket событий + this.initMessageEvent(); + this.initTypingEvent(); + this.initDeleteEvent(); + this.initEditEvent(); + this.initReadEvent(); + + this.initializationProfile(); + } + + /** + * Инициализирует загрузку файлов чата + * Для прямых чатов: загружает файлы из прямого чата + * Для чатов проектов: загружает файлы из проекта + * + */ + initializationChatFiles(): void { + if (this.chatType === "project") { + // Загрузка файлов чата проекта + this.chatService + .loadProjectFiles(Number(this.route.parent?.snapshot.paramMap.get("projectId"))) + .pipe(takeUntil(this.destroy$)) + .subscribe(files => { + this.chatFiles.set(files); + }); + } else if (this.chatType === "direct") { + // Загрузка файлов прямого чата + const chatId = this.chat()?.id; + if (chatId) { + this.chatService + .loadDirectChatFiles(chatId) + .pipe(takeUntil(this.destroy$)) + .subscribe( + files => { + this.chatFiles.set(files); + }, + error => { + // Если метод не поддерживается на сервере, логируем ошибку + console.warn("Failed to load direct chat files:", error); + } + ); + } + } + } + + private initializationProfile(): void { + this.authService.profile.pipe(takeUntil(this.destroy$)).subscribe(u => { + this.currentUserId.set(u.id); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + + this.chatDirectUIInfoService.clearTypingTimeouts(); + } + + /** + * Получает ID чата в зависимости от типа + */ + private getChatId(): string { + return this.chatType === "direct" + ? this.chat()?.id ?? "" + : this.route.parent?.snapshot.paramMap.get("projectId") ?? ""; + } + + /** + * Загружает сообщения чата с сервера с поддержкой пагинации + */ + private fetchMessages(type: "direct" | "project"): Observable> { + return type === "direct" + ? this.chatDirectService + .loadMessages( + this.getChatId(), + this.messages().length > 0 ? this.messages().length : 0, + this.chatDirectUIInfoService.messagesPerFetch + ) + .pipe( + tap(messages => { + this.chatDirectUIInfoService.applyInitMessagesEvent(messages); + }) + ) + : this.chatService + .loadMessages( + this.getChatId(), + this.messages().length > 0 ? this.messages().length : 0, + this.chatDirectUIInfoService.messagesPerFetch + ) + .pipe( + tap(messages => { + this.chatDirectUIInfoService.applyInitMessagesEvent(messages); + }) + ); + } + + private initMessageEvent(): void { + this.chatService + .onMessage() + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + this.chatDirectUIInfoService.applyMessageEvent(result); + }); + } + + private initTypingEvent(): void { + this.chatService + .onTyping() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.chatDirectUIInfoService.applyTypingEvent(); + }); + } + + private initEditEvent(): void { + this.chatService + .onEditMessage() + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + this.chatDirectUIInfoService.editMessahesEvent(result); + }); + } + + private initDeleteEvent(): void { + this.chatService + .onDeleteMessage() + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + this.chatDirectUIInfoService.deleteMessagesEvent(result); + }); + } + + private initReadEvent(): void { + this.chatService + .onReadMessage() + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + this.chatDirectUIInfoService.readMessagesEvent(result); + }); + } + + /** + * Обработчик запроса на загрузку дополнительных сообщений + */ + onFetchMessages(): void { + if ( + (this.messages().length < this.chatDirectUIInfoService.messagesTotalCount() || + this.chatDirectUIInfoService.messagesTotalCount() === 0) && + !this.fetching() + ) { + this.fetching.set(true); + this.fetchMessages(this.chatType) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.fetching.set(false); + }); + } + } + + /** + * Обработчик отправки нового сообщения + */ + onSubmitMessage(message: any): void { + this.chatService.sendMessage({ + replyTo: message.replyTo, + text: message.text, + fileUrls: message.fileUrls, + chatType: this.chatType, + chatId: this.getChatId(), + }); + } + + /** + * Обработчик редактирования сообщения + */ + onEditMessage(message: ChatMessage): void { + this.chatService.editMessage({ + text: message.text, + messageId: message.id, + chatType: this.chatType, + chatId: this.getChatId(), + }); + } + + /** + * Обработчик удаления сообщения + */ + onDeleteMessage(messageId: number): void { + this.chatService.deleteMessage({ + chatId: this.getChatId(), + chatType: this.chatType, + messageId, + }); + } + + /** + * Обработчик события печатания + * Использует сохранённый chatType + */ + onType(): void { + this.chatService.startTyping({ + chatType: this.chatType, + chatId: this.getChatId(), + }); + } + + /** + * Обработчик прочтения сообщения + * Использует сохранённый chatType + */ + onReadMessage(messageId: number): void { + this.chatService.readMessage({ + chatType: this.chatType, + chatId: this.getChatId(), + messageId, + }); + } +} diff --git a/projects/social_platform/src/app/api/chat/facedes/chat-info.service.ts b/projects/social_platform/src/app/api/chat/facedes/chat-info.service.ts new file mode 100644 index 000000000..3e4fe6044 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/facedes/chat-info.service.ts @@ -0,0 +1,84 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { NavService } from "@ui/services/nav/nav.service"; +import { AuthService } from "../../auth"; +import { ChatService } from "../chat.service"; +import { ChatListItem } from "../../../domain/chat/chat-item.model"; +import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs"; +import { toObservable } from "@angular/core/rxjs-interop"; +import { ChatUIInfoService } from "./ui/chat-ui-info.service"; + +@Injectable() +export class ChatInfoService { + private readonly navService = inject(NavService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + private readonly chatService = inject(ChatService); + private readonly chatUIInfoService = inject(ChatUIInfoService); + + private readonly destroy$ = new Subject(); + + private readonly chatsData = this.chatUIInfoService.chatsData; + + readonly chats: Observable = combineLatest([ + this.authService.profile, + toObservable(this.chatsData), + ]).pipe( + map(([profile, chats]) => + chats.map(chat => ({ + ...chat, + unread: profile.id !== chat.lastMessage.author.id && !chat.lastMessage.isRead, + })) + ), + map(chats => chats.sort((a, b) => Number(b.unread) - Number(a.unread))), + map(chats => chats.map(({ unread, ...chat }) => chat)), + takeUntil(this.destroy$) + ); + + initializationChats(): void { + this.navService.setNavTitle("Чат"); + + setTimeout(() => { + this.chatService.unread$.next(false); + }); + + this.initializationChatMessage(); + + this.route.data + .pipe( + map(r => r["data"]), + takeUntil(this.destroy$) + ) + .subscribe(chats => { + this.chatsData.set(chats); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private initializationChatMessage(): void { + this.chatService + .onMessage() + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + this.chatUIInfoService.applyInitializationMessages(result); + }); + } + + onGotoChat(id: string | number) { + const redirectUrl = + typeof id === "string" && id.includes("_") + ? `/office/chats/${id}` + : `/office/projects/${id}/chat`; + + this.router + .navigateByUrl(redirectUrl) + .then(() => console.debug("Route changed from ChatComponent")); + } +} diff --git a/projects/social_platform/src/app/api/chat/facedes/ui/chat-direct-ui-info.service.ts b/projects/social_platform/src/app/api/chat/facedes/ui/chat-direct-ui-info.service.ts new file mode 100644 index 000000000..954692aa9 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/facedes/ui/chat-direct-ui-info.service.ts @@ -0,0 +1,99 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { ChatWindowComponent } from "@ui/components/chat-window/chat-window.component"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { ChatItem } from "projects/social_platform/src/app/domain/chat/chat-item.model"; +import { + ChatFile, + ChatMessage, +} from "projects/social_platform/src/app/domain/chat/chat-message.model"; +import { + OnChatMessageDto, + OnDeleteChatMessageDto, + OnEditChatMessageDto, + OnReadChatMessageDto, +} from "projects/social_platform/src/app/domain/chat/chat.model"; + +@Injectable() +export class ChatDirectUIInfoService { + /** Список пользователей, которые сейчас печатают */ + readonly typingPersons = signal([]); + private readonly typingTimeouts = new Set(); + + /** Данные текущего чата */ + readonly chat = signal(undefined); + + /** Массив сообщений чата */ + readonly messages = signal([]); + + /** Все файлы, загруженные в чат */ + readonly chatFiles = signal([]); + + /** ID текущего пользователя */ + readonly currentUserId = signal(undefined); + + /** Флаг процесса загрузки сообщений */ + readonly fetching = signal(false); + + /** + * Количество сообщений, загружаемых за один запрос + */ + readonly messagesPerFetch = 20; + + /** + * Общее количество сообщений в чате (приходит с сервера) + */ + readonly messagesTotalCount = signal(0); + + readMessagesEvent(result: OnReadChatMessageDto): void { + this.messages.update(list => + list.map(m => (m.id === result.messageId ? { ...m, isRead: true } : m)) + ); + } + + deleteMessagesEvent(result: OnDeleteChatMessageDto): void { + this.messages.update(list => list.filter(m => m.id !== result.messageId)); + } + + editMessahesEvent(result: OnEditChatMessageDto): void { + this.messages.update(list => list.map(m => (m.id === result.message.id ? result.message : m))); + } + + applyTypingEvent(): void { + if (!this.chat()?.opponent) return; + + const userId = this.chat()!.opponent.id; + + this.typingPersons.update(list => [ + ...list, + { + firstName: this.chat()!.opponent.firstName, + lastName: this.chat()!.opponent.lastName, + userId, + }, + ]); + + const timeoutId = window.setTimeout(() => { + this.typingPersons.update(list => list.filter(p => p.userId !== userId)); + this.typingTimeouts.delete(timeoutId); + }, 2000); + + this.typingTimeouts.add(timeoutId); + } + + applyMessageEvent(result: OnChatMessageDto): void { + this.messages.update(() => [...this.messages(), result.message]); + } + + applyInitMessagesEvent(messages: ApiPagination): void { + // Добавляем новые сообщения в начало массива (реверсируем порядок с сервера) + this.messages.update(() => messages.results.reverse().concat(this.messages())); + this.messagesTotalCount.set(messages.count); + } + + clearTypingTimeouts(): void { + this.typingTimeouts.forEach(id => clearTimeout(id)); + this.typingTimeouts.clear(); + } +} diff --git a/projects/social_platform/src/app/api/chat/facedes/ui/chat-ui-info.service.ts b/projects/social_platform/src/app/api/chat/facedes/ui/chat-ui-info.service.ts new file mode 100644 index 000000000..2d6be5924 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/facedes/ui/chat-ui-info.service.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { ChatListItem } from "projects/social_platform/src/app/domain/chat/chat-item.model"; +import { OnChatMessageDto } from "projects/social_platform/src/app/domain/chat/chat.model"; + +@Injectable() +export class ChatUIInfoService { + readonly chatsData = signal([]); + + applyInitializationMessages(result: OnChatMessageDto): void { + this.chatsData.update(list => { + const idx = list.findIndex(c => c.id === result.chatId); + if (idx === -1) return list; + + return list.map((chat, i) => (i === idx ? { ...chat, lastMessage: result.message } : chat)); + }); + } +} diff --git a/projects/social_platform/src/app/api/expand/expand.service.ts b/projects/social_platform/src/app/api/expand/expand.service.ts new file mode 100644 index 000000000..d7b9ad8f7 --- /dev/null +++ b/projects/social_platform/src/app/api/expand/expand.service.ts @@ -0,0 +1,50 @@ +/** @format */ + +import { ElementRef, Injectable, signal } from "@angular/core"; +import { expandElement } from "@utils/expand-element"; + +@Injectable({ providedIn: "root" }) +export class ExpandService { + readonly readFullDescription = signal(false); + readonly descriptionExpandable = signal(false); + + readonly readFullSkills = signal(false); + readonly skillsExpandable = signal(false); + + readonly readAllAchievements = signal(false); // Флаг показа всех достижений + + readonly readAllVacancies = signal(false); // Флаг показа всех вакансий + + readonly readAllMembers = signal(false); // Флаг показа всех участников + + readonly readAllProjects = signal(false); + + readonly readAllPrograms = signal(false); + + readonly readAllLinks = signal(false); + + readonly readAllEducation = signal(false); + + readonly readAllLanguages = signal(false); + + readonly readAllWorkExperience = signal(false); + + onExpand( + type: "description" | "skills", + elem: HTMLElement, + expandedClass: string, + isExpanded: boolean + ): void { + expandElement(elem, expandedClass, isExpanded); + type === "description" + ? this.readFullDescription.set(!isExpanded) + : this.readFullSkills.set(!isExpanded); + } + + checkExpandable(type: "description" | "skills", hasText: boolean, descEl?: ElementRef): void { + const el = descEl?.nativeElement; + type === "description" + ? this.descriptionExpandable.set(!!el && hasText && el.scrollHeight > el.clientHeight) + : this.skillsExpandable.set(!!el && hasText && el.scrollHeight > el.clientHeight); + } +} diff --git a/projects/social_platform/src/app/api/feed/facades/feed-info.service.ts b/projects/social_platform/src/app/api/feed/facades/feed-info.service.ts new file mode 100644 index 000000000..7a0d1f896 --- /dev/null +++ b/projects/social_platform/src/app/api/feed/facades/feed-info.service.ts @@ -0,0 +1,230 @@ +/** @format */ + +import { ElementRef, inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { + concatMap, + EMPTY, + fromEvent, + map, + Observable, + skip, + Subject, + takeUntil, + tap, + throttleTime, +} from "rxjs"; +import { ProjectNewsService } from "../../project/project-news.service"; +import { ProfileNewsService } from "../../profile/profile-news.service"; +import { FeedService } from "../feed.service"; +import { FeedItem, FeedItemType } from "../../../domain/feed/feed-item.model"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { FeedUIInfoService } from "./ui/feed-ui-info.service"; + +@Injectable() +export class FeedInfoService { + private readonly route = inject(ActivatedRoute); + private readonly projectNewsService = inject(ProjectNewsService); + private readonly profileNewsService = inject(ProfileNewsService); + private readonly feedService = inject(FeedService); + private readonly feedUIInfoService = inject(FeedUIInfoService); + + private observer?: IntersectionObserver; + private readonly destroy$ = new Subject(); + + readonly feedItems = this.feedUIInfoService.feedItems; + + private readonly includes = signal([]); + + initializationFeedNews(feedRoot: ElementRef): void { + this.route.data + .pipe( + map(r => r["data"]), + takeUntil(this.destroy$) + ) + .subscribe((feed: ApiPagination) => { + this.feedUIInfoService.applyInitializationFeedNewsEvent(feed); + + this.observer?.disconnect(); + + this.observer = new IntersectionObserver(this.onFeedItemView.bind(this), { + root: document.querySelector(".office__body"), + threshold: 0, + }); + }); + + this.route.queryParams + .pipe( + map(params => params["includes"]), + tap(includes => { + this.includes.set(includes); + }), + skip(1), + concatMap(includes => { + this.feedUIInfoService.totalItemsCount.set(0); + this.feedUIInfoService.feedPage.set(0); + + return this.onFetch( + 0, + this.feedUIInfoService.perFetchTake(), + includes ?? ["vacancy", "project", "news"] + ); + }), + takeUntil(this.destroy$) + ) + .subscribe(feed => { + this.feedUIInfoService.applyFeedFilters(feed); + + setTimeout(() => { + feedRoot?.nativeElement.children[0].scrollIntoView({ behavior: "smooth" }); + }); + }); + } + + // onScroll Section + // ------------------- + + private onScroll(target: HTMLElement, feedRoot: ElementRef): Observable { + if ( + this.feedUIInfoService.totalItemsCount() && + this.feedItems().length >= this.feedUIInfoService.totalItemsCount() + ) + return EMPTY; + + if (!target || !feedRoot) return EMPTY; + + const diff = + target.scrollTop - feedRoot.nativeElement.getBoundingClientRect().height + window.innerHeight; + + if (diff > 0) { + const currentOffset = this.feedItems().length; + + return this.onFetch( + currentOffset, + this.feedUIInfoService.perFetchTake(), + this.includes() + ).pipe( + tap((feedChunk: FeedItem[]) => { + const existingIds = new Set(this.feedItems().map(item => item.content.id)); + const uniqueNewItems = feedChunk.filter(item => !existingIds.has(item.content.id)); + + if (uniqueNewItems.length > 0) { + this.feedUIInfoService.feedPage.update(page => page + uniqueNewItems.length); + this.feedItems.update(items => { + const next = [...items, ...uniqueNewItems]; + queueMicrotask(() => this.observeFeedItems()); + return next; + }); + } + }) + ); + } + + return EMPTY; + } + + // target for Scroll Section + // ------------------- + + initScroll(target: HTMLElement, feedRoot: ElementRef): void { + if (target) { + fromEvent(target, "scroll") + .pipe( + throttleTime(100), + concatMap(() => this.onScroll(target, feedRoot)), + takeUntil(this.destroy$) + ) + .subscribe(); + } + } + + private onFetch( + offset: number, + limit: number, + includes: FeedItemType[] = ["project", "vacancy", "news"] + ) { + return this.feedService.getFeed(offset, limit, includes).pipe( + tap(res => { + this.feedUIInfoService.totalItemsCount.set(res.count); + }), + map(res => res.results) + ); + } + + private onFeedItemView(entries: IntersectionObserverEntry[]): void { + const items = entries + .map(e => { + return Number((e.target as HTMLElement).dataset["id"]); + }) + .map(id => this.feedItems().find(item => item.content.id === id)) + .filter(Boolean) as FeedItem[]; + + const projectNews = items.filter( + item => item.typeModel === "news" && !("email" in item.content.contentObject) + ); + const profileNews = items.filter( + item => item.typeModel === "news" && "email" in item.content.contentObject + ); + + projectNews.forEach(news => { + if (news.typeModel !== "news") return; + this.projectNewsService + .readNews(news.content.contentObject.id, [news.content.id]) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + }); + + profileNews.forEach(news => { + if (news.typeModel !== "news") return; + this.profileNewsService + .readNews(news.content.contentObject.id, [news.content.id]) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + }); + } + + onLike(newsId: number) { + const itemIdx = this.feedItems().findIndex(n => n.content.id === newsId); + + const item = this.feedItems()[itemIdx]; + if (!item || item.typeModel !== "news") return; + + if ("email" in item.content.contentObject) { + this.profileNewsService + .toggleLike( + item.content.contentObject.id as unknown as string, + newsId, + !item.content.isUserLiked + ) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.feedUIInfoService.applyLikeNews(itemIdx); + }); + } else if ("leader" in item.content.contentObject) { + this.projectNewsService + .toggleLike( + item.content.contentObject.id as unknown as string, + newsId, + !item.content.isUserLiked + ) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.feedUIInfoService.applyLikeNews(itemIdx); + }); + } + } + + private observeFeedItems(): void { + if (!this.observer) return; + + document.querySelectorAll(".page__item").forEach(el => { + this.observer!.observe(el); + }); + } + + destroy(): void { + this.observer?.disconnect(); + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.ts b/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.ts new file mode 100644 index 000000000..325aa52d4 --- /dev/null +++ b/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.ts @@ -0,0 +1,44 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { FeedItem } from "projects/social_platform/src/app/domain/feed/feed-item.model"; + +@Injectable() +export class FeedUIInfoService { + readonly feedItems = signal([]); + + readonly totalItemsCount = signal(0); + readonly feedPage = signal(0); + readonly perFetchTake = signal(20); + + applyInitializationFeedNewsEvent(feed: ApiPagination): void { + this.feedItems.set(feed.results); + this.totalItemsCount.set(feed.count); + this.feedPage.set(feed.results.length); + } + + applyFeedFilters(feed: FeedItem[]): void { + this.feedItems.set(feed); + this.feedPage.set(feed.length); + } + + applyLikeNews(itemIdx: number): void { + this.feedItems.update((items: any) => { + const item = items[itemIdx]; + + const updated = { + ...item, + content: { + ...item.content, + likesCount: item.content.isUserLiked + ? item.content.likesCount - 1 + : item.content.likesCount + 1, + isUserLiked: !item.content.isUserLiked, + }, + }; + + return items.map((it: any, i: number) => (i === itemIdx ? updated : it)); + }); + } +} diff --git a/projects/social_platform/src/app/office/feed/services/feed.service.ts b/projects/social_platform/src/app/api/feed/feed.service.ts similarity index 95% rename from projects/social_platform/src/app/office/feed/services/feed.service.ts rename to projects/social_platform/src/app/api/feed/feed.service.ts index 740d0cc1b..5a193940a 100644 --- a/projects/social_platform/src/app/office/feed/services/feed.service.ts +++ b/projects/social_platform/src/app/api/feed/feed.service.ts @@ -3,9 +3,9 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { Observable } from "rxjs"; -import { FeedItem, FeedItemType } from "@office/feed/models/feed-item.model"; -import { ApiPagination } from "@models/api-pagination.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; +import { FeedItem, FeedItemType } from "../../domain/feed/feed-item.model"; /** * СЕРВИС ДЛЯ РАБОТЫ С ЛЕНТОЙ НОВОСТЕЙ diff --git a/projects/social_platform/src/app/office/services/industry.service.spec.ts b/projects/social_platform/src/app/api/industry/industry.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/services/industry.service.spec.ts rename to projects/social_platform/src/app/api/industry/industry.service.spec.ts diff --git a/projects/social_platform/src/app/office/services/industry.service.ts b/projects/social_platform/src/app/api/industry/industry.service.ts similarity index 97% rename from projects/social_platform/src/app/office/services/industry.service.ts rename to projects/social_platform/src/app/api/industry/industry.service.ts index 6fb9b8f1e..e4f489dd3 100644 --- a/projects/social_platform/src/app/office/services/industry.service.ts +++ b/projects/social_platform/src/app/api/industry/industry.service.ts @@ -3,7 +3,7 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { BehaviorSubject, catchError, map, Observable, tap, throwError } from "rxjs"; -import { Industry } from "@models/industry.model"; +import { Industry } from "projects/social_platform/src/app/domain/industry/industry.model"; import { plainToInstance } from "class-transformer"; /** diff --git a/projects/social_platform/src/app/office/services/invite.service.spec.ts b/projects/social_platform/src/app/api/invite/invite.service.spec.ts similarity index 90% rename from projects/social_platform/src/app/office/services/invite.service.spec.ts rename to projects/social_platform/src/app/api/invite/invite.service.spec.ts index b4e18f320..866f32d30 100644 --- a/projects/social_platform/src/app/office/services/invite.service.spec.ts +++ b/projects/social_platform/src/app/api/invite/invite.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { InviteService } from "./invite.service"; +import { InviteService } from "../office/services/invite.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { of } from "rxjs"; import { AuthService } from "@auth/services"; diff --git a/projects/social_platform/src/app/office/services/invite.service.ts b/projects/social_platform/src/app/api/invite/invite.service.ts similarity index 97% rename from projects/social_platform/src/app/office/services/invite.service.ts rename to projects/social_platform/src/app/api/invite/invite.service.ts index 787186781..f948d74ba 100644 --- a/projects/social_platform/src/app/office/services/invite.service.ts +++ b/projects/social_platform/src/app/api/invite/invite.service.ts @@ -4,9 +4,9 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { concatMap, map, Observable, take } from "rxjs"; import { plainToInstance } from "class-transformer"; -import { Invite } from "@models/invite.model"; +import { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; import { HttpParams } from "@angular/common/http"; -import { AuthService } from "@auth/services"; +import { AuthService } from "../auth"; /** * Сервис для управления приглашениями в проекты diff --git a/projects/social_platform/src/app/api/kanban/dto/comment.model.dto.ts b/projects/social_platform/src/app/api/kanban/dto/comment.model.dto.ts new file mode 100644 index 000000000..7ea79202e --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/dto/comment.model.dto.ts @@ -0,0 +1,11 @@ +/** @format */ + +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; + +export interface CommentDto { + id: number; + text: string; + files: FillMode[]; + author: User; + createdAt: string; +} diff --git a/projects/social_platform/src/app/api/kanban/dto/performer.model.dto.ts b/projects/social_platform/src/app/api/kanban/dto/performer.model.dto.ts new file mode 100644 index 000000000..b069ae9e3 --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/dto/performer.model.dto.ts @@ -0,0 +1,7 @@ +/** @format */ + +export interface PerformerDto { + id: number; + avatar: string; + name: string; +} diff --git a/projects/social_platform/src/app/api/kanban/dto/tag.model.dto.ts b/projects/social_platform/src/app/api/kanban/dto/tag.model.dto.ts new file mode 100644 index 000000000..fcba6dabd --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/dto/tag.model.dto.ts @@ -0,0 +1,7 @@ +/** @format */ + +export interface TagDto { + id?: number; + name: string; + color: string; +} diff --git a/projects/social_platform/src/app/api/kanban/kanban-board-detail-info.service.ts b/projects/social_platform/src/app/api/kanban/kanban-board-detail-info.service.ts new file mode 100644 index 000000000..02d002d5f --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/kanban-board-detail-info.service.ts @@ -0,0 +1,177 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { TaskDetail } from "../../domain/kanban/task.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { filter, Observable, of, Subject } from "rxjs"; +import { ProjectDataService } from "../project/project-data.service"; +import { ActivatedRoute, Router } from "@angular/router"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; + +@Injectable({ + providedIn: "root", +}) +export class KanbanBoardDetailInfoService { + taskDetail = signal( + undefined + // { + // id: 5, + // columnId: 0, + // title: "Начинаем новый проект", + // priority: 0, + // type: 2, + // description: null, + // deadlineDate: "12-11-2025", + // tags: [], + // goal: null, + // files: [], + // responsible: null, + // performers: [], + // score: 10, + // creator: + // { + // id: 56, + // avatar: "https://api.selcdn.ru/v1/SEL_228194/procollab_media/5388035211510428528/2458680223122098610_2202079899633949339.webp", + // firstName: "Егоg", + // lastName: "Токареg", + // }, + // datetimeCreated: "11-10-2025 12:00", + // datetimeTaskStart: "11-11-2025", + // requiredSkills: [], + // isLeaderLeaveComment: false, + // projectGoal: null, + // result: null, + + // // result: { + // // isVerified: false, + // // description: "123", + // // accompanyingFile: null, + // // whoVerified: { + // // id: 11, + // // firstName: "Егоg", + // // lastName: "Токареg", + // // } + // // }, + // } + ); + + readonly currentUser = signal(null); + + private readonly authService = inject(AuthService); + private readonly projectDataService = inject(ProjectDataService); + private readonly router = inject(Router); + readonly route = inject(ActivatedRoute); + + private deleteTaskSubject = new Subject(); + + constructor() { + this.authService.profile + .pipe(filter(Boolean)) + .subscribe(profile => this.currentUser.set(profile)); + } + + setTaskDetailInfo(detail: TaskDetail | undefined) { + this.taskDetail.set(detail); + } + + leaderId = this.projectDataService.leaderId; + collaborators = this.projectDataService.collaborators; + + isLeader = computed(() => { + const user = this.currentUser(); + const leader = this.leaderId(); + return !!user && user.id === leader; + }); + + isCreator = computed(() => { + const user = this.currentUser(); + const task = this.taskDetail(); + return !!user && user.id === task?.creator?.id; + }); + + isPerformer = computed(() => { + const user = this.currentUser(); + const task = this.taskDetail(); + return !!user && !!task?.performers?.some(p => p.id === user.id); + }); + + isResponsible = computed(() => { + const user = this.currentUser(); + const task = this.taskDetail(); + return !!user && user.id === task?.responsible?.id; + }); + + isExternal = computed(() => { + return !(this.isLeader() || this.isCreator() || this.isPerformer() || this.isResponsible()); + }); + + isTaskResult = computed(() => { + return this.taskDetail()?.result; + }); + + isLeaderAcceptResult = computed(() => { + const result = this.isTaskResult(); + const leaderId = this.leaderId(); + + if (!leaderId || !result) return false; + + return !!result && result.isVerified && result.whoVerified.id === leaderId; + }); + + isLeaderLeaveComment = computed(() => this.taskDetail()?.isLeaderLeaveComment); + + isArchivePage = computed(() => { + return location.href.includes("archive"); + }); + + isOverdue = computed(() => { + const task = this.taskDetail(); + + if (!task) return; + + if (!task.deadlineDate) return false; + + const nowDate = new Date(); + const deadline = new Date(task.deadlineDate); + + return nowDate > deadline; + }); + + diffDaysOfCompletedTask = computed(() => { + const task = this.taskDetail(); + + if (!task) return; + + if (!task.deadlineDate || !task.startDate) return 0; + + const start = new Date(task.startDate); + const end = new Date(task.deadlineDate); + + const diffMs = end.getTime() - start.getTime(); + + return diffMs / (1000 * 60 * 60 * 24); + }); + + closeDetailTask(): void { + this.router.navigate([], { + queryParams: { taskId: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + + openDetailTask(taskId: number): void { + this.router.navigate([], { + queryParams: { taskId }, + queryParamsHandling: "merge", + }); + } + + requestDeleteTask(taskId: number): void { + this.deleteTaskSubject.next(taskId); + } + + onTaskDelete(): Observable { + return this.deleteTaskSubject.asObservable(); + } +} diff --git a/projects/social_platform/src/app/api/kanban/kanban-board-info.service.ts b/projects/social_platform/src/app/api/kanban/kanban-board-info.service.ts new file mode 100644 index 000000000..9c5eeeaf0 --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/kanban-board-info.service.ts @@ -0,0 +1,58 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { Board } from "../../domain/kanban/board.model"; + +@Injectable({ providedIn: "root" }) +export class KanbanBoardInfoService { + readonly boardInfo = signal({ + id: 1, + name: "123", + description: + "bhbhhbhbbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhhbbhbhhbbhbhbhhbbhbhbhbhbhbhbhhbhbhbbhbhbhbhhbbhhbbhbhbhbhhbhbbhbhbhbhhbhbbhbhbhhbbhhbhbhbhbhbhbbhbhhbbhhbhbhbbhbhhbbhbhbhbhbhbhbhbhbhhbbhbhbhbhbhbhhb", + color: "accent", + icon: "task", + }); + + readonly boards = signal([ + { + id: 1, + name: "123", + description: + "bhbhhbhbbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhbhhbbhbhhbbhbhbhhbbhbhbhbhbhbhbhhbhbhbbhbhbhbhhbbhhbbhbhbhbhhbhbbhbhbhbhhbhbbhbhbhhbbhhbhbhbhbhbhbbhbhhbbhhbhbhbbhbhhbbhbhbhbhbhbhbhbhbhhbbhbhbhbhbhbhhb", + color: "accent", + icon: "task", + }, + { + id: 2, + name: "456", + description: "kklmklmsklmcslkmcdskmcdslkcdslkmcdsklmscklmcdsklmsdc", + color: "blue-dark", + icon: "command", + }, + ]); + + readonly selectedBoardId = signal(0); + + setBoardInProject(board: Board): void { + this.boardInfo.set(board); + } + + setBoardsInProject(boards: Board[]): void { + this.boards.set(boards); + } + + setSelectedBoard(id: number) { + this.selectedBoardId.set(id); + } + + isFirstBoard = computed(() => { + const id = this.selectedBoardId(); + const boards = this.boards(); + + if (!boards || id === null) return false; + + const index = boards.findIndex(board => board.id === id); + return index === 0; + }); +} diff --git a/projects/social_platform/src/app/api/kanban/kanban-board.service.ts b/projects/social_platform/src/app/api/kanban/kanban-board.service.ts new file mode 100644 index 000000000..bd2e51f73 --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/kanban-board.service.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { TaskDetail } from "../../domain/kanban/task.model"; +import { Observable } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class KanbanBoardService { + private readonly apiService = inject(ApiService); + private readonly KANBAN_BOARD_URL = ""; + + getBoardByProjectId(projectId: number) { + return this.apiService.get(`${this.KANBAN_BOARD_URL}/`); + } + + getTasksByColumnId(columnId: number) { + return this.apiService.get(`${this.KANBAN_BOARD_URL}/`); + } + + getTaskById(taskId: number): Observable { + return this.apiService.get(`${this.KANBAN_BOARD_URL}/`); + } +} diff --git a/projects/social_platform/src/app/api/member/facades/members-info.service.ts b/projects/social_platform/src/app/api/member/facades/members-info.service.ts new file mode 100644 index 000000000..919343c90 --- /dev/null +++ b/projects/social_platform/src/app/api/member/facades/members-info.service.ts @@ -0,0 +1,228 @@ +/** @format */ + +import { ElementRef, inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { NavService } from "@ui/services/nav/nav.service"; +import { + concatMap, + debounceTime, + distinctUntilChanged, + EMPTY, + filter, + fromEvent, + map, + skip, + Subject, + switchMap, + take, + takeUntil, + tap, + throttleTime, +} from "rxjs"; +import { MemberService } from "../member.service"; +import { AuthService } from "../../auth"; +import { User } from "../../../domain/auth/user.model"; +import { AbstractControl, FormGroup } from "@angular/forms"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { MembersUIInfoService } from "./ui/members-ui-info.service"; +import { NavigationService } from "../../paths/navigation.service"; + +@Injectable() +export class MembersInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly navService = inject(NavService); + private readonly memberService = inject(MemberService); + private readonly membersUIInfoService = inject(MembersUIInfoService); + private readonly authService = inject(AuthService); + private readonly navigationService = inject(NavigationService); + + private readonly searchParams = signal>({}); // Signal для параметров поиска + + private readonly membersTake = this.membersUIInfoService.membersTake; // Количество участников на странице + readonly members = this.membersUIInfoService.members; // Массив участников для отображения + private readonly profileId = this.membersUIInfoService.profileId; + + private readonly searchForm = this.membersUIInfoService.searchForm; + private readonly filterForm = this.membersUIInfoService.filterForm; + + private readonly destroy$ = new Subject(); + + /** + * Инициализация компонента + * + * Выполняет: + * - Очистку URL параметров + * - Установку заголовка навигации + * - Загрузку начальных данных из резолвера + * - Настройку подписок на изменения форм и URL параметровК + */ + initializationMembers(): void { + // Очищаем URL параметры при инициализации + this.router.navigate([], { queryParams: {} }); + + // Устанавливаем заголовок страницы + this.navService.setNavTitle("Участники"); + + this.initializationProfile(); + + this.initializationControls(); + + // Подписываемся на изменения URL параметров для обновления списка участников + this.initializationQueryParams(); + } + + private initializationProfile(): void { + this.authService.profile + .pipe( + filter(user => !!user), + takeUntil(this.destroy$) + ) + .subscribe({ + next: user => { + this.membersUIInfoService.applyProfileId(user); + }, + }); + } + + private initializationControls(): void { + this.route.data + .pipe( + take(1), + map(r => r["data"]), + takeUntil(this.destroy$) + ) + .subscribe((members: ApiPagination) => { + this.membersUIInfoService.applyMembersPagination(members); + }); + + // Настраиваем синхронизацию значений форм с URL параметрами + this.saveControlValue(this.searchForm.get("search"), "fullname"); + this.saveControlValue(this.filterForm.get("keySkill"), "skills__contains"); + this.saveControlValue(this.filterForm.get("speciality"), "speciality__icontains"); + this.saveControlValue(this.filterForm.get("age"), "age"); + this.saveControlValue(this.filterForm.get("isMosPolytechStudent"), "is_mospolytech_student"); + } + + private initializationQueryParams(): void { + this.route.queryParams + .pipe( + skip(1), // Пропускаем первое значение + distinctUntilChanged(), // Игнорируем одинаковые значения + debounceTime(100), // Задержка для предотвращения частых запросов + takeUntil(this.destroy$), + switchMap(params => { + // Формируем параметры для API запроса + const fetchParams: Record = {}; + + if (params["fullname"]) fetchParams["fullname"] = params["fullname"]; + if (params["skills__contains"]) + fetchParams["skills__contains"] = params["skills__contains"]; + if (params["speciality__icontains"]) + fetchParams["speciality__icontains"] = params["speciality__icontains"]; + if (params["is_mospolytech_student"]) + fetchParams["is_mospolytech_student"] = params["is_mospolytech_student"]; + + // Проверяем формат параметра возраста (должен быть "число,число") + if (params["age"] && /\d+,\d+/.test(params["age"])) fetchParams["age"] = params["age"]; + + this.searchParams.set(fetchParams); + return this.onFetch(0, 20, fetchParams); + }) + ) + .subscribe(members => { + this.membersUIInfoService.applyQueryParams(members); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Обработчик события прокрутки для бесконечной прокрутки + * + * @returns Observable с дополнительными участниками или пустой объект + */ + private onScroll(target: HTMLElement, membersRoot: ElementRef) { + // Проверяем, есть ли еще участники для загрузки + const total = this.membersUIInfoService.membersTotalCount(); + + if (total !== undefined && this.membersUIInfoService.members().length >= total) { + return EMPTY; + } + + if (!target || !membersRoot?.nativeElement) return EMPTY; + + // Вычисляем, достиг ли пользователь конца списка + const diff = + target.scrollTop - + membersRoot.nativeElement.getBoundingClientRect().height + + window.innerHeight; + + if (diff > 0) { + // Загружаем следующую порцию участников + return this.onFetch(this.members().length, this.membersTake(), this.searchParams()).pipe( + tap(membersChunk => { + this.membersUIInfoService.applyMembersChunk(membersChunk); + }) + ); + } + + return EMPTY; + } + + initScroll(target: HTMLElement, membersRoot: ElementRef): void { + fromEvent(target, "scroll") + .pipe( + throttleTime(500), + concatMap(() => this.onScroll(target, membersRoot)), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + /** + * Сохраняет значение элемента формы в URL параметрах + * + * @param control - Элемент управления формы + * @param queryName - Имя параметра в URL + */ + private saveControlValue(control: AbstractControl | null, queryName: string): void { + if (!control) return; + + control.valueChanges + .pipe(throttleTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(value => { + this.router + .navigate([], { + queryParams: { [queryName]: value.toString() }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => console.debug("QueryParams changed from MembersComponent")); + }); + } + + /** + * Выполняет запрос на получение участников с заданными параметрами + * + * @param skip - Количество записей для пропуска (для пагинации) + * @param take - Количество записей для получения + * @param params - Дополнительные параметры фильтрации + * @returns Observable - Массив участников + */ + private onFetch(skip: number, take: number, params?: Record) { + return this.memberService.getMembers(skip, take, params).pipe( + takeUntil(this.destroy$), + tap(members => { + this.membersUIInfoService.applyMembersPagination(members); + }) + ); + } + + redirectToProfile(): void { + this.navigationService.profileRedirect(this.profileId()); + } +} diff --git a/projects/social_platform/src/app/api/member/facades/ui/members-ui-info.service.ts b/projects/social_platform/src/app/api/member/facades/ui/members-ui-info.service.ts new file mode 100644 index 000000000..d226cc7de --- /dev/null +++ b/projects/social_platform/src/app/api/member/facades/ui/members-ui-info.service.ts @@ -0,0 +1,53 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; + +@Injectable() +export class MembersUIInfoService { + private readonly fb = inject(FormBuilder); + + readonly membersTotalCount = signal(undefined); // Общее количество участников + readonly membersTake = signal(20); // Количество участников на странице + private readonly membersPage = signal(1); // Текущая страница для пагинации + + readonly profileId = signal(undefined); + + readonly members = signal([]); // Массив участников для отображения + + // Форма поиска с обязательным полем для ввода имени + readonly searchForm = this.fb.group({ + search: ["", [Validators.required]], + }); + + // Форма фильтрации с полями для различных критериев + readonly filterForm = this.fb.group({ + keySkill: ["", Validators.required], // Ключевой навык + speciality: ["", Validators.required], // Специальность + age: [[null, null]], // Диапазон возраста [от, до] + isMosPolytechStudent: [false], // Является ли студентом МосПолитеха + }); + + applyProfileId(user: User): void { + this.profileId.set(user.id); + } + + applyMembersPagination(members: ApiPagination) { + this.membersTotalCount.set(members.count); + this.members.set(members.results); + } + + applyQueryParams(members: ApiPagination): void { + this.members.set(members.results || []); + this.membersTotalCount.set(members.count); + this.membersPage.set(1); + } + + applyMembersChunk(membersChunk: ApiPagination): void { + this.membersPage.update(page => page + 1); + this.members.update(list => [...list, ...(membersChunk.results || [])]); + this.membersTotalCount.set(membersChunk.count); + } +} diff --git a/projects/social_platform/src/app/office/services/member.service.spec.ts b/projects/social_platform/src/app/api/member/member.service.spec.ts similarity index 91% rename from projects/social_platform/src/app/office/services/member.service.spec.ts rename to projects/social_platform/src/app/api/member/member.service.spec.ts index adcb05050..acc58fead 100644 --- a/projects/social_platform/src/app/office/services/member.service.spec.ts +++ b/projects/social_platform/src/app/api/member/member.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { MemberService } from "./member.service"; +import { MemberService } from "../member.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; describe("MemberService", () => { diff --git a/projects/social_platform/src/app/office/services/member.service.ts b/projects/social_platform/src/app/api/member/member.service.ts similarity index 94% rename from projects/social_platform/src/app/office/services/member.service.ts rename to projects/social_platform/src/app/api/member/member.service.ts index 47467f72a..b6577d0d6 100644 --- a/projects/social_platform/src/app/office/services/member.service.ts +++ b/projects/social_platform/src/app/api/member/member.service.ts @@ -4,8 +4,8 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { Observable } from "rxjs"; import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; /** * Сервис для работы с участниками платформы diff --git a/projects/social_platform/src/app/api/news/news-info.service.ts b/projects/social_platform/src/app/api/news/news-info.service.ts new file mode 100644 index 000000000..9862d32b0 --- /dev/null +++ b/projects/social_platform/src/app/api/news/news-info.service.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { FeedNews } from "../../domain/project/project-news.model"; +import { ApiPagination } from "../../domain/other/api-pagination.model"; + +@Injectable({ providedIn: "root" }) +export class NewsInfoService { + readonly news = signal([]); // Массив новостей + + applySetNews( + news: + | ApiPagination + | { + results: never[]; + count: number; + } + ): void { + this.news.set(news.results); + } + + applyAddNews(newsRes: any): void { + this.news.update(list => [newsRes, ...list]); + } + + applyUpdateNews(results: FeedNews[]): void { + this.news.update(news => [...news, ...results]); + } + + applyDeleteNews(newsId: number): void { + this.news.update(news => news.filter(n => n.id !== newsId)); + } + + applyEditNews(resNews: any): void { + this.news.update(news => news.map(n => (n.id === resNews.id ? resNews : n))); + } + + applyLikeNews(newsId: number): void { + this.news.update(list => + list.map(n => + n.id === newsId + ? { + ...n, + isUserLiked: !n.isUserLiked, + likesCount: n.isUserLiked ? n.likesCount - 1 : n.likesCount + 1, + } + : n + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/office/facades/office-info.service.ts b/projects/social_platform/src/app/api/office/facades/office-info.service.ts new file mode 100644 index 000000000..f423f7b31 --- /dev/null +++ b/projects/social_platform/src/app/api/office/facades/office-info.service.ts @@ -0,0 +1,145 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { map, Subject, takeUntil } from "rxjs"; +import { IndustryService } from "../../industry/industry.service"; +import { ActivatedRoute, Router } from "@angular/router"; +import { AuthService } from "../../auth"; +import { InviteService } from "../../invite/invite.service"; +import { ChatService } from "../../chat/chat.service"; +import { Invite } from "../../../domain/invite/invite.model"; +import { OfficeUIInfoService } from "./ui/office-ui-info.service"; + +@Injectable() +export class OfficeInfoService { + private readonly industryService = inject(IndustryService); + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly inviteService = inject(InviteService); + private readonly router = inject(Router); + private readonly chatService = inject(ChatService); + private readonly officeUIInfoService = inject(OfficeUIInfoService); + + readonly invites = this.officeUIInfoService.invites; + + private readonly destroy$ = new Subject(); + + initializationOffice(): void { + this.industryService.getAll().pipe(takeUntil(this.destroy$)).subscribe(); + + this.initializationNavItems(); + + this.initializationInvites(); + + this.initializationStatus(); + + if (!this.router.url.includes("chats")) { + this.chatService + .hasUnreads() + .pipe(takeUntil(this.destroy$)) + .subscribe(unreads => { + this.chatService.unread$.next(unreads); + }); + } + + this.officeUIInfoService.applyVerificationModal(); + } + + private initializationNavItems(): void { + this.authService.profile.pipe(takeUntil(this.destroy$)).subscribe(profile => { + this.officeUIInfoService.applyCreateNavItems(profile.id); + + if (!profile?.doesCompleted()) { + this.router + .navigateByUrl("/office/onboarding") + .then(() => console.debug("Route changed from OfficeComponent")); + } else if ( + profile?.verificationDate === null && + localStorage.getItem("waitVerificationAccepted") !== "true" + ) { + this.officeUIInfoService.applyOpenVerificationModal(); + } + }); + } + + private initializationStatus(): void { + this.chatService.connect().pipe(takeUntil(this.destroy$)).subscribe(); + + this.chatService + .onSetOffline() + .pipe(takeUntil(this.destroy$)) + .subscribe(evt => { + this.chatService.setOnlineStatus(evt.userId, false); + }); + + this.chatService + .onSetOnline() + .pipe(takeUntil(this.destroy$)) + .subscribe(evt => { + this.chatService.setOnlineStatus(evt.userId, true); + }); + } + + private initializationInvites(): void { + this.route.data + .pipe( + map(r => r["invites"]), + map(invites => invites.filter((invite: Invite) => invite.isAccepted === null)), + takeUntil(this.destroy$) + ) + .subscribe(invites => { + this.officeUIInfoService.applySetInvites(invites); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onRejectInvite(inviteId: number): void { + this.inviteService + .rejectInvite(inviteId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.invites.update(invites => invites.filter(invite => invite.id !== inviteId)); + }, + error: () => { + this.officeUIInfoService.applyOpenInviteErrorModal(); + }, + }); + } + + onAcceptInvite(inviteId: number): void { + const invite = this.invites().find(i => i.id === inviteId); + if (!invite) return; + + this.inviteService + .acceptInvite(inviteId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.invites.update(invites => invites.filter(invite => invite.id !== inviteId)); + + this.router + .navigateByUrl(`/office/projects/${invite.project.id}`) + .then(() => console.debug("Route changed from SidebarComponent")); + }, + error: () => { + this.officeUIInfoService.applyOpenInviteErrorModal(); + }, + }); + } + + onLogout() { + this.authService + .logout() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => + this.router + .navigateByUrl("/auth") + .then(() => console.debug("Route changed from OfficeComponent")) + ); + } +} diff --git a/projects/social_platform/src/app/api/office/facades/ui/office-ui-info.service.ts b/projects/social_platform/src/app/api/office/facades/ui/office-ui-info.service.ts new file mode 100644 index 000000000..c4a4ca8a8 --- /dev/null +++ b/projects/social_platform/src/app/api/office/facades/ui/office-ui-info.service.ts @@ -0,0 +1,61 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; + +@Injectable() +export class OfficeUIInfoService { + readonly invites = signal([]); + + readonly waitVerificationModal = signal(false); + readonly waitVerificationAccepted = signal(false); + readonly inviteErrorModal = signal(false); + + readonly navItems = signal< + { name: string; link: string; icon: string; isExternal?: boolean; isActive?: boolean }[] + >([]); + + applyVerificationModal(): void { + // Не показываем модалку, если пользователь уже принял подтверждение + if (localStorage.getItem("waitVerificationAccepted") === "true") { + // eslint-disable-next-line no-useless-return + return; + } + } + + applyOpenVerificationModal(): void { + this.waitVerificationModal.set(true); + } + + applyOpenInviteErrorModal(): void { + this.inviteErrorModal.set(true); + } + + applyAcceptWaitVerification() { + this.waitVerificationAccepted.set(true); + localStorage.setItem("waitVerificationAccepted", "true"); + } + + applySetInvites(invites: any): void { + this.invites.set(invites); + } + + applyCreateNavItems(profileId: number): void { + this.navItems.set([ + { name: "мой профиль", icon: "person", link: `profile/${profileId}` }, + { name: "новости", icon: "feed", link: "feed" }, + { name: "проекты", icon: "projects", link: "projects" }, + { name: "участники", icon: "people-bold", link: "members" }, + { name: "программы", icon: "program", link: "program" }, + { name: "вакансии", icon: "search-sidebar", link: "vacancies" }, + { + name: "траектории", + icon: "trajectories", + link: "skills", + isExternal: true, + isActive: false, + }, + { name: "чаты", icon: "message", link: "chats" }, + ]); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/onboarding-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/onboarding-info.service.ts new file mode 100644 index 000000000..81bd3b9d1 --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/onboarding-info.service.ts @@ -0,0 +1,61 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; +import { OnboardingService } from "../onboarding.service"; + +@Injectable() +export class OnboardingInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly onboardingService = inject(OnboardingService); + + readonly stage = signal(0); + readonly activeStage = signal(0); + + private readonly destroy$ = new Subject(); + + initializationOnboarding(): void { + this.onboardingService.currentStage$.pipe(takeUntil(this.destroy$)).subscribe(s => { + if (s === null) { + this.router + .navigateByUrl("/office") + .then(() => console.debug("Route changed from OnboardingComponent")); + return; + } + + if (this.router.url.includes("stage")) { + this.stage.set(Number.parseInt(this.router.url.split("-")[1])); + } else { + this.stage.set(s); + } + + this.router + .navigate([`stage-${this.stage()}`], { relativeTo: this.route }) + .then(() => console.debug("Route changed from OnboardingComponent")); + }); + + this.updateStage(); + + this.router.events.pipe(takeUntil(this.destroy$)).subscribe(this.updateStage.bind(this)); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + updateStage(): void { + this.activeStage.set(Number.parseInt(this.router.url.split("-")[1])); + this.stage.set(Number.parseInt(this.router.url.split("-")[1])); + } + + goToStep(stage: number): void { + if (this.stage() < stage) return; + + this.router + .navigate([`stage-${stage}`], { relativeTo: this.route }) + .then(() => console.debug("Route changed from OnboardingComponent")); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-one-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-one-info.service.ts new file mode 100644 index 000000000..6a21d99b5 --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-one-info.service.ts @@ -0,0 +1,97 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { Specialization } from "projects/social_platform/src/app/domain/specializations/specialization"; +import { concatMap, Subject, take, takeUntil } from "rxjs"; +import { AuthService } from "../../../auth"; +import { OnboardingService } from "../../onboarding.service"; +import { ValidationService } from "@corelib"; +import { Router } from "@angular/router"; +import { SearchesService } from "../../../searches/searches.service"; +import { OnboardingStageOneUIInfoService } from "./ui/onboarding-stage-one-ui-info.service"; +import { OnboardingUIInfoService } from "./ui/onboarding-ui-info.service"; + +@Injectable() +export class OnboardingStageOneInfoService { + private readonly authService = inject(AuthService); + private readonly onboardingService = inject(OnboardingService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly validationService = inject(ValidationService); + private readonly router = inject(Router); + private readonly onboardingStageOneUIInfoService = inject(OnboardingStageOneUIInfoService); + private readonly searchesService = inject(SearchesService); + + private readonly destroy$ = new Subject(); + + private stageForm = this.onboardingStageOneUIInfoService.stageForm; + + private readonly stageSubmitting = this.onboardingUIInfoService.stageSubmitting; + private readonly skipSubmitting = this.onboardingUIInfoService.skipSubmitting; + + readonly inlineSpecializations = this.searchesService.inlineSpecs; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + initializationFormValues(): void { + this.onboardingService.formValue$.pipe(take(1), takeUntil(this.destroy$)).subscribe(fv => { + this.onboardingStageOneUIInfoService.applyInitFormValues(fv); + }); + + this.stageForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(value => { + this.onboardingService.setFormValue(value); + }); + } + + initializationSpeciality(): void { + this.onboardingService.formValue$.pipe(takeUntil(this.destroy$)).subscribe(fv => { + this.onboardingStageOneUIInfoService.applyInitSpeciality(fv); + }); + } + + onSkipRegistration(): void { + if (!this.validationService.getFormValidation(this.stageForm)) { + return; + } + + this.completeRegistration(3); + } + + onSubmit(): void { + if (!this.validationService.getFormValidation(this.stageForm)) { + return; + } + + this.stageSubmitting.set(true); + + this.authService + .saveProfile(this.stageForm.value) + .pipe( + concatMap(() => this.authService.setOnboardingStage(2)), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => this.completeRegistration(2), + error: () => this.stageSubmitting.set(false), + }); + } + + onSelectSpec(speciality: Specialization): void { + this.searchesService.onSelectSpec(this.stageForm, speciality); + } + + onSearchSpec(query: string): void { + this.searchesService.onSearchSpec(query); + } + + private completeRegistration(stage: number): void { + this.skipSubmitting.set(true); + this.onboardingService.setFormValue(this.stageForm.value); + this.router.navigateByUrl( + stage === 2 ? `/office/onboarding/stage-${stage}` : "/office/onboarding/stage-3" + ); + this.skipSubmitting.set(false); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-three-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-three-info.service.ts new file mode 100644 index 000000000..f8ce43a90 --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-three-info.service.ts @@ -0,0 +1,57 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { concatMap, Subject, take, takeUntil } from "rxjs"; +import { OnboardingService } from "../../onboarding.service"; +import { AuthService } from "../../../auth"; +import { Router } from "@angular/router"; +import { OnboardingUIInfoService } from "./ui/onboarding-ui-info.service"; +import { OnboardingStageThreeUIInfoService } from "./ui/onboarding-stage-three-ui-info.service"; + +@Injectable() +export class OnboardingStageThreeInfoService { + private readonly onboardingService = inject(OnboardingService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly onboardingStageThreeUIInfoService = inject(OnboardingStageThreeUIInfoService); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + + private readonly destroy$ = new Subject(); + + private readonly userRole = this.onboardingStageThreeUIInfoService.userRole; + + private readonly stageTouched = this.onboardingUIInfoService.stageTouched; + private readonly stageSubmitting = this.onboardingUIInfoService.stageSubmitting; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + initializationFormValues(): void { + this.onboardingService.formValue$.pipe(take(1), takeUntil(this.destroy$)).subscribe(fv => { + this.onboardingStageThreeUIInfoService.applyInitFormValues(fv); + }); + } + + onSubmit() { + if (this.userRole() === -1) { + this.stageTouched.set(true); + return; + } + + this.stageSubmitting.set(true); + + this.authService + .saveProfile({ userType: this.userRole() }) + .pipe( + concatMap(() => this.authService.setOnboardingStage(null)), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.router + .navigateByUrl("/office") + .then(() => console.debug("Route changed from OnboardingStageTwo")); + }); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-two-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-two-info.service.ts new file mode 100644 index 000000000..bf416e634 --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-two-info.service.ts @@ -0,0 +1,114 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { Router } from "@angular/router"; +import { concatMap, Subject, take, takeUntil } from "rxjs"; +import { ValidationService } from "@corelib"; +import { AuthService } from "../../../auth"; +import { OnboardingService } from "../../onboarding.service"; +import { Skill } from "projects/social_platform/src/app/domain/skills/skill"; +import { SkillsInfoService } from "../../../skills/facades/skills-info.service"; +import { OnboardingUIInfoService } from "./ui/onboarding-ui-info.service"; +import { OnboardingStageTwoUIInfoService } from "./ui/onboarding-stage-two-ui-info.service"; + +@Injectable() +export class OnboardingStageTwoInfoService { + private readonly authService = inject(AuthService); + private readonly onboardingService = inject(OnboardingService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly onboardingStageTwoUIInfoService = inject(OnboardingStageTwoUIInfoService); + private readonly validationService = inject(ValidationService); + private readonly router = inject(Router); + private readonly skillsInfoService = inject(SkillsInfoService); + + private readonly destroy$ = new Subject(); + + private readonly stageForm = this.onboardingStageTwoUIInfoService.stageForm; + + private readonly stageSubmitting = this.onboardingUIInfoService.stageSubmitting; + private readonly skipSubmitting = this.onboardingUIInfoService.skipSubmitting; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + initializationFormValues(): void { + this.onboardingService.formValue$ + .pipe(take(1), takeUntil(this.destroy$)) + .subscribe(({ skills }) => this.onboardingStageTwoUIInfoService.applyInitFormValues(skills)); + + this.stageForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(value => { + this.onboardingService.setFormValue(value); + }); + } + + initializationSkills(): void { + this.onboardingService.formValue$.pipe(takeUntil(this.destroy$)).subscribe(fv => { + this.onboardingStageTwoUIInfoService.applyInitSkills(fv); + }); + } + + onSkipRegistration(): void { + if (!this.validationService.getFormValidation(this.stageForm)) { + return; + } + + this.completeRegistration(3); + } + + onSubmit(): void { + if (!this.validationService.getFormValidation(this.stageForm)) { + return; + } + + this.stageSubmitting.set(true); + + const { skills } = this.stageForm.getRawValue(); + + this.authService + .saveProfile({ skillsIds: skills.map((skill: Skill) => skill.id) }) + .pipe( + concatMap(() => this.authService.setOnboardingStage(2)), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => this.completeRegistration(3), + error: err => { + this.stageSubmitting.set(false); + this.onboardingStageTwoUIInfoService.applySubmitErrorModal(err); + }, + }); + } + + onAddSkill(newSkill: Skill): void { + this.skillsInfoService.onAddSkill(newSkill, this.stageForm); + } + + onRemoveSkill(oddSkill: Skill): void { + this.skillsInfoService.onRemoveSkill(oddSkill, this.stageForm); + } + + onOptionToggled(toggledSkill: Skill): void { + const { skills } = this.stageForm.getRawValue(); + + const isPresent = skills.some((skill: Skill) => skill.id === toggledSkill.id); + + if (isPresent) { + this.onRemoveSkill(toggledSkill); + } else { + this.onAddSkill(toggledSkill); + } + } + + onSearchSkill(query: string): void { + this.skillsInfoService.onSearchSkill(query); + } + + private completeRegistration(stage: number): void { + this.skipSubmitting.set(true); + this.onboardingService.setFormValue(this.stageForm.value); + this.router.navigateByUrl(`/office/onboarding/stage-${stage}`); + this.skipSubmitting.set(false); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.ts new file mode 100644 index 000000000..6b399b3ae --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.ts @@ -0,0 +1,121 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { concatMap, Subject, takeUntil } from "rxjs"; +import { OnboardingService } from "../../onboarding.service"; +import { ValidationService } from "@corelib"; +import { Router } from "@angular/router"; +import { AuthService } from "../../../auth"; +import { OnboardingStageZeroUIInfoService } from "./ui/onboarding-stage-zero-ui-info.service"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { OnboardingUIInfoService } from "./ui/onboarding-ui-info.service"; + +@Injectable() +export class OnboardingStageZeroInfoService { + private readonly onboardingService = inject(OnboardingService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly onboardingStageZeroUIInfoService = inject(OnboardingStageZeroUIInfoService); + private readonly validationService = inject(ValidationService); + private readonly authService = inject(AuthService); + private readonly router = inject(Router); + + private readonly destroy$ = new Subject(); + + private readonly stageForm = this.onboardingStageZeroUIInfoService.stageForm; + private readonly achievements = this.onboardingStageZeroUIInfoService.achievements; + private readonly education = this.onboardingStageZeroUIInfoService.education; + private readonly workExperience = this.onboardingStageZeroUIInfoService.workExperience; + private readonly userLanguages = this.onboardingStageZeroUIInfoService.userLanguages; + + private readonly stageSubmitting = this.onboardingUIInfoService.stageSubmitting; + private readonly skipSubmitting = this.onboardingUIInfoService.skipSubmitting; + + initializationStageZero(): void { + this.authService.profile.pipe(takeUntil(this.destroy$)).subscribe(p => { + this.onboardingStageZeroUIInfoService.applySetProfile(p); + }); + + this.onboardingService.formValue$.pipe(takeUntil(this.destroy$)).subscribe(fv => { + this.onboardingStageZeroUIInfoService.applyInitStageZero(fv); + }); + } + + initializationFormValues(): void { + this.onboardingService.formValue$.pipe(takeUntil(this.destroy$)).subscribe(formValues => { + this.onboardingStageZeroUIInfoService.applyInitFormValues(formValues); + + this.onboardingStageZeroUIInfoService.applyInitWorkExperience(formValues); + this.onboardingStageZeroUIInfoService.applyInitEducation(formValues); + this.onboardingStageZeroUIInfoService.applyInitUserLanguages(formValues); + this.onboardingStageZeroUIInfoService.applyInitAchievements(formValues); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSkipRegistration(): void { + if (!this.validationService.getFormValidation(this.stageForm)) { + return; + } + + const onboardingSkipInfo = { + avatar: this.stageForm.get("avatar")?.value, + city: this.stageForm.get("city")?.value, + }; + + this.skipSubmitting.set(true); + this.authService + .saveProfile(onboardingSkipInfo as Partial) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => this.completeRegistration(3), + error: error => { + this.skipSubmitting.set(false); + this.onboardingStageZeroUIInfoService.applySkipRegistrationModalError(error); + }, + }); + } + + onSubmit(): void { + if (!this.validationService.getFormValidation(this.stageForm)) { + this.achievements.markAllAsTouched(); + return; + } + + const newStageForm = { + avatar: this.stageForm.get("avatar")?.value, + city: this.stageForm.get("city")?.value, + education: this.education.value, + workExperience: this.workExperience.value, + userLanguages: this.userLanguages.value, + achievements: this.achievements.value, + }; + + this.stageSubmitting.set(true); + this.authService + .saveProfile(newStageForm as Partial) + .pipe( + concatMap(() => this.authService.setOnboardingStage(1)), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => this.completeRegistration(1), + error: error => { + this.stageSubmitting.set(false); + this.onboardingStageZeroUIInfoService.applySubmitModalError(error); + }, + }); + } + + private completeRegistration(stage: number): void { + this.skipSubmitting.set(true); + this.onboardingService.setFormValue(this.stageForm.value as Partial); + this.router.navigateByUrl( + stage === 1 ? "/office/onboarding/stage-1" : "/office/onboarding/stage-3" + ); + this.skipSubmitting.set(false); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.ts new file mode 100644 index 000000000..788f00d68 --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { NonNullableFormBuilder } from "@angular/forms"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; + +@Injectable() +export class OnboardingStageOneUIInfoService { + private readonly nnFb = inject(NonNullableFormBuilder); + + // Для управления открытыми группами специализаций + readonly openSpecializationGroup = signal(null); + + readonly stageForm = this.nnFb.group({ + speciality: [""], + }); + + /** + * Проверяет, есть ли открытые группы специализаций + */ + hasOpenSpecializationsGroups(): boolean { + return this.openSpecializationGroup() !== null; + } + + /** + * Обработчик переключения группы специализаций + * @param isOpen - флаг открытия/закрытия группы + * @param groupName - название группы + */ + onSpecializationsGroupToggled(isOpen: boolean, groupName: string): void { + this.openSpecializationGroup.set(isOpen ? groupName : null); + } + + /** + * Проверяет, должна ли группа специализаций быть отключена + * @param groupName - название группы для проверки + */ + isSpecializationGroupDisabled(groupName: string): boolean { + return this.openSpecializationGroup() !== null && this.openSpecializationGroup() !== groupName; + } + + applyInitFormValues(fv: Partial): void { + this.stageForm.patchValue({ + speciality: fv.speciality, + }); + } + + applyInitSpeciality(fv: Partial): void { + this.stageForm.patchValue({ speciality: fv.speciality }); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-three-ui-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-three-ui-info.service.ts new file mode 100644 index 000000000..4e8f84f8a --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-three-ui-info.service.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { OnboardingService } from "../../../onboarding.service"; + +@Injectable() +export class OnboardingStageThreeUIInfoService { + private readonly onboardingService = inject(OnboardingService); + + readonly userRole = signal(-1); + + applyInitFormValues(fv: Partial): void { + this.userRole.set(fv.userType ? fv.userType : -1); + } + + applySetRole(role: number) { + this.userRole.set(role); + this.onboardingService.setFormValue({ userType: role }); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-two-ui-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-two-ui-info.service.ts new file mode 100644 index 000000000..6098f8d2c --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-two-ui-info.service.ts @@ -0,0 +1,61 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { NonNullableFormBuilder } from "@angular/forms"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { Skill } from "projects/social_platform/src/app/domain/skills/skill"; + +@Injectable() +export class OnboardingStageTwoUIInfoService { + private readonly nnFb = inject(NonNullableFormBuilder); + + readonly isChooseSkill = signal(false); + readonly isChooseSkillText = signal(""); + + readonly searchedSkills = signal([]); + + readonly openSkillGroup = signal(null); + + readonly stageForm = this.nnFb.group({ + skills: this.nnFb.control([]), + }); + + /** + * Проверяет, есть ли открытые группы навыков + */ + hasOpenSkillsGroups(): boolean { + return this.openSkillGroup() !== null; + } + + /** + * Обработчик переключения группы навыков + * @param skillName - название навыка + * @param isOpen - флаг открытия/закрытия группы + */ + onSkillGroupToggled(isOpen: boolean, skillName: string): void { + this.openSkillGroup.set(isOpen ? skillName : null); + } + + /** + * Проверяет, должна ли группа навыков быть отключена + * @param skillName - название навыка + */ + isSkillGroupDisabled(skillName: string): boolean { + return this.openSkillGroup() !== null && this.openSkillGroup() !== skillName; + } + + applyInitFormValues(skills: Skill[] | undefined): void { + this.stageForm.patchValue({ skills: skills ?? [] }); + } + + applyInitSkills(fv: Partial): void { + this.stageForm.patchValue({ skills: fv.skills }); + } + + applySubmitErrorModal(err: any): void { + if (err.status === 400) { + this.isChooseSkill.set(true); + this.isChooseSkillText.set(err.error[0]); + } + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service.ts new file mode 100644 index 000000000..3cd9a814b --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service.ts @@ -0,0 +1,517 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, Validators } from "@angular/forms"; +import { generateOptionsList } from "@utils/generate-options-list"; +import { transformYearStringToNumber } from "@utils/helpers/transformYear"; +import { yearRangeValidators } from "@utils/helpers/yearRangeValidators"; +import { + educationUserLevel, + educationUserType, +} from "projects/core/src/consts/lists/education-info-list.const"; +import { + languageLevelsList, + languageNamesList, +} from "projects/core/src/consts/lists/language-info-list.const"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { OnboardingUIInfoService } from "./onboarding-ui-info.service"; + +@Injectable() +export class OnboardingStageZeroUIInfoService { + private readonly fb = inject(FormBuilder); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + + readonly profile = signal(undefined); + + readonly educationItems = signal([]); + readonly workItems = signal([]); + readonly languageItems = signal([]); + + readonly editIndex = signal(null); + readonly editEducationClick = signal(false); + readonly editWorkClick = signal(false); + readonly editLanguageClick = signal(false); + + readonly selectedEntryYearEducationId = signal(undefined); + readonly selectedComplitionYearEducationId = signal(undefined); + readonly selectedEducationStatusId = signal(undefined); + readonly selectedEducationLevelId = signal(undefined); + + readonly selectedEntryYearWorkId = signal(undefined); + readonly selectedComplitionYearWorkId = signal(undefined); + + readonly selectedLanguageId = signal(undefined); + readonly selectedLanguageLevelId = signal(undefined); + + readonly isModalErrorYear = signal(false); + readonly isModalErrorYearText = signal(""); + + readonly yearListEducation = generateOptionsList(55, "years"); + readonly educationStatusList = educationUserType; + readonly educationLevelList = educationUserLevel; + + readonly languageList = languageNamesList; + readonly languageLevelList = languageLevelsList; + + readonly stageForm = this.fb.nonNullable.group({ + avatar: ["", [Validators.required]], + city: ["", [Validators.required]], + + education: this.fb.array([]), + workExperience: this.fb.array([]), + userLanguages: this.fb.array([]), + achievements: this.fb.array([]), + + // education + organizationName: [""], + entryYear: [null], + completionYear: [null], + description: [null], + educationStatus: [null], + educationLevel: [null], + + // work + organizationNameWork: [""], + entryYearWork: [null], + completionYearWork: [null], + descriptionWork: [null], + jobPosition: [null], + + // language + language: [null], + languageLevel: [null], + }); + + readonly achievements = this.stageForm.get("achievements") as FormArray; + readonly education = this.stageForm.get("education") as FormArray; + readonly workExperience = this.stageForm.get("workExperience") as FormArray; + readonly userLanguages = this.stageForm.get("userLanguages") as FormArray; + + applySetProfile(p: User): void { + this.profile.set(p); + } + + applyInitStageZero(fv: Partial): void { + this.stageForm.patchValue({ + avatar: fv.avatar, + city: fv.city, + education: fv.education, + workExperience: fv.workExperience, + }); + } + + applyInitFormValues(fv: Partial): void { + this.stageForm.patchValue({ + avatar: fv.avatar ?? "", + city: fv.city ?? "", + }); + } + + applyInitWorkExperience(formValues: Partial): void { + this.workExperience.clear(); + formValues.workExperience?.forEach(work => { + this.workExperience.push( + this.fb.group( + { + organizationName: work.organizationName, + entryYear: work.entryYear, + completionYear: work.completionYear, + description: work.description, + jobPosition: work.jobPosition, + }, + { + validators: yearRangeValidators("entryYear", "completionYear"), + } + ) + ); + }); + } + + applyInitEducation(formValues: Partial): void { + this.education.clear(); + formValues?.education?.forEach(edu => { + this.education.push( + this.fb.group( + { + organizationName: edu.organizationName, + entryYear: edu.entryYear, + completionYear: edu.completionYear, + description: edu.description, + educationStatus: edu.educationStatus, + educationLevel: edu.educationLevel, + }, + { + validators: yearRangeValidators("entryYear", "completionYear"), + } + ) + ); + }); + } + + applyInitUserLanguages(formValues: Partial): void { + this.userLanguages.clear(); + formValues.userLanguages?.forEach(lang => { + this.userLanguages.push( + this.fb.group({ + language: lang.language, + languageLevel: lang.languageLevel, + }) + ); + }); + } + + applyInitAchievements(formValues: Partial): void { + formValues.achievements?.length && + formValues.achievements?.forEach(achievement => + this.addAchievement(achievement.id, achievement.title, achievement.status) + ); + } + + addEducation() { + ["organizationName", "educationStatus"].forEach(name => + this.stageForm.get(name)?.clearValidators() + ); + ["organizationName", "educationStatus"].forEach(name => + this.stageForm.get(name)?.setValidators([Validators.required]) + ); + ["organizationName", "educationStatus"].forEach(name => + this.stageForm.get(name)?.updateValueAndValidity() + ); + ["organizationName", "educationStatus"].forEach(name => + this.stageForm.get(name)?.markAsTouched() + ); + + const valEntry = this.stageForm.get("entryYear")?.value as string | null; + const entryYear = typeof valEntry === "string" ? +valEntry.slice(0, 5) : valEntry; + + const valCompletion = this.stageForm.get("completionYear")?.value as string | null; + const completionYear = + typeof valCompletion === "string" ? +valCompletion.slice(0, 5) : valCompletion; + + if (entryYear !== null && completionYear !== null && entryYear > completionYear) { + this.applyYearModalError(); + return; + } + + const educationItem = this.fb.group({ + organizationName: this.stageForm.get("organizationName")?.value, + entryYear, + completionYear, + description: this.stageForm.get("description")?.value, + educationStatus: this.stageForm.get("educationStatus")?.value, + educationLevel: this.stageForm.get("educationLevel")?.value, + }); + + const isOrganizationValid = this.stageForm.get("organizationName")?.valid; + const isStatusValid = this.stageForm.get("educationStatus")?.valid; + + if (isOrganizationValid && isStatusValid) { + if (this.editIndex() !== null) { + this.educationItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = educationItem.value; + + this.education.at(this.editIndex()!).patchValue(educationItem.value); + return updatedItems; + }); + this.editIndex.set(null); + } else { + this.educationItems.update(items => [...items, educationItem.value]); + this.education.push(educationItem); + } + [ + "organizationName", + "entryYear", + "completionYear", + "description", + "educationStatus", + "educationLevel", + ].forEach(name => { + this.stageForm.get(name)?.reset(); + this.stageForm.get(name)?.setValue(""); + this.stageForm.get(name)?.clearValidators(); + this.stageForm.get(name)?.markAsPristine(); + this.stageForm.get(name)?.updateValueAndValidity(); + }); + } + this.editEducationClick.set(false); + } + + editEducation(index: number) { + this.editEducationClick.set(true); + const educationItem = this.education.value[index]; + + this.yearListEducation.forEach(entryYearWork => { + if (transformYearStringToNumber(entryYearWork.value as string) === educationItem.entryYear) { + this.selectedEntryYearEducationId.set(entryYearWork.id); + } + }); + + this.yearListEducation.forEach(completionYearWork => { + if ( + transformYearStringToNumber(completionYearWork.value as string) === + educationItem.completionYear + ) { + this.selectedComplitionYearEducationId.set(completionYearWork.id); + } + }); + + this.educationLevelList.forEach(educationLevel => { + if (educationLevel.value === educationItem.educationLevel) { + this.selectedEducationLevelId.set(educationLevel.id); + } + }); + + this.educationStatusList.forEach(educationStatus => { + if (educationStatus.value === educationItem.educationStatus) { + this.selectedEducationStatusId.set(educationStatus.id); + } + }); + + this.stageForm.patchValue({ + organizationName: educationItem.organizationName, + entryYear: educationItem.entryYear, + completionYear: educationItem.completionYear, + description: educationItem.description, + educationStatus: educationItem.educationStatus, + educationLevel: educationItem.educationLevel, + }); + this.editIndex.set(index); + } + + removeEducation(i: number) { + this.educationItems.update(items => items.filter((_, index) => index !== i)); + + this.education.removeAt(i); + } + + addWork() { + ["organizationNameWork", "jobPosition"].forEach(name => + this.stageForm.get(name)?.clearValidators() + ); + ["organizationNameWork", "jobPosition"].forEach(name => + this.stageForm.get(name)?.setValidators([Validators.required]) + ); + ["organizationNameWork", "jobPosition"].forEach(name => + this.stageForm.get(name)?.updateValueAndValidity() + ); + ["organizationNameWork", "jobPosition"].forEach(name => + this.stageForm.get(name)?.markAsTouched() + ); + + const valEntry = this.stageForm.get("entryYearWork")?.value as string | null; + const entryYear = typeof valEntry === "string" ? +valEntry.slice(0, 5) : valEntry; + + const valCompletion = this.stageForm.get("completionYearWork")?.value as string | null; + const completionYear = + typeof valCompletion === "string" ? +valCompletion.slice(0, 5) : valCompletion; + + if (entryYear !== null && completionYear !== null && entryYear > completionYear) { + this.isModalErrorYear.set(true); + this.isModalErrorYearText.set("Год начала работы должен быть меньше года окончания"); + return; + } + + const workItem = this.fb.group({ + organizationName: this.stageForm.get("organizationNameWork")?.value, + entryYear, + completionYear, + description: this.stageForm.get("descriptionWork")?.value, + jobPosition: this.stageForm.get("jobPosition")?.value, + }); + + const isOrganizationValid = this.stageForm.get("organizationNameWork")?.valid; + const isPositionValid = this.stageForm.get("jobPosition")?.valid; + + if (isOrganizationValid && isPositionValid) { + if (this.editIndex() !== null) { + this.workItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = workItem.value; + + this.workExperience.at(this.editIndex()!).patchValue(workItem.value); + return updatedItems; + }); + this.editIndex.set(null); + } else { + this.workItems.update(items => [...items, workItem.value]); + this.workExperience.push(workItem); + } + [ + "organizationNameWork", + "entryYearWork", + "completionYearWork", + "descriptionWork", + "jobPosition", + ].forEach(name => { + this.stageForm.get(name)?.reset(); + this.stageForm.get(name)?.setValue(""); + this.stageForm.get(name)?.clearValidators(); + this.stageForm.get(name)?.markAsPristine(); + this.stageForm.get(name)?.updateValueAndValidity(); + }); + } + this.editWorkClick.set(false); + } + + editWork(index: number) { + this.editWorkClick.set(true); + const workItem = this.workExperience.value[index]; + + if (workItem) { + this.yearListEducation.forEach(entryYearWork => { + if ( + transformYearStringToNumber(entryYearWork.value as string) === workItem.entryYearWork || + transformYearStringToNumber(entryYearWork.value as string) === workItem.entryYear + ) { + this.selectedEntryYearWorkId.set(entryYearWork.id); + } + }); + + this.yearListEducation.forEach(complitionYearWork => { + if ( + transformYearStringToNumber(complitionYearWork.value as string) === + workItem.completionYearWork || + transformYearStringToNumber(complitionYearWork.value as string) === + workItem.completionYear + ) { + this.selectedComplitionYearWorkId.set(complitionYearWork.id); + } + }); + + this.stageForm.patchValue({ + organizationNameWork: workItem.organization || workItem.organizationName, + entryYearWork: workItem.entryYearWork || workItem.entryYear, + completionYearWork: workItem.completionYearWork || workItem.completionYear, + descriptionWork: workItem.descriptionWork || workItem.description, + jobPosition: workItem.jobPosition, + }); + this.editIndex.set(index); + } + } + + removeWork(i: number) { + this.workItems.update(items => items.filter((_, index) => index !== i)); + + this.workExperience.removeAt(i); + } + + addLanguage() { + const languageValue = this.stageForm.get("language")?.value; + const languageLevelValue = this.stageForm.get("languageLevel")?.value; + + ["language", "languageLevel"].forEach(name => { + this.stageForm.get(name)?.clearValidators(); + }); + + if ((languageValue && !languageLevelValue) || (!languageValue && languageLevelValue)) { + ["language", "languageLevel"].forEach(name => { + this.stageForm.get(name)?.setValidators([Validators.required]); + }); + } + + ["language", "languageLevel"].forEach(name => { + this.stageForm.get(name)?.updateValueAndValidity(); + this.stageForm.get(name)?.markAsTouched(); + }); + + const isLanguageValid = this.stageForm.get("language")?.valid; + const isLanguageLevelValid = this.stageForm.get("languageLevel")?.valid; + + if (!isLanguageValid || !isLanguageLevelValid) { + return; + } + + const languageItem = this.fb.group({ + language: languageValue, + languageLevel: languageLevelValue, + }); + + if (languageValue && languageLevelValue) { + if (this.editIndex() !== null) { + this.languageItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = languageItem.value; + + this.userLanguages.at(this.editIndex()!).patchValue(languageItem.value); + return updatedItems; + }); + this.editIndex.set(null); + } else { + this.languageItems.update(items => [...items, languageItem.value]); + this.userLanguages.push(languageItem); + } + ["language", "languageLevel"].forEach(name => { + this.stageForm.get(name)?.reset(); + this.stageForm.get(name)?.setValue(null); + this.stageForm.get(name)?.clearValidators(); + this.stageForm.get(name)?.markAsPristine(); + this.stageForm.get(name)?.updateValueAndValidity(); + }); + + this.editLanguageClick.set(false); + } + } + + editLanguage(index: number) { + this.editLanguageClick.set(true); + const languageItem = this.userLanguages.value[index]; + + this.languageList.forEach(language => { + if (language.value === languageItem.language) { + this.selectedLanguageId.set(language.id); + } + }); + + this.languageLevelList.forEach(languageLevel => { + if (languageLevel.value === languageItem.languageLevel) { + this.selectedLanguageLevelId.set(languageLevel.id); + } + }); + + this.stageForm.patchValue({ + language: languageItem.language, + languageLevel: languageItem.languageLevel, + }); + + this.editIndex.set(index); + } + + removeLanguage(i: number) { + this.languageItems.update(items => items.filter((_, index) => index !== i)); + + this.userLanguages.removeAt(i); + } + + addAchievement(id?: number, title?: string, status?: string): void { + this.achievements.push( + this.fb.group({ + title: [title ?? "", [Validators.required]], + status: [status ?? "", [Validators.required]], + id: [id], + }) + ); + } + + removeAchievement(i: number): void { + this.achievements.removeAt(i); + } + + applyYearModalError(): void { + this.isModalErrorYear.set(true); + this.isModalErrorYearText.set("Год начала обучения должен быть меньше года окончания"); + } + + applySubmitModalError(error: any): void { + this.isModalErrorYear.set(true); + + if (error.error.language) { + this.isModalErrorYearText.set(error.error.language); + } + } + + applySkipRegistrationModalError(error: any): void { + this.isModalErrorYear.set(true); + this.isModalErrorYearText.set(error.error?.message || "Ошибка сохранения"); + } +} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-ui-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-ui-info.service.ts new file mode 100644 index 000000000..403434cba --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-ui-info.service.ts @@ -0,0 +1,10 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; + +@Injectable() +export class OnboardingUIInfoService { + readonly stageSubmitting = signal(false); + readonly skipSubmitting = signal(false); + readonly stageTouched = signal(false); +} diff --git a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.spec.ts b/projects/social_platform/src/app/api/onboarding/onboarding.service.spec.ts similarity index 92% rename from projects/social_platform/src/app/office/onboarding/services/onboarding.service.spec.ts rename to projects/social_platform/src/app/api/onboarding/onboarding.service.spec.ts index 85fb23bb7..1fcc0c9b7 100644 --- a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.spec.ts +++ b/projects/social_platform/src/app/api/onboarding/onboarding.service.spec.ts @@ -2,9 +2,9 @@ import { TestBed } from "@angular/core/testing"; -import { OnboardingService } from "./onboarding.service"; -import { AuthService } from "@auth/services"; import { of } from "rxjs"; +import { OnboardingService } from "./onboarding.service"; +import { AuthService } from "../auth"; describe("OnboardingService", () => { let service: OnboardingService; diff --git a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts b/projects/social_platform/src/app/api/onboarding/onboarding.service.ts similarity index 96% rename from projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts rename to projects/social_platform/src/app/api/onboarding/onboarding.service.ts index d2d674365..7e1d94ba8 100644 --- a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts +++ b/projects/social_platform/src/app/api/onboarding/onboarding.service.ts @@ -1,9 +1,9 @@ /** @format */ import { Injectable } from "@angular/core"; -import { User } from "@auth/models/user.model"; -import { AuthService } from "@auth/services"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; import { BehaviorSubject, take } from "rxjs"; +import { AuthService } from "../auth"; /** * СЕРВИС УПРАВЛЕНИЯ СОСТОЯНИЕМ ОНБОРДИНГА diff --git a/projects/social_platform/src/app/api/paths/navigation.service.ts b/projects/social_platform/src/app/api/paths/navigation.service.ts new file mode 100644 index 000000000..23840847b --- /dev/null +++ b/projects/social_platform/src/app/api/paths/navigation.service.ts @@ -0,0 +1,17 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Router } from "@angular/router"; + +@Injectable({ providedIn: "root" }) +export class NavigationService { + private readonly router = inject(Router); + + profileRedirect(profileId?: number): void { + if (!profileId) return; + + this.router + .navigateByUrl(`/office/profile/${profileId}`) + .then(() => console.debug("Router Changed form ProfileEditComponent")); + } +} diff --git a/projects/social_platform/src/app/api/paths/paths.service.ts b/projects/social_platform/src/app/api/paths/paths.service.ts new file mode 100644 index 000000000..be979c5e0 --- /dev/null +++ b/projects/social_platform/src/app/api/paths/paths.service.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { NavigationEnd, Router } from "@angular/router"; +import { filter } from "rxjs"; + +@Injectable({ providedIn: "root" }) +export class PathsService { + private readonly router = inject(Router); + + readonly basePath = signal("/office/"); + readonly url = signal(this.router.url); + + constructor() { + this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => { + this.url.set(this.router.url); + }); + } + + readonly isAllVacanciesPage = computed(() => this.url().includes("/vacancies/all")); + + readonly isMyVacanciesPage = computed(() => this.url().includes("/vacancies/my")); +} diff --git a/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-info.service.ts b/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-info.service.ts new file mode 100644 index 000000000..d14c0b5f1 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-info.service.ts @@ -0,0 +1,185 @@ +/** @format */ + +import { ElementRef, inject, Injectable } from "@angular/core"; +import { concatMap, filter, map, Subject, takeUntil, tap } from "rxjs"; +import { ProfileNews } from "../../../../domain/profile/profile-news.model"; +import { ActivatedRoute } from "@angular/router"; +import { AuthService } from "../../../auth"; +import { ProfileNewsService } from "../../profile-news.service"; +import { ExpandService } from "../../../expand/expand.service"; +import { calculateProfileProgress } from "@utils/calculateProgress"; +import { ProfileDetailUIInfoService } from "./ui/profile-detail-ui-info.service"; +import { NewsInfoService } from "../../../news/news-info.service"; +import { ProjectsDetailUIInfoService } from "../../../project/facades/detail/ui/projects-detail-ui.service"; + +@Injectable() +export class ProfileDetailInfoService { + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly profileNewsService = inject(ProfileNewsService); + private readonly expandService = inject(ExpandService); + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly newsInfoService = inject(NewsInfoService); + + private observer?: IntersectionObserver; + private readonly destroy$ = new Subject(); + + private readonly user = this.profileDetailUIInfoService.user; + private readonly news = this.newsInfoService.news; + + destroy(): void { + this.observer?.disconnect(); + this.destroy$.next(); + this.destroy$.complete(); + } + + initializationProfile(): void { + this.route.data + .pipe( + map(data => { + const user = data["data"]["user"]; + return { + ...data, + data: { + ...data["data"], + user: { + ...user, + progress: calculateProfileProgress(user), + }, + }, + }; + }), + filter(data => !!data["data"]["user"]), + takeUntil(this.destroy$) + ) + .subscribe({ + next: data => { + this.profileDetailUIInfoService.applyInitProfile(data); + }, + }); + + this.initializationProfileVields(); + this.initializationProfileNews(); + } + + initCheckDescription(descEl?: ElementRef): void { + setTimeout(() => { + this.expandService.checkExpandable("description", !!this.user()?.aboutMe, descEl); + }, 150); + } + + /** + * Добавление новой новости в профиль + * @param news - объект с текстом и файлами новости + */ + onAddNews(news: { text: string; files: string[] }) { + return this.profileNewsService.addNews(this.route.snapshot.params["id"], news).pipe( + tap(newsRes => { + this.newsInfoService.applyAddNews(newsRes); + }), + takeUntil(this.destroy$) + ); + } + + /** + * Удаление новости из профиля + * @param newsId - идентификатор удаляемой новости + */ + onDeleteNews(newsId: number): void { + this.newsInfoService.applyDeleteNews(newsId); + + this.profileNewsService + .delete(this.route.snapshot.params["id"], newsId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ next: () => {} }); + } + + /** + * Переключение лайка новости + * @param newsId - идентификатор новости для лайка/дизлайка + */ + onLike(newsId: number) { + const item = this.news().find(n => n.id === newsId); + if (!item) return; + + this.profileNewsService + .toggleLike(this.route.snapshot.params["id"], newsId, !item.isUserLiked) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.newsInfoService.applyLikeNews(newsId); + }); + } + + /** + * Редактирование существующей новости + * @param news - обновленные данные новости + * @param newsItemId - идентификатор редактируемой новости + */ + onEditNews(news: ProfileNews, newsItemId: number) { + return this.profileNewsService + .editNews(this.route.snapshot.params["id"], newsItemId, news) + .pipe( + tap(resNews => { + this.newsInfoService.applyEditNews(resNews); + }) + ); + } + + /** + * Обработчик появления новостей в области видимости + * Отмечает новости как просмотренные при скролле + * @param entries - массив элементов, попавших в область видимости + */ + onNewsInView(entries: IntersectionObserverEntry[]): void { + const ids = entries.map(e => { + return Number((e.target as HTMLElement).dataset["id"]); + }); + + this.profileNewsService + .readNews(Number(this.route.snapshot.params["id"]), ids) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + private initializationProfileVields(): void { + this.authService.profile.pipe(takeUntil(this.destroy$)).subscribe({ + next: user => { + this.projectsDetailUIInfoService.applySetLoggedUserId("logged", user.id); + }, + }); + + this.profileDetailUIInfoService.applyProfileEmpty(); + } + + private initializationProfileNews(descEl?: ElementRef): void { + this.route.params + .pipe( + map(r => r["id"]), + concatMap(userId => this.profileNewsService.fetchNews(userId)), + takeUntil(this.destroy$) + ) + .subscribe(news => { + this.newsInfoService.applySetNews(news); + + setTimeout(() => { + this.setupNewsObserver(); + }, 100); + }); + + this.initCheckDescription(descEl); + } + + private setupNewsObserver(): void { + this.observer?.disconnect(); + + this.observer = new IntersectionObserver(this.onNewsInView.bind(this), { + root: document.querySelector(".office__body"), + threshold: 0, + }); + + document.querySelectorAll(".news__item").forEach(el => { + this.observer!.observe(el); + }); + } +} diff --git a/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-projects-info.service.ts b/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-projects-info.service.ts new file mode 100644 index 000000000..79d1c1233 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-projects-info.service.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; +import { Project } from "../../../../domain/project/project.model"; +import { ActivatedRoute } from "@angular/router"; +import { ProjectsDetailUIInfoService } from "../../../project/facades/detail/ui/projects-detail-ui.service"; +import { ProfileDetailUIInfoService } from "./ui/profile-detail-ui-info.service"; + +@Injectable() +export class ProfileDetailProjectsInfoService { + private readonly route = inject(ActivatedRoute); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + private readonly destroy$ = new Subject(); + + readonly user = this.profileDetailUIInfoService.user; + readonly loggedUserId = this.projectsDetailUIInfoService.loggedUserId; + readonly subs = signal(undefined); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + initializationProfileProjects(): void { + this.route.data.pipe(takeUntil(this.destroy$)).subscribe({ + next: ({ data }) => { + this.applyInitProfileProjects(data); + }, + }); + } + + private applyInitProfileProjects(data: any): void { + this.user.set(data.user); + this.projectsDetailUIInfoService.applySetLoggedUserId("logged", data.user.id); + this.subs.set(data.subs); + } +} diff --git a/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.ts b/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.ts new file mode 100644 index 000000000..99a9d2b3c --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.ts @@ -0,0 +1,55 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { DirectionItem, directionItemBuilder } from "@utils/helpers/directionItemBuilder"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { ProjectsDetailUIInfoService } from "../../../../project/facades/detail/ui/projects-detail-ui.service"; + +@Injectable() +export class ProfileDetailUIInfoService { + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + + readonly user = signal(undefined); + readonly loggedUserId = this.projectsDetailUIInfoService.loggedUserId; + + readonly isProfileEmpty = signal(undefined); + readonly isProfileFill = signal(false); + + readonly directions = signal([]); + readonly isShowModal = signal(false); + + applyInitProfile(data: any): void { + const userWithProgress = data["data"]["user"]; + this.initializationDirections(userWithProgress); + this.user.set(userWithProgress); + this.isProfileFill.set(userWithProgress.progress! < 100); + } + + applyProfileEmpty(): void { + this.isProfileEmpty.set( + !( + this.user()?.firstName && + this.user()?.lastName && + this.user()?.email && + this.user()?.avatar && + this.user()?.birthday + ) + ); + } + + applyOpenWorkInfoModal(): void { + this.isShowModal.set(true); + } + + private initializationDirections(user: User): void { + this.directions.set( + directionItemBuilder( + 2, + ["навыки", "достижения"], + ["squiz", "medal"], + [user.skills, user.achievements], + ["array", "array"] + )! + ); + } +} diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-achievements-info.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-achievements-info.service.ts new file mode 100644 index 000000000..2fe5b34e3 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-achievements-info.service.ts @@ -0,0 +1,142 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Subject } from "rxjs"; +import { ProfileFormService } from "./profile-form.service"; +import { ProfileEditInfoService } from "./profile-edit-info.service"; +import { transformYearStringToNumber } from "@utils/helpers/transformYear"; + +@Injectable() +export class ProfileEditAchievementsInfoService { + private readonly fb = inject(FormBuilder); + private readonly profileFormService = inject(ProfileFormService); + private readonly profileEditInfoService = inject(ProfileEditInfoService); + + private readonly destroy$ = new Subject(); + + private readonly profileForm = this.profileFormService.getForm(); + private readonly editIndex = this.profileEditInfoService.editIndex; + + readonly achievementItems = signal([]); + private readonly achievements = this.profileFormService.achievements; + + private readonly achievementsYearList = this.profileFormService.achievementsYearList; + + readonly editAchievementsClick = signal(false); + readonly showAchievementsFields = signal(false); + + readonly selectedAchievementsYearId = signal(undefined); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Добавление записи об достижении + * Валидирует форму и добавляет новую запись в массив достижений + */ + addAchievement(): void { + if (!this.showAchievementsFields()) { + this.showAchievementsFields.set(true); + + this.profileForm.patchValue({ + title: "", + status: "", + year: null, + files: "", + }); + + return; + } + + ["title", "status", "year"].forEach(name => this.profileForm.get(name)?.clearValidators()); + ["title", "status", "year"].forEach(name => + this.profileForm.get(name)?.setValidators([Validators.required]) + ); + ["title", "status", "year"].forEach(name => + this.profileForm.get(name)?.updateValueAndValidity() + ); + ["title", "status", "year"].forEach(name => this.profileForm.get(name)?.markAsTouched()); + + const achievementsYear = + typeof this.profileForm.get("year")?.value === "string" + ? +this.profileForm.get("year")?.value.slice(0, 5) + : this.profileForm.get("year")?.value; + + const achievementsItem = this.fb.group({ + id: [null], + title: this.profileForm.get("title")?.value, + status: this.profileForm.get("status")?.value, + year: achievementsYear, + files: Array.isArray(this.profileForm.get("files")?.value) + ? this.profileForm.get("files")?.value + : [this.profileForm.get("files")?.value].filter(Boolean), + }); + + if (this.editIndex() !== null) { + const existingId = this.achievements.at(this.editIndex()!).get("id")?.value; + + this.achievements.at(this.editIndex()!).patchValue({ + ...achievementsItem.value, + id: existingId, + }); + + this.achievementItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = { ...achievementsItem.value, id: existingId }; + return updatedItems; + }); + + this.editIndex.set(null); + } else { + this.achievementItems.update(items => [...items, achievementsItem.value]); + this.achievements.push(achievementsItem); + } + ["title", "status", "year", "files"].forEach(name => { + this.profileForm.get(name)?.reset(); + this.profileForm.get(name)?.setValue(""); + this.profileForm.get(name)?.clearValidators(); + this.profileForm.get(name)?.markAsPristine(); + this.profileForm.get(name)?.markAsUntouched(); + this.profileForm.get(name)?.updateValueAndValidity(); + }); + + this.showAchievementsFields.set(false); + this.editAchievementsClick.set(false); + } + + /** + * Редактирование записи об достижений + * @param index - индекс записи в массиве достижений + */ + editAchievements(index: number) { + this.editAchievementsClick.set(true); + this.showAchievementsFields.set(true); + const achievementItem = this.achievements.value[index]; + + this.achievementsYearList.forEach(achievementYear => { + if (transformYearStringToNumber(achievementYear.value as string) === achievementItem.year) { + this.selectedAchievementsYearId.set(achievementYear.id); + } + }); + + this.profileForm.patchValue({ + title: achievementItem.title, + status: achievementItem.status, + year: achievementItem.year, + files: achievementItem.files, + }); + this.editIndex.set(index); + } + + /** + * Удаление записи об достижении + * @param i - индекс записи для удаления + */ + removeAchievement(i: number): void { + this.achievementItems.update(items => items.filter((_, index) => index !== i)); + this.achievements.removeAt(i); + } +} diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-education-info.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-education-info.service.ts new file mode 100644 index 000000000..bf3d15458 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-education-info.service.ts @@ -0,0 +1,185 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Subject } from "rxjs"; +import { ProfileFormService } from "./profile-form.service"; +import { ProfileEditInfoService } from "./profile-edit-info.service"; +import { transformYearStringToNumber } from "@utils/helpers/transformYear"; + +@Injectable() +export class ProfileEditEducationInfoService { + private readonly fb = inject(FormBuilder); + private readonly profileFormService = inject(ProfileFormService); + private readonly profileEditInfoService = inject(ProfileEditInfoService); + + private readonly destroy$ = new Subject(); + + private readonly profileForm = this.profileFormService.getForm(); + + private readonly editIndex = this.profileEditInfoService.editIndex; + + readonly showEducationFields = signal(false); + readonly editEducationClick = signal(false); + + readonly educationItems = signal([]); + private readonly education = this.profileFormService.education; + + private readonly yearListEducation = this.profileFormService.yearListEducation; + private readonly educationStatusList = this.profileFormService.educationStatusList; + private readonly educationLevelList = this.profileFormService.educationLevelList; + + readonly selectedEntryYearEducationId = signal(undefined); + readonly selectedComplitionYearEducationId = signal(undefined); + readonly selectedEducationStatusId = signal(undefined); + readonly selectedEducationLevelId = signal(undefined); + + private readonly isModalErrorSkillsChoose = this.profileEditInfoService.isModalErrorSkillsChoose; + private readonly isModalErrorSkillChooseText = + this.profileEditInfoService.isModalErrorSkillChooseText; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Добавление записи об образовании + * Валидирует форму и добавляет новую запись в массив образования + */ + addEducation() { + if (!this.showEducationFields()) { + this.showEducationFields.set(true); + return; + } + + ["organizationName", "educationStatus"].forEach(name => + this.profileForm.get(name)?.clearValidators() + ); + ["organizationName", "educationStatus"].forEach(name => + this.profileForm.get(name)?.setValidators([Validators.required]) + ); + ["organizationName", "educationStatus"].forEach(name => + this.profileForm.get(name)?.updateValueAndValidity() + ); + ["organizationName", "educationStatus"].forEach(name => + this.profileForm.get(name)?.markAsTouched() + ); + + const entryYear = + typeof this.profileForm.get("entryYear")?.value === "string" + ? +this.profileForm.get("entryYear")?.value.slice(0, 5) + : this.profileForm.get("entryYear")?.value; + const completionYear = + typeof this.profileForm.get("completionYear")?.value === "string" + ? +this.profileForm.get("completionYear")?.value.slice(0, 5) + : this.profileForm.get("completionYear")?.value; + + if (entryYear !== null && completionYear !== null && entryYear > completionYear) { + this.isModalErrorSkillsChoose.set(true); + this.isModalErrorSkillChooseText.set("Год начала обучения должен быть меньше года окончания"); + return; + } + + const educationItem = this.fb.group({ + organizationName: this.profileForm.get("organizationName")?.value, + entryYear, + completionYear, + description: this.profileForm.get("description")?.value, + educationStatus: this.profileForm.get("educationStatus")?.value, + educationLevel: this.profileForm.get("educationLevel")?.value, + }); + + const isOrganizationValid = this.profileForm.get("organizationName")?.valid; + const isStatusValid = this.profileForm.get("educationStatus")?.valid; + + if (isOrganizationValid && isStatusValid) { + if (this.editIndex() !== null) { + this.educationItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = educationItem.value; + + this.education.at(this.editIndex()!).patchValue(educationItem.value); + return updatedItems; + }); + this.editIndex.set(null); + } else { + this.educationItems.update(items => [...items, educationItem.value]); + this.education.push(educationItem); + } + [ + "organizationName", + "entryYear", + "completionYear", + "description", + "educationStatus", + "educationLevel", + ].forEach(name => { + this.profileForm.get(name)?.reset(); + this.profileForm.get(name)?.setValue(""); + this.profileForm.get(name)?.clearValidators(); + this.profileForm.get(name)?.markAsPristine(); + this.profileForm.get(name)?.updateValueAndValidity(); + }); + this.showEducationFields.set(false); + } + this.editEducationClick.set(false); + } + + /** + * Редактирование записи об образовании + * @param index - индекс записи в массиве образования + */ + editEducation(index: number) { + this.editEducationClick.set(true); + this.showEducationFields.set(true); + const educationItem = this.education.value[index]; + + this.yearListEducation.forEach(entryYearWork => { + if (transformYearStringToNumber(entryYearWork.value as string) === educationItem.entryYear) { + this.selectedEntryYearEducationId.set(entryYearWork.id); + } + }); + + this.yearListEducation.forEach(completionYearWork => { + if ( + transformYearStringToNumber(completionYearWork.value as string) === + educationItem.completionYear + ) { + this.selectedComplitionYearEducationId.set(completionYearWork.id); + } + }); + + this.educationLevelList.forEach(educationLevel => { + if (educationLevel.value === educationItem.educationLevel) { + this.selectedEducationLevelId.set(educationLevel.id); + } + }); + + this.educationStatusList.forEach(educationStatus => { + if (educationStatus.value === educationItem.educationStatus) { + this.selectedEducationStatusId.set(educationStatus.id); + } + }); + + this.profileForm.patchValue({ + organizationName: educationItem.organizationName, + entryYear: educationItem.entryYear, + completionYear: educationItem.completionYear, + description: educationItem.description, + educationStatus: educationItem.educationStatus, + educationLevel: educationItem.educationLevel, + }); + this.editIndex.set(index); + } + + /** + * Удаление записи об образовании + * @param i - индекс записи для удаления + */ + removeEducation(i: number) { + this.educationItems.update(items => items.filter((_, index) => index !== i)); + + this.education.removeAt(i); + } +} diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-experience-info.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-experience-info.service.ts new file mode 100644 index 000000000..95a79a020 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-experience-info.service.ts @@ -0,0 +1,160 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { transformYearStringToNumber } from "@utils/helpers/transformYear"; +import { Subject } from "rxjs"; +import { ProfileFormService } from "./profile-form.service"; +import { ProfileEditInfoService } from "./profile-edit-info.service"; + +@Injectable() +export class ProfileEditExperienceInfoService { + private readonly fb = inject(FormBuilder); + private readonly profileFormService = inject(ProfileFormService); + private readonly profileEditInfoService = inject(ProfileEditInfoService); + + private readonly destroy$ = new Subject(); + + private readonly profileForm = this.profileFormService.getForm(); + private readonly editIndex = this.profileEditInfoService.editIndex; + + readonly workItems = signal([]); + private readonly workExperience = this.profileFormService.workExperience; + + readonly editWorkClick = signal(false); + readonly showWorkFields = signal(false); + + readonly yearListEducation = this.profileFormService.yearListEducation; + + readonly selectedEntryYearWorkId = signal(undefined); + readonly selectedComplitionYearWorkId = signal(undefined); + + private readonly isModalErrorSkillsChoose = this.profileEditInfoService.isModalErrorSkillsChoose; + private readonly isModalErrorSkillChooseText = + this.profileEditInfoService.isModalErrorSkillChooseText; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Добавление записи об опыте работы + * Валидирует форму и добавляет новую запись в массив опыта работы + */ + addWork() { + if (!this.showWorkFields()) { + this.showWorkFields.set(true); + return; + } + + ["organization", "jobPosition"].forEach(name => this.profileForm.get(name)?.clearValidators()); + ["organization", "jobPosition"].forEach(name => + this.profileForm.get(name)?.setValidators([Validators.required]) + ); + ["organization", "jobPosition"].forEach(name => + this.profileForm.get(name)?.updateValueAndValidity() + ); + ["organization", "jobPosition"].forEach(name => this.profileForm.get(name)?.markAsTouched()); + + const entryYear = + typeof this.profileForm.get("entryYearWork")?.value === "string" + ? this.profileForm.get("entryYearWork")?.value.slice(0, 5) + : this.profileForm.get("entryYearWork")?.value; + const completionYear = + typeof this.profileForm.get("completionYearWork")?.value === "string" + ? this.profileForm.get("completionYearWork")?.value.slice(0, 5) + : this.profileForm.get("completionYearWork")?.value; + + if (entryYear !== null && completionYear !== null && entryYear > completionYear) { + this.isModalErrorSkillsChoose.set(true); + this.isModalErrorSkillChooseText.set("Год начала работы должен быть меньше года окончания"); + return; + } + + const workItem = this.fb.group({ + organizationName: this.profileForm.get("organization")?.value, + entryYear, + completionYear, + description: this.profileForm.get("descriptionWork")?.value, + jobPosition: this.profileForm.get("jobPosition")?.value, + }); + + const isOrganizationValid = this.profileForm.get("organization")?.valid; + const isPositionValid = this.profileForm.get("jobPosition")?.valid; + + if (isOrganizationValid && isPositionValid) { + if (this.editIndex() !== null) { + this.workItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = workItem.value; + + this.workExperience.at(this.editIndex()!).patchValue(workItem.value); + return updatedItems; + }); + this.editIndex.set(null); + } else { + this.workItems.update(items => [...items, workItem.value]); + this.workExperience.push(workItem); + } + [ + "organization", + "entryYearWork", + "completionYearWork", + "descriptionWork", + "jobPosition", + ].forEach(name => { + this.profileForm.get(name)?.reset(); + this.profileForm.get(name)?.setValue(""); + this.profileForm.get(name)?.clearValidators(); + this.profileForm.get(name)?.markAsPristine(); + this.profileForm.get(name)?.updateValueAndValidity(); + }); + this.showWorkFields.set(false); + } + this.editWorkClick.set(false); + } + + editWork(index: number) { + this.editWorkClick.set(true); + this.showWorkFields.set(true); + const workItem = this.workExperience.value[index]; + + if (workItem) { + this.yearListEducation.forEach(entryYearWork => { + if ( + transformYearStringToNumber(entryYearWork.value as string) === workItem.entryYearWork || + transformYearStringToNumber(entryYearWork.value as string) === workItem.entryYear + ) { + this.selectedEntryYearWorkId.set(entryYearWork.id); + } + }); + + this.yearListEducation.forEach(complitionYearWork => { + if ( + transformYearStringToNumber(complitionYearWork.value as string) === + workItem.completionYearWork || + transformYearStringToNumber(complitionYearWork.value as string) === + workItem.completionYear + ) { + this.selectedComplitionYearWorkId.set(complitionYearWork.id); + } + }); + + this.profileForm.patchValue({ + organization: workItem.organization || workItem.organizationName, + entryYearWork: workItem.entryYearWork || workItem.entryYear, + completionYearWork: workItem.completionYearWork || workItem.completionYear, + descriptionWork: workItem.descriptionWork || workItem.description, + jobPosition: workItem.jobPosition, + }); + this.editIndex.set(index); + } + } + + removeWork(i: number) { + this.workItems.update(items => items.filter((_, index) => index !== i)); + + this.workExperience.removeAt(i); + } +} diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts new file mode 100644 index 000000000..900bac2a1 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts @@ -0,0 +1,158 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { concatMap, Subject, takeUntil } from "rxjs"; +import { ProfileFormService } from "./profile-form.service"; +import { Achievement } from "projects/social_platform/src/app/domain/auth/user.model"; +import dayjs from "dayjs"; +import { AuthService } from "../../../auth"; +import { Skill } from "projects/social_platform/src/app/domain/skills/skill"; +import { NavigationService } from "../../../paths/navigation.service"; + +@Injectable() +export class ProfileEditInfoService { + private readonly profileFormService = inject(ProfileFormService); + private readonly authService = inject(AuthService); + private readonly navigationService = inject(NavigationService); + + private readonly destroy$ = new Subject(); + + private readonly profileForm = this.profileFormService.getForm(); + + readonly editIndex = signal(null); + + readonly profileFormSubmitting = signal(false); + + readonly isModalErrorSkillsChoose = signal(false); + readonly isModalErrorSkillChooseText = signal(""); + + private readonly typeSpecific = this.profileFormService.typeSpecific; + private readonly achievements = this.profileFormService.achievements; + private readonly profileId = this.profileFormService.profileId; + + private userTypeMap: { [type: number]: string } = { + 1: "member", + 2: "mentor", + 3: "expert", + 4: "investor", + }; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Сохранение профиля пользователя + * Валидирует всю форму и отправляет данные на сервер + */ + saveProfile(): void { + this.profileForm.markAllAsTouched(); + this.profileForm.updateValueAndValidity(); + + const tempFields = [ + "organizationName", + "entryYear", + "completionYear", + "description", + "educationLevel", + "educationStatus", + "organization", + "entryYearWork", + "completionYearWork", + "descriptionWork", + "jobPosition", + "language", + "languageLevel", + "title", + "status", + "year", + "files", + "phoneNumber", + ]; + + tempFields.forEach(name => { + const control = this.profileForm.get(name); + if (control) { + control.clearValidators(); + control.updateValueAndValidity(); + } + }); + + const mainFieldsValid = ["firstName", "lastName", "birthday", "speciality", "city"].every( + name => this.profileForm.get(name)?.valid + ); + + if (!mainFieldsValid || this.profileFormSubmitting()) { + this.isModalErrorSkillsChoose.set(true); + return; + } + + this.profileFormSubmitting.set(true); + + const achievements = this.achievements.value.map((achievement: Achievement) => ({ + ...(achievement.id && { id: achievement.id }), + title: achievement.title, + status: achievement.status, + year: achievement.year, + fileLinks: + achievement.files && Array.isArray(achievement.files) + ? achievement.files + .map((file: any) => (typeof file === "string" ? file : file.link)) + .filter(Boolean) + : achievement.files + ? [achievement.files] + : [], + })); + + const newProfile = { + ...this.profileForm.value, + achievements, + [this.userTypeMap[this.profileForm.value.userType]]: this.typeSpecific.value, + typeSpecific: undefined, + birthday: this.profileForm.value.birthday + ? dayjs(this.profileForm.value.birthday, "DD.MM.YYYY").format("YYYY-MM-DD") + : undefined, + skillsIds: this.profileForm.value.skills.map((s: Skill) => s.id), + phoneNumber: + typeof this.profileForm.value.phoneNumber === "string" + ? this.profileForm.value.phoneNumber.replace(/^([87])/, "+7") + : this.profileForm.value.phoneNumber, + }; + + this.authService + .saveProfile(newProfile) + .pipe( + concatMap(() => this.authService.getProfile()), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => { + this.profileFormSubmitting.set(false); + this.navigationService.profileRedirect(this.profileId()); + }, + error: error => { + this.profileFormSubmitting.set(false); + this.isModalErrorSkillsChoose.set(true); + if (error.error.phone_number) { + this.isModalErrorSkillChooseText.set(error.error.phone_number[0]); + } else if (error.error.language) { + this.isModalErrorSkillChooseText.set(error.error.language); + } else if (error.error.achievements) { + this.isModalErrorSkillChooseText.set(error.error.achievements[0]); + } else if (error.error.work_experience?.[2]) { + const errorText = error.error.work_experience[2].entry_year + ? error.error.work_experience[2].entry_year + : error.error.work_experience[2].completion_year; + this.isModalErrorSkillChooseText.set(errorText); + } else if (error.error.first_name?.[0]) { + this.isModalErrorSkillChooseText.set(error.error.first_name?.[0]); + } else if (error.error.last_name?.[0]) { + this.isModalErrorSkillChooseText.set(error.error.last_name?.[0]); + } else { + this.isModalErrorSkillChooseText.set(error.error[0]); + } + }, + }); + } +} diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-skills-info.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-skills-info.service.ts new file mode 100644 index 000000000..e74c3c9db --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-skills-info.service.ts @@ -0,0 +1,130 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Subject } from "rxjs"; +import { ProfileEditInfoService } from "./profile-edit-info.service"; +import { ProfileFormService } from "./profile-form.service"; + +@Injectable() +export class ProfileEditSkillsInfoService { + private readonly fb = inject(FormBuilder); + private readonly profileEditInfoService = inject(ProfileEditInfoService); + private readonly profileFormService = inject(ProfileFormService); + + private readonly destroy$ = new Subject(); + + protected readonly editIndex = this.profileEditInfoService.editIndex; + + private readonly profileForm = this.profileFormService.getForm(); + + readonly editLanguageClick = signal(false); + readonly showLanguageFields = signal(false); + + readonly languageItems = signal([]); + private readonly userLanguages = this.profileFormService.userLanguages; + + private readonly languageList = this.profileFormService.languageList; + private readonly languageLevelList = this.profileFormService.languageLevelList; + + readonly selectedLanguageId = signal(undefined); + readonly selectedLanguageLevelId = signal(undefined); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + addLanguage() { + if (!this.showLanguageFields()) { + this.showLanguageFields.set(true); + return; + } + + const languageValue = this.profileForm.get("language")?.value; + const languageLevelValue = this.profileForm.get("languageLevel")?.value; + + ["language", "languageLevel"].forEach(name => { + this.profileForm.get(name)?.clearValidators(); + }); + + if ((languageValue && !languageLevelValue) || (!languageValue && languageLevelValue)) { + ["language", "languageLevel"].forEach(name => { + this.profileForm.get(name)?.setValidators([Validators.required]); + }); + } + + ["language", "languageLevel"].forEach(name => { + this.profileForm.get(name)?.updateValueAndValidity(); + this.profileForm.get(name)?.markAsTouched(); + }); + + const isLanguageValid = this.profileForm.get("language")?.valid; + const isLanguageLevelValid = this.profileForm.get("languageLevel")?.valid; + + if (!isLanguageValid || !isLanguageLevelValid) { + return; + } + + const languageItem = this.fb.group({ + language: languageValue, + languageLevel: languageLevelValue, + }); + + if (languageValue && languageLevelValue) { + if (this.editIndex() !== null) { + this.languageItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = languageItem.value; + this.userLanguages.at(this.editIndex()!).patchValue(languageItem.value); + return updatedItems; + }); + this.editIndex.set(null); + } else { + this.languageItems.update(items => [...items, languageItem.value]); + this.userLanguages.push(languageItem); + } + + ["language", "languageLevel"].forEach(name => { + this.profileForm.get(name)?.reset(); + this.profileForm.get(name)?.setValue(null); + this.profileForm.get(name)?.clearValidators(); + this.profileForm.get(name)?.markAsPristine(); + this.profileForm.get(name)?.updateValueAndValidity(); + }); + this.showLanguageFields.set(false); + } + this.editLanguageClick.set(false); + } + + editLanguage(index: number) { + this.editLanguageClick.set(true); + this.showLanguageFields.set(true); + const languageItem = this.userLanguages.value[index]; + + this.languageList.forEach(language => { + if (language.value === languageItem.language) { + this.selectedLanguageId.set(language.id); + } + }); + + this.languageLevelList.forEach(languageLevel => { + if (languageLevel.value === languageItem.languageLevel) { + this.selectedLanguageLevelId.set(languageLevel.id); + } + }); + + this.profileForm.patchValue({ + language: languageItem.language, + languageLevel: languageItem.languageLevel, + }); + + this.editIndex.set(index); + } + + removeLanguage(i: number) { + this.languageItems.update(items => items.filter((_, index) => index !== i)); + + this.userLanguages.removeAt(i); + } +} diff --git a/projects/social_platform/src/app/api/profile/facades/edit/profile-form.service.ts b/projects/social_platform/src/app/api/profile/facades/edit/profile-form.service.ts new file mode 100644 index 000000000..0674bc541 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-form.service.ts @@ -0,0 +1,369 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { concatMap, first, map, Observable, skip, Subject, takeUntil } from "rxjs"; +import { AuthService } from "../../../auth"; +import dayjs from "dayjs"; +import { yearRangeValidators } from "@utils/helpers/yearRangeValidators"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { Specialization } from "projects/social_platform/src/app/domain/specializations/specialization"; +import { SelectComponent } from "@ui/components"; +import { generateOptionsList } from "@utils/generate-options-list"; +import { + educationUserLevel, + educationUserType, +} from "projects/core/src/consts/lists/education-info-list.const"; +import { + languageLevelsList, + languageNamesList, +} from "projects/core/src/consts/lists/language-info-list.const"; + +@Injectable({ providedIn: "root" }) +export class ProfileFormService { + private readonly fb = inject(FormBuilder); + private readonly authService = inject(AuthService); + + private readonly destroy$ = new Subject(); + + private profileForm!: FormGroup; + + readonly inlineSpecs = signal([]); + readonly profileId = signal(undefined); + + readonly roles = signal([]); + + readonly newPreferredIndustryTitle = signal(""); + + readonly yearListEducation = generateOptionsList(55, "years").reverse(); + readonly educationStatusList = educationUserType; + readonly educationLevelList = educationUserLevel; + + readonly achievementsYearList = generateOptionsList(25, "years"); + + readonly languageList = languageNamesList; + readonly languageLevelList = languageLevelsList; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + constructor() { + this.initializeProfileForm(); + + this.authService.changeableRoles + .pipe( + map(roles => roles.map(role => ({ id: role.id, value: role.id, label: role.name }))), + takeUntil(this.destroy$) + ) + .subscribe({ + next: roles => { + this.roles.set(roles); + }, + }); + } + + private initializeProfileForm(): void { + this.profileForm = this.fb.group({ + firstName: ["", [Validators.required]], + lastName: ["", [Validators.required]], + email: ["", [Validators.email, Validators.maxLength(50)]], + userType: [0], + birthday: ["", [Validators.required]], + city: ["", [Validators.required, Validators.maxLength(100)]], + phoneNumber: ["", Validators.maxLength(12)], + additionalRole: [null], + coverImageAddress: [null], + + // education + organizationName: ["", Validators.maxLength(100)], + entryYear: [null], + completionYear: [null], + description: [null, Validators.maxLength(400)], + educationLevel: [null], + educationStatus: [""], + isMospolytechStudent: [false], + studyGroup: ["", Validators.maxLength(10)], + + // language + language: [null], + languageLevel: [null], + + // achievements + title: [null], + status: [null], + year: [null], + files: [""], + + education: this.fb.array([]), + workExperience: this.fb.array([]), + userLanguages: this.fb.array([]), + links: this.fb.array([]), + achievements: this.fb.array([]), + + // work + organization: ["", Validators.maxLength(50)], + entryYearWork: [null], + completionYearWork: [null], + descriptionWork: [null, Validators.maxLength(400)], + jobPosition: [""], + + // skills + speciality: ["", [Validators.required]], + skills: [[]], + avatar: [""], + aboutMe: ["", Validators.maxLength(300)], + typeSpecific: this.fb.group({}), + }); + + this.profileForm + .get("userType") + ?.valueChanges.pipe( + skip(1), + concatMap(this.changeUserType.bind(this)), + takeUntil(this.destroy$) + ) + .subscribe(); + + this.profileForm + .get("avatar") + ?.valueChanges.pipe( + skip(1), + concatMap(url => this.authService.saveAvatar(url)), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + public initializeProfileData(): void { + this.authService.profile.pipe(first(), takeUntil(this.destroy$)).subscribe((profile: User) => { + this.profileId.set(profile.id); + + this.profileForm.patchValue({ + firstName: profile.firstName ?? "", + lastName: profile.lastName ?? "", + email: profile.email ?? "", + userType: profile.userType ?? 1, + birthday: profile.birthday ? dayjs(profile.birthday).format("DD.MM.YYYY") : "", + city: profile.city ?? "", + coverImageAddress: profile.coverImageAddress ?? "", + phoneNumber: profile.phoneNumber ?? "", + additionalRole: profile.v2Speciality?.name ?? "", + speciality: profile.speciality ?? "", + skills: profile.skills ?? [], + avatar: profile.avatar ?? "", + aboutMe: profile.aboutMe ?? "", + isMospolytechStudent: profile.isMospolytechStudent ?? false, + studyGroup: profile.studyGroup ?? "", + }); + + this.workExperience.clear(); + profile.workExperience.forEach(work => { + this.workExperience.push( + this.fb.group( + { + organizationName: work.organizationName, + entryYear: work.entryYear, + completionYear: work.completionYear, + description: work.description, + jobPosition: work.jobPosition, + }, + { + validators: yearRangeValidators("entryYear", "completionYear"), + } + ) + ); + }); + + this.education.clear(); + profile.education.forEach(edu => { + this.education.push( + this.fb.group( + { + organizationName: edu.organizationName, + entryYear: edu.entryYear, + completionYear: edu.completionYear, + description: edu.description, + educationStatus: edu.educationStatus, + educationLevel: edu.educationLevel, + }, + { + validators: yearRangeValidators("entryYear", "completionYear"), + } + ) + ); + }); + + this.userLanguages.clear(); + profile.userLanguages.forEach(lang => { + this.userLanguages.push( + this.fb.group({ + language: lang.language, + languageLevel: lang.languageLevel, + }) + ); + }); + + this.achievements.clear(); + profile.achievements.forEach(achievement => { + this.achievements.push( + this.fb.group({ + id: [achievement.id], + title: [achievement.title, Validators.required], + status: [achievement.status, Validators.required], + year: [achievement.year, Validators.required], + files: [achievement.files ?? []], + }) + ); + }); + + profile.links.length && profile.links.forEach(l => this.addLink(l)); + + if ([2, 3, 4].includes(profile.userType)) { + this.typeSpecific?.addControl("preferredIndustries", this.fb.array([])); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + profile[this.userTypeMap[profile.userType]].preferredIndustries.forEach( + (industry: string) => this.addPreferredIndustry(industry) + ); + } + + if ([1, 3, 4].includes(profile.userType)) { + const userTypeData = profile.member ?? profile.mentor ?? profile.expert; + this.typeSpecific.addControl("usefulToProject", this.fb.control("")); + this.typeSpecific.get("usefulToProject")?.patchValue(userTypeData?.usefulToProject); + } + }); + } + + /** + * Возвращает основную форму проекта. + * @returns FormGroup экземпляр формы проекта + */ + public getForm(): FormGroup { + return this.profileForm; + } + + get avatar(): FormControl { + return this.profileForm.get("avatar") as FormControl; + } + + get coverImageAddress(): FormControl { + return this.profileForm.get("coverImageAddress") as FormControl; + } + + get firstName(): FormControl { + return this.profileForm.get("firstName") as FormControl; + } + + get lastName(): FormControl { + return this.profileForm.get("lastName") as FormControl; + } + + get city(): FormControl { + return this.profileForm.get("city") as FormControl; + } + + get birthday(): FormControl { + return this.profileForm.get("birthday") as FormControl; + } + + get userType(): FormControl { + return this.profileForm.get("userType") as FormControl; + } + + get speciality(): FormControl { + return this.profileForm.get("speciality") as FormControl; + } + + get aboutMe(): FormControl { + return this.profileForm.get("aboutMe") as FormControl; + } + + get phoneNumber(): FormControl { + return this.profileForm.get("phoneNumber") as FormControl; + } + + get achievements(): FormArray { + return this.profileForm.get("achievements") as FormArray; + } + + get education(): FormArray { + return this.profileForm.get("education") as FormArray; + } + + get workExperience(): FormArray { + return this.profileForm.get("workExperience") as FormArray; + } + + get userLanguages(): FormArray { + return this.profileForm.get("userLanguages") as FormArray; + } + + get links(): FormArray { + return this.profileForm.get("links") as FormArray; + } + + get typeSpecific(): FormGroup { + return this.profileForm.get("typeSpecific") as FormGroup; + } + + get usefulToProject(): FormControl { + return this.typeSpecific.get("usefulToProject") as FormControl; + } + + get preferredIndustries(): FormArray { + return this.typeSpecific.get("preferredIndustries") as FormArray; + } + + addPreferredIndustry(title?: string): void { + const fromState = title ?? this.newPreferredIndustryTitle; + if (!fromState) { + return; + } + + const control = this.fb.control(fromState, [Validators.required]); + this.preferredIndustries.push(control); + + this.newPreferredIndustryTitle.set(""); + } + + removePreferredIndustry(i: number): void { + this.preferredIndustries.removeAt(i); + } + + protected readonly newLink = signal(""); + + addLink(title?: string): void { + const fromState = title ?? this.newLink; + + const control = this.fb.control(fromState, [Validators.required]); + this.links.push(control); + + this.newLink.set(""); + } + + removeLink(i: number): void { + this.links.removeAt(i); + } + + /** + * Изменение типа пользователя + * @param typeId - новый тип пользователя + * @returns Observable - результат операции изменения типа + */ + changeUserType(typeId: number): Observable { + return this.authService + .saveProfile({ + email: this.profileForm.value.email, + firstName: this.profileForm.value.firstName, + lastName: this.profileForm.value.lastName, + userType: typeId, + }) + .pipe( + map(() => location.reload()), + takeUntil(this.destroy$) + ); + } +} diff --git a/projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts b/projects/social_platform/src/app/api/profile/profile-date.service.ts similarity index 89% rename from projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts rename to projects/social_platform/src/app/api/profile/profile-date.service.ts index 9c53ceec0..739ed93e8 100644 --- a/projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts +++ b/projects/social_platform/src/app/api/profile/profile-date.service.ts @@ -1,9 +1,9 @@ /** @format */ import { Injectable } from "@angular/core"; -import { User } from "@auth/models/user.model"; -import { Project } from "@office/models/project.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; import { BehaviorSubject, filter, map } from "rxjs"; +import { Project } from "../../domain/project/project.model"; @Injectable({ providedIn: "root", diff --git a/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.spec.ts b/projects/social_platform/src/app/api/profile/profile-news.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/profile/detail/services/profile-news.service.spec.ts rename to projects/social_platform/src/app/api/profile/profile-news.service.spec.ts diff --git a/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.ts b/projects/social_platform/src/app/api/profile/profile-news.service.ts similarity index 96% rename from projects/social_platform/src/app/office/profile/detail/services/profile-news.service.ts rename to projects/social_platform/src/app/api/profile/profile-news.service.ts index e411da09b..23b59f171 100644 --- a/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.ts +++ b/projects/social_platform/src/app/api/profile/profile-news.service.ts @@ -3,11 +3,11 @@ import { inject, Injectable } from "@angular/core"; import { forkJoin, map, Observable, tap } from "rxjs"; import { ApiService } from "projects/core"; -import { ProfileNews } from "../models/profile-news.model"; +import { ProfileNews } from "../../domain/profile/profile-news.model"; import { HttpParams } from "@angular/common/http"; import { plainToInstance } from "class-transformer"; -import { StorageService } from "@services/storage.service"; -import { ApiPagination } from "@models/api-pagination.model"; +import { StorageService } from "projects/social_platform/src/app/api/storage/storage.service"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; /** * Сервис для работы с новостями профиля пользователя diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts new file mode 100644 index 000000000..bbab3d9cb --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts @@ -0,0 +1,357 @@ +/** @format */ + +import { ElementRef, inject, Injectable } from "@angular/core"; +import { + catchError, + concatMap, + distinctUntilChanged, + EMPTY, + fromEvent, + map, + of, + Subject, + switchMap, + takeUntil, + tap, + throttleTime, +} from "rxjs"; +import { ProgramService } from "../../program.service"; +import { ActivatedRoute, Router } from "@angular/router"; +import { ProjectRatingService } from "../../../project/project-rating.service"; +import { AuthService } from "../../../auth"; +import { SubscriptionService } from "../../../subsriptions/subscription.service"; +import { FormGroup } from "@angular/forms"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import Fuse from "fuse.js"; +import { ProgramDetailListUIInfoService } from "./ui/program-detail-list-ui-info.service"; +import { ProjectRate } from "projects/social_platform/src/app/domain/project/project-rate"; + +@Injectable() +export class ProgramDetailListInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly programService = inject(ProgramService); + private readonly projectRatingService = inject(ProjectRatingService); + private readonly authService = inject(AuthService); + private readonly subscriptionService = inject(SubscriptionService); + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + + private readonly destroy$ = new Subject(); + + private readonly listType = this.programDetailListUIInfoService.listType; + private readonly searchParamName = this.programDetailListUIInfoService.searchParamName; + private readonly list = this.programDetailListUIInfoService.list; + private readonly searchedList = this.programDetailListUIInfoService.searchedList; + + private readonly listPage = this.programDetailListUIInfoService.listPage; + private readonly itemsPerPage = this.programDetailListUIInfoService.itemsPerPage; + private readonly listTotalCount = this.programDetailListUIInfoService.listTotalCount; + + private readonly searchForm = this.programDetailListUIInfoService.searchForm; + + initializationListData(): void { + this.route.data + .pipe( + tap(data => this.listType.set(data["listType"])), + switchMap(r => of(r["data"])), + takeUntil(this.destroy$) + ) + .subscribe(data => { + this.programDetailListUIInfoService.applyInitializationProgramListData(data); + }); + + this.setupSearch(this.searchForm); + + if (this.listType() === "projects") this.setupProfile(); + + this.setupFilters(); + } + + initScroll(target: HTMLElement, listRoot: ElementRef): void { + fromEvent(target, "scroll") + .pipe( + throttleTime(200), + switchMap(() => this.onScroll(target, listRoot)), + catchError(err => { + console.error("Scroll error:", err); + return of({}); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Сброс всех активных фильтров + * Очищает все query параметры и возвращает к состоянию по умолчанию + */ + onClearFilters(): void { + this.router + .navigate([], { + queryParams: { + search: undefined, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => console.log("Query change from ProjectsComponent")); + } + + private setupSearch(searchForm: FormGroup): void { + searchForm + .get("search") + ?.valueChanges.pipe(throttleTime(200), takeUntil(this.destroy$)) + .subscribe(search => { + this.router + .navigate([], { + queryParams: { [this.searchParamName()]: search || null }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => console.debug("QueryParams changed from ProgramListComponent")); + }); + + this.route.queryParams + .pipe( + map(q => q["search"]), + takeUntil(this.destroy$) + ) + .subscribe(search => { + this.searchedList.set(this.applySearch(search)); + }); + } + + setupProfile(): void { + this.authService.profile + .pipe( + switchMap(p => { + return this.subscriptionService.getSubscriptions(p.id); + }), + takeUntil(this.destroy$) + ) + .subscribe({ + next: subs => { + this.programDetailListUIInfoService.applySetupProfile(subs); + }, + }); + } + + private setupFilters(): void { + if (this.listType() === "members") return; + + this.route.queryParams + .pipe( + distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)), + concatMap(q => { + const { filters, extraParams } = this.buildFilterQuery(q); + const programId = this.route.parent?.snapshot.params["programId"]; + + this.listPage.set(0); + + const params = new HttpParams({ + fromObject: { + offset: "0", + limit: this.itemsPerPage().toString(), + ...extraParams, + }, + }); + + if (this.listType() === "rating") { + if (Object.keys(filters).length > 0) { + return this.projectRatingService.postFilters(programId, filters, params); + } + return this.projectRatingService.getAll(programId, params); + } + + if (Object.keys(filters).length > 0) { + return this.programService.createProgramFilters(programId, filters, params); + } + return this.programService.getAllProjects(programId, params); + }), + catchError(err => { + console.error("Error in setupFilters:", err); + return of({ count: 0, results: [] }); + }), + takeUntil(this.destroy$) + ) + .subscribe(result => { + if (!result) return; + + this.programDetailListUIInfoService.applyInitializationProgramListData(result); + this.listPage.set(0); + }); + } + + getInitialSearchValue(): string { + const qp = this.route.snapshot.queryParams; + const raw = qp["search"] ?? qp["name__contains"]; + return raw ? decodeURIComponent(raw) : ""; + } + + initializeSearchForm(): void { + const initialValue = this.getInitialSearchValue(); + this.programDetailListUIInfoService.applyInitializationSearchForm(initialValue); + } + + // Универсальный метод скролла + private onScroll(target: HTMLElement, listRoot: ElementRef) { + const total = this.listTotalCount(); + + if (total && this.list().length >= total) { + return EMPTY; + } + + if (!target || !listRoot.nativeElement) return EMPTY; + + let shouldFetch = false; + + if (this.listType() === "rating") { + const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + shouldFetch = scrollBottom <= 200; + } else { + if (!listRoot) return EMPTY; + const diff = + target.scrollTop - + listRoot.nativeElement.getBoundingClientRect().height + + window.innerHeight; + + const threshold = this.listType() === "projects" ? -200 : 0; + shouldFetch = diff > threshold; + } + + if (shouldFetch) { + this.listPage.update(p => p + 1); + return this.onFetch(); + } + + return of({}); + } + + // Универсальный метод загрузки данных + // Универсальный метод загрузки данных + private onFetch() { + const programId = this.route.parent?.snapshot.params["programId"]; + const offset = this.listPage() * this.itemsPerPage(); + + // Получаем текущие query параметры для фильтров + const currentQuery = this.route.snapshot.queryParams; + const { filters, extraParams } = this.buildFilterQuery(currentQuery); + + const params = new HttpParams({ + fromObject: { + offset: offset.toString(), + limit: this.itemsPerPage().toString(), + ...extraParams, + }, + }); + + switch (this.listType()) { + case "rating": { + const ratingRequest$ = + Object.keys(filters).length > 0 + ? this.projectRatingService.postFilters(programId, filters, params) + : this.projectRatingService.getAll(programId, params); + + return ratingRequest$.pipe( + tap((rating: ApiPagination) => { + this.programDetailListUIInfoService.applyFetchProgramData(rating); + }), + catchError(err => { + console.error("Error fetching ratings:", err); + this.listPage.update(p => p - 1); + return of({ count: this.listTotalCount || 0, results: [] }); + }), + takeUntil(this.destroy$) + ); + } + + case "projects": { + const projectsRequest$ = + Object.keys(filters).length > 0 + ? this.programService.createProgramFilters(programId, filters, params) + : this.programService.getAllProjects(programId, params); + + return projectsRequest$.pipe( + tap((projects: ApiPagination) => { + this.programDetailListUIInfoService.applyFetchProgramData(projects); + }), + catchError(err => { + console.error("Error fetching projects:", err); + this.listPage.update(p => p - 1); + return of({ count: this.listTotalCount || 0, results: [] }); + }), + takeUntil(this.destroy$) + ); + } + + case "members": { + return this.programService.getAllMembers(programId, offset, this.itemsPerPage()).pipe( + tap((members: ApiPagination) => { + this.programDetailListUIInfoService.applyFetchProgramData(members); + }), + catchError(err => { + console.error("Error fetching members:", err); + this.listPage.update(p => p - 1); + return of({ count: this.listTotalCount || 0, results: [] }); + }), + takeUntil(this.destroy$) + ); + } + + default: + return of({ count: 0, results: [] }); + } + } + + // Построение запроса для фильтров (кроме участников) + private buildFilterQuery(q: any): { + filters: Record; + extraParams: Record; + } { + if (this.listType() === "members") return { filters: {}, extraParams: {} }; + + const filters: Record = {}; + const extraParams: Record = {}; + + Object.keys(q).forEach(key => { + const value = q[key]; + if (value === undefined || value === "" || value === null) return; + + if (this.listType() === "rating" && (key === "search" || key === "name__contains")) { + extraParams["name__contains"] = value; + return; + } + + if (this.listType() === "rating" && key === "is_rated_by_expert") { + extraParams["is_rated_by_expert"] = value; + return; + } + + filters[key] = Array.isArray(value) ? value : [value]; + }); + + return { filters, extraParams }; + } + + private applySearch(search: string) { + if (!search) return this.list(); + + const searchKeys = + this.listType() === "projects" || this.listType() === "rating" + ? ["name"] + : ["firstName", "lastName"]; + + const fuse = new Fuse(this.list(), { + keys: searchKeys, + }); + return fuse.search(search).map(r => r.item); + } +} diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts new file mode 100644 index 000000000..008ed7b0d --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts @@ -0,0 +1,231 @@ +/** @format */ + +import { ElementRef, inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { concatMap, fromEvent, map, of, Subject, takeUntil, tap, throttleTime } from "rxjs"; +import { ProgramNewsService } from "../../program-news.service"; +import { FeedNews } from "projects/social_platform/src/app/domain/project/project-news.model"; +import { LoadingService } from "@ui/services/loading/loading.service"; +import { ExpandService } from "../../../expand/expand.service"; +import { ProgramDetailMainUIInfoService } from "./ui/program-detail-main-ui-info.service"; +import { NewsInfoService } from "../../../news/news-info.service"; + +@Injectable() +export class ProgramDetailMainService { + private readonly programNewsService = inject(ProgramNewsService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly loadingService = inject(LoadingService); + private readonly expandService = inject(ExpandService); + private readonly newsInfoService = inject(NewsInfoService); + private readonly programDetailMainUIInfoService = inject(ProgramDetailMainUIInfoService); + + private observer?: IntersectionObserver; + private readonly destroy$ = new Subject(); + + private readonly totalNewsCount = this.programDetailMainUIInfoService.totalNewsCount; + readonly fetchLimit = signal(10); + readonly fetchPage = signal(0); + + readonly news = this.newsInfoService.news; + readonly program = this.programDetailMainUIInfoService.program; + readonly programId = this.programDetailMainUIInfoService.programId; + + initializationProgramDetailMain(descEl: ElementRef | undefined): void { + this.initializationProgramId(); + this.initializationProgramQueryParams(); + this.initializationProgram(descEl); + } + + private initializationProgramId(): void { + this.route.params + .pipe( + map(params => params["programId"]), + tap(programId => { + this.programId.set(programId); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + private initializationProgramQueryParams(): void { + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe(param => { + if (param["access"] === "accessDenied") { + this.loadingService.hide(); + this.programDetailMainUIInfoService.applyInitProgramQueryParams(); + + this.router.navigate([], { + relativeTo: this.route, + queryParams: { access: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + }); + } + + private initializationProgram(descEl: ElementRef | undefined): void { + this.route.data + .pipe( + map(r => r["data"]), + tap(program => { + this.programDetailMainUIInfoService.applyFormatingProgramData(program); + }), + concatMap(program => { + if (program.isUserMember) { + return this.fetchNews(0, this.fetchLimit()); + } else { + return of({ results: [], count: 0 }); + } + }), + takeUntil(this.destroy$) + ) + .subscribe({ + next: news => { + if (news.results?.length) { + this.newsInfoService.applySetNews(news); + this.programDetailMainUIInfoService.applyInitProgram(news); + + setTimeout(() => { + this.setupNewsObserver(); + }, 100); + + this.loadingService.hide(); + } + }, + error: () => { + this.loadingService.hide(); + + this.programDetailMainUIInfoService.applyProgramOpenModal("error"); + }, + }); + + this.checkDescriptionTimeout(descEl); + } + + initScroll(target: HTMLElement, descEl: ElementRef | undefined): void { + this.checkDescriptionTimeout(descEl); + + fromEvent(target, "scroll") + .pipe( + throttleTime(2000), + concatMap(() => this.onScroll()), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + destroy(): void { + this.observer?.disconnect(); + + this.destroy$.next(); + this.destroy$.complete(); + } + + private onScroll() { + if (this.news().length >= this.totalNewsCount()) { + return of(null); + } + + const nextPage = this.fetchPage() + 1; + const offset = nextPage * this.fetchLimit(); + + this.fetchPage.set(nextPage); + + return this.fetchNews(offset, this.fetchLimit()).pipe( + tap(({ count, results }) => { + this.totalNewsCount.set(count); + this.newsInfoService.applyUpdateNews(results); + + setTimeout(() => { + this.setupNewsObserver(); + }, 100); + }) + ); + } + + private fetchNews(offset: number, limit: number) { + const programId = this.route.snapshot.params["programId"]; + return this.programNewsService.fetchNews(limit, offset, programId); + } + + private onNewsInVew(entries: IntersectionObserverEntry[]): void { + const ids = entries.map(e => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return e.target.dataset.id; + }); + this.programNewsService + .readNews(this.route.snapshot.params["programId"], ids) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + onAddNews(news: { text: string; files: string[] }) { + return this.programNewsService.addNews(this.route.snapshot.params["programId"], news).pipe( + tap(newsRes => { + this.newsInfoService.applyAddNews(newsRes); + }) + ); + } + + onDelete(newsId: number) { + const item = this.news().find((n: any) => n.id === newsId); + if (!item) return; + this.programNewsService + .deleteNews(this.route.snapshot.params["programId"], newsId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.newsInfoService.applyDeleteNews(newsId); + }, + }); + } + + onLike(newsId: number) { + const item = this.news().find((n: any) => n.id === newsId); + if (!item) return; + + this.programNewsService + .toggleLike(this.route.snapshot.params["programId"], newsId, !item.isUserLiked) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.newsInfoService.applyLikeNews(newsId); + }); + } + + onEdit(news: FeedNews, newsId: number) { + return this.programNewsService + .editNews(this.route.snapshot.params["programId"], newsId, news) + .pipe( + tap((newsRes: any) => { + this.newsInfoService.applyEditNews(newsRes); + }) + ); + } + + closeModal(): void { + this.programDetailMainUIInfoService.applyProgramCloseModal(); + this.loadingService.hide(); + } + + private setupNewsObserver(): void { + this.observer?.disconnect(); + + this.observer = new IntersectionObserver(this.onNewsInVew.bind(this), { + root: document.querySelector(".office__body"), + threshold: 0, + }); + + document.querySelectorAll(".news__item").forEach(el => { + this.observer!.observe(el); + }); + } + + checkDescriptionTimeout(descEl: ElementRef | undefined): void { + setTimeout(() => { + this.expandService.checkExpandable("description", !!this.program()?.description, descEl); + }, 100); + } +} diff --git a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-list-ui-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-list-ui-info.service.ts new file mode 100644 index 000000000..593638c4e --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-list-ui-info.service.ts @@ -0,0 +1,86 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { ProjectRate } from "projects/social_platform/src/app/domain/project/project-rate"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; + +@Injectable() +export class ProgramDetailListUIInfoService { + private readonly fb = inject(FormBuilder); + + readonly listType = signal<"projects" | "members" | "rating">("projects"); + + readonly listTotalCount = signal(0); + readonly listPage = signal(0); + readonly listTake = signal(20); + readonly perPage = signal(21); + + readonly list = signal([]); + readonly searchedList = signal([]); + + readonly profileSubscriptions = signal([]); + readonly profileProjSubsIds = computed(() => this.profileSubscriptions().map(sub => sub.id)); + + itemsPerPage = computed(() => { + return this.listType() === "rating" + ? 10 + : this.listType() === "projects" + ? this.perPage() + : this.listTake(); + }); + + searchParamName = computed(() => { + return this.listType() === "rating" ? "name__contains" : "search"; + }); + + readonly searchForm = this.fb.group({ + search: [""], + }); + + routerLink(linkId: number): string { + switch (this.listType()) { + case "projects": + return `/office/projects/${linkId}`; + + case "members": + return `/office/profile/${linkId}`; + + default: + return ""; + } + } + + applyInitializationSearchForm(value: string): void { + this.searchForm.setValue({ search: value }); + } + + applyInitializationProgramListData(data: any): void { + this.list.set(data.results); + this.searchedList.set(data.results); + this.listTotalCount.set(data.count); + } + + applySetupProfile(subs: ApiPagination): void { + this.profileSubscriptions.set(subs.results); + } + + applyFetchProgramData( + data: ApiPagination | ApiPagination | ApiPagination + ): void { + this.listTotalCount.set(data.count); + + if (this.listPage() === 0) { + this.list.set(data.results); + } else { + const newResults = data.results.filter( + newItem => !this.list().some(existingItem => existingItem.id === newItem.id) + ); + this.list.update(() => [...this.list(), ...newResults]); + } + + this.searchedList.set(this.list()); + } +} diff --git a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts new file mode 100644 index 000000000..f53b71636 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts @@ -0,0 +1,56 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { Program } from "projects/social_platform/src/app/domain/program/program.model"; +import { FeedNews } from "projects/social_platform/src/app/domain/project/project-news.model"; + +@Injectable() +export class ProgramDetailMainUIInfoService { + readonly program = signal(undefined); + readonly programId = signal(undefined); + + readonly totalNewsCount = signal(0); + + // Сигналы для работы с модальными окнами с текстом + readonly showProgramModal = signal(false); + readonly showProgramModalErrorMessage = signal(null); + + readonly registerDateExpired = signal(false); + + applyInitProgramQueryParams(): void { + this.applyProgramOpenModal("access"); + } + + applyInitProgram( + news: + | ApiPagination + | { + results: never[]; + count: number; + } + ): void { + if (news.results?.length) { + this.totalNewsCount.set(news.count); + } + } + + applyFormatingProgramData(program: any): void { + this.program.set(program); + this.registerDateExpired.set(Date.now() > Date.parse(program.datetimeRegistrationEnds)); + } + + applyProgramOpenModal(type: "access" | "error"): void { + const errorText = + type === "access" + ? "У вас не доступа к этой вкладке!" + : "Произошла ошибка при загрузке программы"; + + this.showProgramModal.set(true); + this.showProgramModalErrorMessage.set(errorText); + } + + applyProgramCloseModal(): void { + this.showProgramModal.set(false); + } +} diff --git a/projects/social_platform/src/app/api/program/facades/program-info.service.ts b/projects/social_platform/src/app/api/program/facades/program-info.service.ts new file mode 100644 index 000000000..30ded1eed --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/program-info.service.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { NavService } from "@ui/services/nav/nav.service"; +import { Subject, takeUntil } from "rxjs"; +import { FormGroup } from "@angular/forms"; + +@Injectable() +export class ProgramInfoService { + private readonly route = inject(ActivatedRoute); + private readonly navService = inject(NavService); + private readonly router = inject(Router); + + private readonly destroy$ = new Subject(); + + initializationPrograms(searchForm: FormGroup): void { + this.navService.setNavTitle("Программы"); + + this.initilizationSearchValue(searchForm); + } + + private initilizationSearchValue(searchForm: FormGroup): void { + searchForm + .get("search") + ?.valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(search => { + this.router + .navigate([], { + queryParams: { search }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => console.debug("QueryParams changed from ProjectsComponent")); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/social_platform/src/app/api/program/facades/program-main-info.service.ts b/projects/social_platform/src/app/api/program/facades/program-main-info.service.ts new file mode 100644 index 000000000..710c18912 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/program-main-info.service.ts @@ -0,0 +1,91 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Params, Router } from "@angular/router"; +import { NavService } from "@ui/services/nav/nav.service"; +import { combineLatest, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs"; +import { ProgramService } from "../program.service"; +import { Program } from "../../../domain/program/program.model"; +import { HttpParams } from "@angular/common/http"; +import { ProgramMainUIInfoService } from "./ui/program-main-ui-info.service"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import Fuse from "fuse.js"; + +@Injectable() +export class ProgramMainInfoService { + private readonly navService = inject(NavService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly programService = inject(ProgramService); + private readonly programMainUIInfoService = inject(ProgramMainUIInfoService); + + private readonly destroy$ = new Subject(); + + readonly isPparticipating = this.programMainUIInfoService.isPparticipating; + + readonly programs = this.programMainUIInfoService.programs; + + initializationMainPrograms(): void { + this.navService.setNavTitle("Программы"); + + combineLatest([ + this.route.queryParams.pipe( + map(q => ({ filter: this.buildFilterQuery(q), search: q["search"] || "" })), + distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)) + ), + ]) + .pipe( + switchMap(([{ filter, search }]) => { + this.isPparticipating.set(filter["participating"] === "true"); + + return this.programService + .getAll(0, 20, new HttpParams({ fromObject: filter })) + .pipe(map(response => ({ response, search }))); + }), + takeUntil(this.destroy$) + ) + .subscribe(({ response, search }) => { + const programs = this.applySearch(response, search); + + this.programMainUIInfoService.applyPrograms(programs, response.count); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Переключает состояние чекбокса "участвую" + */ + onTogglePparticipating(): void { + const newValue = !this.isPparticipating(); + this.isPparticipating.set(newValue); + + this.router.navigate([], { + queryParams: { + participating: newValue ? "true" : null, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }); + } + + private buildFilterQuery(q: Params): Record { + const reqQuery: Record = {}; + + if (q["participating"]) { + reqQuery["participating"] = q["participating"]; + } + + return reqQuery; + } + + private applySearch(response: ApiPagination, search: string): Program[] { + if (!search) return response.results; + + const fuse = new Fuse(response.results, { keys: ["name"], threshold: 0.3 }); + return fuse.search(search).map(r => r.item); + } +} diff --git a/projects/social_platform/src/app/api/program/facades/ui/program-main-ui-info.service.ts b/projects/social_platform/src/app/api/program/facades/ui/program-main-ui-info.service.ts new file mode 100644 index 000000000..151d2fded --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/ui/program-main-ui-info.service.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { generateOptionsList } from "@utils/generate-options-list"; +import { Program } from "projects/social_platform/src/app/domain/program/program.model"; + +@Injectable() +export class ProgramMainUIInfoService { + readonly programCount = signal(0); + readonly isPparticipating = signal(false); + + readonly programs = signal([]); + + readonly programOptionsFilter = generateOptionsList(4, "strings", [ + "все", + "актуальные", + "архив", + "участвовал", + ]); + + applyPrograms(programs: Program[], count: number): void { + this.programCount.set(count); + this.programs.set(programs); + } +} diff --git a/projects/social_platform/src/app/api/program/program-data.service.ts b/projects/social_platform/src/app/api/program/program-data.service.ts new file mode 100644 index 000000000..eae50f6f1 --- /dev/null +++ b/projects/social_platform/src/app/api/program/program-data.service.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { Program } from "../../domain/program/program.model"; + +@Injectable({ + providedIn: "root", +}) +export class ProgramDataService { + program = signal(undefined); + + setProgram(program: Program): void { + return this.program.set(program); + } + + programDateFinished = computed(() => { + const program = this.program(); + return program?.datetimeFinished ? Date.now() > Date.parse(program.datetimeFinished) : false; + }); + + registerDateExpired = computed(() => { + const program = this.program(); + return program?.datetimeRegistrationEnds + ? Date.now() > Date.parse(program.datetimeRegistrationEnds) + : false; + }); +} diff --git a/projects/social_platform/src/app/office/program/services/program-news.service.spec.ts b/projects/social_platform/src/app/api/program/program-news.service.spec.ts similarity index 83% rename from projects/social_platform/src/app/office/program/services/program-news.service.spec.ts rename to projects/social_platform/src/app/api/program/program-news.service.spec.ts index b1b76beb9..eafa80156 100644 --- a/projects/social_platform/src/app/office/program/services/program-news.service.spec.ts +++ b/projects/social_platform/src/app/api/program/program-news.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { ProgramNewsService } from "./program-news.service"; +import { ProgramNewsService } from "../ui/pages/program/services/program-news.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; describe("ProgramNewsService", () => { diff --git a/projects/social_platform/src/app/office/program/services/program-news.service.ts b/projects/social_platform/src/app/api/program/program-news.service.ts similarity index 94% rename from projects/social_platform/src/app/office/program/services/program-news.service.ts rename to projects/social_platform/src/app/api/program/program-news.service.ts index 9f7c51ec2..c684df078 100644 --- a/projects/social_platform/src/app/office/program/services/program-news.service.ts +++ b/projects/social_platform/src/app/api/program/program-news.service.ts @@ -3,10 +3,10 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { forkJoin, map, Observable } from "rxjs"; -import { ApiPagination } from "@models/api-pagination.model"; -import { FeedNews } from "@office/projects/models/project-news.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; import { plainToInstance } from "class-transformer"; +import { FeedNews } from "../../domain/project/project-news.model"; /** * Сервис для работы с новостями программ diff --git a/projects/social_platform/src/app/office/program/services/program.service.spec.ts b/projects/social_platform/src/app/api/program/program.service.spec.ts similarity index 87% rename from projects/social_platform/src/app/office/program/services/program.service.spec.ts rename to projects/social_platform/src/app/api/program/program.service.spec.ts index 922a404c7..6568e6527 100644 --- a/projects/social_platform/src/app/office/program/services/program.service.spec.ts +++ b/projects/social_platform/src/app/api/program/program.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing"; -import { ProgramService } from "./program.service"; +import { ProgramService } from "../ui/pages/program/services/program.service"; import { RouterTestingModule } from "@angular/router/testing"; import { HttpClientTestingModule } from "@angular/common/http/testing"; diff --git a/projects/social_platform/src/app/office/program/services/program.service.ts b/projects/social_platform/src/app/api/program/program.service.ts similarity index 90% rename from projects/social_platform/src/app/office/program/services/program.service.ts rename to projects/social_platform/src/app/api/program/program.service.ts index f9e261853..a6e284c72 100644 --- a/projects/social_platform/src/app/office/program/services/program.service.ts +++ b/projects/social_platform/src/app/api/program/program.service.ts @@ -4,13 +4,13 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { map, Observable } from "rxjs"; import { HttpParams } from "@angular/common/http"; -import { ProgramCreate } from "@office/program/models/program-create.model"; -import { Program, ProgramDataSchema } from "@office/program/models/program.model"; -import { Project } from "@models/project.model"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { ProjectAdditionalFields } from "@office/projects/models/project-additional-fields.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { PartnerProgramFields } from "projects/social_platform/src/app/domain/program/partner-program-fields.model"; +import { Program, ProgramDataSchema } from "../../domain/program/program.model"; +import { ProgramCreate } from "../../domain/program/program-create.model"; +import { Project } from "../../domain/project/project.model"; +import { ProjectAdditionalFields } from "../../domain/project/project-additional-fields.model"; /** * Сервис для работы с программами diff --git a/projects/social_platform/src/app/api/project/dto/create-vacancy.model.ts b/projects/social_platform/src/app/api/project/dto/create-vacancy.model.ts new file mode 100644 index 000000000..b4f7081ec --- /dev/null +++ b/projects/social_platform/src/app/api/project/dto/create-vacancy.model.ts @@ -0,0 +1,12 @@ +/** @format */ + +export interface CreateVacancyDto { + role: string; + requiredSkillsIds?: number[]; + description?: string; + requiredExperience: string; + workFormat: string; + workSchedule: string; + specialization?: string; + salary: number | null; +} diff --git a/projects/social_platform/src/app/api/project/facades/dashboard/projects-dashboard-info.service.ts b/projects/social_platform/src/app/api/project/facades/dashboard/projects-dashboard-info.service.ts new file mode 100644 index 000000000..55c2ac48b --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/dashboard/projects-dashboard-info.service.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { combineLatest, Subject, switchMap, takeUntil } from "rxjs"; +import { ActivatedRoute } from "@angular/router"; +import { AuthService } from "../../../auth"; +import { SubscriptionService } from "../../../subsriptions/subscription.service"; +import { ProjectsDashboardUIInfoService } from "./ui/projects-dashboard-ui-info.service"; + +@Injectable() +export class ProjectsDashboardInfoService { + private readonly route = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + private readonly subscriptionService = inject(SubscriptionService); + private readonly projectsDashboardUIInfoService = inject(ProjectsDashboardUIInfoService); + + private readonly destroy$ = new Subject(); + + initializationDashboardItems(): void { + const subscriptions$ = this.authService.profile.pipe( + switchMap(p => this.subscriptionService.getSubscriptions(p.id)) + ); + + combineLatest([this.route.data, subscriptions$]) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: ([ + { + data: { all, my }, + }, + subs, + ]) => { + this.projectsDashboardUIInfoService.applySetDashboardItems(all, my, subs); + }, + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/dashboard/ui/projects-dashboard-ui-info.service.ts b/projects/social_platform/src/app/api/project/facades/dashboard/ui/projects-dashboard-ui-info.service.ts new file mode 100644 index 000000000..b756f6bec --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/dashboard/ui/projects-dashboard-ui-info.service.ts @@ -0,0 +1,46 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { DashboardItem, dashboardItemBuilder } from "@utils/helpers/dashboardItemBuilder"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; +import { ProgramDetailListUIInfoService } from "../../../../program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; + +@Injectable() +export class ProjectsDashboardUIInfoService { + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + + readonly dashboardItems = signal([]); + + private readonly profileSubs = this.programDetailListUIInfoService.profileSubscriptions; + + applySetDashboardItems( + all: ApiPagination, + my: ApiPagination, + subs: ApiPagination + ): void { + this.profileSubs.set(subs.results); + + const allProjects = all.results.slice(0, 4); + const myProjects = my.results.slice(0, 4); + const subsProjects = subs.results.slice(0, 4); + + this.dashBoardItemsBuilder(allProjects, myProjects, subsProjects); + } + + private dashBoardItemsBuilder( + myProjects: Project[], + subsProjects: Project[], + allProjects: Project[] + ): void { + this.dashboardItems.set( + dashboardItemBuilder( + 3, + ["my", "subscriptions", "all"], + ["мои проекты", "мои подписки", "витрина проектов"], + ["main", "favourities", "folders"], + [myProjects, subsProjects, allProjects] + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/detail/chat/projects-detail-chat.service.ts b/projects/social_platform/src/app/api/project/facades/detail/chat/projects-detail-chat.service.ts new file mode 100644 index 000000000..b6f6523b5 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/detail/chat/projects-detail-chat.service.ts @@ -0,0 +1,14 @@ +/** @format */ + +import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; + +@Injectable() +export class ProjectsDetailChatService { + private readonly destroy$ = new Subject(); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts b/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts new file mode 100644 index 000000000..849362568 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts @@ -0,0 +1,214 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { filter, map, Observable, Subject, takeUntil, tap } from "rxjs"; +import { ProjectService } from "../../project.service"; +import { AuthService } from "../../../auth"; +import { NavService } from "@ui/services/nav/nav.service"; +import { FeedNews } from "projects/social_platform/src/app/domain/project/project-news.model"; +import { ActivatedRoute } from "@angular/router"; +import { ProjectNewsService } from "../../project-news.service"; +import { Collaborator } from "projects/social_platform/src/app/domain/project/collaborator.model"; +import { ExpandService } from "../../../expand/expand.service"; +import { ProjectsDetailUIInfoService } from "./ui/projects-detail-ui.service"; +import { NewsInfoService } from "../../../news/news-info.service"; + +@Injectable({ providedIn: "root" }) +export class ProjectsDetailService { + private readonly projectService = inject(ProjectService); + private readonly projectsDetailUIService = inject(ProjectsDetailUIInfoService); + private readonly authService = inject(AuthService); + private readonly navService = inject(NavService); + private readonly route = inject(ActivatedRoute); // Сервис для работы с активным маршрутом + private readonly projectNewsService = inject(ProjectNewsService); // Сервис новостей проекта + private readonly expandService = inject(ExpandService); + private readonly newsInfoService = inject(NewsInfoService); + + private observer?: IntersectionObserver; + private readonly destroy$ = new Subject(); + + private readonly project = this.projectsDetailUIService.project; + private readonly projectId = this.projectsDetailUIService.projectId; + + private readonly news = this.newsInfoService.news; + + destroy(): void { + this.observer?.disconnect(); + + this.destroy$.next(); + this.destroy$.complete(); + } + + initializationTeam(): void { + this.authService.profile + .pipe( + filter(profile => !!profile), + map(profile => profile.id), + takeUntil(this.destroy$) + ) + .subscribe({ + next: profileId => { + if (profileId) { + this.projectsDetailUIService.applySetLoggedUserId("logged", profileId); + } + }, + }); + } + + initializationProjectInfo(): void { + this.navService.setNavTitle("Профиль проекта"); + + this.projectsDetailUIService.applyDirectionItems(); + + this.initCheckDescription(); + + // Загрузка новостей проекта + this.initializationNews(); + + // Получение ID текущего пользователя + this.initializationProfile(); + } + + initializationNews(): void { + this.projectNewsService + .fetchNews(String(this.project()?.id)) + .pipe(takeUntil(this.destroy$)) + .subscribe(news => { + this.newsInfoService.applySetNews(news); + + // Настройка наблюдателя для отслеживания просмотра новостей + setTimeout(() => { + const observer = new IntersectionObserver(this.onNewsInVew.bind(this), { + root: document.querySelector(".office__body"), + rootMargin: "0px 0px 0px 0px", + threshold: 0, + }); + document.querySelectorAll(".news__item").forEach(e => { + observer.observe(e); + }); + }); + }); + } + + initializationProfile(): void { + this.authService.profile.pipe(takeUntil(this.destroy$)).subscribe(profile => { + this.projectsDetailUIService.applySetLoggedUserId("profile", profile.id); + }); + } + + initCheckDescription(): void { + setTimeout(() => { + this.expandService.checkExpandable("description", !!this.project()?.description); + }, 150); + } + + removeCollaboratorFromProject(userId: number): void { + const projectId = this.projectId(); + if (!projectId) return; + + this.projectService + .removeColloborator(this.projectId()!, userId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.projectsDetailUIService.removeCollaborators(userId); + }, + }); + } + + /** + * Обработчик появления новостей в области видимости + * Отмечает новости как просмотренные + * @param entries - массив элементов, попавших в область видимости + */ + onNewsInVew(entries: IntersectionObserverEntry[]): void { + const ids = entries.map(e => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return e.target.dataset.id; + }); + + this.projectNewsService + .readNews(Number(this.project()?.id), ids) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + /** + * Добавление новой новости + * @param news - объект с текстом и файлами новости + */ + onAddNews(news: { text: string; files: string[] }) { + return this.projectNewsService.addNews(this.projectId()!.toString(), news).pipe( + tap(newsRes => this.newsInfoService.applyAddNews(newsRes)), + takeUntil(this.destroy$) + ); + } + + /** + * Удаление новости + * @param newsId - ID удаляемой новости + */ + onDeleteNews(newsId: number): void { + this.newsInfoService.applyDeleteNews(newsId); + + this.projectNewsService + .delete(this.projectId()!.toString(), newsId) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => {}); + } + + /** + * Переключение лайка новости + * @param newsId - ID новости для лайка + */ + onLike(newsId: number) { + const item = this.news().find(n => n.id === newsId); + if (!item) return; + + this.projectNewsService + .toggleLike(this.projectId()!.toString(), newsId, !item.isUserLiked) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.newsInfoService.applyLikeNews(newsId); + }); + } + + /** + * Редактирование новости + * @param news - обновленные данные новости + * @param newsItemId - ID редактируемой новости + */ + onEditNews(news: FeedNews, newsItemId: number): Observable { + return this.projectNewsService.editNews(this.projectId()!.toString(), newsItemId, news).pipe( + tap(resNews => this.newsInfoService.applyEditNews(resNews)), + takeUntil(this.destroy$) + ); + } + + /** + * Удаление участника из проекта + * @param id - ID удаляемого участника + */ + onRemoveMember(id: Collaborator["userId"]) { + this.projectService + .removeColloborator(this.projectId()!, id) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.projectsDetailUIService.applyMembersManipulation(id); + }); + } + + /** + * Передача лидерства другому участнику + * @param id - ID нового лидера + */ + onTransferOwnership(id: Collaborator["userId"]) { + this.projectService + .switchLeader(this.projectId()!, id) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.projectsDetailUIService.applyMembersManipulation(id); + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/detail/ui/projects-detail-ui.service.ts b/projects/social_platform/src/app/api/project/facades/detail/ui/projects-detail-ui.service.ts new file mode 100644 index 000000000..b5cf88f55 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/detail/ui/projects-detail-ui.service.ts @@ -0,0 +1,77 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { DirectionItem, directionItemBuilder } from "@utils/helpers/directionItemBuilder"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; + +@Injectable({ providedIn: "root" }) +export class ProjectsDetailUIInfoService { + readonly collaborators = computed(() => this.project()?.collaborators); + readonly vacancies = computed(() => this.project()?.vacancies); + readonly leaderId = computed(() => this.project()?.leader); + readonly projectId = computed(() => this.project()?.id); + readonly goals = computed(() => this.project()?.goals); + + readonly project = signal(undefined); + readonly loggedUserId = signal(0); + + readonly profileId = signal(0); // ID текущего пользователя + + // Состояние компонента + readonly isCompleted = signal(false); // Флаг завершенности проекта + readonly directions = signal([]); + + applySetProject(project: Project) { + this.project.set(project); + } + + applySetLoggedUserId(type: "logged" | "profile", profileId: number): void { + type === "logged" ? this.loggedUserId.set(profileId) : this.profileId.set(profileId); + } + + applyDirectionItems(): void { + this.directions.set( + directionItemBuilder( + 5, + ["проблема", "целевая аудитория", "актуаль-сть", "цели", "партнеры"], + ["key", "smile", "graph", "goal", "team"], + [ + this.project()!.problem, + this.project()!.targetAudience, + this.project()!.actuality, + this.project()!.goals, + this.project()!.partners, + ], + ["string", "string", "string", "array", "array"] + ) ?? [] + ); + } + + applyRemoveCollaborator(userId: number): void { + this.project.update(project => { + if (!project) return; + + return { + ...project, + collaborators: this.collaborators()?.filter(c => c.userId !== userId) ?? [], + }; + }); + } + + applyMembersManipulation(id: number): void { + this.project.update(p => + p ? { ...p, collaborators: p.collaborators.filter(c => c.userId !== id) } : p + ); + } + + removeCollaborators(userId: number): void { + this.project.update(project => { + if (!project) return; + + return { + ...project, + collaborators: this.collaborators()?.filter(c => c.userId !== userId) ?? [], + }; + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/detail/work-section/projects-detail-work-section-info.service.ts b/projects/social_platform/src/app/api/project/facades/detail/work-section/projects-detail-work-section-info.service.ts new file mode 100644 index 000000000..d46589567 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/detail/work-section/projects-detail-work-section-info.service.ts @@ -0,0 +1,67 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { map, Subject, takeUntil } from "rxjs"; +import { VacancyService } from "../../../../vacancy/vacancy.service"; +import { ActivatedRoute } from "@angular/router"; +import { VacancyResponse } from "projects/social_platform/src/app/domain/vacancy/vacancy-response.model"; +import { ProjectsDetailWorkSectionUIInfoService } from "./ui/projects-detail-work-section-ui-info.service"; + +@Injectable() +export class ProjectsDetailWorkSectionInfoService { + private readonly route = inject(ActivatedRoute); + private readonly vacancyService = inject(VacancyService); + private readonly projectsDetailWorkSectionUIInfoService = inject( + ProjectsDetailWorkSectionUIInfoService + ); + + private readonly destroy$ = new Subject(); + + readonly projectId = signal(undefined); + + initializationWorkSection(): void { + this.route.data + .pipe( + map(r => r["data"]), + takeUntil(this.destroy$) + ) + .subscribe({ + next: (responses: VacancyResponse[]) => { + this.projectsDetailWorkSectionUIInfoService.applyInitVacancies(responses); + }, + }); + + this.projectId.set(this.route.parent?.snapshot.params["projectId"]); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Принятие отклика на вакансию + * @param responseId - ID отклика для принятия + */ + acceptResponse(responseId: number) { + this.vacancyService + .acceptResponse(responseId) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.projectsDetailWorkSectionUIInfoService.applyFilterVacacnies(responseId); + }); + } + + /** + * Отклонение отклика на вакансию + * @param responseId - ID отклика для отклонения + */ + rejectResponse(responseId: number) { + this.vacancyService + .rejectResponse(responseId) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.projectsDetailWorkSectionUIInfoService.applyFilterVacacnies(responseId); + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/detail/work-section/ui/projects-detail-work-section-ui-info.service.ts b/projects/social_platform/src/app/api/project/facades/detail/work-section/ui/projects-detail-work-section-ui-info.service.ts new file mode 100644 index 000000000..b21c0c49a --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/detail/work-section/ui/projects-detail-work-section-ui-info.service.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { VacancyResponse } from "projects/social_platform/src/app/domain/vacancy/vacancy-response.model"; + +@Injectable() +export class ProjectsDetailWorkSectionUIInfoService { + readonly vacancies = signal([]); + + applyInitVacancies(responses: VacancyResponse[]): void { + this.vacancies.set( + responses.filter((response: VacancyResponse) => response.isApproved === null) + ); + } + + applyFilterVacacnies(responseId: number): void { + this.vacancies.update(vacancies => vacancies.filter(vacancy => vacancy.id !== responseId)); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-achievements.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-achievements.service.ts new file mode 100644 index 000000000..95af4a7d5 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-achievements.service.ts @@ -0,0 +1,163 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormGroup } from "@angular/forms"; +import { ProjectFormService } from "./project-form.service"; + +/** + * Сервис для управления достижениями проекта. + * Предоставляет методы для добавления, редактирования, удаления достижений, + * а также очистки ошибок валидации. + */ +@Injectable({ + providedIn: "root", +}) +export class ProjectAchievementsService { + /** FormBuilder для создания FormGroup элементов */ + private readonly fb = inject(FormBuilder); + /** Сервис для управления индексом редактируемого достижения */ + private readonly projectFormService = inject(ProjectFormService); + /** Сигнал для хранения списка достижений (массив объектов) */ + public readonly achievementsItems = signal([]); + private initialized = false; + + /** + * Инициализирует сигнал achievementsItems из данных FormArray + * Вызывается при первом обращении к данным + */ + private initializeAchievementsItems(achievementsFormArray: FormArray): void { + if (this.initialized) return; + + if (achievementsFormArray && achievementsFormArray.length > 0) { + // Синхронизируем сигнал с данными из FormArray + this.achievementsItems.set(achievementsFormArray.value); + } + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncAchievementsItems(achievementsFormArray: FormArray): void { + if (achievementsFormArray) { + this.achievementsItems.set(achievementsFormArray.value); + } + } + + private readonly achievements = this.projectFormService.achievements; + readonly hasAchievements = computed( + () => this.achievementsItems().length > 0 || this.achievements.length > 0 + ); + + /** + * Добавляет новое достижение или сохраняет изменения существующего. + * @param achievementsFormArray FormArray, содержащий формы достижений + * @param projectForm основная форма проекта (FormGroup) + */ + public addAchievement(achievementsFormArray: FormArray, projectForm: FormGroup): void { + // Инициализируем сигнал при первом вызове + this.initializeAchievementsItems(achievementsFormArray); + + // Считываем вводимые данные + const title = projectForm.get("title")?.value; + const status = projectForm.get("status")?.value; + + // Проверяем, что поля не пустые + if (!title || !status || title.trim().length === 0 || status.trim().length === 0) { + return; // Выходим из функции, если поля пустые + } + + // Создаем FormGroup для нового достижения + const achievementItem = this.fb.group({ + id: achievementsFormArray.length, + title: title.trim(), + status: status.trim(), + }); + + // Проверяем, редактируется ли существующее достижение + const editIdx = this.projectFormService.editIndex(); + if (editIdx !== null) { + // Обновляем массив сигналов и соответствующий контрол в FormArray + this.achievementsItems.update(items => { + const updated = [...items]; + updated[editIdx] = achievementItem.value; + return updated; + }); + achievementsFormArray.at(editIdx).patchValue(achievementItem.value); + // Сбрасываем индекс редактирования + this.projectFormService.editIndex.set(null); + } else { + // Добавляем новое достижение в сигнал и FormArray + this.achievementsItems.update(items => [...items, achievementItem.value]); + achievementsFormArray.push(achievementItem); + } + + // Очищаем поля ввода формы проекта + projectForm.get("title")?.reset(); + projectForm.get("title")?.setValue(""); + + projectForm.get("status")?.reset(); + projectForm.get("status")?.setValue(""); + } + + /** + * Инициализирует редактирование существующего достижения. + * @param index индекс достижения в списке + * @param achievementsFormArray FormArray достижений + * @param projectForm основная форма проекта + */ + public editAchievement( + index: number, + achievementsFormArray: FormArray, + projectForm: FormGroup + ): void { + // Инициализируем сигнал при необходимости + this.initializeAchievementsItems(achievementsFormArray); + + // Используем данные из FormArray как источник истины + const source = achievementsFormArray.value[index]; + + // Заполняем поля формы проекта для редактирования + projectForm.patchValue({ + achievementsName: source?.achievementsName || "", + achievementsDate: source?.achievementsDate || "", + }); + // Устанавливаем текущий индекс редактирования в сервисе + this.projectFormService.editIndex.set(index); + } + + /** + * Удаляет достижение по указанному индексу. + * @param index индекс удаляемого достижения + * @param achievementsFormArray FormArray достижений + */ + public removeAchievement(index: number, achievementsFormArray: FormArray): void { + // Удаляем из сигнала и из FormArray + this.achievementsItems.update(items => items.filter((_, i) => i !== index)); + achievementsFormArray.removeAt(index); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray достижений. + * @param achievements FormArray достижений + */ + public clearAllAchievementsErrors(achievements: FormArray): void { + achievements.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Сбрасывает состояние сервиса + * Полезно при смене проекта или очистке формы + */ + public reset(): void { + this.achievementsItems.set([]); + this.initialized = false; + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts new file mode 100644 index 000000000..b37db560f --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts @@ -0,0 +1,221 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { + PartnerProgramFields, + PartnerProgramFieldsValues, + projectNewAdditionalProgramVields, +} from "projects/social_platform/src/app/domain/program/partner-program-fields.model"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { Observable } from "rxjs"; +import { ProgramService } from "../../../program/program.service"; + +/** + * Сервис для управления дополнительными полями проекта в партнерской программе. + * Предоставляет методы для инициализации формы, валидации, переключения значений, + * подготовки к отправке и работы со статусами отправки и ошибок. + */ +@Injectable() +export class ProjectAdditionalService { + private additionalForm!: FormGroup; + readonly partnerProgramFields = signal([]); + private partnerProgramFieldsValues: PartnerProgramFieldsValues[] = []; + + private readonly fb = inject(FormBuilder); + private readonly projectService = inject(ProjectService); + private readonly programService = inject(ProgramService); + + readonly isSendingDecision = signal(false); + readonly isAssignProjectToProgramError = signal(false); + readonly errorAssignProjectToProgramModalMessage = signal<{ non_field_errors: string[] } | null>( + null + ); + + constructor() { + // Инициализируем пустую форму + this.additionalForm = this.fb.group({}); + } + + /** + * Возвращает форму дополнительных полей. + */ + public getAdditionalForm(): FormGroup { + return this.additionalForm; + } + + /** + * Возвращает массив сохраненных значений полей. + */ + public getPartnerProgramFieldsValues(): PartnerProgramFieldsValues[] { + return this.partnerProgramFieldsValues; + } + + /** + * Инициализирует форму дополнительных полей согласно конфигурации и значениям. + * @param fields описание полей партнерской программы + * @param values сохраненные значения полей + */ + public initializeAdditionalForm( + fields: PartnerProgramFields[], + values: PartnerProgramFieldsValues[] = [] + ): void { + this.partnerProgramFields.set(fields); + this.partnerProgramFieldsValues = values; + + // Создаем новую пустую форму + this.additionalForm = this.fb.group({}); + + // Добавляем контролы для каждого поля + this.partnerProgramFields().forEach(field => { + this.getInitialValue(field, values); + const validators = field.isRequired ? [Validators.required] : []; + const initialValue = this.getInitialValue(field, values); + + this.additionalForm.addControl(field.name, new FormControl(initialValue, validators)); + + // Добавляем дополнительную валидацию по типу поля + this.addFieldTypeValidators(field); + }); + + // Применяем валидацию ко всей форме + this.additionalForm.updateValueAndValidity(); + } + + /** + * Переключает значение для checkbox и radio полей. + * @param fieldType тип поля (checkbox | radio и др.) + * @param fieldName имя контрола в форме + */ + public toggleAdditionalFormValues( + fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + fieldName: string + ): void { + if (fieldType === "checkbox" || fieldType === "radio") { + const control = this.additionalForm.get(fieldName); + if (control) { + control.setValue(!control.value); + } + } + } + + /** + * Проверяет обязательные поля на валидность и помечает их как touched. + * @returns true если есть невалидные обязательные поля + */ + public validateRequiredFields(): boolean { + this.additionalForm.updateValueAndValidity(); + this.partnerProgramFields() + .filter(f => f.isRequired) + .forEach(f => this.additionalForm.get(f.name)?.markAsTouched()); + + return this.partnerProgramFields() + .filter(f => f.isRequired) + .some(f => this.additionalForm.get(f.name)?.invalid); + } + + /** + * Убирает валидаторы с заполненных обязательных полей перед отправкой. + */ + public prepareFieldsForSubmit(): void { + this.partnerProgramFields() + .filter(f => f.isRequired) + .forEach(f => { + const ctrl = this.additionalForm.get(f.name); + if (ctrl && ctrl.value) { + ctrl.clearValidators(); + ctrl.updateValueAndValidity({ emitEvent: false }); + } + }); + } + + /** + * Отправляет значения дополнительных полей на сервер. + * @param projectId идентификатор проекта + * @returns Observable результат запроса + */ + public sendAdditionalFieldsValues(projectId: number): Observable { + this.isSendingDecision.set(true); + const newFieldsFormValues: projectNewAdditionalProgramVields[] = []; + + this.partnerProgramFields().forEach((field: PartnerProgramFields) => { + const fieldValue = this.additionalForm.get(field.name)?.value; + newFieldsFormValues.push({ + field_id: field.id, + value_text: String(fieldValue), + }); + }); + + return this.projectService.sendNewProjectFieldsValues(projectId, newFieldsFormValues); + } + + /** + * Сабмитит проект привязанный к конкурсной программе + * @param relationId идентификатор связи + * @returns Observable результат запроса + */ + public submitCompettetiveProject(relationId: number): Observable { + return this.programService.submitCompettetiveProject(relationId); + } + + /** + * Сбрасывает флаг процесса отправки. + */ + public resetSendingState(): void { + this.isSendingDecision.set(false); + } + + /** + * Устанавливает сообщение и флаг ошибки при привязке проекта. + * @param error объект с массивом полей non_field_errors + */ + public setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { + this.errorAssignProjectToProgramModalMessage.set(error); + this.isAssignProjectToProgramError.set(true); + } + + /** + * Сбрасывает сообщение и флаг ошибки при привязке проекта. + */ + public clearAssignProjectToProgramError(): void { + this.errorAssignProjectToProgramModalMessage.set(null); + this.isAssignProjectToProgramError.set(false); + } + + /** + * Вычисляет начальное значение контрола по сохраненным данным или типу поля. + * @param field описание поля + * @param values массив сохраненных значений + * @returns первоначальное значение контрола + */ + private getInitialValue(field: PartnerProgramFields, values: PartnerProgramFieldsValues[]): any { + const saved = values.find(v => v.fieldName === field.name); + if (!saved) { + return field.fieldType === "checkbox" || field.fieldType === "radio" ? false : ""; + } + + const text = saved.value.trim().toLowerCase(); + if (field.fieldType === "checkbox" || field.fieldType === "radio") { + return text === "true"; + } + return saved.value; + } + + /** + * Добавляет валидаторы по типу текстового поля. + * @param field описание поля для обработки валидаторов + */ + private addFieldTypeValidators(field: PartnerProgramFields): void { + const control = this.additionalForm.get(field.name); + if (!control) return; + + switch (field.fieldType) { + case "text": + control.addValidators([Validators.maxLength(500)]); + break; + case "textarea": + control.addValidators([Validators.maxLength(300)]); + break; + } + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts new file mode 100644 index 000000000..45617555f --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts @@ -0,0 +1,136 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormGroup, FormControl, Validators } from "@angular/forms"; +import { ProjectFormService } from "./project-form.service"; + +/** + * Сервис для управления контактами проекта. + * Предоставляет методы для добавления, редактирования, удаления ссылок, + * а также очистки ошибок валидации. + */ +@Injectable({ + providedIn: "root", +}) +export class ProjectContactsService { + /** FormBuilder для создания FormGroup элементов */ + private readonly fb = inject(FormBuilder); + /** Сервис для управления индексом редактируемой ссылки */ + private readonly projectFormService = inject(ProjectFormService); + /** Сигнал для хранения списка ссылок (массив объектов) */ + public readonly linksItems = signal([]); + private initialized = false; + + /** + * Инициализирует сигнал linksItems из данных FormArray + * Вызывается при первом обращении к данным + */ + private initializeLinksItems(linksFormArray: FormArray): void { + if (this.initialized) return; + + if (linksFormArray && linksFormArray.length > 0) { + // Синхронизируем сигнал с данными из FormArray + this.linksItems.set(linksFormArray.value); + } + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncLinksItems(linksFormArray: FormArray): void { + if (linksFormArray) { + this.linksItems.set(linksFormArray.value); + } + } + + readonly hasLinks = computed(() => this.linksItems().length > 0); + + /** + * Получает основную форму проекта + */ + private get projectForm(): FormGroup { + return this.projectFormService.getForm(); + } + + /** + * Получает FormArray ссылок + */ + public get links(): FormArray { + return this.projectForm.get("links") as FormArray; + } + + /** + * Получает FormControl для поля ввода ссылки + */ + public get link(): FormControl { + return this.projectForm.get("link") as FormControl; + } + + /** + * Добавляет новую ссылку или сохраняет изменения существующей. + * @param linksFormArray FormArray, содержащий формы ссылок + * @param projectForm основная форма проекта (FormGroup) + */ + public addLink(linksFormArray: FormArray): void { + this.initializeLinksItems(linksFormArray); + linksFormArray.push(this.fb.control("", Validators.required)); + this.linksItems.update(items => [...items, ""]); + } + + /** + * Инициализирует редактирование существующей ссылки. + * @param index индекс ссылки в списке + * @param linksFormArray FormArray ссылок + * @param projectForm основная форма проекта + */ + public editLink(index: number, linksFormArray: FormArray, projectForm: FormGroup): void { + // Инициализируем сигнал при необходимости + this.initializeLinksItems(linksFormArray); + + // Используем данные из FormArray как источник истины + const source = linksFormArray.value[index]; + + // Заполняем поле формы проекта для редактирования + projectForm.patchValue({ + link: source?.link || "", + }); + // Устанавливаем текущий индекс редактирования в сервисе + this.projectFormService.editIndex.set(index); + } + + /** + * Удаляет ссылку по указанному индексу. + * @param index индекс удаляемой ссылки + * @param linksFormArray FormArray ссылок + */ + public removeLink(index: number, linksFormArray: FormArray): void { + // Удаляем из сигнала и из FormArray + this.linksItems.update(items => items.filter((_, i) => i !== index)); + linksFormArray.removeAt(index); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray ссылок. + * @param links FormArray ссылок + */ + public clearAllLinksErrors(links: FormArray): void { + links.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Сбрасывает состояние сервиса + * Полезно при смене проекта или очистке формы + */ + public reset(): void { + this.linksItems.set([]); + this.initialized = false; + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts new file mode 100644 index 000000000..17303b0f2 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts @@ -0,0 +1,370 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { + FormBuilder, + FormGroup, + Validators, + FormArray, + FormControl, + ValidatorFn, +} from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { PartnerProgramFields } from "projects/social_platform/src/app/domain/program/partner-program-fields.model"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { stripNullish } from "@utils/helpers/stripNull"; +import { concatMap, filter, Subject, takeUntil } from "rxjs"; +import { Project } from "../../../../domain/project/project.model"; +/** + * Сервис для управления основной формой проекта и формой дополнительных полей партнерской программы. + * Обеспечивает создание, инициализацию, валидацию, автосохранение, сброс и получение данных форм. + */ +@Injectable({ providedIn: "root" }) +export class ProjectFormService { + private projectForm!: FormGroup; + private additionalForm!: FormGroup; + private readonly fb = inject(FormBuilder); + private readonly route = inject(ActivatedRoute); + private readonly projectService = inject(ProjectService); + public editIndex = signal(null); + public relationId = signal(0); + + private readonly destroy$ = new Subject(); + + constructor() { + this.initializeForm(); + } + + /** + * Создает и настраивает основную форму проекта с набором контролов и валидаторов. + * Подписывается на изменения полей 'presentationAddress' и 'coverImageAddress' для автосохранения при очищении. + */ + private initializeForm(): void { + this.projectForm = this.fb.group({ + imageAddress: [""], + name: ["", [Validators.required]], + region: ["", [Validators.required]], + implementationDeadline: [null], + trl: [null], + links: this.fb.array([]), + link: ["", Validators.pattern(/^(https?:\/\/)/)], + industryId: [undefined, [Validators.required]], + description: ["", [Validators.required, Validators.minLength(0), Validators.maxLength(800)]], + presentationAddress: [""], + coverImageAddress: ["", [Validators.required]], + actuality: ["", [Validators.maxLength(400)]], + targetAudience: ["", [Validators.required, Validators.maxLength(400)]], + problem: ["", [Validators.required, Validators.maxLength(400)]], + partnerProgramId: [null], + achievements: this.fb.array([]), + title: [""], + status: [""], + + draft: [null], + }); + + // Автосохранение при очистке presentationAddress + this.presentationAddress?.valueChanges + .pipe( + filter(value => !value), + concatMap(() => + this.projectService.updateProject(Number(this.route.snapshot.params["projectId"]), { + presentationAddress: "", + draft: true, + }) + ), + takeUntil(this.destroy$) + ) + .subscribe(); + + // Автосохранение при очистке coverImageAddress + this.coverImageAddress?.valueChanges + .pipe( + filter(value => !value), + concatMap(() => + this.projectService.updateProject(Number(this.route.snapshot.params["projectId"]), { + coverImageAddress: "", + draft: true, + }) + ), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + /** + * Заполняет основную форму данными существующего проекта. + * @param project экземпляр Project с текущими данными + */ + public initializeProjectData(project: Project): void { + // Заполняем простые поля + this.projectForm.patchValue({ + imageAddress: project.imageAddress, + name: project.name, + region: project.region, + industryId: project.industry, + description: project.description, + implementationDeadline: project.implementationDeadline ?? null, + targetAudience: project.targetAudience ?? null, + actuality: project.actuality ?? "", + trl: project.trl ?? "", + problem: project.problem ?? "", + presentationAddress: project.presentationAddress, + coverImageAddress: project.coverImageAddress, + partnerProgramId: project.partnerProgram?.programId ?? null, + }); + + if (project.partnerProgram) { + this.relationId.set(project.partnerProgram?.programLinkId); + } + + this.populateLinksFormArray(project.links || []); + this.populateAchievementsFormArray(project.achievements || []); + } + + /** + * Заполняет FormArray ссылок данными из проекта + * @param links массив ссылок из проекта + */ + private populateLinksFormArray(links: string[]): void { + const linksFormArray = this.projectForm.get("links") as FormArray; + + while (linksFormArray.length !== 0) { + linksFormArray.removeAt(0); + } + + links.forEach(link => { + linksFormArray.push(this.fb.control(link, [Validators.required])); + }); + } + + /** + * Заполняет FormArray достижений данными из проекта + * @param achievements массив достижений из проекта + */ + private populateAchievementsFormArray(achievements: any[]): void { + const achievementsFormArray = this.projectForm.get("achievements") as FormArray; + const currentYear = new Date().getFullYear(); + + while (achievementsFormArray.length !== 0) { + achievementsFormArray.removeAt(0); + } + + achievements.forEach((achievement, index) => { + const achievementGroup = this.fb.group({ + id: achievement.id ?? index, + title: [achievement.title || "", Validators.required], + status: [ + achievement.status || "", + [ + Validators.required, + Validators.min(2000), + Validators.max(currentYear), + Validators.pattern(/^\d{4}$/), + ], + ], + }); + achievementsFormArray.push(achievementGroup); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Возвращает основную форму проекта. + * @returns FormGroup экземпляр формы проекта + */ + public getForm(): FormGroup { + return this.projectForm; + } + + /** + * Патчит частичные значения в основную форму. + * @param values объект с частичными значениями Project + */ + public patchFormValues(values: Partial): void { + this.projectForm.patchValue(values); + } + + /** + * Проверяет валидность основной формы проекта. + * @returns true если все контролы валидны + */ + public validateForm(): boolean { + return this.projectForm.valid; + } + + /** + * Получает текущее значение формы без null или undefined. + * @returns объект значений формы без nullish + */ + public getFormValue(): any { + return stripNullish(this.projectForm.value); + } + + // Геттеры для быстрого доступа к контролам основной формы + public get name() { + return this.projectForm.get("name"); + } + + public get region() { + return this.projectForm.get("region"); + } + + public get industry() { + return this.projectForm.get("industryId"); + } + + public get description() { + return this.projectForm.get("description"); + } + + public get actuality() { + return this.projectForm.get("actuality"); + } + + public get implementationDeadline() { + return this.projectForm.get("implementationDeadline"); + } + + public get problem() { + return this.projectForm.get("problem"); + } + + public get targetAudience() { + return this.projectForm.get("targetAudience"); + } + + public get trl() { + return this.projectForm.get("trl"); + } + + public get presentationAddress() { + return this.projectForm.get("presentationAddress"); + } + + public get coverImageAddress() { + return this.projectForm.get("coverImageAddress"); + } + + public get imageAddress() { + return this.projectForm.get("imageAddress"); + } + + public get partnerProgramId() { + return this.projectForm.get("partnerProgramId"); + } + + public get achievements(): FormArray { + return this.projectForm.get("achievements") as FormArray; + } + + public get links(): FormArray { + return this.projectForm.get("links") as FormArray; + } + + /** + * Очищает все ошибки валидации в основной форме и в массиве достижений. + */ + public clearAllValidationErrors(): void { + Object.keys(this.projectForm.controls).forEach(ctrl => { + this.projectForm.get(ctrl)?.setErrors(null); + }); + this.clearAchievementsErrors(this.achievements); + } + + /** + * Инициализирует форму дополнительных полей программы партнерства. + * @param partnerProgramFields массив метаданных полей + */ + public initializeAdditionalForm(partnerProgramFields: PartnerProgramFields[]): void { + this.additionalForm = this.fb.group({}); + partnerProgramFields.forEach(field => { + const validators: ValidatorFn[] = []; + if (field.isRequired) validators.push(Validators.required); + if (field.fieldType === "text") validators.push(Validators.maxLength(500)); + if (field.fieldType === "textarea") validators.push(Validators.maxLength(300)); + const initialValue = field.fieldType === "checkbox" ? false : ""; + const fieldCtrl = new FormControl(initialValue, validators); + this.additionalForm.addControl(field.name, fieldCtrl); + }); + this.additionalForm.updateValueAndValidity(); + } + + /** + * Возвращает форму дополнительных полей. + * @returns FormGroup экземпляр дополнительной формы + */ + public getAdditionalForm(): FormGroup { + return this.additionalForm; + } + + /** + * Проверяет валидность дополнительной формы. + * @returns true если форма инициализирована и валидна + */ + public validateAdditionalForm(): boolean { + return this.additionalForm?.valid ?? true; + } + + /** + * Возвращает очищенные значения дополнительной формы. + * @returns объект значений без nullish + */ + public getAdditionalFormValue(): any { + return this.additionalForm ? stripNullish(this.additionalForm.value) : {}; + } + + /** + * Сбрасывает основную и дополнительную формы в первоначальное состояние. + */ + public resetForms(): void { + this.projectForm.reset(); + this.additionalForm?.reset(); + this.clearFormArrays(); + } + + /** + * Очищает все FormArray в форме + */ + private clearFormArrays(): void { + const linksArray = this.links; + const achievementsArray = this.achievements; + + while (linksArray.length !== 0) { + linksArray.removeAt(0); + } + + while (achievementsArray.length !== 0) { + achievementsArray.removeAt(0); + } + } + + /** + * Проверяет валидность обеих форм (основной и дополнительной) включая цели. + * @returns true если все формы валидны + */ + public validateAllForms(): boolean { + const mainFormValid = this.validateForm(); + const additionalFormValid = this.validateAdditionalForm(); + + return mainFormValid && additionalFormValid; + } + + /** + * Удаляет ошибки валидации внутри массива достижений. + * @param achievements FormArray достижений + */ + private clearAchievementsErrors(achievements: FormArray): void { + achievements.controls.forEach(group => { + if (group instanceof FormGroup) { + Object.keys(group.controls).forEach(name => { + group.get(name)?.setErrors(null); + }); + } + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-goals.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-goals.service.ts new file mode 100644 index 000000000..baa6ca26a --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-goals.service.ts @@ -0,0 +1,301 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { ProjectFormService } from "./project-form.service"; +import { catchError, forkJoin, map, of, Subject, takeUntil, tap } from "rxjs"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { Goal, GoalDto } from "../../../../domain/project/goals.model"; +import { ProjectGoalsUIService } from "./ui/project-goals-ui.service"; + +/** + * Сервис для управления целями проекта + * Предоставляет полный набор методов для работы с целями: + * - инициализация, добавление, редактирование, удаление + * - валидация и очистка ошибок + * - управление состоянием модального окна выбора лидера + */ +@Injectable({ + providedIn: "root", +}) +export class ProjectGoalService { + private readonly fb = inject(FormBuilder); + private goalForm!: FormGroup; + private readonly projectFormService = inject(ProjectFormService); + private readonly projectService = inject(ProjectService); + private readonly projectGoalsUIService = inject(ProjectGoalsUIService); + + private readonly destroy$ = new Subject(); + + /** Флаг инициализации сервиса */ + private initialized = false; + private readonly goalItems = this.projectGoalsUIService.goalItems; + + constructor() { + this.initializeGoalForm(); + } + + private initializeGoalForm(): void { + this.goalForm = this.fb.group({ + goals: this.fb.array([]), + title: [null], + completionDate: [null], + responsible: [null], + }); + } + + /** + * Инициализирует сигнал goalItems из данных FormArray + * Вызывается при первом обращении к данным + */ + public initializeGoalItems(goalFormArray: FormArray): void { + if (this.initialized) return; + + if (goalFormArray && goalFormArray.length > 0) { + this.goalItems.set(goalFormArray.value); + } + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncGoalItems(goalFormArray: FormArray): void { + if (goalFormArray) { + this.goalItems.set(goalFormArray.value); + } + } + + /** + * Инициализирует цели из данных проекта + * Заполняет FormArray целей данными из проекта + */ + public initializeGoalsFromProject(goals: Goal[]): void { + const goalsFormArray = this.goals; + + while (goalsFormArray.length !== 0) { + goalsFormArray.removeAt(0); + } + + if (goals && Array.isArray(goals)) { + goals.forEach(goal => { + const goalsGroup = this.fb.group({ + id: [goal.id ?? null], + title: [goal.title || "", Validators.required], + completionDate: [goal.completionDate || "", Validators.required], + responsible: [goal.responsibleInfo?.id?.toString() || "", Validators.required], + isDone: [goal.isDone || false], + }); + goalsFormArray.push(goalsGroup); + }); + + this.syncGoalItems(goalsFormArray); + } else { + this.goalItems.set([]); + } + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Возвращает форму целей. + * @returns FormGroup экземпляр формы целей + */ + public getForm(): FormGroup { + return this.goalForm; + } + + /** + * Получает FormArray целей + */ + public get goals(): FormArray { + return this.goalForm.get("goals") as FormArray; + } + + /** + * Получает FormControl для поля ввода названия цели + */ + public get goalName(): FormControl { + return this.goalForm.get("title") as FormControl; + } + + /** + * Получает FormControl для поля ввода даты цели + */ + public get goalDate(): FormControl { + return this.goalForm.get("completionDate") as FormControl; + } + + /** + * Получает FormControl для поля лидера(исполнителя/ответственного) цели + */ + public get goalLeader(): FormControl { + return this.goalForm.get("responsible") as FormControl; + } + + /** + * Добавляет новую цель или сохраняет изменения существующей. + * @param goalName - название цели (опционально) + * @param goalDate - дата цели (опционально) + * @param goalLeader - лидер цели (опционально) + */ + public addGoal(goalName?: string, goalDate?: string, goalLeader?: string): void { + const goalFormArray = this.goals; + + this.initializeGoalItems(goalFormArray); + + const name = goalName || this.goalForm.get("title")?.value; + const date = goalDate || this.goalForm.get("completionDate")?.value; + const leader = goalLeader || this.goalForm.get("responsible")?.value; + + if (!name || !date || name.trim().length === 0 || date.trim().length === 0) { + return; + } + + const goalItem = this.fb.group({ + id: [null], + title: [name.trim(), Validators.required], + completionDate: [date.trim(), Validators.required], + responsible: [leader, Validators.required], + isDone: [false], + }); + + const editIdx = this.projectFormService.editIndex(); + if (editIdx !== null) { + goalFormArray.at(editIdx).patchValue(goalItem.value); + this.projectFormService.editIndex.set(null); + } else { + this.goalItems.update(items => [...items, goalItem.value]); + goalFormArray.push(goalItem); + } + + this.syncGoalItems(goalFormArray); + } + + /** + * Удаляет цель по указанному индексу. + * @param index индекс удаляемой цели + */ + public removeGoal(index: number, goalId: number, projectId: number): void { + const goalFormArray = this.goals; + + this.goalItems.update(items => items.filter((_, i) => i !== index)); + goalFormArray.removeAt(index); + + this.projectService.deleteGoals(projectId, goalId).pipe(takeUntil(this.destroy$)).subscribe(); + } + + /** + * Получает выбранного лидера для конкретной цели + * @param goalIndex - индекс цели + * @param collaborators - список коллабораторов + */ + public getSelectedLeaderForGoal(goalIndex: number, collaborators: any[]) { + const goalFormGroup = this.goals.at(goalIndex); + const leaderId = goalFormGroup?.get("responsible")?.value; + + if (!leaderId) return null; + + return collaborators.find(collab => collab.userId.toString() === leaderId.toString()); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray цели. + */ + public clearAllGoalsErrors(): void { + const goals = this.goals; + + goals.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Получает данные всех целей для отправки на сервер + * @returns массив объектов целей + */ + public getGoalsData(): any[] { + return this.goals.value.map((g: any) => ({ + id: g.id ?? null, + title: g.title, + completionDate: g.completionDate, + responsible: + g.responsible === null || g.responsible === undefined || g.responsible === "" + ? null + : Number(g.responsible), + isDone: !!g.isDone, + })); + } + + /** + * Сохраняет только новые цели (у которых id === null) — отправляет POST. + * После ответов присваивает полученные id в соответствующие FormGroup. + * Возвращает Observable массива результатов (в порядке отправки). + */ + public saveGoals(projectId: number, newGoals: Goal[]) { + return this.projectService.addGoals(projectId, newGoals).pipe( + tap(results => { + results.forEach((createdGoal: any, idx: number) => { + const formGroup = this.goals.at(idx); + if (formGroup && createdGoal?.id != null) { + formGroup.patchValue({ id: createdGoal.id }); + } + }); + }), + catchError(err => { + console.error("Error saving goals:", err); + return of({ __error: true, err, original: newGoals }); + }) + ); + } + + public editGoals(projectId: number, existingGoals: Goal[]) { + const requests = existingGoals.map((item, idx) => { + const payload: GoalDto = { + id: item.id, + title: item.title, + completionDate: item.completionDate, + responsible: item.responsible, + isDone: item.isDone, + }; + + return this.projectService.editGoal(projectId, item.id, payload).pipe( + map(res => ({ res, idx })), + catchError(err => of({ __error: true, err, original: item, idx })) + ); + }); + + return forkJoin(requests); + } + + /** + * Сбрасывает состояние сервиса + * Полезно при смене проекта или очистке формы + */ + public reset(): void { + this.goalItems.set([]); + this.initialized = false; + this.projectGoalsUIService.applyCloseGoalLeaderModal(); + } + + /** + * Очищает FormArray целей + */ + public clearGoalsFormArray(): void { + const goalFormArray = this.goals; + + while (goalFormArray.length !== 0) { + goalFormArray.removeAt(0); + } + + this.goalItems.set([]); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-partner.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-partner.service.ts new file mode 100644 index 000000000..eb63b400e --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-partner.service.ts @@ -0,0 +1,279 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { catchError, forkJoin, map, of, Subject, takeUntil, tap } from "rxjs"; +import { Partner, PartnerDto } from "../../../../domain/project/partner.model"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectPartnerService { + private readonly fb = inject(FormBuilder); + private partnerForm!: FormGroup; + private readonly projectService = inject(ProjectService); + public readonly partnerItems = signal< + Partial<{ id: null; name: string; inn: string; contribution: string; decisionMaker: string }>[] + >([]); + + private readonly destroy$ = new Subject(); + + /** Флаг инициализации сервиса */ + private initialized = false; + + constructor() { + this.initializePartnerForm(); + } + + private initializePartnerForm(): void { + this.partnerForm = this.fb.group({ + partners: this.fb.array([]), + name: [null], + inn: [null, [Validators.minLength(10), Validators.maxLength(10)]], + contribution: [null, Validators.maxLength(200)], + decisionMaker: [null], + }); + } + + /** + * Инициализирует сигнал partnerItems из данных FormArray + * Вызывается при первом обращении к данным + */ + public initializePartnerItems(partnerFormArray: FormArray): void { + if (this.initialized) return; + + if (partnerFormArray && this.partnerItems().length > 0) { + this.partnerItems.set(partnerFormArray.value); + } + + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncPartnerItems(partnerFormArray: FormArray): void { + if (partnerFormArray) { + this.partnerItems.set(partnerFormArray.value); + } + } + + /** + * Инициализирует партнера из данных проекта + * Заполняет FormArray целей данными из проекта + */ + public initializePartnerFromProject(partners: Partner[]): void { + const partnerFormArray = this.partners; + + while (partnerFormArray.length !== 0) { + partnerFormArray.removeAt(0); + } + + if (partners && Array.isArray(partners)) { + partners.forEach(partner => { + const partnerGroup = this.fb.group({ + id: [partner.id], + name: [partner.company.name, Validators.required], + inn: [partner.company.inn, Validators.required], + contribution: [partner.contribution, Validators.required], + company: [partner.company], + decisionMaker: [ + "https://app.procollab.ru/office/profile/" + partner.decisionMaker, + Validators.required, + ], + }); + partnerFormArray.push(partnerGroup); + }); + + this.syncPartnerItems(partnerFormArray); + } else { + this.partnerItems.set([]); + } + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + readonly hasPartners = computed(() => this.partnerItems().length > 0); + + /** + * Возвращает форму партнеров и ресурсов. + * @returns FormGroup экземпляр формы целей + */ + public getForm(): FormGroup { + return this.partnerForm; + } + + /** + * Получает FormArray партнеров и ресурсов + */ + public get partners(): FormArray { + return this.partnerForm.get("partners") as FormArray; + } + + public get partnerName(): FormControl { + return this.partnerForm.get("name") as FormControl; + } + + public get partnerINN(): FormControl { + return this.partnerForm.get("inn") as FormControl; + } + + public get partnerMention(): FormControl { + return this.partnerForm.get("contribution") as FormControl; + } + + public get partnerProfileLink(): FormControl { + return this.partnerForm.get("decisionMaker") as FormControl; + } + + /** + * Добавляет нового партнера или сохраняет изменения существующей. + * @param name - название партнера (опционально) + * @param inn - инн (опционально) + * @param contribution - вклад партнера (опционально) + * @param decisionMaker - ссылка на профиль представителя компании (опционально) + */ + public addPartner( + name?: string, + inn?: string, + contribution?: string, + decisionMaker?: string + ): void { + const partnerFormArray = this.partners; + + this.initializePartnerItems(partnerFormArray); + + const partnerName = name || this.partnerForm.get("name")?.value; + const INN = inn || this.partnerForm.get("inn")?.value; + const mention = contribution || this.partnerForm.get("contribution")?.value; + const profileLink = decisionMaker || this.partnerForm.get("decisionMaker")?.value; + + if ( + !partnerName || + !INN || + !mention || + !profileLink || + partnerName.trim().length === 0 || + mention.trim().length === 0 || + INN.trim().length === 0 || + profileLink.trim().length === 0 + ) { + return; + } + + const partnerItem = this.fb.group({ + id: [null], + name: [partnerName.trim(), Validators.required], + inn: [INN.trim(), Validators.required], + contribution: [mention, Validators.required], + decisionMaker: [profileLink, Validators.required], + }); + + this.partnerItems.update(items => [...items, partnerItem.value]); + partnerFormArray.push(partnerItem); + } + + /** + * Удаляет партнера по указанному индексу. + * @param index индекс удаляемого партнера + */ + public removePartner(index: number, partnersId: number, projectId: number): void { + const partnerFormArray = this.partners; + + this.partnerItems.update(items => items.filter((_, i) => i !== index)); + partnerFormArray.removeAt(index); + + this.projectService + .deletePartner(projectId, partnersId) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray партнера. + */ + public clearAllPartnerErrors(): void { + const partners = this.partners; + + partners.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Получает данные всех партнеров для отправки на сервер + * @returns массив объектов партнеров + */ + public getPartnersData(): any[] { + return this.partners.value.map((partner: any) => ({ + id: partner.id ?? null, + name: partner.name, + inn: partner.inn, + contribution: partner.contribution, + decisionMaker: partner.decisionMaker, + })); + } + + /** + * Сохраняет только новых партнеров (у которых id === null) — отправляет POST. + * После ответов присваивает полученные id в соответствующие FormGroup. + * Возвращает Observable массива результатов (в порядке отправки). + */ + public savePartners(projectId: number) { + const partners = this.getPartnersData(); + + if (partners.length === 0) { + return of([]); + } + + const requests = partners.map(partner => { + const decisionMaker = Number(partner.decisionMaker.split("/").at(-1)); + + const payload: PartnerDto = { + name: partner.name, + inn: partner.inn, + contribution: partner.contribution, + decisionMaker, + }; + + return this.projectService.addPartner(projectId, payload).pipe( + map((res: any) => ({ res, idx: partner.id })), + catchError(err => of({ __error: true, err, original: partner })) + ); + }); + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + console.error("Failed to post partner", r.err, "original:", r.original); + return; + } + + const created = r.res; + const idx = r.idx; + + if (created && created.id !== undefined && created.id !== null) { + const formGroup = this.partners.at(idx); + if (formGroup) { + formGroup.get("id")?.setValue(created.id); + } + } else { + console.warn("addPartner response has no id field:", r.res); + } + }); + + this.syncPartnerItems(this.partners); + }) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-resources.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-resources.service.ts new file mode 100644 index 000000000..e54a3c3a6 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-resources.service.ts @@ -0,0 +1,291 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { catchError, forkJoin, map, of, Subject, takeUntil, tap } from "rxjs"; +import { Resource, ResourceDto } from "../../../../domain/project/resource.model"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectResourceService { + private readonly fb = inject(FormBuilder); + private readonly projectService = inject(ProjectService); + private resourceForm!: FormGroup; + public readonly resourceItems = signal< + Partial<{ id: null; type: string; description: string; partnerCompany: string }>[] + >([]); + + private readonly destroy$ = new Subject(); + + /** Флаг инициализации сервиса */ + private initialized = false; + + constructor() { + this.initializeResourceForm(); + } + + private initializeResourceForm(): void { + this.resourceForm = this.fb.group({ + resources: this.fb.array([]), + type: [null], + description: [null, Validators.maxLength(200)], + partnerCompany: [null], + }); + } + + /** + * Инициализирует сигнал resourceItems из данных FormArray + * Вызывается при первом обращении к данным + */ + public initializePartnerItems(resourceFormArray: FormArray): void { + if (this.initialized) return; + + if (resourceFormArray && this.resourceItems().length > 0) { + this.resourceItems.set(resourceFormArray.value); + } + + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncResourceItems(resourceFormArray: FormArray): void { + if (resourceFormArray) { + this.resourceItems.set(resourceFormArray.value); + } + } + + /** + * Инициализирует ресурсы из данных проекта + * Заполняет FormArray целей данными из проекта + */ + public initializeResourcesFromProject(resources: Resource[]): void { + const resourcesFormArray = this.resources; + + while (resourcesFormArray.length !== 0) { + resourcesFormArray.removeAt(0); + } + + if (resources && Array.isArray(resources)) { + resources.forEach(resource => { + const partnerGroup = this.fb.group({ + id: [resource.id ?? null], + type: [resource.type, Validators.required], + description: [resource.description, Validators.required], + partnerCompany: [resource.partnerCompany, Validators.required], + }); + resourcesFormArray.push(partnerGroup); + }); + + this.syncResourceItems(resourcesFormArray); + } else { + this.resourceItems.set([]); + } + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + readonly hasResources = computed(() => this.resourceItems().length > 0); + + /** + * Возвращает форму партнеров и ресурсов. + * @returns FormGroup экземпляр формы целей + */ + public getForm(): FormGroup { + return this.resourceForm; + } + + /** + * Получает FormArray партнеров и ресурсов + */ + public get resources(): FormArray { + return this.resourceForm.get("resources") as FormArray; + } + + public get resoruceType(): FormControl { + return this.resourceForm.get("type") as FormControl; + } + + public get resoruceDescription(): FormControl { + return this.resourceForm.get("description") as FormControl; + } + + public get resourcePartner(): FormControl { + return this.resourceForm.get("partnerCompany") as FormControl; + } + + /** + * Добавляет нового ресурса или сохраняет изменения существующей. + * @param type - тип ресурса (опционально) + * @param description - описание ресурса (опционально) + * @param partnerCompany - ссылка на партнера (опционально) + */ + public addResource(type?: string, description?: string, partnerCompany?: string): void { + const resourcesFormArray = this.resources; + + this.initializePartnerItems(resourcesFormArray); + + const resourceType = type || this.resourceForm.get("type")?.value; + const resourceDescription = description || this.resourceForm.get("description")?.value; + const partner = partnerCompany || this.resourceForm.get("partnerCompany")?.value; + + if ( + !resourceType || + !resourceDescription || + !partner || + resourceType.trim().length === 0 || + resourceDescription.trim().length === 0 || + partner.trim().length === 0 + ) { + return; + } + + const resourceItem = this.fb.group({ + id: [null], + type: [resourceType.trim(), Validators.required], + description: [resourceDescription.trim(), Validators.required], + partnerCompany: [partner, Validators.required], + }); + + this.resourceItems.update(items => [...items, resourceItem.value]); + resourcesFormArray.push(resourceItem); + } + + /** + * Удаляет ресурс по указанному индексу. + * @param index индекс удаляемого партнера + */ + public removeResource(index: number, resourceId: number, projectId: number): void { + const resourceFormArray = this.resources; + + this.resourceItems.update(items => items.filter((_, i) => i !== index)); + resourceFormArray.removeAt(index); + + this.projectService + .deleteResource(projectId, resourceId) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray ресурса. + */ + public clearAllResourceErrors(): void { + const resources = this.resources; + + resources.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Получает данные все ресурсы для отправки на сервер + * @returns массив объектов ресурсов + */ + public getResourcesData(): any[] { + return this.resources.value.map((resource: Resource) => ({ + id: resource.id ?? null, + type: resource.type, + description: resource.description, + partnerCompany: resource.partnerCompany, + })); + } + + /** + * Сохраняет только новых ресурсов (у которых id === null) — отправляет POST. + * После ответов присваивает полученные id в соответствующие FormGroup. + * Возвращает Observable массива результатов (в порядке отправки). + */ + public saveResources(projectId: number) { + const resources = this.getResourcesData(); + + const requests = resources.map(resource => { + const payload: Omit = { + type: resource.type, + description: resource.description, + partnerCompany: resource.partnerCompany ?? "запрос к рынку", + }; + + return this.projectService.addResource(projectId, payload).pipe( + map((res: any) => ({ res, idx: resource.idx })), + catchError(err => of({ __error: true, err, original: resource })) + ); + }); + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + console.error("Failed to post resource", r.err, "original:", r.original); + return; + } + + const created = r.res; + const idx = r.idx; + + if (created && created.id !== undefined && created.id !== null) { + const formGroup = this.resources.at(idx); + if (formGroup) { + formGroup.get("id")?.setValue(created.id); + } + } + }); + + this.syncResourceItems(this.resources); + }) + ); + } + + public editResources(projectId: number) { + const resources = this.getResourcesData(); + + const requests = resources.map(resource => { + const payload: Omit = { + type: resource.type, + description: resource.description, + partnerCompany: resource.partnerCompany ?? "запрос к рынку", + }; + + return this.projectService.editResource(projectId, resource.id, payload).pipe( + map((res: any) => ({ res })), + catchError(err => of({ __error: true, err, original: resource })) + ); + }); + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + console.error("Failed to add resource", r.err, "original:", r.original); + return; + } + + const created = r.res; + const idx = r.idx; + + if (created && created.id !== undefined && created.id !== null) { + const formGroup = this.resources.at(idx); + if (formGroup) { + formGroup.get("id")?.setValue(created.id); + } + } else { + console.warn("addResource response has no id field:", r.res); + } + }); + + this.syncResourceItems(this.resources); + }) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts new file mode 100644 index 000000000..8d956ba59 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts @@ -0,0 +1,106 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ValidationService } from "@corelib"; +import { InviteService } from "projects/social_platform/src/app/api/invite/invite.service"; +import { Subject, takeUntil } from "rxjs"; +import { ProjectTeamUIService } from "./ui/project-team-ui.service"; + +/** + * Сервис для управления приглашениями участников команды проекта. + * Предоставляет функциональность для создания и валидации формы приглашения, + * отправки, редактирования и удаления приглашений, управления состоянием модального окна и ошибок. + */ +@Injectable() +export class ProjectTeamService { + private readonly inviteService = inject(InviteService); + private readonly projectTeamUIService = inject(ProjectTeamUIService); + private readonly validationService = inject(ValidationService); + + private readonly destroy$ = new Subject(); + + private readonly inviteForm = this.projectTeamUIService.inviteForm; + private readonly inviteSubmitInitiated = this.projectTeamUIService.inviteSubmitInitiated; + private readonly inviteFormIsSubmitting = this.projectTeamUIService.inviteFormIsSubmitting; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Отправляет приглашение пользователю по ссылке. + * @returns результат отправки + */ + public submitInvite(projectId: number): void { + this.inviteSubmitInitiated.set(true); + // Проверка валидности формы + if (!this.validationService.getFormValidation(this.inviteForm)) { + return; + } + + this.inviteFormIsSubmitting.set(true); + + // Извлечение profileId из URL ссылки + const linkUrl = new URL(this.inviteForm.get("link")?.value ?? ""); + const pathSegments = linkUrl.pathname.split("/"); + const profileId = Number(pathSegments[pathSegments.length - 1]); + + this.inviteService + .sendForUser( + profileId, + projectId, + this.inviteForm.get("role")?.value ?? "", + this.inviteForm.get("specialization")?.value ?? "" + ) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: invite => { + this.projectTeamUIService.applySubmitInvite(invite); + }, + error: err => { + this.projectTeamUIService.applyErrorSubmitInvite(err); + }, + }); + } + + /** + * Обновляет параметры существующего приглашения. + * @param params объект с inviteId, role и specialization + */ + public editInvitation(params: { inviteId: number; role: string; specialization: string }): void { + const { inviteId, role, specialization } = params; + this.inviteService + .updateInvite(inviteId, role, specialization) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => {}); + } + + /** + * Удаляет приглашение по идентификатору. + * @param invitationId идентификатор приглашения + */ + public removeInvitation(invitationId: number): void { + this.inviteService + .revokeInvite(invitationId) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => {}); + } + + /** + * Настроивает динамическую валидацию для поля link: + * сбрасывает валидаторы при пустом значении и очищает ошибку. + */ + public setupDynamicValidation(): void { + this.inviteForm + .get("link") + ?.valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(value => { + if (value === "") { + this.inviteForm.get("link")?.clearValidators(); + this.inviteForm.get("link")?.updateValueAndValidity(); + } + this.projectTeamUIService.applyClearLinkError(); + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.ts new file mode 100644 index 000000000..d36f7a330 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.ts @@ -0,0 +1,119 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Validators } from "@angular/forms"; +import { ValidationService } from "@corelib"; +import { Skill } from "../../../../domain/skills/skill"; +import { VacancyService } from "../../../vacancy/vacancy.service"; +import { Subject, takeUntil } from "rxjs"; +import { ProjectVacancyUIService } from "./ui/project-vacancy-ui.service"; +import { CreateVacancyDto } from "../../dto/create-vacancy.model"; + +/** + * Сервис для управления вакансиями проекта. + * Обеспечивает создание, валидацию, отправку, + * редактирование и удаление вакансий, а также работу с формой вакансии + * и синхронизацию с API. + */ +@Injectable() +export class ProjectVacancyService { + private readonly vacancyService = inject(VacancyService); + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly validationService = inject(ValidationService); + + private readonly destroy$ = new Subject(); + + private readonly vacancyForm = this.projectVacancyUIService.vacancyForm; + private readonly selectedSkills = this.projectVacancyUIService.selectedSkills; + + private readonly vacancyIsSubmitting = this.projectVacancyUIService.vacancyIsSubmitting; + private readonly vacancySubmitInitiated = this.projectVacancyUIService.vacancySubmitInitiated; + + constructor() { + this.vacancyForm + .get("skills") + ?.valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(skills => { + this.selectedSkills.set(skills ?? []); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Отправляет форму вакансии: настраивает валидаторы, проверяет форму, + * создаёт вакансию через API и сбрасывает форму. + * @returns Promise - true при успехе, false при ошибке валидации или API + */ + public submitVacancy(projectId: number) { + // Настройка валидаторов для обязательных полей + this.vacancyForm.get("role")?.setValidators([Validators.required]); + this.vacancyForm.get("skills")?.setValidators([Validators.required]); + this.vacancyForm.get("requiredExperience")?.setValidators([Validators.required]); + this.vacancyForm.get("workFormat")?.setValidators([Validators.required]); + this.vacancyForm.get("workSchedule")?.setValidators([Validators.required]); + this.vacancyForm + .get("salary") + ?.setValidators([Validators.pattern("^(\\d{1,3}( \\d{3})*|\\d+)$")]); + + // Обновление валидности и отображение ошибок + Object.keys(this.vacancyForm.controls).forEach(name => { + const ctrl = this.vacancyForm.get(name); + ctrl?.updateValueAndValidity(); + if (["role", "skills"].includes(name)) ctrl?.markAsTouched(); + }); + + this.vacancySubmitInitiated.set(true); + + // Проверка валидации формы + if (!this.validationService.getFormValidation(this.vacancyForm)) { + return; + } + + // Подготовка payload для API + this.vacancyIsSubmitting.set(true); + + const form = this.vacancyForm.value; + + const payload: CreateVacancyDto = { + role: form.role!, + requiredSkillsIds: (form.skills ?? []).map(s => s.id), + description: form.description ?? "", + requiredExperience: form.requiredExperience!, + workFormat: form.workFormat!, + workSchedule: form.workSchedule!, + specialization: form.specialization ?? undefined, + salary: typeof form.salary === "string" ? +form.salary : null, + }; + + // Вызов API для создания вакансии + this.vacancyService + .postVacancy(projectId, payload) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: vacancy => { + this.projectVacancyUIService.applySubmitVacancy(vacancy); + }, + error: () => { + this.vacancyIsSubmitting.set(false); + }, + }); + } + + /** + * Удаляет вакансию по её идентификатору с подтверждением пользователя. + * @param vacancyId идентификатор вакансии для удаления + */ + public removeVacancy(vacancyId: number): void { + if (!confirm("Вы точно хотите удалить вакансию?")) return; + this.vacancyService + .deleteVacancy(vacancyId) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.projectVacancyUIService.applyRemoveVacancy(vacancyId); + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts new file mode 100644 index 000000000..40d6c82cd --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts @@ -0,0 +1,525 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { NavService } from "@ui/services/nav/nav.service"; +import { ProjectAssign } from "projects/social_platform/src/app/domain/project/project-assign.model"; +import { + distinctUntilChanged, + forkJoin, + map, + Observable, + of, + Subject, + switchMap, + takeUntil, + tap, +} from "rxjs"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; +import { ActivatedRoute, Router } from "@angular/router"; +import { Goal } from "projects/social_platform/src/app/domain/project/goals.model"; +import { Partner } from "projects/social_platform/src/app/domain/project/partner.model"; +import { Resource } from "projects/social_platform/src/app/domain/project/resource.model"; +import { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; +import { Skill } from "projects/social_platform/src/app/domain/skills/skill"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ValidationService } from "@corelib"; +import { SnackbarService } from "@ui/services/snackbar/snackbar.service"; +import { EditStep, ProjectStepService } from "../../project-step.service"; +import { ProjectService } from "../../project.service"; +import { IndustryService } from "../../../industry/industry.service"; +import { SkillsService } from "../../../skills/skills.service"; +import { ProjectFormService } from "./project-form.service"; +import { ProjectGoalService } from "./project-goals.service"; +import { ProjectVacancyService } from "./project-vacancy.service"; +import { ProjectPartnerService } from "./project-partner.service"; +import { ProjectResourceService } from "./project-resources.service"; +import { ProjectAchievementsService } from "./project-achievements.service"; +import { ProjectAdditionalService } from "./project-additional.service"; +import { SkillsInfoService } from "../../../skills/facades/skills-info.service"; +import { ProjectsEditUIInfoService } from "./ui/projects-edit-ui-info.service"; +import { ProjectVacancyUIService } from "./ui/project-vacancy-ui.service"; +import { ProjectTeamUIService } from "./ui/project-team-ui.service"; + +@Injectable() +export class ProjectsEditInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly skillsInfoService = inject(SkillsInfoService); + + private readonly projectStepService = inject(ProjectStepService); + private readonly projectService = inject(ProjectService); + + private readonly projectTeamUIService = inject(ProjectTeamUIService); + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); + + private readonly industryService = inject(IndustryService); + private readonly skillsService = inject(SkillsService); + private readonly navService = inject(NavService); + private readonly validationService = inject(ValidationService); + private readonly snackBarService = inject(SnackbarService); + + private readonly projectFormService = inject(ProjectFormService); + + private readonly projectVacancyService = inject(ProjectVacancyService); + private readonly projectGoalsService = inject(ProjectGoalService); + + private readonly projectPartnerService = inject(ProjectPartnerService); + private readonly projectResourceService = inject(ProjectResourceService); + + private readonly projectAchievementsService = inject(ProjectAchievementsService); + private readonly projectAdditionalService = inject(ProjectAdditionalService); + + private readonly destroy$ = new Subject(); + + // Текущий шаг редактирования + readonly editingStep = this.projectStepService.currentStep; + + // Получаем сигналы из сервиса + readonly achievements = this.projectFormService.achievements; + + // Id связи проекта и программы + readonly relationId = computed(() => this.projectFormService.relationId); + private readonly leaderId = this.projectsEditUIInfoService.leaderId; + + private readonly isCompetitive = this.projectsEditUIInfoService.isCompetitive; + private readonly isProjectAssignToProgram = + this.projectsEditUIInfoService.isProjectAssignToProgram; + + private readonly fromProgram = this.projectsEditUIInfoService.fromProgram; + + // Получаем форму проекта из сервиса + readonly projectForm = this.projectFormService.getForm(); + + // Получаем форму дополнительных полей из сервиса + readonly additionalForm = this.projectAdditionalService.getAdditionalForm(); + + // Геттеры для работы с целями + readonly goals = computed(() => this.projectGoalsService.goals); + + readonly partners = computed(() => this.projectPartnerService.partners); + + readonly resources = computed(() => this.projectResourceService.resources); + + // Observables для данных + readonly industries$ = this.industryService.industries.pipe( + map(industries => + industries.map(industry => ({ value: industry.id, id: industry.id, label: industry.name })) + ), + takeUntil(this.destroy$) + ); + + readonly profileId = signal(+this.route.snapshot.params["projectId"]); + + // Сигналы для управления состоянием + readonly inlineSkills = this.skillsInfoService.inlineSkills; + readonly nestedSkills$ = this.skillsService.getSkillsNested(); + + // Состояние отправки форм + readonly projSubmitInitiated = signal(false); + readonly submitMode = signal<"draft" | "published" | null>(null); + readonly projFormIsSubmittingAsPublished = signal(false); + readonly projFormIsSubmittingAsDraft = signal(false); + readonly openGroupIds = signal>(new Set()); + + initializationEditInfo(): void { + this.navService.setNavTitle("Создание проекта"); + + // Получение текущего шага редактирования из query параметров + this.setupEditingStep(); + + // Получение Id лидера проекта + this.setupLeaderIdSubscription(); + } + + initializationLoadingProjectData(): void { + this.loadProgramTagsAndProject(); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // Методы для управления состоянием ошибок через сервис + setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { + this.projectAdditionalService.setAssignProjectToProgramError(error); + } + + /** + * Привязка проекта к программе выбранной + * Перенаправление её на редактирование "нового" проекта + */ + assignProjectToProgram(): void { + this.projectService + .assignProjectToProgram( + Number(this.route.snapshot.paramMap.get("projectId")), + this.projectForm.get("partnerProgramId")?.value + ) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: r => { + this.projectsEditUIInfoService.applyOpenAssignProjectModal(r); + this.router.navigateByUrl(`/office/projects/${r.newProjectId}/edit?editingStep=main`); + }, + + error: err => { + if (err instanceof HttpErrorResponse) { + if (err.status === 400) { + this.setAssignProjectToProgramError(err.error); + } + } + }, + }); + } + + // Методы для управления состоянием отправки форм + setIsSubmittingAsPublished(status: boolean): void { + this.projFormIsSubmittingAsPublished.set(status); + } + + setIsSubmittingAsDraft(status: boolean): void { + this.projFormIsSubmittingAsDraft.set(status); + } + + onGroupToggled(isOpen: boolean, skillsGroupId: number): void { + this.openGroupIds.update(set => { + const next = new Set(set); + next.clear(); + if (isOpen) next.add(skillsGroupId); + return next; + }); + } + + /** + * Удаление проекта с проверкой удаления у пользователя + */ + deleteProject(): void { + this.projectService + .remove(Number(this.route.snapshot.paramMap.get("projectId"))) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.router.navigateByUrl(`/office/projects/my`); + }, + }); + } + + /** + * Сохранение проекта как опубликованного с проверкой доп. полей + */ + saveProjectAsPublished(): void { + this.projectForm.get("draft")?.patchValue(false); + this.submitMode.set("published"); + + if (!this.isCompetitive()) { + this.projFormIsSubmittingAsPublished.set(true); + this.submitProjectForm(); + return; + } + + this.projectForm.markAllAsTouched(); + this.projectFormService.achievements.markAllAsTouched(); + + const projectValid = this.validationService.getFormValidation(this.projectForm); + const additionalValid = this.validationService.getFormValidation(this.additionalForm); + + if (!projectValid || !additionalValid) { + this.projSubmitInitiated.set(true); + return; + } + + if (this.validateAdditionalFields()) { + this.projSubmitInitiated.set(true); + return; + } + + this.projectsEditUIInfoService.applySendDescision(); + } + + /** + * Сохранение проекта как черновика + */ + saveProjectAsDraft(): void { + this.clearAllValidationErrors(); + this.projectForm.get("draft")?.patchValue(true); + this.submitMode.set("draft"); + const partnerProgramId = this.projectForm.get("partnerProgramId")?.value; + this.projectForm.patchValue({ partnerProgramId }); + this.projFormIsSubmittingAsDraft.set(true); + + if (this.isCompetitive()) { + const projectId = Number(this.route.snapshot.params["projectId"]); + const relationId = this.relationId(); + this.sendAdditionalFields(projectId, relationId()); + } else { + this.submitProjectForm(); + } + } + + /** + * Отправка формы проекта + */ + submitProjectForm(): void { + const isDraft = this.projectForm.get("draft")?.value === true; + + this.projectFormService.achievements.controls.forEach(achievementForm => { + achievementForm.markAllAsTouched(); + }); + + const payload = this.projectFormService.getFormValue(); + const projectId = Number(this.route.snapshot.paramMap.get("projectId")); + + if (this.projectVacancyUIService.isDirty()) { + this.projectVacancyService.submitVacancy(projectId); + } + + if (isDraft) { + if ( + !this.validationService.getFormValidation(this.projectForm) || + this.projectVacancyUIService.applyValidateForm() + ) { + return; + } + } else { + if ( + !this.validationService.getFormValidation(this.projectForm) || + !this.validationService.getFormValidation(this.additionalForm) || + this.projectVacancyUIService.applyValidateForm() + ) { + return; + } + } + + this.submitMode.set(null); + this.projectService + .updateProject(projectId, payload) + .pipe( + switchMap(() => this.saveOrEditGoals(projectId)), + switchMap(() => this.savePartners(projectId)), + switchMap(() => this.saveOrEditResources(projectId)) + ) + .subscribe({ + next: () => { + this.completeSubmitedProjectForm(projectId); + }, + error: () => { + this.submitMode.set(null); + this.projFormIsSubmittingAsPublished.set(false); + this.projFormIsSubmittingAsDraft.set(false); + this.snackBarService.error("ошибка при сохранении данных"); + }, + }); + } + + closeSendingDescisionModal(): void { + this.projectsEditUIInfoService.applyCloseSendDescisionModal(); + + const projectId = Number(this.route.snapshot.params["projectId"]); + const relationId = this.relationId(); + + this.projFormIsSubmittingAsPublished.set(true); + this.sendAdditionalFields(projectId, relationId()); + } + + loadProgramTagsAndProject(): void { + // Сброс состояния перед загрузкой + this.isCompetitive.set(false); + this.isProjectAssignToProgram.set(false); + + this.route.data + .pipe( + map(d => d["data"]), + takeUntil(this.destroy$) + ) + .subscribe( + ([project, goals, partners, resources, invites]: [ + Project, + Goal[], + Partner[], + Resource[], + Invite[] + ]) => { + // Используем сервис для инициализации данных проекта + this.projectFormService.initializeProjectData(project); + this.projectGoalsService.initializeGoalsFromProject(goals); + this.projectPartnerService.initializePartnerFromProject(partners); + this.projectResourceService.initializeResourcesFromProject(resources); + this.projectTeamUIService.applySetInvites(invites); + this.projectTeamUIService.applySetCollaborators(project.collaborators); + + if (project.partnerProgram) { + this.isCompetitive.set( + !!project.partnerProgram.programId && project.partnerProgram.canSubmit + ); + this.isProjectAssignToProgram.set(!!project.partnerProgram.programId); + + this.projectAdditionalService.initializeAdditionalForm( + project.partnerProgram?.programFields, + project.partnerProgram?.programFieldValues + ); + } + + this.projectVacancyUIService.applySetVacancies(project.vacancies); + this.projectTeamUIService.applySetInvites(invites); + } + ); + } + + /** + * Поиск навыков + * @param query - поисковый запрос + */ + onSearchSkill(query: string): void { + this.skillsInfoService.onSearchSkill(query); + } + + private setupEditingStep(): void { + const stepFromUrl = this.route.snapshot.queryParams["editingStep"] as EditStep; + if (stepFromUrl) { + this.projectStepService.setStepFromRoute(stepFromUrl); + } + + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe(params => { + const step = params["editingStep"] as EditStep; + this.fromProgram.set(params["fromProgram"]); + if (step && step !== this.editingStep()) { + this.projectStepService.setStepFromRoute(step); + } + }); + } + + private setupLeaderIdSubscription(): void { + this.route.data + .pipe( + distinctUntilChanged(), + map(d => d["data"]), + takeUntil(this.destroy$) + ) + .subscribe(([project]: [Project]) => { + this.leaderId.set(project.leader); + }); + } + + /** + * Валидация дополнительных полей для публикации + * Делегирует валидацию сервису + * @returns true если есть ошибки валидации + */ + private validateAdditionalFields(): boolean { + const partnerProgramFields = this.projectAdditionalService.partnerProgramFields(); + + // Если нет дополнительных полей - пропускаем валидацию + if (!partnerProgramFields?.length) { + return false; + } + + // Проверяем только обязательные поля + const hasInvalid = this.projectAdditionalService.validateRequiredFields(); + + if (hasInvalid) { + return true; + } + + // Подготавливаем поля для отправки (убираем валидаторы с заполненных полей) + this.projectAdditionalService.prepareFieldsForSubmit(); + return false; + } + + /** + * Очистка всех ошибок валидации + */ + private clearAllValidationErrors(): void { + // Очистка основной формы + this.projectFormService.clearAllValidationErrors(); + this.projectAchievementsService.clearAllAchievementsErrors(this.achievements); + + // Очистка ошибок целей теперь входит в clearAllValidationErrors() ProjectFormService + } + + private saveOrEditGoals(projectId: number) { + const goals = this.goals().value as Goal[]; + + const newGoals = goals.filter(g => !g.id); + const existingGoals = goals.filter(g => g.id); + + const requests: Observable[] = []; + + if (newGoals.length > 0) { + requests.push(this.projectGoalsService.saveGoals(projectId, newGoals)); + } + + if (existingGoals.length > 0) { + requests.push(this.projectGoalsService.editGoals(projectId, existingGoals)); + } + + if (requests.length === 0) { + return of(null); + } + + return forkJoin(requests).pipe( + tap(() => { + this.projectGoalsService.syncGoalItems(this.projectGoalsService.goals); + }) + ); + } + + private savePartners(projectId: number) { + const partners = this.partners().value; + + if (!partners.length) { + return of([]); + } + + return this.projectPartnerService.savePartners(projectId); + } + + private saveOrEditResources(projectId: number) { + const resources = this.resources().value; + const hasExistingResources = resources.some((r: Resource) => r.id != null); + + if (!resources.length) { + return of([]); + } + + return hasExistingResources + ? this.projectResourceService.editResources(projectId) + : this.projectResourceService.saveResources(projectId); + } + + private completeSubmitedProjectForm(projectId: number) { + this.snackBarService.success("данные успешно сохранены"); + this.submitMode.set(null); + this.projFormIsSubmittingAsPublished.set(false); + this.projFormIsSubmittingAsDraft.set(false); + this.router.navigateByUrl(`/office/projects/${projectId}`); + } + + /** + * Отправка дополнительных полей через сервис + * @param projectId - ID проекта + * @param relationId - ID связи проекта и конкурсной программы + */ + private sendAdditionalFields(projectId: number, relationId: number): void { + const isDraft = this.projectForm.get("draft")?.value === true; + + this.projectAdditionalService + .sendAdditionalFieldsValues(projectId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + if (!isDraft) { + this.projectAdditionalService.submitCompettetiveProject(relationId).subscribe(_ => { + this.submitProjectForm(); + }); + } else { + this.submitProjectForm(); + } + }, + error: error => { + console.error("Error sending additional fields:", error); + this.submitMode.set("draft"); + }, + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-goals-ui.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-goals-ui.service.ts new file mode 100644 index 000000000..8237d5dfd --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-goals-ui.service.ts @@ -0,0 +1,75 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { FormArray } from "@angular/forms"; + +@Injectable({ providedIn: "root" }) +export class ProjectGoalsUIService { + readonly goalLeaderShowModal = signal(false); + readonly activeGoalIndex = signal(null); + readonly selectedLeaderId = signal(""); + + readonly goalItems = signal([]); + + readonly hasGoals = computed(() => this.goalItems().length > 0); + + /** + * Обработчик изменения радио-кнопки для выбора лидера + * @param event - событие изменения + */ + applyOnLeaderRadioChange(event: Event): void { + const target = event.target as HTMLInputElement; + this.selectedLeaderId.set(target.value); + } + + /** + * Добавляет лидера на определенную цель + */ + applyAddLeaderToGoal(goals: FormArray): void { + const goalIndex = this.activeGoalIndex(); + const leaderId = this.selectedLeaderId(); + + if (goalIndex === null || !leaderId) { + return; + } + + const goalFormGroup = goals.at(goalIndex); + goalFormGroup?.get("responsible")?.setValue(leaderId); + + this.applyCloseGoalLeaderModal(); + } + + /** + * Открывает модальное окно выбора лидера для конкретной цели + * @param index - индекс цели + */ + applyOpenGoalLeaderModal(goals: FormArray, index: number): void { + this.activeGoalIndex.set(index); + + const currentLeader = goals.at(index)?.get("responsible")?.value; + this.selectedLeaderId.set(currentLeader || ""); + + this.goalLeaderShowModal.set(true); + } + + /** + * Закрывает модальное окно выбора лидера + */ + applyCloseGoalLeaderModal(): void { + this.goalLeaderShowModal.set(false); + this.activeGoalIndex.set(null); + this.selectedLeaderId.set(""); + } + + /** + * Переключает состояние модального окна выбора лидера + * @param index - индекс цели (опционально) + */ + applyToggleGoalLeaderModal(goals: FormArray, index?: number): void { + if (this.goalLeaderShowModal()) { + this.applyCloseGoalLeaderModal(); + } else if (index !== undefined) { + this.applyOpenGoalLeaderModal(goals, index); + } + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts new file mode 100644 index 000000000..5ab098ed0 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts @@ -0,0 +1,148 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, FormGroup, Validators } from "@angular/forms"; +import { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; +import { Collaborator } from "projects/social_platform/src/app/domain/project/collaborator.model"; + +@Injectable({ providedIn: "root" }) +export class ProjectTeamUIService { + private readonly fb = inject(FormBuilder); + + readonly invites = signal([]); + readonly collaborators = signal([]); + readonly isInviteModalOpen = signal(false); + readonly inviteNotExistingError = signal(null); + + // Состояние отправки формы + readonly inviteSubmitInitiated = signal(false); + readonly inviteFormIsSubmitting = signal(false); + + /** + * Создает форму приглашения с контролами role, link и specialization, устанавливая валидаторы. + */ + readonly inviteForm = this.fb.group({ + role: ["", [Validators.required]], + link: [ + "", + [ + Validators.required, + Validators.pattern(/^http(s)?:\/\/.+(:[0-9]*)?\/office\/profile\/\d+$/), + ], + ], + specialization: [null], + }); + + // Геттеры для контролов формы приглашения + get role() { + return this.inviteForm.get("role"); + } + + get link() { + return this.inviteForm.get("link"); + } + + get specialization() { + return this.inviteForm.get("specialization"); + } + + /** + * Сбрасывает ошибку отсутствия пользователя при изменении ссылки. + */ + applyClearLinkError(): void { + if (this.inviteNotExistingError()) { + this.inviteNotExistingError.set(null); + } + } + + /** + * Устанавливает список приглашений. + * @param invites массив Invite + */ + applySetInvites(invites: Invite[]): void { + this.invites.set(invites); + } + + /** + * Устанавливает список команды + * @param collaborators массив Collaborator + */ + applySetCollaborators(collaborators: Collaborator[]): void { + this.collaborators.set(collaborators); + } + + /** + * Открывает модальное окно для отправки приглашения. + */ + applyOpenInviteModal(): void { + this.isInviteModalOpen.set(true); + } + + /** + * Закрывает модальное окно для отправки приглашения. + */ + applyCloseInviteModal(): void { + this.isInviteModalOpen.set(false); + } + + applySubmitInvite(invite: Invite): void { + this.invites.update(list => [...list, invite]); + this.resetInviteForm(); + this.applyCloseInviteModal(); + } + + applyErrorSubmitInvite(err: any): void { + this.inviteNotExistingError.set(err); + this.inviteFormIsSubmitting.set(false); + } + + applyEditInvitation(params: { inviteId: number; role: string; specialization: string }): void { + const { inviteId, role, specialization } = params; + this.invites.update(list => + list.map(i => (i.id === inviteId ? { ...i, role, specialization } : i)) + ); + } + + applyRemoveInvitation(invitationId: number): void { + this.invites.update(list => list.filter(i => i.id !== invitationId)); + } + + /** + * Удаляет участника по идентификатору. + * @param collaboratorId идентификатор приглашения + */ + applyRemoveCollaborator(collaboratorId: number): void { + this.collaborators.update(list => list.filter(i => i.userId !== collaboratorId)); + } + + /** + * Проверяет валидность формы приглашения. + * @returns boolean true если форма валидна + */ + applyValidateInviteForm(): boolean { + return this.inviteForm.valid; + } + + /** + * Возвращает текущее значение формы приглашения. + * @returns any объект значений формы + */ + applyGetInviteFormValue(): any { + return this.inviteForm.value; + } + + /** + * Сбрасывает форму приглашения и очищает ошибки. + */ + resetInviteForm(): void { + this.inviteForm.reset(); + Object.keys(this.inviteForm.controls).forEach(name => { + const ctrl = this.inviteForm.get(name); + ctrl?.clearValidators(); + ctrl?.markAsPristine(); + ctrl?.updateValueAndValidity(); + }); + this.inviteNotExistingError.set(null); + this.inviteFormIsSubmitting.set(false); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.ts new file mode 100644 index 000000000..935caab94 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.ts @@ -0,0 +1,197 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; +import { workExperienceList } from "projects/core/src/consts/lists/work-experience-list.const"; +import { workFormatList } from "projects/core/src/consts/lists/work-format-list.const"; +import { workScheludeList } from "projects/core/src/consts/lists/work-schelude-list.const"; +import { Skill } from "projects/social_platform/src/app/domain/skills/skill"; +import { Vacancy } from "projects/social_platform/src/app/domain/vacancy/vacancy.model"; +import { ProjectFormService } from "../project-form.service"; +import { ValidationService } from "@corelib"; +import { stripNullish } from "@utils/helpers/stripNull"; + +@Injectable({ providedIn: "root" }) +export class ProjectVacancyUIService { + private readonly fb = inject(FormBuilder); + private readonly projectFormService = inject(ProjectFormService); + private readonly validationService = inject(ValidationService); + + /** Константы для выпадающих списков */ + public readonly workExperienceList = workExperienceList; + public readonly workFormatList = workFormatList; + public readonly workScheludeList = workScheludeList; + public readonly rolesMembersList = rolesMembersList; + + /** Сигналы для выбранных значений селектов */ + public readonly selectedRequiredExperienceId = signal(undefined); + public readonly selectedWorkFormatId = signal(undefined); + public readonly selectedWorkScheduleId = signal(undefined); + public readonly selectedVacanciesSpecializationId = signal(undefined); + + readonly selectedSkills = signal([]); + readonly skillsGroupsModalOpen = signal(false); + + // Состояние отправки формы + readonly vacancySubmitInitiated = signal(false); + readonly vacancyIsSubmitting = signal(false); + + readonly vacancies = signal([]); + readonly onEditClicked = signal(false); + + readonly vacancyForm = this.fb.group({ + role: this.fb.control(null), + skills: this.fb.control([]), + description: this.fb.control(""), + requiredExperience: this.fb.control(null), + workFormat: this.fb.control(null), + salary: this.fb.control(""), + workSchedule: this.fb.control(null), + specialization: this.fb.control(null), + }); + + /** + * Устанавливает список вакансий. + * @param vacancies массив объектов Vacancy + */ + applySetVacancies(vacancies: Vacancy[]): void { + this.vacancies.set(vacancies); + } + + /** + * Проставляет значения в форму вакансии. + * @param values частичные поля Vacancy для патчинга + */ + applyPatchFormValues(values: Partial): void { + this.vacancyForm.patchValue(values); + } + + /** + * Проверяет валидность формы вакансии. + * @returns true если форма валидна + */ + applyValidateForm(): boolean { + return !this.validationService.getFormValidation(this.vacancyForm); + } + + /** + * Проверяет на "грязность" формы вакансии. + * @returns true если "грязная" форма + */ + isDirty(): boolean { + return this.vacancyForm.dirty; + } + + /** + * Возвращает очищенные от nullish значения формы. + * @returns объект значений формы без null и undefined + */ + getFormValue(): any { + return stripNullish(this.vacancyForm.value); + } + + // Геттеры для быстрого доступа к контролам формы + get role() { + return this.vacancyForm.get("role"); + } + + get skills() { + return this.vacancyForm.get("skills"); + } + + get description() { + return this.vacancyForm.get("description"); + } + + get requiredExperience() { + return this.vacancyForm.get("requiredExperience"); + } + + get workFormat() { + return this.vacancyForm.get("workFormat"); + } + + get salary() { + return this.vacancyForm.get("salary"); + } + + get workSchedule() { + return this.vacancyForm.get("workSchedule"); + } + + get specialization() { + return this.vacancyForm.get("specialization"); + } + + applyRemoveVacancy(vacancyId: number): void { + this.vacancies.update(list => list.filter(v => v.id !== vacancyId)); + } + + applySubmitVacancy(vacancy: Vacancy): void { + this.vacancies.update(list => [...list, vacancy]); + this.applyResetVacancyForm(); + } + + /** + * Инициализирует редактирование вакансии по индексу в массиве: + * заполняет форму, выставляет сигналы и переключает режим редактирования. + * @param index индекс вакансии в списке vacancies + */ + applyEditVacancy(index: number): void { + const item = this.vacancies()[index]; + // Установка выбранных значений селектов по сопоставлению + this.workExperienceList.find(e => e.value === item.requiredExperience) && + this.selectedRequiredExperienceId.set( + this.workExperienceList.find(e => e.value === item.requiredExperience)!.id + ); + + this.workFormatList.find(f => f.value === item.workFormat) && + this.selectedWorkFormatId.set(this.workFormatList.find(f => f.value === item.workFormat)!.id); + + this.workScheludeList.find(s => s.value === item.workSchedule) && + this.selectedWorkScheduleId.set( + this.workScheludeList.find(s => s.value === item.workSchedule)!.id + ); + + this.rolesMembersList.find(r => r.value === item.specialization) && + this.selectedVacanciesSpecializationId.set( + this.rolesMembersList.find(r => r.value === item.specialization)!.id + ); + + // Патчинг формы значениями вакансии + this.vacancyForm.patchValue({ + role: item.role, + skills: item.requiredSkills, + description: item.description, + requiredExperience: item.requiredExperience, + workFormat: item.workFormat, + salary: item.salary ?? null, + workSchedule: item.workSchedule, + specialization: item.specialization, + }); + this.projectFormService.editIndex.set(index); + this.onEditClicked.set(true); + } + + /** + * Сбрасывает форму вакансии к начальному состоянию: + * очищает значения, валидаторы и состояния контролов, + * сбрасывает сигналы выбранных селектов. + */ + applyResetVacancyForm(): void { + this.vacancyForm.reset(); + Object.keys(this.vacancyForm.controls).forEach(name => { + const ctrl = this.vacancyForm.get(name); + ctrl?.reset(name === "skills" ? [] : ""); + ctrl?.clearValidators(); + ctrl?.markAsPristine(); + ctrl?.updateValueAndValidity(); + }); + this.selectedRequiredExperienceId.set(undefined); + this.selectedWorkFormatId.set(undefined); + this.selectedWorkScheduleId.set(undefined); + this.selectedVacanciesSpecializationId.set(undefined); + this.vacancyIsSubmitting.set(false); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/projects-edit-ui-info.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/projects-edit-ui-info.service.ts new file mode 100644 index 000000000..5cbd48a90 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/projects-edit-ui-info.service.ts @@ -0,0 +1,58 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { ProjectAssign } from "projects/social_platform/src/app/domain/project/project-assign.model"; + +@Injectable() +export class ProjectsEditUIInfoService { + // Id Лидера проекта + readonly leaderId = signal(0); + readonly fromProgram = signal(""); + + // Маркер того является ли проект привязанный к конкурсной программе + readonly isCompetitive = signal(false); + readonly isProjectAssignToProgram = signal(false); + + // Состояние компонента + readonly isCompleted = signal(false); + readonly isSendDescisionToPartnerProgramProject = signal(false); + readonly isAssignProjectToProgramModalOpen = signal(false); + + // Маркер что проект привязан + readonly isProjectBoundToProgram = signal(false); + + // Сигналы для работы с модальными окнами с ошибкой + readonly errorModalMessage = signal<{ + program_name: string; + whenCanEdit: string; + daysUntilResolution: string; + } | null>(null); + + readonly onEditClicked = signal(false); + readonly warningModalSeen = signal(false); + + // Сигналы для работы с модальными окнами с текстом + readonly assignProjectToProgramModalMessage = signal(null); + + applyOpenAssignProjectModal(r: any): void { + this.assignProjectToProgramModalMessage.set(r); + this.isAssignProjectToProgramModalOpen.set(true); + } + + applySendDescision(): void { + this.isSendDescisionToPartnerProgramProject.set(true); + } + + applyCloseSendDescisionModal(): void { + this.isSendDescisionToPartnerProgramProject.set(false); + } + + // Методы для работы с модальными окнами + applyCloseWarningModal(): void { + this.warningModalSeen.set(true); + } + + closeAssignProjectToProgramModal(): void { + this.isAssignProjectToProgramModalOpen.set(false); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/list/projects-list-info.service.ts b/projects/social_platform/src/app/api/project/facades/list/projects-list-info.service.ts new file mode 100644 index 000000000..5ad7b7f6b --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/list/projects-list-info.service.ts @@ -0,0 +1,218 @@ +/** @format */ + +import { ElementRef, inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Params } from "@angular/router"; +import { + concatMap, + distinctUntilChanged, + EMPTY, + fromEvent, + map, + of, + Subject, + takeUntil, + tap, + throttleTime, +} from "rxjs"; +import { NavService } from "@ui/services/nav/nav.service"; +import { ProjectService } from "../../project.service"; +import { ProjectsInfoService } from "../projects-info.service"; +import { ProgramDetailListInfoService } from "../../../program/facades/detail/program-detail-list-info.service"; +import { inviteToProjectMapper } from "@utils/helpers/inviteToProjectMapper"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; + +@Injectable() +export class ProjectsListInfoService { + private readonly route = inject(ActivatedRoute); + private readonly navService = inject(NavService); + private readonly projectService = inject(ProjectService); + private readonly projectsInfoService = inject(ProjectsInfoService); + private readonly programDetailListInfoService = inject(ProgramDetailListInfoService); + + private readonly destroy$ = new Subject(); + + private readonly projectsCount = signal(0); + private readonly currentPage = signal(1); + private readonly projectsPerFetch = signal(15); + + private readonly currentSearchQuery = signal(undefined); + private previousReqQuery = signal>({}); + + readonly projects = signal([]); + + private readonly isAll = this.projectsInfoService.isAll; + private readonly isSubs = this.projectsInfoService.isSubs; + private readonly isDashboard = this.projectsInfoService.isDashboard; + private readonly isInvites = this.projectsInfoService.isInvites; + + initializationProjectsList(): void { + this.navService.setNavTitle("Проекты"); + + this.projectsInfoService.initializationRouterEvents(); + + if (this.isDashboard() || this.isSubs()) { + this.programDetailListInfoService.setupProfile(); + } + + this.route.queryParams + .pipe( + map(q => q["name__contains"]), + takeUntil(this.destroy$) + ) + .subscribe(search => { + if (search !== this.currentSearchQuery()) { + this.currentSearchQuery.set(search); + this.currentPage.set(1); + } + }); + + if (location.href.includes("/all")) { + const observable = this.route.queryParams.pipe( + distinctUntilChanged(), + concatMap(q => { + const reqQuery = this.buildFilterQuery(q); + + if (JSON.stringify(reqQuery) !== JSON.stringify(this.previousReqQuery())) { + try { + this.previousReqQuery.set(reqQuery); + return this.projectService.getAll(new HttpParams({ fromObject: reqQuery })); + } catch (e) { + console.error(e); + this.previousReqQuery.set(reqQuery); + return this.projectService.getAll(); + } + } + + this.previousReqQuery.set(reqQuery); + + return of(0); + }), + takeUntil(this.destroy$) + ); + + observable.pipe(takeUntil(this.destroy$)).subscribe(projects => { + if (typeof projects === "number") return; + + this.projects.set(projects.results); + }); + } + + this.route.data + .pipe( + map(r => r["data"]), + takeUntil(this.destroy$) + ) + .subscribe(projects => { + this.projectsCount.set(projects.count); + + if (this.isInvites()) { + this.projects.set(inviteToProjectMapper(projects ?? [])); + } else { + this.projects.set(projects.results ?? []); + } + }); + } + + initScroll(target: HTMLElement, listRoot: ElementRef): void { + fromEvent(target, "scroll") + .pipe( + throttleTime(300), + concatMap(() => this.onScroll(target, listRoot)), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private buildFilterQuery(q: Params): Record { + const reqQuery: Record = {}; + + if (q["name__contains"]) { + reqQuery["name__contains"] = q["name__contains"]; + } + if (q["industry"]) { + reqQuery["industry"] = q["industry"]; + } + if (q["step"]) { + reqQuery["step"] = q["step"]; + } + if (q["membersCount"]) { + reqQuery["collaborator__count__gte"] = q["membersCount"]; + } + if (q["anyVacancies"]) { + reqQuery["any_vacancies"] = q["anyVacancies"]; + } + if (q["is_rated_by_expert"]) { + reqQuery["is_rated_by_expert"] = q["is_rated_by_expert"]; + } + if (q["is_mospolytech"]) { + reqQuery["is_mospolytech"] = q["is_mospolytech"]; + reqQuery["partner_program"] = q["partner_program"]; + } + + return reqQuery; + } + + private onScroll(target: HTMLElement, listRoot: ElementRef) { + if (this.isSubs() || this.isInvites()) { + return EMPTY; + } + + if (this.projectsCount() && this.projects().length >= this.projectsCount()) return EMPTY; + + if (!target || !listRoot.nativeElement) return EMPTY; + + const diff = + target.scrollTop - listRoot.nativeElement.getBoundingClientRect().height + window.innerHeight; + + if (diff > 0) { + return this.onFetch( + this.currentPage() * this.projectsPerFetch(), + this.projectsPerFetch() + ).pipe( + tap(chunk => { + this.currentPage.update(p => p + 1); + this.projects.update(() => [...this.projects(), ...chunk]); + }) + ); + } + + return EMPTY; + } + + private onFetch(skip: number, take: number) { + if (this.isAll()) { + const queries = this.route.snapshot.queryParams; + + const queryParams = { + offset: skip, + limit: take, + ...this.buildFilterQuery(queries), + }; + + return this.projectService.getAll(new HttpParams({ fromObject: queryParams })).pipe( + map((projects: ApiPagination) => { + return projects.results; + }) + ); + } else { + return this.projectService.getMy().pipe( + map((projects: ApiPagination) => { + this.projectsCount.set(projects.count); + return projects.results; + }) + ); + } + } + + sliceInvitesArray(inviteId: number): void { + this.projects.update(projects => projects.filter(p => p.inviteId !== inviteId)); + this.projectsCount.update(() => Math.max(0, this.projectsCount() - 1)); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/projects-info.service.ts b/projects/social_platform/src/app/api/project/facades/projects-info.service.ts new file mode 100644 index 000000000..dd81c8dcf --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/projects-info.service.ts @@ -0,0 +1,91 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; +import { NavService } from "@ui/services/nav/nav.service"; +import { debounceTime, distinctUntilChanged, filter, map, Subject, takeUntil } from "rxjs"; +import { ProjectService } from "../project.service"; +import { ProjectsUIInfoService } from "./ui/projects-ui-info.service"; + +@Injectable() +export class ProjectsInfoService { + private readonly navService = inject(NavService); + private readonly route = inject(ActivatedRoute); + private readonly projectService = inject(ProjectService); + private readonly projectsUIInfoService = inject(ProjectsUIInfoService); + private readonly router = inject(Router); + + private readonly destroy$ = new Subject(); + + private readonly url = signal(this.router.url); + private readonly searchForm = this.projectsUIInfoService.searchForm; + + readonly isMy = computed(() => this.url().includes("/my")); + readonly isAll = computed(() => this.url().includes("/all")); + readonly isSubs = computed(() => this.url().includes("/subscriptions")); + readonly isInvites = computed(() => this.url().includes("/invites")); + readonly isDashboard = computed(() => this.url().includes("/dashboard")); + + initializationProjects(): void { + this.navService.setNavTitle("Проекты"); + + this.route.data + .pipe(map(r => r["data"])) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: invites => { + this.projectsUIInfoService.applySetProjectsInvites(invites); + }, + }); + + this.searchForm + .get("search") + ?.valueChanges.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(search => { + this.router + .navigate([], { + queryParams: { name__contains: search }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => console.debug("QueryParams changed from ProjectsComponent")); + }); + + this.initializationRouterEvents(); + } + + initializationRouterEvents(): void { + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd), + takeUntil(this.destroy$) + ) + .subscribe(() => this.url.set(this.router.url)); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + addProject(): void { + const fromProgram = + this.route.snapshot.parent?.routeConfig?.path === "programs" ? { fromProgram: true } : null; + + this.projectService + .create() + .pipe(takeUntil(this.destroy$)) + .subscribe(project => { + this.projectService.projectsCount.next({ + ...this.projectService.projectsCount.getValue(), + my: this.projectService.projectsCount.getValue().my + 1, + }); + + this.router + .navigate([`/office/projects/${project.id}/edit`], { + queryParams: { editingStep: "main", fromProgram }, + }) + .then(() => console.debug("Route change from ProjectsComponent")); + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/ui/projects-ui-info.service.ts b/projects/social_platform/src/app/api/project/facades/ui/projects-ui-info.service.ts new file mode 100644 index 000000000..d2b6a61ea --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/ui/projects-ui-info.service.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { inviteToProjectMapper } from "@utils/helpers/inviteToProjectMapper"; +import { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; + +@Injectable() +export class ProjectsUIInfoService { + private readonly fb = inject(FormBuilder); + + readonly myInvites = computed(() => this.allInvites().slice(0, 1)); + + readonly allInvites = signal([]); + + readonly searchForm = this.fb.group({ + search: [""], + }); + + applySetProjectsInvites(invites: Invite[]): void { + this.allInvites.set(inviteToProjectMapper(invites)); + } + + applyAcceptOrRejectInvite(inviteId: number): void { + this.allInvites.update(list => list.filter(invite => invite.inviteId !== inviteId)); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts b/projects/social_platform/src/app/api/project/project-achievements.service.ts similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts rename to projects/social_platform/src/app/api/project/project-achievements.service.ts diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts b/projects/social_platform/src/app/api/project/project-additional.service.ts similarity index 97% rename from projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts rename to projects/social_platform/src/app/api/project/project-additional.service.ts index 8b27ae100..90540abbf 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts +++ b/projects/social_platform/src/app/api/project/project-additional.service.ts @@ -6,10 +6,10 @@ import { PartnerProgramFields, PartnerProgramFieldsValues, projectNewAdditionalProgramVields, -} from "@office/models/partner-program-fields.model"; -import { ProgramService } from "@office/program/services/program.service"; -import { ProjectService } from "@services/project.service"; +} from "projects/social_platform/src/app/domain/program/partner-program-fields.model"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; import { Observable } from "rxjs"; +import { ProgramService } from "../program/program.service"; /** * Сервис для управления дополнительными полями проекта в партнерской программе. diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts b/projects/social_platform/src/app/api/project/project-contacts.service.ts similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts rename to projects/social_platform/src/app/api/project/project-contacts.service.ts diff --git a/projects/social_platform/src/app/api/project/project-data.service.ts b/projects/social_platform/src/app/api/project/project-data.service.ts new file mode 100644 index 000000000..54dc022b4 --- /dev/null +++ b/projects/social_platform/src/app/api/project/project-data.service.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { Project } from "../../domain/project/project.model"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectDataService { + project = signal(undefined); + + setProject(project: Project) { + this.project.set(project); + } + + collaborators = computed(() => this.project()?.collaborators); + vacancies = computed(() => this.project()?.vacancies); + leaderId = computed(() => this.project()?.leader); + projectId = computed(() => this.project()?.id); + goals = computed(() => this.project()?.goals); +} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts b/projects/social_platform/src/app/api/project/project-form.service.ts similarity index 97% rename from projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts rename to projects/social_platform/src/app/api/project/project-form.service.ts index ceaeae0ad..2171fc464 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts +++ b/projects/social_platform/src/app/api/project/project-form.service.ts @@ -10,12 +10,12 @@ import { ValidatorFn, } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { Project } from "@office/models/project.model"; -import { ProjectService } from "@office/services/project.service"; -import { optionalUrlOrMentionValidator } from "@utils/optionalUrl.validator"; -import { stripNullish } from "@utils/stripNull"; +import { PartnerProgramFields } from "projects/social_platform/src/app/domain/program/partner-program-fields.model"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { stripNullish } from "@utils/helpers/stripNull"; import { concatMap, filter } from "rxjs"; +import { Project } from "../../domain/project/project.model"; +import { optionalUrlOrMentionValidator } from "@utils/optionalUrl.validator"; /** * Сервис для управления основной формой проекта и формой дополнительных полей партнерской программы. * Обеспечивает создание, инициализацию, валидацию, автосохранение, сброс и получение данных форм. diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts b/projects/social_platform/src/app/api/project/project-goals.service.ts similarity index 98% rename from projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts rename to projects/social_platform/src/app/api/project/project-goals.service.ts index 9aff1cc92..fd3c42c01 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts +++ b/projects/social_platform/src/app/api/project/project-goals.service.ts @@ -3,9 +3,9 @@ import { inject, Injectable, signal } from "@angular/core"; import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; import { ProjectFormService } from "./project-form.service"; -import { Goal, GoalPostForm } from "@office/models/goals.model"; import { catchError, forkJoin, map, of, tap } from "rxjs"; -import { ProjectService } from "@office/services/project.service"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { Goal, GoalDto } from "../../domain/project/goals.model"; /** * Сервис для управления целями проекта @@ -312,7 +312,7 @@ export class ProjectGoalService { public editGoals(projectId: number, existingGoals: Goal[]) { const requests = existingGoals.map((item, idx) => { - const payload: GoalPostForm = { + const payload: GoalDto = { id: item.id, title: item.title, completionDate: item.completionDate, diff --git a/projects/social_platform/src/app/office/projects/detail/services/project-news.service.ts b/projects/social_platform/src/app/api/project/project-news.service.ts similarity index 96% rename from projects/social_platform/src/app/office/projects/detail/services/project-news.service.ts rename to projects/social_platform/src/app/api/project/project-news.service.ts index 03319cf99..d11b5601b 100644 --- a/projects/social_platform/src/app/office/projects/detail/services/project-news.service.ts +++ b/projects/social_platform/src/app/api/project/project-news.service.ts @@ -2,12 +2,12 @@ import { inject, Injectable } from "@angular/core"; import { ApiService } from "projects/core"; -import { FeedNews } from "@office/projects/models/project-news.model"; import { forkJoin, map, Observable, tap } from "rxjs"; import { plainToInstance } from "class-transformer"; import { HttpParams } from "@angular/common/http"; -import { StorageService } from "@services/storage.service"; -import { ApiPagination } from "@models/api-pagination.model"; +import { StorageService } from "projects/social_platform/src/app/api/storage/storage.service"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { FeedNews } from "../../domain/project/project-news.model"; /** * СЕРВИС ДЛЯ РАБОТЫ С НОВОСТЯМИ ПРОЕКТА diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts b/projects/social_platform/src/app/api/project/project-partner.service.ts similarity index 97% rename from projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts rename to projects/social_platform/src/app/api/project/project-partner.service.ts index d94183e54..6df113a24 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts +++ b/projects/social_platform/src/app/api/project/project-partner.service.ts @@ -2,9 +2,9 @@ import { inject, Injectable, signal } from "@angular/core"; import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; -import { Partner, PartnerPostForm } from "@office/models/partner.model"; -import { ProjectService } from "@office/services/project.service"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; import { catchError, forkJoin, map, Observable, of, tap } from "rxjs"; +import { Partner, PartnerDto } from "../../domain/project/partner.model"; @Injectable({ providedIn: "root", @@ -222,7 +222,7 @@ export class ProjectPartnerService { const requests = partners.map(partner => { const decisionMaker = Number(partner.decisionMaker.split("/").at(-1)); - const payload: PartnerPostForm = { + const payload: PartnerDto = { name: partner.name, inn: partner.inn, contribution: partner.contribution, diff --git a/projects/social_platform/src/app/office/program/services/project-rating.service.spec.ts b/projects/social_platform/src/app/api/project/project-rating.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/program/services/project-rating.service.spec.ts rename to projects/social_platform/src/app/api/project/project-rating.service.spec.ts diff --git a/projects/social_platform/src/app/office/program/services/project-rating.service.ts b/projects/social_platform/src/app/api/project/project-rating.service.ts similarity index 92% rename from projects/social_platform/src/app/office/program/services/project-rating.service.ts rename to projects/social_platform/src/app/api/project/project-rating.service.ts index e30cb490e..67461c54f 100644 --- a/projects/social_platform/src/app/office/program/services/project-rating.service.ts +++ b/projects/social_platform/src/app/api/project/project-rating.service.ts @@ -4,10 +4,10 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { Observable } from "rxjs"; import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { ProjectRate } from "../models/project-rate"; -import { ProjectRatingCriterion } from "../models/project-rating-criterion"; -import { ProjectRatingCriterionOutput } from "../models/project-rating-criterion-output"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { ProjectRate } from "../../domain/project/project-rate"; +import { ProjectRatingCriterionOutput } from "../../domain/project/project-rating-criterion-output"; +import { ProjectRatingCriterion } from "../../domain/project/project-rating-criterion"; /** * Сервис для оценки проектов в рамках программы diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts b/projects/social_platform/src/app/api/project/project-resources.service.ts similarity index 96% rename from projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts rename to projects/social_platform/src/app/api/project/project-resources.service.ts index 15b661535..51043678a 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts +++ b/projects/social_platform/src/app/api/project/project-resources.service.ts @@ -2,9 +2,9 @@ import { inject, Injectable, signal } from "@angular/core"; import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; -import { Resource, ResourcePostForm } from "@office/models/resource.model"; -import { ProjectService } from "@office/services/project.service"; -import { catchError, forkJoin, map, Observable, of, tap } from "rxjs"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; +import { catchError, forkJoin, map, of, tap } from "rxjs"; +import { Resource, ResourceDto } from "../../domain/project/resource.model"; @Injectable({ providedIn: "root", @@ -195,7 +195,7 @@ export class ProjectResourceService { const resources = this.getResourcesData(); const requests = resources.map(resource => { - const payload: Omit = { + const payload: Omit = { type: resource.type, description: resource.description, partnerCompany: resource.partnerCompany ?? "запрос к рынку", @@ -233,10 +233,9 @@ export class ProjectResourceService { public editResources(projectId: number) { const resources = this.getResourcesData(); - console.log(resources); const requests = resources.map(resource => { - const payload: Omit = { + const payload: Omit = { type: resource.type, description: resource.description, partnerCompany: resource.partnerCompany ?? "запрос к рынку", diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-step.service.ts b/projects/social_platform/src/app/api/project/project-step.service.ts similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/services/project-step.service.ts rename to projects/social_platform/src/app/api/project/project-step.service.ts diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts b/projects/social_platform/src/app/api/project/project-team.service.ts similarity index 96% rename from projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts rename to projects/social_platform/src/app/api/project/project-team.service.ts index be1597dd9..5f534426e 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts +++ b/projects/social_platform/src/app/api/project/project-team.service.ts @@ -3,9 +3,9 @@ import { computed, inject, Injectable, signal } from "@angular/core"; import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { ValidationService } from "@corelib"; -import { Collaborator } from "@office/models/collaborator.model"; -import { Invite } from "@office/models/invite.model"; -import { InviteService } from "@services/invite.service"; +import { Collaborator } from "projects/social_platform/src/app/domain/project/collaborator.model"; +import { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; +import { InviteService } from "projects/social_platform/src/app/api/invite/invite.service"; /** * Сервис для управления приглашениями участников команды проекта. diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts b/projects/social_platform/src/app/api/project/project-vacancy.service.ts similarity index 97% rename from projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts rename to projects/social_platform/src/app/api/project/project-vacancy.service.ts index 997a65f58..3530217b6 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts +++ b/projects/social_platform/src/app/api/project/project-vacancy.service.ts @@ -4,15 +4,15 @@ import { inject, Injectable, signal } from "@angular/core"; import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; import { ValidationService } from "@corelib"; -import { Skill } from "@office/models/skill"; -import { Vacancy } from "@office/models/vacancy.model"; -import { VacancyService } from "@office/services/vacancy.service"; -import { stripNullish } from "@utils/stripNull"; +import { Vacancy } from "projects/social_platform/src/app/domain/vacancy/vacancy.model"; +import { stripNullish } from "@utils/helpers/stripNull"; import { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; import { ProjectFormService } from "./project-form.service"; import { workExperienceList } from "projects/core/src/consts/lists/work-experience-list.const"; import { workFormatList } from "projects/core/src/consts/lists/work-format-list.const"; import { workScheludeList } from "projects/core/src/consts/lists/work-schelude-list.const"; +import { Skill } from "../../domain/skills/skill"; +import { VacancyService } from "../vacancy/vacancy.service"; /** * Сервис для управления вакансиями проекта. diff --git a/projects/social_platform/src/app/office/services/project.service.spec.ts b/projects/social_platform/src/app/api/project/project.service.spec.ts similarity index 93% rename from projects/social_platform/src/app/office/services/project.service.spec.ts rename to projects/social_platform/src/app/api/project/project.service.spec.ts index f14d9fd1c..73c5ad294 100644 --- a/projects/social_platform/src/app/office/services/project.service.spec.ts +++ b/projects/social_platform/src/app/api/project/project.service.spec.ts @@ -2,10 +2,10 @@ import { TestBed } from "@angular/core/testing"; -import { ProjectService } from "./project.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { of } from "rxjs"; -import { AuthService } from "@auth/services"; +import { AuthService } from "../auth"; +import { ProjectService } from "./project.service"; describe("ProjectService", () => { let service: ProjectService; diff --git a/projects/social_platform/src/app/office/services/project.service.ts b/projects/social_platform/src/app/api/project/project.service.ts similarity index 91% rename from projects/social_platform/src/app/office/services/project.service.ts rename to projects/social_platform/src/app/api/project/project.service.ts index b99c0444a..86063664e 100644 --- a/projects/social_platform/src/app/office/services/project.service.ts +++ b/projects/social_platform/src/app/api/project/project.service.ts @@ -2,17 +2,17 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject, map, Observable, tap } from "rxjs"; -import { Project, ProjectCount, ProjectStep } from "@models/project.model"; import { ApiService } from "projects/core"; import { plainToInstance } from "class-transformer"; import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { Collaborator } from "@office/models/collaborator.model"; -import { ProjectAssign } from "@office/projects/models/project-assign.model"; -import { projectNewAdditionalProgramVields } from "@office/models/partner-program-fields.model"; -import { Goal, GoalPostForm } from "@office/models/goals.model"; -import { Partner, PartnerPostForm } from "@office/models/partner.model"; -import { Resource, ResourcePostForm } from "@office/models/resource.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { Collaborator } from "projects/social_platform/src/app/domain/project/collaborator.model"; +import { projectNewAdditionalProgramVields } from "projects/social_platform/src/app/domain/program/partner-program-fields.model"; +import { Project, ProjectCount, ProjectStep } from "../../domain/project/project.model"; +import { Partner, PartnerDto } from "../../domain/project/partner.model"; +import { Resource, ResourceDto } from "../../domain/project/resource.model"; +import { Goal, GoalDto } from "../../domain/project/goals.model"; +import { ProjectAssign } from "../../domain/project/project-assign.model"; /** * Сервис для управления проектами @@ -80,7 +80,7 @@ export class ProjectService { * Если компания с таким ИНН уже существует — создаёт или обновляет связь ProjectCompany. * Если компании нет — создаёт новую и тут же привязывает. */ - addPartner(projectId: number, params: PartnerPostForm) { + addPartner(projectId: number, params: PartnerDto) { return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/companies/`, params); } @@ -108,7 +108,7 @@ export class ProjectService { editParter( projectId: number, companyId: number, - params: Pick + params: Pick ) { return this.apiService.patch( `${this.PROJECTS_URL}/${projectId}/companies/${companyId}/`, @@ -133,7 +133,7 @@ export class ProjectService { * @returns Создать новый ресурс в проекте. * Если partner_company указана, проверяется, что она действительно является партнёром данного проекта. */ - addResource(projectId: number, params: Omit) { + addResource(projectId: number, params: Omit) { return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/resources/`, { project_id: projectId, ...params, @@ -157,7 +157,7 @@ export class ProjectService { * @returns Полностью обновить данные ресурса. * Используется, если нужно заменить все поля сразу. */ - editResource(projectId: number, resourceId: number, params: Omit) { + editResource(projectId: number, resourceId: number, params: Omit) { return this.apiService.patch(`${this.PROJECTS_URL}/${projectId}/resources/${resourceId}/`, { project_id: projectId, ...params, @@ -187,14 +187,14 @@ export class ProjectService { /** * Отправляем цель */ - addGoals(projectId: number, params: GoalPostForm[]) { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/goals/`, params); + addGoals(projectId: number, params: GoalDto[]) { + return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/goals/`, params); } /** * Редактирование цели */ - editGoal(projectId: number, goalId: number, params: GoalPostForm) { + editGoal(projectId: number, goalId: number, params: GoalDto) { return this.apiService.put(`${this.PROJECTS_URL}/${projectId}/goals/${goalId}`, params); } @@ -202,9 +202,7 @@ export class ProjectService { * Удаляем цель */ deleteGoals(projectId: number, goalId: number) { - return this.apiService.delete( - `${this.PROJECTS_URL}/${projectId}/goals/${goalId}` - ); + return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/goals/${goalId}`); } /** diff --git a/projects/social_platform/src/app/office/projects/services/projects.service.ts b/projects/social_platform/src/app/api/project/projects.service.ts similarity index 89% rename from projects/social_platform/src/app/office/projects/services/projects.service.ts rename to projects/social_platform/src/app/api/project/projects.service.ts index 745c0ea34..048aea209 100644 --- a/projects/social_platform/src/app/office/projects/services/projects.service.ts +++ b/projects/social_platform/src/app/api/project/projects.service.ts @@ -2,7 +2,7 @@ import { inject, Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { ProjectService } from "@office/services/project.service"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; @Injectable({ providedIn: "root", diff --git a/projects/social_platform/src/app/api/searches/searches.service.ts b/projects/social_platform/src/app/api/searches/searches.service.ts new file mode 100644 index 000000000..a84a01ff5 --- /dev/null +++ b/projects/social_platform/src/app/api/searches/searches.service.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { Subject, take, takeUntil } from "rxjs"; +import { Specialization } from "../../domain/specializations/specialization"; +import { FormGroup } from "@angular/forms"; +import { SpecializationsService } from "../specializations/specializations.service"; + +@Injectable({ providedIn: "root" }) +export class SearchesService { + private readonly specsService = inject(SpecializationsService); + + readonly inlineSpecs = signal([]); + + private readonly destroy$ = new Subject(); + + /** + * Выбор специальности из автокомплита + * @param speciality - выбранная специальность + */ + onSelectSpec(form: FormGroup, speciality: Specialization): void { + form.patchValue({ speciality: speciality.name }); + } + + onSearchSpec(query: string): void { + this.specsService + .getSpecializationsInline(query, 1000, 0) + .pipe(take(1), takeUntil(this.destroy$)) + .subscribe(({ results }) => { + this.inlineSpecs.set(results); + }); + } +} diff --git a/projects/social_platform/src/app/api/skills/facades/skills-info.service.ts b/projects/social_platform/src/app/api/skills/facades/skills-info.service.ts new file mode 100644 index 000000000..42ca61968 --- /dev/null +++ b/projects/social_platform/src/app/api/skills/facades/skills-info.service.ts @@ -0,0 +1,67 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { Subject } from "rxjs"; +import { Skill } from "../../../domain/skills/skill"; +import { FormGroup } from "@angular/forms"; +import { SkillsService } from "../skills.service"; + +@Injectable({ providedIn: "root" }) +export class SkillsInfoService { + private readonly skillsService = inject(SkillsService); + + private readonly destroy$ = new Subject(); + + readonly inlineSkills = signal([]); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Переключение навыка (добавление/удаление) + * @param toggledSkill - навык для переключения + */ + onToggleSkill(toggledSkill: Skill, form: FormGroup): void { + const { skills }: { skills: Skill[] } = form.value; + + const isPresent = skills.some(skill => skill.id === toggledSkill.id); + + if (isPresent) { + this.onRemoveSkill(toggledSkill, form); + } else { + this.onAddSkill(toggledSkill, form); + } + } + + /** + * Добавление нового навыка + * @param newSkill - новый навык для добавления + */ + onAddSkill(newSkill: Skill, form: FormGroup): void { + const { skills }: { skills: Skill[] } = form.value; + + const isPresent = skills.some(skill => skill.id === newSkill.id); + + if (isPresent) return; + + form.patchValue({ skills: [newSkill, ...skills] }); + } + + /** + * Удаление навыка + * @param oddSkill - навык для удаления + */ + onRemoveSkill(oddSkill: Skill, form: FormGroup): void { + const { skills }: { skills: Skill[] } = form.value; + + form.patchValue({ skills: skills.filter(skill => skill.id !== oddSkill.id) }); + } + + onSearchSkill(query: string): void { + this.skillsService.getSkillsInline(query, 1000, 0).subscribe(({ results }) => { + this.inlineSkills.set(results); + }); + } +} diff --git a/projects/social_platform/src/app/office/services/skills.service.ts b/projects/social_platform/src/app/api/skills/skills.service.ts similarity index 91% rename from projects/social_platform/src/app/office/services/skills.service.ts rename to projects/social_platform/src/app/api/skills/skills.service.ts index 528320d79..76a739614 100644 --- a/projects/social_platform/src/app/office/services/skills.service.ts +++ b/projects/social_platform/src/app/api/skills/skills.service.ts @@ -1,12 +1,12 @@ /** @format */ import { Injectable } from "@angular/core"; -import { SkillsGroup } from "../models/skills-group"; import { Observable } from "rxjs"; import { ApiService } from "@corelib"; -import { Skill } from "../models/skill"; -import { ApiPagination } from "@office/models/api-pagination.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; +import { SkillsGroup } from "../../domain/skills/skills-group"; +import { Skill } from "../../domain/skills/skill"; /** * Сервис для работы с навыками пользователей diff --git a/projects/social_platform/src/app/office/services/specializations.service.ts b/projects/social_platform/src/app/api/specializations/specializations.service.ts similarity index 90% rename from projects/social_platform/src/app/office/services/specializations.service.ts rename to projects/social_platform/src/app/api/specializations/specializations.service.ts index 5d4a3ac2e..6d027f3e9 100644 --- a/projects/social_platform/src/app/office/services/specializations.service.ts +++ b/projects/social_platform/src/app/api/specializations/specializations.service.ts @@ -1,12 +1,12 @@ /** @format */ import { Injectable } from "@angular/core"; -import { SpecializationsGroup } from "../models/specializations-group"; import { Observable } from "rxjs"; import { ApiService } from "@corelib"; -import { Specialization } from "../models/specialization"; -import { ApiPagination } from "@office/models/api-pagination.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; +import { SpecializationsGroup } from "../../domain/specializations/specializations-group"; +import { Specialization } from "../../domain/specializations/specialization"; /** * Сервис для работы со специализациями пользователей diff --git a/projects/social_platform/src/app/office/services/storage.service.ts b/projects/social_platform/src/app/api/storage/storage.service.ts similarity index 100% rename from projects/social_platform/src/app/office/services/storage.service.ts rename to projects/social_platform/src/app/api/storage/storage.service.ts diff --git a/projects/social_platform/src/app/office/services/subscription.service.spec.ts b/projects/social_platform/src/app/api/subsriptions/subscription.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/services/subscription.service.spec.ts rename to projects/social_platform/src/app/api/subsriptions/subscription.service.spec.ts diff --git a/projects/social_platform/src/app/office/services/subscription.service.ts b/projects/social_platform/src/app/api/subsriptions/subscription.service.ts similarity index 93% rename from projects/social_platform/src/app/office/services/subscription.service.ts rename to projects/social_platform/src/app/api/subsriptions/subscription.service.ts index 16785f7cb..9734b2139 100644 --- a/projects/social_platform/src/app/office/services/subscription.service.ts +++ b/projects/social_platform/src/app/api/subsriptions/subscription.service.ts @@ -3,10 +3,10 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { Observable } from "rxjs"; -import { ProjectSubscriber } from "@office/models/project-subscriber.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { Project } from "@office/models/project.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; +import { ProjectSubscriber } from "../../domain/project/project-subscriber.model"; +import { Project } from "../../domain/project/project.model"; /** * Сервис для управления подписками на проекты diff --git a/projects/social_platform/src/app/api/swipe/swipe.service.ts b/projects/social_platform/src/app/api/swipe/swipe.service.ts new file mode 100644 index 000000000..b5e6362bb --- /dev/null +++ b/projects/social_platform/src/app/api/swipe/swipe.service.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { ElementRef, inject, Injectable, Renderer2, signal } from "@angular/core"; + +@Injectable() +export class SwipeService { + private readonly renderer = inject(Renderer2); + + private swipeStartY = signal(0); + private swipeThreshold = signal(50); + private isSwiping = signal(false); + isFilterOpen = signal(false); + + onSwipeStart(event: TouchEvent): void { + this.swipeStartY.set(event.touches[0].clientY); + this.isSwiping.set(true); + } + + onSwipeMove(event: TouchEvent, filterBody: ElementRef): void { + if (!this.isSwiping) return; + + const currentY = event.touches[0].clientY; + const deltaY = currentY - this.swipeStartY(); + + const progress = Math.min(deltaY / this.swipeThreshold(), 1); + this.renderer.setStyle( + filterBody.nativeElement, + "transform", + `translateY(${progress * 100}px)` + ); + } + + onSwipeEnd(event: TouchEvent, filterBody: ElementRef): void { + if (!this.isSwiping) return; + + const endY = event.changedTouches[0].clientY; + const deltaY = endY - this.swipeStartY(); + + if (deltaY > this.swipeThreshold()) { + this.closeFilter(); + } + + this.isSwiping.set(false); + + this.renderer.setStyle(filterBody.nativeElement, "transform", "translateY(0)"); + } + + closeFilter(): void { + this.isFilterOpen.set(false); + } +} diff --git a/projects/social_platform/src/app/api/tooltip/tooltip-info.service.ts b/projects/social_platform/src/app/api/tooltip/tooltip-info.service.ts new file mode 100644 index 000000000..ae2174b67 --- /dev/null +++ b/projects/social_platform/src/app/api/tooltip/tooltip-info.service.ts @@ -0,0 +1,134 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; + +@Injectable() +export class TooltipInfoService { + readonly isTooltipVisible = signal(false); + + readonly isHintPhotoVisible = signal(false); + readonly isHintCityVisible = signal(false); + readonly isHintEducationVisible = signal(false); + readonly isHintEducationDescriptionVisible = signal(false); + readonly isHintWorkVisible = signal(false); + readonly isHintWorkNameVisible = signal(false); + readonly isHintWorkDescriptionVisible = signal(false); + readonly isHintAchievementsVisible = signal(false); + readonly isHintLanguageVisible = signal(false); + readonly isHintAuthVisible = signal(false); + readonly isHintLibVisible = signal(false); + + /** Показать подсказку */ + showTooltip( + type: + | "base" + | "photo" + | "city" + | "education" + | "educationDescription" + | "work" + | "workName" + | "workDescription" + | "achievements" + | "language" + | "auth" + | "lib" = "base" + ): void { + switch (type) { + case "photo": + this.isHintPhotoVisible.set(true); + break; + case "city": + this.isHintCityVisible.set(true); + break; + case "education": + this.isHintEducationVisible.set(true); + break; + case "educationDescription": + this.isHintEducationDescriptionVisible.set(true); + break; + case "work": + this.isHintWorkVisible.set(true); + break; + case "workName": + this.isHintWorkNameVisible.set(true); + break; + case "workDescription": + this.isHintWorkDescriptionVisible.set(true); + break; + case "achievements": + this.isHintAchievementsVisible.set(true); + break; + case "language": + this.isHintLanguageVisible.set(true); + break; + case "auth": + this.isHintAuthVisible.set(true); + break; + case "lib": + this.isHintLibVisible.set(true); + break; + + default: + this.isTooltipVisible.set(true); + break; + } + } + + /** Скрыть подсказку */ + hideTooltip( + type: + | "base" + | "photo" + | "city" + | "education" + | "educationDescription" + | "work" + | "workName" + | "workDescription" + | "achievements" + | "language" + | "auth" + | "lib" = "base" + ): void { + switch (type) { + case "photo": + this.isHintPhotoVisible.set(false); + break; + case "city": + this.isHintCityVisible.set(false); + break; + case "education": + this.isHintEducationVisible.set(false); + break; + case "educationDescription": + this.isHintEducationDescriptionVisible.set(false); + break; + case "work": + this.isHintWorkVisible.set(false); + break; + case "workName": + this.isHintWorkNameVisible.set(false); + break; + case "workDescription": + this.isHintWorkDescriptionVisible.set(false); + break; + case "achievements": + this.isHintAchievementsVisible.set(false); + break; + case "language": + this.isHintLanguageVisible.set(false); + break; + case "auth": + this.isHintAuthVisible.set(false); + break; + case "lib": + this.isHintLibVisible.set(false); + break; + + default: + this.isTooltipVisible.set(false); + break; + } + } +} diff --git a/projects/social_platform/src/app/api/vacancy/facades/ui/vacancy-detail-ui-info.service.ts b/projects/social_platform/src/app/api/vacancy/facades/ui/vacancy-detail-ui-info.service.ts new file mode 100644 index 000000000..6dfc16eb1 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/facades/ui/vacancy-detail-ui-info.service.ts @@ -0,0 +1,47 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Params } from "@angular/router"; +import { Vacancy } from "projects/social_platform/src/app/domain/vacancy/vacancy.model"; + +@Injectable() +export class VacancyDetailUIInfoService { + private readonly fb = inject(FormBuilder); + + readonly vacancy = signal(undefined); + + readonly openModal = signal(false); + readonly resultModal = signal(false); + readonly sendFormIsSubmitting = signal(false); + + // Создание формы отклика с валидацией + readonly sendForm = this.fb.group({ + whyMe: ["", [Validators.required, Validators.minLength(20), Validators.maxLength(2000)]], + accompanyingFile: ["", Validators.required], + }); + + applySetVacancies(vacancy: Vacancy): void { + this.vacancy.set(vacancy); + } + + applyNoResponseOpenModal(data: Params): void { + if (data["sendResponse"]) { + this.openModal.set(true); + } + } + + applySubmitVacancyResponse(): void { + this.sendFormIsSubmitting.set(false); + this.resultModal.set(true); + this.applyNoResponseCloseModal(); + } + + applyErrorFormSubmit(): void { + this.sendFormIsSubmitting.set(false); + } + + applyNoResponseCloseModal(): void { + this.openModal.set(false); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/facades/ui/vacancy-ui-info.service.ts b/projects/social_platform/src/app/api/vacancy/facades/ui/vacancy-ui-info.service.ts new file mode 100644 index 000000000..863cc1d26 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/facades/ui/vacancy-ui-info.service.ts @@ -0,0 +1,107 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { VacancyResponse } from "projects/social_platform/src/app/domain/vacancy/vacancy-response.model"; +import { Vacancy } from "projects/social_platform/src/app/domain/vacancy/vacancy.model"; + +@Injectable() +export class VacancyUIInfoService { + private readonly fb = inject(FormBuilder); + // Переменная определяющая тип страницы для списка данных и пагинации + + readonly listType = signal<"all" | "my" | null>(null); + readonly totalItemsCount = signal(0); + readonly vacancyPage = signal(1); + readonly perFetchTake = signal(20); + + // Переменные для работы с фильтрами + + readonly requiredExperience = signal(undefined); + readonly roleContains = signal(undefined); + readonly workFormat = signal(undefined); + readonly workSchedule = signal(undefined); + readonly salary = signal(undefined); + + // Переменные для работы с модалкой + + readonly isMyModal = signal(false); + + // Переменные для списка вакансий и пагинации + + readonly vacancyList = signal([]); + readonly responsesList = signal([]); + + readonly searchForm = this.fb.group({ + search: [""], + }); + + applyQueryParams(result: ApiPagination | ApiPagination): void { + this.applySetTotalItems(result); + this.vacancyPage.set(1); + } + + applyVacancyListData(result: ApiPagination | ApiPagination): void { + if (!result || !Array.isArray(result.results)) { + return; + } + + if (this.listType() === "all") { + this.vacancyList.set(result.results as Vacancy[]); + } else if (this.listType() === "my") { + this.responsesList.set(result.results as VacancyResponse[]); + } + } + + applySetTotalItems(vacancy: ApiPagination | ApiPagination): void { + if (!vacancy || typeof vacancy.count !== "number") { + this.totalItemsCount.set(0); + return; + } + + this.totalItemsCount.set(vacancy.count); + } + + applyUpdateListOnScroll(result: any): void { + this.vacancyPage.update(page => page + 1); + this.vacancyList.update(items => [...items, ...result.results]); + } + + applySearhValueChanged(searchValue: string) { + this.searchForm.get("search")?.setValue(searchValue); + } + + // myVacanciesPage Modal Section + // ------------------- + + myModalSetup() { + if (this.listType() === "my" && this.responsesList().length === 0) { + this.isMyModal.set(true); + } else { + this.isMyModal.set(false); + } + } + + setFilters( + requiredExperience: any, + roleContains: any, + workFormat: any, + workSchedule: any, + salary: any + ): void { + this.requiredExperience.set(requiredExperience); + this.roleContains.set(roleContains); + this.workFormat.set(workFormat); + this.workSchedule.set(workSchedule); + this.salary.set(salary); + } + + resetFilters(): void { + this.requiredExperience.set(undefined); + this.roleContains.set(undefined); + this.workFormat.set(undefined); + this.workSchedule.set(undefined); + this.salary.set(undefined); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/facades/vacancy-detail-info.service.ts b/projects/social_platform/src/app/api/vacancy/facades/vacancy-detail-info.service.ts new file mode 100644 index 000000000..e54d28b64 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/facades/vacancy-detail-info.service.ts @@ -0,0 +1,93 @@ +/** @format */ + +import { ElementRef, inject, Injectable } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { filter, map, Subject, takeUntil } from "rxjs"; +import { ValidationService } from "@corelib"; +import { VacancyService } from "../vacancy.service"; +import { VacancyDetailUIInfoService } from "./ui/vacancy-detail-ui-info.service"; +import { ExpandService } from "../../expand/expand.service"; + +@Injectable() +export class VacancyDetailInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly vacancyService = inject(VacancyService); + private readonly vacancyDetailUIInfoService = inject(VacancyDetailUIInfoService); + private readonly validationService = inject(ValidationService); + private readonly expandService = inject(ExpandService); + + private readonly destroy$ = new Subject(); + + private readonly vacancy = this.vacancyDetailUIInfoService.vacancy; + private readonly sendForm = this.vacancyDetailUIInfoService.sendForm; + private readonly sendFormIsSubmitting = this.vacancyDetailUIInfoService.sendFormIsSubmitting; + + initializeDetailInfo(): void { + this.route.data + .pipe( + map(r => r["data"]), + filter(Boolean), + takeUntil(this.destroy$) + ) + .subscribe(vacancy => { + this.vacancyDetailUIInfoService.applySetVacancies(vacancy); + }); + } + + initializeDetailInfoQueryParams(): void { + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe({ + next: r => { + this.vacancyDetailUIInfoService.applyNoResponseOpenModal(r); + }, + }); + } + + initCheckDescription(descEl?: ElementRef): void { + setTimeout(() => { + this.expandService.checkExpandable("description", !!this.vacancy()?.description, descEl); + }, 150); + } + + initCheckSkills(descEl?: ElementRef): void { + setTimeout(() => { + this.expandService.checkExpandable("skills", !!this.vacancy()?.requiredSkills.length, descEl); + }, 150); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + submitVacancyResponse(): void { + if (!this.validationService.getFormValidation(this.sendForm)) { + return; + } + + this.sendFormIsSubmitting.set(true); + + this.vacancyService + .sendResponse( + Number(this.route.snapshot.paramMap.get("vacancyId")), + this.sendForm.value as any + ) + .subscribe({ + next: () => { + this.vacancyDetailUIInfoService.applySubmitVacancyResponse(); + }, + error: () => { + this.vacancyDetailUIInfoService.applyErrorFormSubmit(); + }, + }); + } + + closeSendResponseModal(): void { + this.vacancyDetailUIInfoService.applyNoResponseCloseModal(); + + this.router.navigate([], { + queryParams: {}, + replaceUrl: true, + }); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/facades/vacancy-info.service.ts b/projects/social_platform/src/app/api/vacancy/facades/vacancy-info.service.ts new file mode 100644 index 000000000..f23c4982b --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/facades/vacancy-info.service.ts @@ -0,0 +1,227 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormGroup } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + concatMap, + debounceTime, + distinctUntilChanged, + EMPTY, + fromEvent, + map, + Observable, + Subject, + switchMap, + takeUntil, + tap, + throttleTime, +} from "rxjs"; +import { VacancyResponse } from "../../../domain/vacancy/vacancy-response.model"; +import { Vacancy } from "../../../domain/vacancy/vacancy.model"; +import { ApiPagination } from "../../../domain/other/api-pagination.model"; +import { VacancyService } from "../vacancy.service"; +import { VacancyUIInfoService } from "./ui/vacancy-ui-info.service"; + +@Injectable() +export class VacancyInfoService { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly vacancyService = inject(VacancyService); + private readonly vacancyUIInfoService = inject(VacancyUIInfoService); + + private readonly destroy$ = new Subject(); + + readonly listType = this.vacancyUIInfoService.listType; + readonly vacancyList = this.vacancyUIInfoService.vacancyList; + readonly responsesList = this.vacancyUIInfoService.responsesList; + + // Переменные для работы с фильтрами + + readonly requiredExperience = this.vacancyUIInfoService.requiredExperience; + readonly roleContains = this.vacancyUIInfoService.roleContains; + readonly workFormat = this.vacancyUIInfoService.workFormat; + readonly workSchedule = this.vacancyUIInfoService.workSchedule; + readonly salary = this.vacancyUIInfoService.salary; + + // Search Section + // -------------- + + onSearchSubmit(searchValue?: string | null): void { + this.router.navigate([], { + queryParams: { role_contains: searchValue || null }, + queryParamsHandling: "merge", + relativeTo: this.route, + }); + } + + initializationSearchValueForm(): void { + this.vacancyUIInfoService.searchForm + .get("search") + ?.valueChanges.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(value => this.onSearchSubmit(value)); + } + + // Initialization + // -------------- + + init(): void { + this.initializeListType(); + this.initializeListData(); + + if (this.listType() === "all") { + this.initializeQueryParams(); + } + + if (this.listType() === "my") { + this.vacancyUIInfoService.resetFilters(); + this.clearQueryParams(); + } + + this.vacancyUIInfoService.myModalSetup(); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // ListType Section + // ---------------- + + initializeListType(): void { + const segment = this.router.url.split("/").pop()?.split("?")[0]; + this.listType.set(segment as "all" | "my"); + } + + // ListItems Section + // ----------------- + + initializeListData(): void { + const routeData$ = + this.listType() === "all" + ? this.route.data.pipe(map(r => r["data"])) + : this.route.data.pipe(map(r => r["data"])); + + routeData$ + .pipe(takeUntil(this.destroy$)) + .subscribe((vacancy: ApiPagination | ApiPagination) => { + if (vacancy) { + this.vacancyUIInfoService.applyVacancyListData(vacancy); + this.vacancyUIInfoService.applySetTotalItems(vacancy); + } + }); + } + + // QueryParams Section + // ------------------- + + initializeQueryParams(): void { + this.route.queryParams + .pipe( + debounceTime(200), + takeUntil(this.destroy$), + tap(params => { + const requiredExperience = params["required_experience"] + ? params["required_experience"] + : undefined; + + const roleContains = params["role_contains"] || undefined; + const workFormat = params["work_format"] ? params["work_format"] : undefined; + const workSchedule = params["work_schedule"] ? params["work_schedule"] : undefined; + const salary = params["salary"] ? params["salary"] : undefined; + + this.vacancyUIInfoService.setFilters( + requiredExperience, + roleContains, + workFormat, + workSchedule, + salary + ); + }), + switchMap(() => this.onFetch(0, 20)) + ) + .subscribe((result: any) => { + this.vacancyUIInfoService.applyVacancyListData(result); + this.vacancyUIInfoService.applyQueryParams(result); + }); + } + + // onScroll Section + // ------------------- + + onScroll(target: HTMLElement): Observable { + if ( + this.vacancyUIInfoService.totalItemsCount() && + this.vacancyList().length >= this.vacancyUIInfoService.totalItemsCount() + ) + return EMPTY; + + if (!target) return EMPTY; + + const diff = target.scrollTop - target.scrollHeight + target.clientHeight; + + if (diff > 0) { + return this.onFetch( + this.vacancyUIInfoService.vacancyPage() * this.vacancyUIInfoService.perFetchTake(), + this.vacancyUIInfoService.perFetchTake() + ).pipe( + tap((result: any) => { + this.vacancyUIInfoService.applyUpdateListOnScroll(result); + }) + ); + } + + return EMPTY; + } + + // target for Scroll Section + // ------------------- + + initScroll(target: HTMLElement): void { + if (target) { + fromEvent(target, "scroll") + .pipe( + throttleTime(500), + concatMap(() => this.onScroll(target)), + takeUntil(this.destroy$) + ) + .subscribe(); + } + } + + // QueryParams Section + // ------------------- + + onFetch(offset: number, limit: number) { + return this.vacancyService + .getForProject( + limit, + offset, + undefined, + this.requiredExperience(), + this.workFormat(), + this.workSchedule(), + this.salary(), + this.roleContains() + ) + .pipe(map(res => res)); + } + + // other methods Section + // ------------------- + + private clearQueryParams(): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + role_contains: null, + required_experience: null, + work_format: null, + work_schedule: null, + salary: null, + }, + queryParamsHandling: "merge", + }); + } +} diff --git a/projects/social_platform/src/app/office/services/vacancy.service.spec.ts b/projects/social_platform/src/app/api/vacancy/vacancy.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/services/vacancy.service.spec.ts rename to projects/social_platform/src/app/api/vacancy/vacancy.service.spec.ts diff --git a/projects/social_platform/src/app/office/services/vacancy.service.ts b/projects/social_platform/src/app/api/vacancy/vacancy.service.ts similarity index 97% rename from projects/social_platform/src/app/office/services/vacancy.service.ts rename to projects/social_platform/src/app/api/vacancy/vacancy.service.ts index fa2fcde51..808f381a9 100644 --- a/projects/social_platform/src/app/office/services/vacancy.service.ts +++ b/projects/social_platform/src/app/api/vacancy/vacancy.service.ts @@ -3,9 +3,9 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; import { map, Observable } from "rxjs"; -import { Vacancy } from "@models/vacancy.model"; +import { Vacancy } from "projects/social_platform/src/app/domain/vacancy/vacancy.model"; import { plainToInstance } from "class-transformer"; -import { VacancyResponse } from "@models/vacancy-response.model"; +import { VacancyResponse } from "projects/social_platform/src/app/domain/vacancy/vacancy-response.model"; import { HttpParams } from "@angular/common/http"; /** diff --git a/projects/social_platform/src/app/app.component.spec.ts b/projects/social_platform/src/app/app.component.spec.ts index 51502f6f2..f45750701 100644 --- a/projects/social_platform/src/app/app.component.spec.ts +++ b/projects/social_platform/src/app/app.component.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { AppComponent } from "./app.component"; -import { AuthService } from "@auth/services"; +import { AuthService } from "./api/auth"; describe("AppComponent", () => { beforeEach(async () => { diff --git a/projects/social_platform/src/app/app.component.ts b/projects/social_platform/src/app/app.component.ts index 861f9e768..b78cc0fed 100644 --- a/projects/social_platform/src/app/app.component.ts +++ b/projects/social_platform/src/app/app.component.ts @@ -2,7 +2,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ResolveEnd, ResolveStart, Router, RouterOutlet } from "@angular/router"; -import { AuthService } from "@auth/services"; import { debounceTime, filter, @@ -18,7 +17,8 @@ import { import { MatProgressBarModule } from "@angular/material/progress-bar"; import { AsyncPipe, NgIf } from "@angular/common"; import { TokenService } from "@corelib"; -import { LoadingService } from "@office/services/loading.service"; +import { LoadingService } from "@ui/services/loading/loading.service"; +import { AuthService } from "./api/auth"; /** * Корневой компонент приложения diff --git a/projects/social_platform/src/app/app.config.ts b/projects/social_platform/src/app/app.config.ts index 9679cff73..03decebe6 100644 --- a/projects/social_platform/src/app/app.config.ts +++ b/projects/social_platform/src/app/app.config.ts @@ -6,7 +6,7 @@ import { provideAnimations } from "@angular/platform-browser/animations"; import { NgxMaskModule } from "ngx-mask"; import { ReactiveFormsModule } from "@angular/forms"; import { BrowserModule } from "@angular/platform-browser"; -import { GlobalErrorHandlerService } from "@error/services/global-error-handler.service"; +import { GlobalErrorHandlerService } from "projects/core/src/lib/services/error/global-error-handler.service"; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; import { provideRouter, withRouterConfig } from "@angular/router"; import { APP_ROUTES } from "./app.routes"; diff --git a/projects/social_platform/src/app/app.routes.ts b/projects/social_platform/src/app/app.routes.ts index fc1b1aa35..683d23e15 100644 --- a/projects/social_platform/src/app/app.routes.ts +++ b/projects/social_platform/src/app/app.routes.ts @@ -2,7 +2,7 @@ import { Routes } from "@angular/router"; import { AppComponent } from "./app.component"; -import { AuthRequiredGuard } from "@auth/guards/auth-required.guard"; +import { AuthRequiredGuard } from "projects/core/src/lib/guards/auth/auth-required.guard"; /** * Основные маршруты приложения @@ -21,15 +21,15 @@ export const APP_ROUTES: Routes = [ }, { path: "auth", - loadChildren: () => import("./auth/auth.routes").then(c => c.AUTH_ROUTES), + loadChildren: () => import("./ui/routes/auth/auth.routes").then(c => c.AUTH_ROUTES), }, { path: "error", - loadChildren: () => import("./error/error.routes").then(c => c.ERROR_ROUTES), + loadChildren: () => import("./ui/routes/error/error.routes").then(c => c.ERROR_ROUTES), }, { path: "office", - loadChildren: () => import("./office/office.routes").then(c => c.OFFICE_ROUTES), + loadChildren: () => import("./ui/routes/office/office.routes").then(c => c.OFFICE_ROUTES), canActivate: [AuthRequiredGuard], }, { diff --git a/projects/social_platform/src/app/core/README.md b/projects/social_platform/src/app/core/README.md deleted file mode 100644 index 70a80480a..000000000 --- a/projects/social_platform/src/app/core/README.md +++ /dev/null @@ -1,59 +0,0 @@ - - -# Core Модуль - -Основные сервисы и утилиты, используемые во всем приложении. - -## Сервисы - -### 📁 FileService - -Сервис для работы с файлами - -- Загрузка файлов на сервер -- Скачивание файлов -- Валидация типов и размеров -- Генерация превью - -### 🔌 WebSocketService - -Сервис для работы с WebSocket соединениями - -- Подключение к серверу -- Отправка и получение сообщений -- Автоматическое переподключение -- Обработка ошибок соединения - -## Пайпы - -### 👤 UserRolePipe - -Преобразование роли пользователя в читаемый вид - -### 🔗 UserLinksPipe - -Форматирование ссылок пользователя - -### 📊 FormattedFileSizePipe - -Форматирование размера файла в читаемый вид - -- Автоматический выбор единиц (B, KB, MB, GB) -- Округление до нужного количества знаков - -## Модели - -### 🌐 HttpModel - -Базовые модели для HTTP запросов и ответов - -- Стандартизированные форматы -- Обработка ошибок -- Типизация ответов сервера - -## Принципы - -1. **Переиспользование**: Все сервисы могут использоваться в любом модуле -2. **Типизация**: Строгая типизация всех данных -3. **Обработка ошибок**: Централизованная обработка ошибок -4. **Производительность**: Оптимизированные алгоритмы для работы с данными diff --git a/projects/social_platform/src/app/auth/models/http.model.ts b/projects/social_platform/src/app/domain/auth/http.model.ts similarity index 100% rename from projects/social_platform/src/app/auth/models/http.model.ts rename to projects/social_platform/src/app/domain/auth/http.model.ts diff --git a/projects/social_platform/src/app/auth/models/password-errors.model.ts b/projects/social_platform/src/app/domain/auth/password-errors.model.ts similarity index 100% rename from projects/social_platform/src/app/auth/models/password-errors.model.ts rename to projects/social_platform/src/app/domain/auth/password-errors.model.ts diff --git a/projects/social_platform/src/app/domain/auth/register.model.ts b/projects/social_platform/src/app/domain/auth/register.model.ts new file mode 100644 index 000000000..3039b6d1a --- /dev/null +++ b/projects/social_platform/src/app/domain/auth/register.model.ts @@ -0,0 +1,10 @@ +/** @format */ + +export class RegisterRequest { + firstName!: string; + lastName!: string; + birthday!: string; + email!: string; + password!: string; + repeatedPassword!: string; +} diff --git a/projects/social_platform/src/app/auth/models/tokens.model.ts b/projects/social_platform/src/app/domain/auth/tokens.model.ts similarity index 100% rename from projects/social_platform/src/app/auth/models/tokens.model.ts rename to projects/social_platform/src/app/domain/auth/tokens.model.ts diff --git a/projects/social_platform/src/app/auth/models/user.model.ts b/projects/social_platform/src/app/domain/auth/user.model.ts similarity index 95% rename from projects/social_platform/src/app/auth/models/user.model.ts rename to projects/social_platform/src/app/domain/auth/user.model.ts index 402ca5dea..f708b3e62 100644 --- a/projects/social_platform/src/app/auth/models/user.model.ts +++ b/projects/social_platform/src/app/domain/auth/user.model.ts @@ -1,9 +1,9 @@ /** @format */ -import { Project } from "@models/project.model"; -import { FileModel } from "@office/models/file.model"; -import { Skill } from "@office/models/skill"; -import { Program } from "@office/program/models/program.model"; +import { FileModel } from "../file/file.model"; +import { Program } from "../program/program.model"; +import { Project } from "../project/project.model"; +import { Skill } from "../skills/skill"; /** * Модели данных пользователя и связанных сущностей diff --git a/projects/social_platform/src/app/office/chat/models/chat-item.model.ts b/projects/social_platform/src/app/domain/chat/chat-item.model.ts similarity index 91% rename from projects/social_platform/src/app/office/chat/models/chat-item.model.ts rename to projects/social_platform/src/app/domain/chat/chat-item.model.ts index e794fde1b..6a52d34ad 100644 --- a/projects/social_platform/src/app/office/chat/models/chat-item.model.ts +++ b/projects/social_platform/src/app/domain/chat/chat-item.model.ts @@ -1,7 +1,7 @@ /** @format */ -import { User } from "@auth/models/user.model"; -import { ChatMessage } from "@models/chat-message.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { ChatMessage } from "projects/social_platform/src/app/domain/chat/chat-message.model"; /** * Модели данных для элементов чата diff --git a/projects/social_platform/src/app/office/models/chat-message.model.ts b/projects/social_platform/src/app/domain/chat/chat-message.model.ts similarity index 97% rename from projects/social_platform/src/app/office/models/chat-message.model.ts rename to projects/social_platform/src/app/domain/chat/chat-message.model.ts index 5d54fdccf..1cbbe87d8 100644 --- a/projects/social_platform/src/app/office/models/chat-message.model.ts +++ b/projects/social_platform/src/app/domain/chat/chat-message.model.ts @@ -1,6 +1,6 @@ /** @format */ -import { User } from "@auth/models/user.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; import * as dayjs from "dayjs"; /** diff --git a/projects/social_platform/src/app/office/models/chat.model.ts b/projects/social_platform/src/app/domain/chat/chat.model.ts similarity index 97% rename from projects/social_platform/src/app/office/models/chat.model.ts rename to projects/social_platform/src/app/domain/chat/chat.model.ts index 454d815cd..55eaa7d06 100644 --- a/projects/social_platform/src/app/office/models/chat.model.ts +++ b/projects/social_platform/src/app/domain/chat/chat.model.ts @@ -1,5 +1,5 @@ /** @format */ -import type { ChatMessage } from "@models/chat-message.model"; +import type { ChatMessage } from "projects/social_platform/src/app/domain/chat/chat-message.model"; /** * Класс для уведомления об изменении статуса пользователя diff --git a/projects/social_platform/src/app/office/feed/models/feed-item.model.ts b/projects/social_platform/src/app/domain/feed/feed-item.model.ts similarity index 93% rename from projects/social_platform/src/app/office/feed/models/feed-item.model.ts rename to projects/social_platform/src/app/domain/feed/feed-item.model.ts index 14dd6cbeb..698fce747 100644 --- a/projects/social_platform/src/app/office/feed/models/feed-item.model.ts +++ b/projects/social_platform/src/app/domain/feed/feed-item.model.ts @@ -1,8 +1,8 @@ /** @format */ -import { FeedNews } from "@office/projects/models/project-news.model"; -import { Vacancy } from "@models/vacancy.model"; -import { Program } from "@office/program/models/program.model"; +import { Program } from "../program/program.model"; +import { FeedNews } from "../project/project-news.model"; +import { Vacancy } from "../vacancy/vacancy.model"; /** * МОДЕЛИ ДАННЫХ ДЛЯ ЭЛЕМЕНТОВ ЛЕНТЫ diff --git a/projects/social_platform/src/app/office/models/file.model.ts b/projects/social_platform/src/app/domain/file/file.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/file.model.ts rename to projects/social_platform/src/app/domain/file/file.model.ts diff --git a/projects/social_platform/src/app/office/models/industry.model.ts b/projects/social_platform/src/app/domain/industry/industry.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/industry.model.ts rename to projects/social_platform/src/app/domain/industry/industry.model.ts diff --git a/projects/social_platform/src/app/office/models/invite.model.ts b/projects/social_platform/src/app/domain/invite/invite.model.ts similarity index 85% rename from projects/social_platform/src/app/office/models/invite.model.ts rename to projects/social_platform/src/app/domain/invite/invite.model.ts index ddcd998c5..58332c560 100644 --- a/projects/social_platform/src/app/office/models/invite.model.ts +++ b/projects/social_platform/src/app/domain/invite/invite.model.ts @@ -1,7 +1,7 @@ /** @format */ -import { User } from "@auth/models/user.model"; -import { Project } from "./project.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { Project } from "../project/project.model"; /** * Модель приглашения в проект diff --git a/projects/social_platform/src/app/domain/kanban/board.model.ts b/projects/social_platform/src/app/domain/kanban/board.model.ts new file mode 100644 index 000000000..97eac0b4a --- /dev/null +++ b/projects/social_platform/src/app/domain/kanban/board.model.ts @@ -0,0 +1,34 @@ +/** @format */ + +export interface Board { + id: number; + name: string; + description: string; + color: + | "primary" + | "secondary" + | "accent" + | "accent-medium" + | "blue-dark" + | "cyan" + | "red" + | "complete" + | "complete-dark" + | "soft"; + icon: + | "task" + | "key" + | "command" + | "anchor" + | "in-search" + | "suitcase" + | "person" + | "deadline" + | "main" + | "attach" + | "send" + | "contacts" + | "graph" + | "phone" + | "people-bold"; +} diff --git a/projects/social_platform/src/app/domain/kanban/column.model.ts b/projects/social_platform/src/app/domain/kanban/column.model.ts new file mode 100644 index 000000000..d100e87d1 --- /dev/null +++ b/projects/social_platform/src/app/domain/kanban/column.model.ts @@ -0,0 +1,12 @@ +/** @format */ + +import { TaskPreview } from "./task.model"; + +export interface Column { + id: number; + name: string; + order: number; + tasks: TaskPreview[]; + datetimeCreated: Date; + datetimeUpdated: Date; +} diff --git a/projects/social_platform/src/app/domain/kanban/comments.model.ts b/projects/social_platform/src/app/domain/kanban/comments.model.ts new file mode 100644 index 000000000..1c8bba4ec --- /dev/null +++ b/projects/social_platform/src/app/domain/kanban/comments.model.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { FileModel } from "projects/social_platform/src/app/domain/file/file.model"; +import { TaskDetail } from "./task.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; + +export interface Comment { + id: number; + taskId: TaskDetail["id"]; + text: string; + file: FileModel; + user: { + id: User["id"]; + firstName: User["firstName"]; + lastName: User["lastName"]; + avatar: User["avatar"]; + role: User["speciality"]; + dateTimeCreated: string; + }; +} diff --git a/projects/social_platform/src/app/domain/kanban/tag.model.ts b/projects/social_platform/src/app/domain/kanban/tag.model.ts new file mode 100644 index 000000000..6e243daa3 --- /dev/null +++ b/projects/social_platform/src/app/domain/kanban/tag.model.ts @@ -0,0 +1,17 @@ +/** @format */ + +export class Tag { + id!: number; + name!: string; + color!: + | "primary" + | "secondary" + | "accent" + | "accent-medium" + | "blue-dark" + | "cyan" + | "red" + | "complete" + | "complete-dark" + | "soft"; +} diff --git a/projects/social_platform/src/app/domain/kanban/task.model.ts b/projects/social_platform/src/app/domain/kanban/task.model.ts new file mode 100644 index 000000000..0a8f8bd4a --- /dev/null +++ b/projects/social_platform/src/app/domain/kanban/task.model.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { FileModel } from "projects/social_platform/src/app/domain/file/file.model"; +import { Column } from "./column.model"; +import { Goal } from "../project/goals.model"; +import { Tag } from "./tag.model"; +import { Skill } from "../skills/skill"; + +export interface TaskResult { + description: string; + accompanyingFile: FileModel | null; + isVerified: boolean; + whoVerified: Pick; +} + +export interface TaskPreview { + id: number; + columnId: Column["id"]; + title: string; + type: number; + priority: number; + description: string | null; + startDate: string | null; + deadlineDate: string | null; + tags: Tag[]; + goal: Pick | null; + files: FileModel[]; + responsible: Pick | null; + performers: Pick[] | null; +} + +export interface TaskDetail extends TaskPreview { + score: number; + creator: Pick; + datetimeCreated: string; + datetimeTaskStart: string; + requiredSkills: Skill[]; + isLeaderLeaveComment: boolean; + projectGoal: Pick | null; + result: TaskResult | null; +} diff --git a/projects/social_platform/src/app/office/models/article.model.ts b/projects/social_platform/src/app/domain/news/article.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/article.model.ts rename to projects/social_platform/src/app/domain/news/article.model.ts diff --git a/projects/social_platform/src/app/office/models/api-pagination.model.ts b/projects/social_platform/src/app/domain/other/api-pagination.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/api-pagination.model.ts rename to projects/social_platform/src/app/domain/other/api-pagination.model.ts diff --git a/projects/social_platform/src/app/office/models/filter-fields.model.ts b/projects/social_platform/src/app/domain/other/filter-fields.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/filter-fields.model.ts rename to projects/social_platform/src/app/domain/other/filter-fields.model.ts diff --git a/projects/social_platform/src/app/office/models/notification.model.ts b/projects/social_platform/src/app/domain/other/notification.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/notification.model.ts rename to projects/social_platform/src/app/domain/other/notification.model.ts diff --git a/projects/social_platform/src/app/office/profile/detail/models/profile-news.model.ts b/projects/social_platform/src/app/domain/profile/profile-news.model.ts similarity index 96% rename from projects/social_platform/src/app/office/profile/detail/models/profile-news.model.ts rename to projects/social_platform/src/app/domain/profile/profile-news.model.ts index 3c78c1070..123dc0791 100644 --- a/projects/social_platform/src/app/office/profile/detail/models/profile-news.model.ts +++ b/projects/social_platform/src/app/domain/profile/profile-news.model.ts @@ -1,7 +1,7 @@ /** @format */ import * as dayjs from "dayjs"; -import { FileModel } from "@models/file.model"; +import { FileModel } from "projects/social_platform/src/app/domain/file/file.model"; /** * Модель данных для новости профиля пользователя diff --git a/projects/social_platform/src/app/office/models/partner-program-fields.model.ts b/projects/social_platform/src/app/domain/program/partner-program-fields.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/partner-program-fields.model.ts rename to projects/social_platform/src/app/domain/program/partner-program-fields.model.ts diff --git a/projects/social_platform/src/app/office/program/models/program-create.model.ts b/projects/social_platform/src/app/domain/program/program-create.model.ts similarity index 100% rename from projects/social_platform/src/app/office/program/models/program-create.model.ts rename to projects/social_platform/src/app/domain/program/program-create.model.ts diff --git a/projects/social_platform/src/app/office/program/models/program.model.ts b/projects/social_platform/src/app/domain/program/program.model.ts similarity index 100% rename from projects/social_platform/src/app/office/program/models/program.model.ts rename to projects/social_platform/src/app/domain/program/program.model.ts diff --git a/projects/social_platform/src/app/office/program/models/programs-result.model.ts b/projects/social_platform/src/app/domain/program/programs-result.model.ts similarity index 100% rename from projects/social_platform/src/app/office/program/models/programs-result.model.ts rename to projects/social_platform/src/app/domain/program/programs-result.model.ts diff --git a/projects/social_platform/src/app/office/models/collaborator.model.ts b/projects/social_platform/src/app/domain/project/collaborator.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/collaborator.model.ts rename to projects/social_platform/src/app/domain/project/collaborator.model.ts diff --git a/projects/social_platform/src/app/office/models/goals.model.ts b/projects/social_platform/src/app/domain/project/goals.model.ts similarity index 96% rename from projects/social_platform/src/app/office/models/goals.model.ts rename to projects/social_platform/src/app/domain/project/goals.model.ts index 7a4aac24f..780620115 100644 --- a/projects/social_platform/src/app/office/models/goals.model.ts +++ b/projects/social_platform/src/app/domain/project/goals.model.ts @@ -17,7 +17,7 @@ class ResponsibleInfo { avatar!: string | null; } -export class GoalPostForm { +export class GoalDto { id?: number; title!: string; completionDate!: string; diff --git a/projects/social_platform/src/app/office/models/partner.model.ts b/projects/social_platform/src/app/domain/project/partner.model.ts similarity index 50% rename from projects/social_platform/src/app/office/models/partner.model.ts rename to projects/social_platform/src/app/domain/project/partner.model.ts index c15980716..b32344f8e 100644 --- a/projects/social_platform/src/app/office/models/partner.model.ts +++ b/projects/social_platform/src/app/domain/project/partner.model.ts @@ -6,15 +6,15 @@ interface Company { inn: string; } -export interface Partner { - id: number; - projecId: number; - company: Company; - contribution: string; - decisionMaker: number; +export class Partner { + id!: number; + projecId!: number; + company!: Company; + contribution!: string; + decisionMaker!: number; } -export interface PartnerPostForm { +export interface PartnerDto { name: string; inn: string; contribution: string; diff --git a/projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts b/projects/social_platform/src/app/domain/project/project-additional-fields.model.ts similarity index 67% rename from projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts rename to projects/social_platform/src/app/domain/project/project-additional-fields.model.ts index 51d13c4b2..016008b33 100644 --- a/projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts +++ b/projects/social_platform/src/app/domain/project/project-additional-fields.model.ts @@ -1,6 +1,6 @@ /** @format */ -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; +import { PartnerProgramFields } from "../program/partner-program-fields.model"; export class ProjectAdditionalFields { programId!: number; diff --git a/projects/social_platform/src/app/office/projects/models/project-assign.model.ts b/projects/social_platform/src/app/domain/project/project-assign.model.ts similarity index 100% rename from projects/social_platform/src/app/office/projects/models/project-assign.model.ts rename to projects/social_platform/src/app/domain/project/project-assign.model.ts diff --git a/projects/social_platform/src/app/office/projects/models/project-news.model.ts b/projects/social_platform/src/app/domain/project/project-news.model.ts similarity index 96% rename from projects/social_platform/src/app/office/projects/models/project-news.model.ts rename to projects/social_platform/src/app/domain/project/project-news.model.ts index 12a069c57..155180525 100644 --- a/projects/social_platform/src/app/office/projects/models/project-news.model.ts +++ b/projects/social_platform/src/app/domain/project/project-news.model.ts @@ -1,7 +1,7 @@ /** @format */ import * as dayjs from "dayjs"; -import { FileModel } from "@models/file.model"; +import { FileModel } from "projects/social_platform/src/app/domain/file/file.model"; /** * Модель для новостей проекта (FeedNews) diff --git a/projects/social_platform/src/app/office/program/models/project-rate.ts b/projects/social_platform/src/app/domain/project/project-rate.ts similarity index 100% rename from projects/social_platform/src/app/office/program/models/project-rate.ts rename to projects/social_platform/src/app/domain/project/project-rate.ts diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts b/projects/social_platform/src/app/domain/project/project-rating-criterion-output.ts similarity index 100% rename from projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts rename to projects/social_platform/src/app/domain/project/project-rating-criterion-output.ts diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts b/projects/social_platform/src/app/domain/project/project-rating-criterion-type.ts similarity index 100% rename from projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts rename to projects/social_platform/src/app/domain/project/project-rating-criterion-type.ts diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts b/projects/social_platform/src/app/domain/project/project-rating-criterion.ts similarity index 100% rename from projects/social_platform/src/app/office/program/models/project-rating-criterion.ts rename to projects/social_platform/src/app/domain/project/project-rating-criterion.ts diff --git a/projects/social_platform/src/app/office/models/project-subscriber.model.ts b/projects/social_platform/src/app/domain/project/project-subscriber.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/project-subscriber.model.ts rename to projects/social_platform/src/app/domain/project/project-subscriber.model.ts diff --git a/projects/social_platform/src/app/office/models/project.model.ts b/projects/social_platform/src/app/domain/project/project.model.ts similarity index 94% rename from projects/social_platform/src/app/office/models/project.model.ts rename to projects/social_platform/src/app/domain/project/project.model.ts index 2d800125b..d9cf358af 100644 --- a/projects/social_platform/src/app/office/models/project.model.ts +++ b/projects/social_platform/src/app/domain/project/project.model.ts @@ -1,11 +1,14 @@ /** @format */ +import { + PartnerProgramFields, + PartnerProgramFieldsValues, +} from "../program/partner-program-fields.model"; +import { Vacancy } from "../vacancy/vacancy.model"; import { Collaborator } from "./collaborator.model"; import { Goal } from "./goals.model"; -import { PartnerProgramFields, PartnerProgramFieldsValues } from "./partner-program-fields.model"; import { Partner } from "./partner.model"; import { Resource } from "./resource.model"; -import { Vacancy } from "./vacancy.model"; /** * Основная модель проекта и связанные классы diff --git a/projects/social_platform/src/app/office/models/resource.model.ts b/projects/social_platform/src/app/domain/project/resource.model.ts similarity index 88% rename from projects/social_platform/src/app/office/models/resource.model.ts rename to projects/social_platform/src/app/domain/project/resource.model.ts index ca4a5542c..6c735fd4c 100644 --- a/projects/social_platform/src/app/office/models/resource.model.ts +++ b/projects/social_platform/src/app/domain/project/resource.model.ts @@ -8,7 +8,7 @@ export interface Resource { partnerCompany: number; } -export interface ResourcePostForm { +export interface ResourceDto { projectId: number; type: string; description: string; diff --git a/projects/social_platform/src/app/office/models/skill.ts b/projects/social_platform/src/app/domain/skills/skill.ts similarity index 100% rename from projects/social_platform/src/app/office/models/skill.ts rename to projects/social_platform/src/app/domain/skills/skill.ts diff --git a/projects/social_platform/src/app/office/models/skills-group.ts b/projects/social_platform/src/app/domain/skills/skills-group.ts similarity index 100% rename from projects/social_platform/src/app/office/models/skills-group.ts rename to projects/social_platform/src/app/domain/skills/skills-group.ts diff --git a/projects/social_platform/src/app/office/models/specialization.ts b/projects/social_platform/src/app/domain/specializations/specialization.ts similarity index 100% rename from projects/social_platform/src/app/office/models/specialization.ts rename to projects/social_platform/src/app/domain/specializations/specialization.ts diff --git a/projects/social_platform/src/app/office/models/specializations-group.ts b/projects/social_platform/src/app/domain/specializations/specializations-group.ts similarity index 89% rename from projects/social_platform/src/app/office/models/specializations-group.ts rename to projects/social_platform/src/app/domain/specializations/specializations-group.ts index 55149beb9..4c928e221 100644 --- a/projects/social_platform/src/app/office/models/specializations-group.ts +++ b/projects/social_platform/src/app/domain/specializations/specializations-group.ts @@ -1,6 +1,6 @@ /** @format */ -import type { Specialization } from "./specialization"; +import { Specialization } from "./specialization"; /** * Модель группы специализаций diff --git a/projects/social_platform/src/app/office/models/vacancy-response.model.ts b/projects/social_platform/src/app/domain/vacancy/vacancy-response.model.ts similarity index 83% rename from projects/social_platform/src/app/office/models/vacancy-response.model.ts rename to projects/social_platform/src/app/domain/vacancy/vacancy-response.model.ts index 4350a0d4c..813e65578 100644 --- a/projects/social_platform/src/app/office/models/vacancy-response.model.ts +++ b/projects/social_platform/src/app/domain/vacancy/vacancy-response.model.ts @@ -1,7 +1,7 @@ /** @format */ -import { User } from "@auth/models/user.model"; -import { FileModel } from "./file.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { FileModel } from "../file/file.model"; /** * Модель отклика на вакансию diff --git a/projects/social_platform/src/app/office/models/vacancy.model.ts b/projects/social_platform/src/app/domain/vacancy/vacancy.model.ts similarity index 90% rename from projects/social_platform/src/app/office/models/vacancy.model.ts rename to projects/social_platform/src/app/domain/vacancy/vacancy.model.ts index bf4d49a93..f11b63d8b 100644 --- a/projects/social_platform/src/app/office/models/vacancy.model.ts +++ b/projects/social_platform/src/app/domain/vacancy/vacancy.model.ts @@ -1,7 +1,7 @@ /** @format */ -import { Project } from "@models/project.model"; -import { Skill } from "./skill"; +import { Project } from "../project/project.model"; +import { Skill } from "../skills/skill"; /** * Модель вакансии в проекте diff --git a/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.ts b/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.ts index 233151650..6a23525bc 100644 --- a/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.ts +++ b/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.ts @@ -4,8 +4,8 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { IconComponent } from "@uilib"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { Program } from "@office/program/models/program.model"; +import { TruncatePipe } from "projects/core/src/lib/pipes/formatters/truncate.pipe"; +import { Program } from "../../../domain/program/program.model"; @Component({ selector: "app-program-sidebar-card", diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.html b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.html deleted file mode 100644 index 64c953008..000000000 --- a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.html +++ /dev/null @@ -1,147 +0,0 @@ - -
-
-
- -
-
-
-
{{ newsItem.name | truncate: 30 }}
-
- {{ newsItem.datetimeCreated | dayjs: "format":"DD.MM.YY" }} -
-
- @if (newsItem.pin) { - - } -
-
-
- @if(isOwner) { -
-
- -
- @if (menuOpen) { -
    - @if (!editMode) { -
  • - редактировать -
  • - } -
  • Удалить
  • -
- } -
- } -
- @if (newsItem.text) { -
- @if (!editMode) { -

- } @else { @if (editForm.get("text"); as text) { - - } } -
- } @if (editMode) { -
    - @for (f of imagesEditList; track f.id) { - - } -
-
    - @for (f of filesEditList; track f.id) { - - } -
- } @if (newsTextExpandable && !editMode) { -
- {{ readMore ? "скрыть" : "подробнее" }} -
- } @if (!editMode) { - - } @if (!editMode && filesViewList.length) { -
- @for (f of filesViewList; track $index) { - - } -
- } @if (!editMode) { - - } @else { - - } -
diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.scss b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.scss deleted file mode 100644 index 021a8cbc8..000000000 --- a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.scss +++ /dev/null @@ -1,259 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.card { - padding: 24px 12px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - - &__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 10px; - } - - &__menu { - position: relative; - } - - &__dots { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - color: var(--black); - cursor: pointer; - } - - &__options { - position: absolute; - top: 120%; - right: 0%; - z-index: 2; - padding: 20px 0; - background-color: var(--white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &__option { - width: 120px; - padding: 5px 20px; - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background-color: var(--light-gray); - } - } - - &__avatar { - width: 40px; - height: 40px; - margin-right: 10px; - border-radius: 50%; - } - - &__title { - display: flex; - align-items: center; - } - - &__top { - display: flex; - gap: 10px; - align-items: center; - } - - &__name { - color: var(--black); - } - - &__date { - color: var(--dark-grey); - } - - &__views { - display: flex; - gap: 3px; - align-items: center; - color: var(--dark-grey); - - i { - margin-bottom: 1px; - } - } - - /* stylelint-disable value-no-vendor-prefix */ - &__text { - white-space: break-spaces; - - @include typography.body-10; - - p { - display: -webkit-box; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 4; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - } - - /* stylelint-enable value-no-vendor-prefix */ - - &__edit-files { - display: flex; - flex-direction: column; - gap: 10px; - - &:not(:empty) { - margin-top: 30px; - } - } - - &__gallery { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 10px; - margin-bottom: 10px; - } - - &__files { - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 20px; - } - - &__img { - position: relative; - - img { - width: 100%; - object-fit: cover; - } - } - - &__img-like { - position: absolute; - top: 50%; - left: 50%; - display: flex; - align-items: center; - justify-content: center; - width: 75px; - height: 75px; - color: var(--accent); - background-color: var(--white); - border-radius: var(--rounded-xl); - transition: transform 0.1s ease-in-out; - transform: translate(-50%, -50%) scale(0); - - &--show { - transform: translate(-50%, -50%) scale(1); - } - } - - &__footer { - margin-top: 10px; - } - - &__read-more { - margin-bottom: 10px; - } -} - -.footer { - display: flex; - align-items: center; - justify-content: space-between; - - &__left { - display: flex; - gap: 5px; - align-items: center; - } - - &__right { - display: flex; - gap: 5px; - align-items: center; - } - - &__item { - display: flex; - align-items: center; - color: var(--dark-grey); - - &:not(:last-child) { - margin-right: 5px; - } - - i { - margin-right: 3px; - } - } - - &__like { - cursor: pointer; - - &--active { - color: var(--accent); - } - } -} - -.share { - color: var(--dark-grey); - - &__icon { - cursor: pointer; - } -} - -.read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } -} - -.editor-footer { - display: flex; - justify-content: space-between; - padding-top: 10px; - margin-top: 20px; - border-top: 1px solid var(--medium-grey-for-outline); - - &__actions { - display: flex; - - app-button { - display: block; - margin-right: 10px; - } - } - - &__attach { - color: var(--dark-grey); - cursor: pointer; - - input { - display: none; - } - } -} diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.spec.ts b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.spec.ts deleted file mode 100644 index 9dbf5bff7..000000000 --- a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgramNewsCardComponent } from "./news-card.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { of } from "rxjs"; -import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; -import { AuthService } from "@auth/services"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { DayjsPipe } from "projects/core"; -import { FeedNews } from "@office/projects/models/project-news.model"; - -describe("NewsCardComponent", () => { - let component: ProgramNewsCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const projectNewsServiceSpy = jasmine.createSpyObj(["addNews"]); - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - ProgramNewsCardComponent, - DayjsPipe, - ], - providers: [ - { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, - { provide: AuthService, useValue: authSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramNewsCardComponent); - component = fixture.componentInstance; - component.newsItem = FeedNews.default(); - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.ts b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.ts deleted file mode 100644 index 38cefcd68..000000000 --- a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - Input, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { ActivatedRoute } from "@angular/router"; -import { expandElement } from "@utils/expand-element"; -import { FileModel } from "@office/models/file.model"; -import { nanoid } from "nanoid"; -import { FileService } from "@core/services/file.service"; -import { forkJoin, noop, Observable, tap } from "rxjs"; -import { DayjsPipe, FormControlPipe, ParseLinksPipe, ValidationService } from "projects/core"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { FileUploadItemComponent } from "@ui/components/file-upload-item/file-upload-item.component"; -import { ImgCardComponent } from "@office/shared/img-card/img-card.component"; -import { FeedNews } from "@office/projects/models/project-news.model"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { ClickOutsideModule } from "ng-click-outside"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; - -/** - * Компонент карточки новости программы - * Отображает новость с возможностью редактирования, лайков, просмотра файлов - * Поддерживает загрузку и удаление файлов, расширение текста, копирование ссылки - */ -@Component({ - selector: "app-program-news-card", - templateUrl: "./news-card.component.html", - styleUrl: "./news-card.component.scss", - standalone: true, - imports: [ - ImgCardComponent, - FileUploadItemComponent, - IconComponent, - FileItemComponent, - ButtonComponent, - TextareaComponent, - ReactiveFormsModule, - DayjsPipe, - FormControlPipe, - TruncatePipe, - ParseLinksPipe, - ClickOutsideModule, - ], -}) -export class ProgramNewsCardComponent implements OnInit, AfterViewInit { - constructor( - private readonly snackbarService: SnackbarService, - private readonly fileService: FileService, - private readonly route: ActivatedRoute, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly cdRef: ChangeDetectorRef - ) { - // Создание формы редактирования новости - this.editForm = this.fb.group({ - text: ["", [Validators.required]], // Текст новости - обязательное поле - }); - } - - @Input({ required: true }) newsItem!: FeedNews; - @Input() isOwner!: boolean; - @Output() delete = new EventEmitter(); - @Output() like = new EventEmitter(); - @Output() edited = new EventEmitter(); - - newsTextExpandable!: boolean; - readMore = false; - editMode = false; - editForm: FormGroup; - - /** Состояние меню действий */ - menuOpen = false; - - /** - * Закрытие меню действий - */ - onCloseMenu() { - this.menuOpen = false; - } - - // Оригинальные списки (не изменяются во время редактирования) - imagesViewList: FileModel[] = []; - filesViewList: FileModel[] = []; - - // Списки для редактирования - imagesEditList: { - id: string; - src: string; - loading: boolean; - error: boolean; - tempFile: File | null; - }[] = []; - - filesEditList: { - id: string; - src: string; - loading: boolean; - error: string; - name: string; - size: number; - type: string; - tempFile: File | null; - }[] = []; - - @ViewChild("newsTextEl") newsTextEl?: ElementRef; - - ngOnInit(): void { - // Установка текущего текста в форму редактирования - this.editForm.setValue({ - text: this.newsItem.text, - }); - - this.showLikes = this.newsItem.files.map(() => false); - - // Инициализация оригинальных списков - this.imagesViewList = this.newsItem.files.filter( - f => f.mimeType.split("/")[0] === "image" || f.mimeType.split("/")[1] === "x-empty" - ); - this.filesViewList = this.newsItem.files.filter( - f => f.mimeType.split("/")[0] !== "image" && f.mimeType.split("/")[1] !== "x-empty" - ); - - // Инициализация списков редактирования из оригинальных данных - this.initEditLists(); - } - - /** - * Инициализация списков редактирования из текущих данных - */ - private initEditLists(): void { - this.imagesEditList = this.imagesViewList.map(file => ({ - src: file.link, - id: nanoid(), - error: false, - loading: false, - tempFile: null, - })); - - this.filesEditList = this.filesViewList.map(file => ({ - src: file.link, - id: nanoid(), - error: "", - loading: false, - name: file.name, - size: file.size, - type: file.mimeType, - tempFile: null, - })); - } - - ngAfterViewInit(): void { - const newsTextElem = this.newsTextEl?.nativeElement; - this.newsTextExpandable = newsTextElem?.clientHeight < newsTextElem?.scrollHeight; - - this.cdRef.detectChanges(); - } - - onCopyLink(): void { - const programId = this.route.snapshot.params["programId"]; - - navigator.clipboard - .writeText(`https://app.procollab.ru/office/program/${programId}/news/${this.newsItem.id}`) - .then(() => { - this.snackbarService.success("Ссылка скопирована"); - }); - } - - /** - * Отправка отредактированной новости - */ - onEditSubmit(): void { - if (!this.validationService.getFormValidation(this.editForm)) return; - - // Собираем только успешно загруженные файлы - const uploadedImages = this.imagesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => f.src); - - // Обновляем оригинальные списки на основе успешно загруженных файлов - this.imagesViewList = this.imagesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => ({ - link: f.src, - name: "Image", - mimeType: "image/jpeg", - size: 0, - datetimeUploaded: "", - extension: "", - user: 0, - })); - - this.filesViewList = this.filesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => ({ - link: f.src, - name: f.name, - size: f.size, - mimeType: f.type, - datetimeUploaded: "", - extension: "", - user: 0, - })); - - // Обновляем текст в newsItem для отображения - this.newsItem.text = this.editForm.value.text; - - // Обновляем файлы в newsItem - this.newsItem.files = [...this.imagesViewList, ...this.filesViewList]; - - this.edited.emit({ - ...this.editForm.value, - files: uploadedImages, - }); - - this.onCloseEditMode(); - this.cdRef.detectChanges(); - } - - /** - * Закрытие режима редактирования - */ - onCloseEditMode() { - this.editMode = false; - // Восстанавливаем списки редактирования из оригинальных данных - this.initEditLists(); - // Сбрасываем форму к исходному значению - this.editForm.setValue({ - text: this.newsItem.text, - }); - } - - onUploadFile(event: Event) { - const files = (event.currentTarget as HTMLInputElement).files; - if (!files) return; - - const observableArray: Observable[] = []; - - for (let i = 0; i < files.length; i++) { - const fileType = files[i].type.split("/")[0]; - - if (fileType === "image") { - const fileObj: ProgramNewsCardComponent["imagesEditList"][0] = { - id: nanoid(2), - src: "", - loading: true, - error: false, - tempFile: files[i], - }; - this.imagesEditList.push(fileObj); - - observableArray.push( - this.fileService.uploadFile(files[i]).pipe( - tap(file => { - fileObj.src = file.url; - fileObj.loading = false; - fileObj.tempFile = null; - }) - ) - ); - } else { - const fileObj: ProgramNewsCardComponent["filesEditList"][0] = { - id: nanoid(2), - loading: true, - error: "", - src: "", - tempFile: files[i], - name: files[i].name, - size: files[i].size, - type: files[i].type, - }; - this.filesEditList.push(fileObj); - - observableArray.push( - this.fileService.uploadFile(files[i]).pipe( - tap(file => { - fileObj.loading = false; - fileObj.src = file.url; - fileObj.tempFile = null; - }) - ) - ); - } - } - - forkJoin(observableArray).subscribe(noop); - - // Сбрасываем input для возможности повторной загрузки того же файла - (event.currentTarget as HTMLInputElement).value = ""; - } - - onDeletePhoto(fId: string) { - const fileIdx = this.imagesEditList.findIndex(f => f.id === fId); - if (fileIdx === -1) return; - - if (this.imagesEditList[fileIdx].src) { - this.imagesEditList[fileIdx].loading = true; - this.fileService.deleteFile(this.imagesEditList[fileIdx].src).subscribe(() => { - this.imagesEditList.splice(fileIdx, 1); - }); - } else { - this.imagesEditList.splice(fileIdx, 1); - } - } - - onDeleteFile(fId: string) { - const fileIdx = this.filesEditList.findIndex(f => f.id === fId); - if (fileIdx === -1) return; - - if (this.filesEditList[fileIdx].src) { - this.filesEditList[fileIdx].loading = true; - this.fileService.deleteFile(this.filesEditList[fileIdx].src).subscribe(() => { - this.filesEditList.splice(fileIdx, 1); - }); - } else { - this.filesEditList.splice(fileIdx, 1); - } - } - - onRetryUpload(id: string) { - const fileObj = this.imagesEditList.find(f => f.id === id); - if (!fileObj || !fileObj.tempFile) return; - - fileObj.loading = true; - fileObj.error = false; - - this.fileService.uploadFile(fileObj.tempFile).subscribe({ - next: file => { - fileObj.src = file.url; - fileObj.loading = false; - fileObj.tempFile = null; - }, - error: () => { - fileObj.error = true; - fileObj.loading = false; - }, - }); - } - - showLikes: boolean[] = []; - lastTouch = 0; - - onTouchImg(_event: TouchEvent, imgIdx: number) { - if (Date.now() - this.lastTouch < 300) { - this.like.emit(this.newsItem.id); - this.showLikes[imgIdx] = true; - - setTimeout(() => { - this.showLikes[imgIdx] = false; - }, 1000); - } - - this.lastTouch = Date.now(); - } - - onExpandNewsText(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readMore = !isExpanded; - } -} diff --git a/projects/social_platform/src/app/office/program/services/program-data.service.ts b/projects/social_platform/src/app/office/program/services/program-data.service.ts index c0c1ef698..5323f0e52 100644 --- a/projects/social_platform/src/app/office/program/services/program-data.service.ts +++ b/projects/social_platform/src/app/office/program/services/program-data.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject } from "rxjs"; -import { Program } from "../models/program.model"; +import { Program } from "../../../domain/program/program.model"; @Injectable({ providedIn: "root", diff --git a/projects/social_platform/src/app/office/projects/detail/services/project-data.service.ts b/projects/social_platform/src/app/office/projects/detail/services/project-data.service.ts deleted file mode 100644 index 94bd854bc..000000000 --- a/projects/social_platform/src/app/office/projects/detail/services/project-data.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { Project } from "@office/models/project.model"; -import { BehaviorSubject, filter, map } from "rxjs"; - -@Injectable({ - providedIn: "root", -}) -export class ProjectDataService { - private projectSubject = new BehaviorSubject(undefined); - project$ = this.projectSubject.asObservable(); - - setProject(project: Project) { - this.projectSubject.next(project); - } - - getTeam() { - return this.project$.pipe( - map(project => project?.collaborators), - filter(team => !!team) - ); - } - - getVacancies() { - return this.project$.pipe( - map(project => project?.vacancies), - filter(vacancies => !!vacancies) - ); - } - - getProjectLeaderId() { - return this.project$.pipe(map(project => project?.leader)); - } - - getProjectId() { - return this.project$.pipe(map(project => project?.id)); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/team/team.component.ts b/projects/social_platform/src/app/office/projects/detail/team/team.component.ts deleted file mode 100644 index 896071959..000000000 --- a/projects/social_platform/src/app/office/projects/detail/team/team.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; -import { IconComponent } from "@uilib"; -import { ProjectDataService } from "../services/project-data.service"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; -import { Subscription } from "rxjs"; -import { Project } from "@office/models/project.model"; -import { ProjectService } from "@office/services/project.service"; -import { AuthService } from "@auth/services"; - -/** - * Компонент страницы команды в деательной информации о проекте - */ -@Component({ - selector: "app-project-eam", - templateUrl: "./team.component.html", - styleUrl: "./team.component.scss", - imports: [CommonModule, IconComponent, InfoCardComponent], - standalone: true, -}) -export class ProjectTeamComponent implements OnInit, OnDestroy { - private readonly projectDataService = inject(ProjectDataService); - private readonly projectService = inject(ProjectService); - private readonly authService = inject(AuthService); - - // массив пользователей в команде - team?: Project["collaborators"]; - projectId = signal(0); - loggedUserId = signal(0); - leaderId = signal(0); - - // массив подписок - subscriptions: Subscription[] = []; - - ngOnInit(): void { - // получение данных из сервиса как потока данных и подписка на них - const teamSub$ = this.projectDataService.getTeam().subscribe({ - next: team => { - this.team = team; - }, - }); - - teamSub$ && this.subscriptions.push(teamSub$); - - const projectId$ = this.projectDataService.getProjectId().subscribe({ - next: projectId => { - if (projectId) { - this.projectId.set(projectId); - } - }, - }); - - if (location.href.includes("/team")) { - const leaderId$ = this.projectDataService.getProjectLeaderId().subscribe({ - next: leaderId => { - if (leaderId) { - this.leaderId.set(leaderId); - } - }, - }); - - const currentProfileId$ = this.authService.profile.subscribe({ - next: profile => { - if (profile) { - this.loggedUserId.set(profile.id); - } - }, - }); - - this.subscriptions.push(leaderId$, currentProfileId$); - } - - this.subscriptions.push(projectId$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - removeCollaboratorFromProject(userId: number): void { - const index = this.team?.findIndex(p => p.userId === userId); - if (index !== -1) { - this.team?.splice(index!, 1); - } - - this.projectService.removeColloborator(this.projectId(), userId).subscribe(); - } -} diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.html b/projects/social_platform/src/app/ui/components/approve-skill/approve-skill.component.html similarity index 100% rename from projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.html rename to projects/social_platform/src/app/ui/components/approve-skill/approve-skill.component.html diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.scss b/projects/social_platform/src/app/ui/components/approve-skill/approve-skill.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.scss rename to projects/social_platform/src/app/ui/components/approve-skill/approve-skill.component.scss diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts b/projects/social_platform/src/app/ui/components/approve-skill/approve-skill.component.ts similarity index 89% rename from projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts rename to projects/social_platform/src/app/ui/components/approve-skill/approve-skill.component.ts index 3a1db27d9..17e29b919 100644 --- a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts +++ b/projects/social_platform/src/app/ui/components/approve-skill/approve-skill.component.ts @@ -3,18 +3,17 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectorRef, Component, inject, Input, OnDestroy, OnInit } from "@angular/core"; import { PluralizePipe } from "@corelib"; -import { Skill } from "@office/models/skill"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { ButtonComponent } from "@ui/components"; import { map, of, Subscription, switchMap } from "rxjs"; -import { AuthService } from "@auth/services"; import { ActivatedRoute } from "@angular/router"; -import { ProfileService } from "projects/skills/src/app/profile/services/profile.service"; -import { ProfileService as profileApproveSkillService } from "@auth/services/profile.service"; -import { SnackbarService } from "@ui/services/snackbar.service"; +import { SnackbarService } from "@ui/services/snackbar/snackbar.service"; import { HttpErrorResponse } from "@angular/common/http"; import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ApproveSkillPeopleComponent } from "@office/shared/approve-skill-people/approve-skill-people.component"; +import { ApproveSkillPeopleComponent } from "@ui/shared/approve-skill-people/approve-skill-people.component"; +import { AuthService } from "../../../api/auth"; +import { ProfileService as ProfileApproveSkillService } from "../../../api/auth/profile.service"; +import { Skill } from "../../../domain/skills/skill"; /** * @params skill - информация о навыке (обязательно) @@ -40,7 +39,7 @@ import { ApproveSkillPeopleComponent } from "@office/shared/approve-skill-people export class ApproveSkillComponent implements OnInit, OnDestroy { private readonly authService = inject(AuthService); private readonly route = inject(ActivatedRoute); - private readonly profileApproveSkillService = inject(profileApproveSkillService); + private readonly profileApproveSkillService = inject(ProfileApproveSkillService); private readonly snackbarService = inject(SnackbarService); private readonly cdRef = inject(ChangeDetectorRef); diff --git a/projects/social_platform/src/app/ui/components/approve-skill/services/approve-skill-info.service.ts b/projects/social_platform/src/app/ui/components/approve-skill/services/approve-skill-info.service.ts new file mode 100644 index 000000000..742f92381 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/approve-skill/services/approve-skill-info.service.ts @@ -0,0 +1,101 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; +import { ProjectsDetailUIInfoService } from "projects/social_platform/src/app/api/project/facades/detail/ui/projects-detail-ui.service"; +import { ProfileService as ProfileApproveSkillService } from "../../../../api/auth/profile.service"; +import { Skill } from "projects/social_platform/src/app/domain/skills/skill"; +import { map, of, Subject, switchMap, takeUntil } from "rxjs"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ApproveSkillUIInfoService } from "./approve-skill-ui-info.service"; + +@Injectable() +export class ApproveskillInfoService { + private readonly authService = inject(AuthService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly route = inject(ActivatedRoute); + private readonly profileApproveSkillService = inject(ProfileApproveSkillService); + private readonly approveSkillUIInfoService = inject(ApproveSkillUIInfoService); + + private readonly destroy$ = new Subject(); + + private readonly loggedUserId = this.projectsDetailUIInfoService.loggedUserId; + + init(): void { + this.authService.profile.pipe(takeUntil(this.destroy$)).subscribe({ + next: profile => { + this.projectsDetailUIInfoService.applySetLoggedUserId("logged", profile.id); + }, + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // Указатель на то что пользватель подтвердил навык + isUserApproveSkill(skill: Skill): boolean { + return skill.approves.some(approve => approve.confirmedBy.id === this.loggedUserId()); + } + + unApproveSkill(userId: number, skillId: number, skill: Skill): void { + this.profileApproveSkillService.unApproveSkill(userId, skillId).subscribe(() => { + skill.approves = skill.approves.filter( + approve => approve.confirmedBy.id !== this.loggedUserId() + ); + }); + } + + approveSkill(userId: number, skillId: number, skill: Skill): void { + this.profileApproveSkillService + .approveSkill(userId, skillId) + .pipe( + switchMap(newApprove => + newApprove.confirmedBy + ? of(newApprove) + : this.authService.profile.pipe( + map(profile => ({ + ...newApprove, + confirmedBy: profile, + })) + ) + ), + takeUntil(this.destroy$) + ) + .subscribe({ + next: updatedApprove => { + this.approveSkillUIInfoService.applyApprovedSkills(skill, updatedApprove); + }, + error: err => { + if (err instanceof HttpErrorResponse) { + if (err.status === 400) { + this.approveSkillUIInfoService.applyOpenErrorModal(); + } + } + }, + }); + } + + /** + * Подтверждение или отмена подтверждения навыка пользователя + * @param skillId - идентификатор навыка + * @param event - событие клика для предотвращения всплытия + * @param skill - объект навыка для обновления + */ + onToggleApprove(skillId: number, event: Event, skill: Skill) { + event.stopPropagation(); + const userId = this.route.snapshot.params["id"]; + + const isApprovedByCurrentUser = skill.approves.some(approve => { + return approve.confirmedBy.id === this.loggedUserId(); + }); + + if (isApprovedByCurrentUser) { + this.unApproveSkill(userId, skillId, skill); + } else { + this.approveSkill(userId, skillId, skill); + } + } +} diff --git a/projects/social_platform/src/app/ui/components/approve-skill/services/approve-skill-ui-info.service.ts b/projects/social_platform/src/app/ui/components/approve-skill/services/approve-skill-ui-info.service.ts new file mode 100644 index 000000000..b9315e897 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/approve-skill/services/approve-skill-ui-info.service.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { SnackbarService } from "@ui/services/snackbar/snackbar.service"; +import { Approve, Skill } from "projects/social_platform/src/app/domain/skills/skill"; + +@Injectable() +export class ApproveSkillUIInfoService { + private readonly snackbarService = inject(SnackbarService); + + // переменные для работы с модальным окном для вывода ошибки с подтверждением своего навыка + approveOwnSkillModal = signal(false); + + applyApprovedSkills(skill: Skill, updatedApprove: Approve): void { + skill.approves = [...skill.approves, updatedApprove]; + this.snackbarService.success("вы подтвердили навык"); + } + + applyOpenErrorModal(): void { + this.approveOwnSkillModal.set(true); + } +} diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts index f48c594b6..ff532571a 100644 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts +++ b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts @@ -3,7 +3,7 @@ import { Component, forwardRef, Input, OnInit } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { nanoid } from "nanoid"; -import { FileService } from "@core/services/file.service"; +import { FileService } from "projects/core/src/lib/services/file/file.service"; import { catchError, concatMap, map, of } from "rxjs"; import { IconComponent, ButtonComponent } from "@ui/components"; import { LoaderComponent } from "../loader/loader.component"; diff --git a/projects/social_platform/src/app/ui/components/badge/badge.component.html b/projects/social_platform/src/app/ui/components/badge/badge.component.html new file mode 100644 index 000000000..36473f908 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/badge/badge.component.html @@ -0,0 +1,14 @@ + + +
+ + + +
diff --git a/projects/social_platform/src/app/ui/components/badge/badge.component.scss b/projects/social_platform/src/app/ui/components/badge/badge.component.scss new file mode 100644 index 000000000..5b16d4943 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/badge/badge.component.scss @@ -0,0 +1,24 @@ +.badge { + padding: 4px; + border-style: solid; + border-width: 0.5px; + border-radius: var(--rounded-xl); + + &--green { + color: var(--green); + background-color: var(--green-light); + border-color: var(--green); + } + + &--red { + color: var(--red); + background-color: var(--red-light); + border-color: var(--red); + } + + &--gold { + color: var(--gold); + background-color: var(--gold-light); + border-color: var(--gold); + } +} diff --git a/projects/social_platform/src/app/ui/components/badge/badge.component.ts b/projects/social_platform/src/app/ui/components/badge/badge.component.ts new file mode 100644 index 000000000..55c71a76b --- /dev/null +++ b/projects/social_platform/src/app/ui/components/badge/badge.component.ts @@ -0,0 +1,16 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +@Component({ + selector: "app-badge", + templateUrl: "./badge.component.html", + styleUrl: "./badge.component.scss", + imports: [CommonModule], + standalone: true, +}) +export class BadgeComponent { + @Input() color: "green" | "red" | "gold" = "red"; + @Input() type: "deadline" | "start" = "deadline"; +} diff --git a/projects/social_platform/src/app/ui/components/button/button.component.scss b/projects/social_platform/src/app/ui/components/button/button.component.scss index 01f70b2c2..ef99e8864 100644 --- a/projects/social_platform/src/app/ui/components/button/button.component.scss +++ b/projects/social_platform/src/app/ui/components/button/button.component.scss @@ -21,7 +21,7 @@ outline: none; &:hover { - background-color: var(--accent-light); + background-color: var(--accent-medium); box-shadow: -2px 3px 3px rgb(51 51 51 / 20%); } @@ -76,12 +76,12 @@ &.button--outline { color: var(--accent); - background-color: transparent; + background: transparent; border: 0.5px solid var(--accent); &:hover { - color: var(--accent-light); - border-color: var(--accent-light); + color: var(--accent-medium); + border-color: var(--accent-medium); box-shadow: -2px 3px 3px rgb(51 51 51 / 20%); } @@ -95,12 +95,21 @@ } } + &.button--gold { + color: var(--gold); + border-color: var(--gold); + } + &.button--white { color: var(--white); - background: transparent; border: 0.5px solid var(--white); } + &.button--green { + color: var(--green); + border: 0.5px solid var(--green); + } + &.button--no-border { border: none; } diff --git a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.spec.ts b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.spec.ts index 789521d33..072882e52 100644 --- a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.spec.ts +++ b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ChatMessageComponent } from "./chat-message.component"; -import { ChatMessage } from "@models/chat-message.model"; +import { ChatMessage } from "projects/social_platform/src/app/domain/chat/chat-message.model"; import { AuthService } from "@auth/services"; import { of } from "rxjs"; import { RouterTestingModule } from "@angular/router/testing"; diff --git a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts index 9aac541a4..e7cf95085 100644 --- a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts +++ b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts @@ -11,17 +11,17 @@ import { Output, ViewChild, } from "@angular/core"; -import { ChatMessage } from "@models/chat-message.model"; -import { SnackbarService } from "@ui/services/snackbar.service"; +import { ChatMessage } from "projects/social_platform/src/app/domain/chat/chat-message.model"; +import { SnackbarService } from "@ui/services/snackbar/snackbar.service"; import { DomPortal } from "@angular/cdk/portal"; import { Overlay, OverlayRef } from "@angular/cdk/overlay"; -import { AuthService } from "@auth/services"; import { DayjsPipe } from "projects/core"; import { IconComponent } from "@ui/components"; import { FileItemComponent } from "../file-item/file-item.component"; import { AsyncPipe } from "@angular/common"; import { AvatarComponent } from "../avatar/avatar.component"; import { ClickOutsideModule } from "ng-click-outside"; +import { AuthService } from "../../../api/auth"; /** * Компонент сообщения в чате с контекстным меню и файловыми вложениями. diff --git a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.html b/projects/social_platform/src/app/ui/components/chat-window/chat-window.component.html similarity index 100% rename from projects/social_platform/src/app/office/features/chat-window/chat-window.component.html rename to projects/social_platform/src/app/ui/components/chat-window/chat-window.component.html diff --git a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.scss b/projects/social_platform/src/app/ui/components/chat-window/chat-window.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/chat-window/chat-window.component.scss rename to projects/social_platform/src/app/ui/components/chat-window/chat-window.component.scss diff --git a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.spec.ts b/projects/social_platform/src/app/ui/components/chat-window/chat-window.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/features/chat-window/chat-window.component.spec.ts rename to projects/social_platform/src/app/ui/components/chat-window/chat-window.component.spec.ts diff --git a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts b/projects/social_platform/src/app/ui/components/chat-window/chat-window.component.ts similarity index 97% rename from projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts rename to projects/social_platform/src/app/ui/components/chat-window/chat-window.component.ts index bef4f4a28..3f1acb23d 100644 --- a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts +++ b/projects/social_platform/src/app/ui/components/chat-window/chat-window.component.ts @@ -16,15 +16,15 @@ import { CdkVirtualForOf, CdkVirtualScrollViewport, } from "@angular/cdk/scrolling"; -import { ChatMessage } from "@models/chat-message.model"; -import { MessageInputComponent } from "@office/features/message-input/message-input.component"; +import { ChatMessage } from "projects/social_platform/src/app/domain/chat/chat-message.model"; +import { MessageInputComponent } from "@ui/components/message-input/message-input.component"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { filter, fromEvent, noop, skip, Subscription, tap, throttleTime } from "rxjs"; import { ModalService } from "@ui/models/modal.service"; -import { AuthService } from "@auth/services"; -import { User } from "@auth/models/user.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; import { PluralizePipe } from "projects/core"; import { ChatMessageComponent } from "@ui/components/chat-message/chat-message.component"; +import { AuthService } from "../../../api/auth"; /** * Компонент окна чата diff --git a/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.html b/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.html new file mode 100644 index 000000000..518773216 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.html @@ -0,0 +1,24 @@ + + +
    + @for (item of visibleItems; track item.id) { +
  • + {{ item.label }} +
  • + @if (item.divider && !$last) { +
  • + } } +
diff --git a/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.scss b/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.scss new file mode 100644 index 000000000..ed9cf86b5 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.scss @@ -0,0 +1,58 @@ +/** @format */ + +.context-menu { + position: fixed; + z-index: 9999; + padding: 8px 0; + margin: 0; + list-style: none; + background-color: white; + border-radius: 8px; + outline: none; + box-shadow: 0 4px 16px rgb(0 0 0 / 12%); + transition: opacity 0.15s ease; + + &__item { + padding: 10px 16px; + white-space: nowrap; + cursor: pointer; + user-select: none; + transition: background-color 0.15s ease; + + &:hover { + background-color: rgb(0 0 0 / 4%); + } + + &:active { + background-color: rgb(0 0 0 / 8%); + } + + &--red { + color: #f44336; + + &:hover { + background-color: rgb(244 67 54 / 8%); + } + + &:active { + background-color: rgb(244 67 54 / 16%); + } + } + + &--primary { + color: #2196f3; + } + + &--disabled { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + } + } + + &__divider { + height: 1px; + margin: 8px 0; + background-color: rgb(0 0 0 / 8%); + } +} diff --git a/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.ts b/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.ts new file mode 100644 index 000000000..79a7d3cae --- /dev/null +++ b/projects/social_platform/src/app/ui/components/context-menu/context-menu.component.ts @@ -0,0 +1,195 @@ +/** @format */ + +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + Output, + ViewChild, +} from "@angular/core"; +import { DomPortal } from "@angular/cdk/portal"; +import { Overlay, OverlayRef } from "@angular/cdk/overlay"; +import { ClickOutsideModule } from "ng-click-outside"; +import { CommonModule } from "@angular/common"; + +/** + * Универсальный компонент контекстного меню + * + * Особенности: + * - Автоматическое позиционирование с учетом границ экрана + * - Гибкая конфигурация пунктов меню + * - Поддержка условного отображения пунктов + * - Автоматическое закрытие при клике вне меню + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: "app-context-menu", + templateUrl: "./context-menu.component.html", + styleUrl: "./context-menu.component.scss", + standalone: true, + imports: [CommonModule, ClickOutsideModule], +}) +export class ContextMenuComponent implements AfterViewInit, OnDestroy { + constructor(private readonly overlay: Overlay) {} + + /** Список пунктов меню */ + @Input({ required: true }) items: any[] = []; + + /** Дополнительный CSS класс для меню */ + @Input() customClass?: string; + + /** Минимальная ширина меню */ + @Input() minWidth?: number; + + /** Событие клика по пункту меню */ + @Output() itemClick = new EventEmitter(); + + /** Событие открытия меню */ + @Output() menuOpen = new EventEmitter(); + + /** Событие закрытия меню */ + @Output() menuClose = new EventEmitter(); + + /** Ссылка на элемент контекстного меню */ + @ViewChild("contextMenu", { static: false }) contextMenu!: ElementRef; + + /** Ссылка на overlay */ + private overlayRef?: OverlayRef; + + /** Portal для контекстного меню */ + private portal?: DomPortal; + + /** Состояние открытия контекстного меню */ + isOpen = false; + + /** Инициализация overlay для контекстного меню */ + ngAfterViewInit(): void { + this.overlayRef = this.overlay.create({ + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.close(), + }); + this.portal = new DomPortal(this.contextMenu); + } + + /** Очистка ресурсов overlay */ + ngOnDestroy(): void { + this.overlayRef?.dispose(); + } + + /** + * Открытие контекстного меню в заданной позиции + * @param event - событие мыши (для автоматического позиционирования) + * @param position - пользовательская позиция меню + */ + open(event?: MouseEvent, position?: { x: number; y: number }): void { + if (this.isOpen) return; + + this.isOpen = true; + this.menuOpen.emit(); + + // Ждем рендеринга меню для получения его размеров + setTimeout(() => { + const menuHeight = this.contextMenu.nativeElement.offsetHeight; + const menuWidth = this.contextMenu.nativeElement.offsetWidth; + + let positionX: number; + let positionY: number; + + if (event) { + // Автоматическое позиционирование относительно курсора + positionX = event.clientX; + positionY = event.clientY; + + // Проверка выхода за правую границу экрана + if (positionX + menuWidth > window.innerWidth) { + positionX = window.innerWidth - menuWidth - 10; + } + + // Проверка выхода за нижнюю границу экрана + if (positionY + menuHeight > window.innerHeight) { + positionY = event.clientY - menuHeight; + } + } else if (position) { + // Использование пользовательской позиции + positionX = position.x; + positionY = position.y; + } else { + // Позиция по умолчанию + positionX = 0; + positionY = 0; + } + + const positionStrategy = this.overlay + .position() + .global() + .left(positionX + "px") + .top(positionY + "px"); + + this.overlayRef?.updatePositionStrategy(positionStrategy); + + if (!this.overlayRef?.hasAttached()) { + this.overlayRef?.attach(this.portal); + } + + this.contextMenu.nativeElement.focus(); + }, 0); + } + + /** + * Закрытие контекстного меню + */ + close(): void { + if (!this.isOpen) return; + + this.isOpen = false; + this.menuClose.emit(); + this.overlayRef?.detach(); + } + + /** + * Переключение состояния меню + * @param event - событие мыши + */ + toggle(event?: MouseEvent): void { + if (this.isOpen) { + this.close(); + } else { + this.open(event); + } + } + + /** + * Обработчик клика по пункту меню + * @param event - событие клика + * @param itemId - идентификатор пункта меню + */ + onItemClick(event: MouseEvent, itemId: string): void { + event.stopPropagation(); + this.itemClick.emit(itemId); + this.close(); + } + + /** + * Обработчик клика вне меню + */ + onClickOutside(): void { + this.close(); + } + + /** + * Фильтр видимых пунктов меню + */ + get visibleItems(): any[] { + return this.items.filter(item => item.visible !== false); + } +} diff --git a/projects/social_platform/src/app/office/features/detail/detail.component.html b/projects/social_platform/src/app/ui/components/detail/detail.component.html similarity index 95% rename from projects/social_platform/src/app/office/features/detail/detail.component.html rename to projects/social_platform/src/app/ui/components/detail/detail.component.html index d5f138e3d..d667e4fac 100644 --- a/projects/social_platform/src/app/office/features/detail/detail.component.html +++ b/projects/social_platform/src/app/ui/components/detail/detail.component.html @@ -248,10 +248,18 @@ - @if (isInProject) { + @if (isKanbanBoardPage && isInProject) { + вернуться + } @else if (isInProject && !isKanbanBoardPage) { @@ -274,6 +282,15 @@ > написать + } @else if (isKanbanBoardPage) { + + диаграмма ганта + } @else { + @if (isKanbanBoardPage) { + + контрольные точки + + } @else { команда - - @if (isInProject) { @if (profile) { @if (profile.id === info().leader) { + } @if (isInProject) { @if (isKanbanBoardPage) { + + статистика + + } @else { @if (profile) { @if (profile.id === info().leader) { выйти из проекта - } } + } } }
(undefined); profile?: User; profileProjects = signal([]); @@ -96,6 +103,10 @@ export class DeatilComponent implements OnInit, OnDestroy { isTeamPage = false; isVacanciesPage = false; isProjectChatPage = false; + isProjectWorkSectionPage = false; + + isKanbanBoardPage = false; + isGantDiagramPage = false; // Сторонние переменные для работы с роутингом или доп проверок backPath?: string; @@ -481,23 +492,20 @@ export class DeatilComponent implements OnInit, OnDestroy { this.isTeamPage = currentUrl.includes("/team"); this.isVacanciesPage = currentUrl.includes("/vacancies"); this.isProjectChatPage = currentUrl.includes("/chat"); + this.isProjectWorkSectionPage = currentUrl.includes("/work-section"); + + this.isGantDiagramPage = currentUrl.includes("/gant-diagram"); + this.isKanbanBoardPage = currentUrl.includes("/kanban"); } private initializeInfo() { if (this.listType === "project") { - const projectSub$ = this.projectDataService.project$ - .pipe(filter(project => !!project)) - .subscribe(project => { - this.info.set(project); + const project = this.projectDataService.project; + this.info = project; - if (project?.partnerProgram) { - this.isEditDisable = project.partnerProgram?.isSubmitted; - } - }); + this.isEditDisable = this.info()?.partnerProgram?.isSubmitted ?? false; this.isInProfileInfo(); - - this.subscriptions.push(projectSub$); } else if (this.listType === "program") { const program$ = this.programDataService.program$ .pipe( @@ -528,7 +536,6 @@ export class DeatilComponent implements OnInit, OnDestroy { }, }); - this.subscriptions.push(program$); this.subscriptions.push(memeberProjects$); this.subscriptions.push(profileDataSub$); } else { diff --git a/projects/social_platform/src/app/ui/components/detail/services/detail-info.service.ts b/projects/social_platform/src/app/ui/components/detail/services/detail-info.service.ts new file mode 100644 index 000000000..b61ee6485 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/detail/services/detail-info.service.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { Subject } from "rxjs"; + +@Injectable() +export class DetailInfoService { + destroy$ = new Subject(); + + info = signal(undefined); + listType: "project" | "program" | "profile" = "project"; + + // Сторонние переменные для работы с роутингом или доп проверок + backPath?: string; + isInProject?: boolean; + + // для проекта + isTeamPage = signal(false); + isVacanciesPage = signal(false); + isProjectChatPage = signal(false); + isProjectWorkSectionPage = signal(false); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/social_platform/src/app/ui/components/detail/services/project/detail-project-info.service.ts b/projects/social_platform/src/app/ui/components/detail/services/project/detail-project-info.service.ts new file mode 100644 index 000000000..c072c42e4 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/detail/services/project/detail-project-info.service.ts @@ -0,0 +1,6 @@ +/** @format */ + +import { Injectable } from "@angular/core"; + +@Injectable() +export class DetailProjectInfoService {} diff --git a/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.html b/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.html new file mode 100644 index 000000000..fe0bf3ab3 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.html @@ -0,0 +1,65 @@ + + +
+ +
+ + +
    + @for (option of options; track option.id; let index = $index) { +
  • +
    + @if (option.additionalInfo) { @switch (type) { @case('icons') { + + + } @case ('avatars') { + + + } @case ('shapes') { +
    + + } @case ('goals') { + {{ + option.label.length > 20 ? option.label.slice(0, 17) + "..." : option.label + }} + } @case ('tags') { + #{{ + option.label.length > 15 ? option.label.slice(0, 12) + "..." : option.label + }} + } } } +
    + + @if (type !== 'tags' && type !== 'goals') { +

    {{ option.label }}

    + } + + +
  • + } @if (type === 'tags') { @if (!creatingTag) { +
  • + +
  • + } @else { + + } } +
+
diff --git a/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.scss b/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.scss new file mode 100644 index 000000000..5f95868d1 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.scss @@ -0,0 +1,74 @@ +.field { + &__options { + z-index: 1000; + display: flex; + flex-direction: column; + gap: 5px; + align-items: flex-start; + width: 100%; + max-height: 200px; + padding: 12px; + overflow-y: auto; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__option { + display: flex; + gap: 5px; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: color 0.2s ease-in-out; + + &--add-object { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + padding: 3px; + cursor: pointer; + border: 0.5px solid var(--accent); + border-radius: var(--rounded-xl); + + i { + color: var(--accent); + } + } + + &:hover { + p { + color: var(--accent); + } + } + + &--additional { + display: flex; + gap: 5px; + align-items: center; + + i { + color: var(--accent); + } + + app-tag { + width: 80px; + } + } + + &-priority { + width: 15px; + height: 15px; + border-radius: var(--rounded-xxl); + } + + &--highlighted { + background-color: var(--light-gray); + } + + &--point { + color: var(--accent); + } + } +} diff --git a/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.ts b/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.ts new file mode 100644 index 000000000..8c55562f2 --- /dev/null +++ b/projects/social_platform/src/app/ui/components/dropdown/dropdown.component.ts @@ -0,0 +1,114 @@ +/** @format */ + +import { ConnectedPosition, OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from "@angular/core"; +import { AvatarComponent } from "../avatar/avatar.component"; +import { IconComponent } from "@uilib"; +import { getPriorityType } from "@utils/helpers/getPriorityType"; +import { TagComponent } from "../tag/tag.component"; +import { ClickOutsideModule } from "ng-click-outside"; +import { TagDto } from "../../../api/kanban/dto/tag.model.dto"; +import { CreateTagFormComponent } from "@ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component"; + +@Component({ + selector: "app-dropdown", + standalone: true, + imports: [ + CommonModule, + OverlayModule, + AvatarComponent, + IconComponent, + TagComponent, + ClickOutsideModule, + CreateTagFormComponent, + ], + templateUrl: "./dropdown.component.html", + styleUrl: "./dropdown.component.scss", +}) +export class DropdownComponent { + /** Состояние для определения списка элементов */ + @Input() options: { id: number; label: string; value: any; additionalInfo?: any }[] = []; + + @Input() type: "icons" | "avatars" | "shapes" | "tags" | "goals" | "text" = "text"; + + /** Состояние для открытия списка выпадающего */ + @Input() isOpen = false; + + /** режим создания тега */ + @Input() creatingTag = false; + + /** Состояние для выделения элемента списка выпадающего */ + @Input() highlightedIndex = -1; + + @Input() colorText: "grey" | "red" = "grey"; + + @Input() editingTag: TagDto | null = null; + + @Output() updateTag = new EventEmitter(); + + /** Событие для выбора элемента */ + @Output() select = new EventEmitter(); + + /** Событие для логики при клике вне списка выпадающего */ + @Output() outside = new EventEmitter(); + + @Output() tagInfo = new EventEmitter<{ name: string; color: string }>(); + + @ViewChild("dropdown", { static: true }) dropdown!: ElementRef; + + getPriorityType = getPriorityType; + + positions: ConnectedPosition[] = [ + { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + offsetY: 4, + }, + { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + offsetY: -4, + }, + ]; + + /** Метод для выбора элемента и emit для родительского компонента */ + onSelect(event: Event, id: number) { + event.stopPropagation(); + this.select.emit(id); + } + + /** Метод для клика вне списка выпадающего */ + onClickOutside() { + this.outside.emit(); + } + + startCreatingTag(event: Event) { + event.stopPropagation(); + this.creatingTag = true; + } + + onConfirmUpdateTag(tagData: TagDto): void { + this.updateTag.emit(tagData); + this.creatingTag = false; + } + + onConfirmCreateTag(tagInfo: { name: string; color: string }): void { + this.tagInfo.emit(tagInfo); + this.creatingTag = false; + } + + getTextColor(colorText: "grey" | "red") { + switch (colorText) { + case "red": + return "color: var(--red)"; + + case "grey": + return "color: var(--grey-for-text)"; + } + } +} diff --git a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.html b/projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.html similarity index 100% rename from projects/social_platform/src/app/office/feed/filter/feed-filter.component.html rename to projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.html diff --git a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.scss b/projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.scss similarity index 100% rename from projects/social_platform/src/app/office/feed/filter/feed-filter.component.scss rename to projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.scss diff --git a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.spec.ts b/projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/feed/filter/feed-filter.component.spec.ts rename to projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.spec.ts diff --git a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts b/projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.ts similarity index 97% rename from projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts rename to projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.ts index fa2020263..4c104d2b4 100644 --- a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts +++ b/projects/social_platform/src/app/ui/components/feed-filter/feed-filter.component.ts @@ -13,11 +13,11 @@ import { import { ActivatedRoute, Router, RouterLink } from "@angular/router"; import { ButtonComponent, CheckboxComponent, IconComponent } from "@ui/components"; import { ClickOutsideModule } from "ng-click-outside"; -import { FeedService } from "@office/feed/services/feed.service"; -import { User } from "@auth/models/user.model"; -import { AuthService } from "@auth/services"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; import { Subscription } from "rxjs"; import { feedFilter } from "projects/core/src/consts/filters/feed-filter.const"; +import { AuthService } from "../../../api/auth"; +import { FeedService } from "../../../api/feed/feed.service"; /** * КОМПОНЕНТ ФИЛЬТРАЦИИ ЛЕНТЫ diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts b/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts index 79d9d9d64..012bb1514 100644 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts +++ b/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts @@ -3,8 +3,8 @@ import { Component, inject, Input, OnInit } from "@angular/core"; import { FileTypePipe } from "@ui/pipes/file-type.pipe"; import { IconComponent } from "@ui/components"; import { UpperCasePipe } from "@angular/common"; -import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; -import { FileService } from "@core/services/file.service"; +import { FileService } from "projects/core/src/lib/services/file/file.service"; +import { FormatedFileSizePipe } from "projects/core/src/lib/pipes/transformers/formatted-file-size.pipe"; /** * Компонент для отображения информации о файле. diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts b/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts index a07d56e9e..5e6330390 100644 --- a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts +++ b/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts @@ -5,7 +5,7 @@ import { FileTypePipe } from "@ui/pipes/file-type.pipe"; import { LoaderComponent } from "../loader/loader.component"; import { IconComponent } from "@ui/components"; import { UpperCasePipe } from "@angular/common"; -import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; +import { FormatedFileSizePipe } from "projects/core/src/lib/pipes/transformers/formatted-file-size.pipe"; /** * Компонент для отображения элемента загружаемого файла. diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.html b/projects/social_platform/src/app/ui/components/info-card/info-card.component.html similarity index 100% rename from projects/social_platform/src/app/office/features/info-card/info-card.component.html rename to projects/social_platform/src/app/ui/components/info-card/info-card.component.html diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.scss b/projects/social_platform/src/app/ui/components/info-card/info-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/info-card/info-card.component.scss rename to projects/social_platform/src/app/ui/components/info-card/info-card.component.scss diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts b/projects/social_platform/src/app/ui/components/info-card/info-card.component.spec.ts similarity index 89% rename from projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts rename to projects/social_platform/src/app/ui/components/info-card/info-card.component.spec.ts index f2e3eb5e9..45735c5a2 100644 --- a/projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/components/info-card/info-card.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { of } from "rxjs"; -import { IndustryService } from "@services/industry.service"; +import { IndustryService } from "projects/social_platform/src/app/api/industry/industry.service"; import { InfoCardComponent } from "./info-card.component"; describe("ProjectCardComponent", () => { diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.ts b/projects/social_platform/src/app/ui/components/info-card/info-card.component.ts similarity index 94% rename from projects/social_platform/src/app/office/features/info-card/info-card.component.ts rename to projects/social_platform/src/app/ui/components/info-card/info-card.component.ts index e1fb4456c..74c1eae9c 100644 --- a/projects/social_platform/src/app/office/features/info-card/info-card.component.ts +++ b/projects/social_platform/src/app/ui/components/info-card/info-card.component.ts @@ -1,18 +1,18 @@ /** @format */ import { Component, EventEmitter, inject, Input, OnInit, Output, signal } from "@angular/core"; -import { IndustryService } from "@services/industry.service"; +import { IndustryService } from "projects/social_platform/src/app/api/industry/industry.service"; import { IconComponent, ButtonComponent } from "@ui/components"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { AsyncPipe, CommonModule } from "@angular/common"; import { ModalComponent } from "@ui/components/modal/modal.component"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { InviteService } from "@office/services/invite.service"; +import { SubscriptionService } from "projects/social_platform/src/app/api/subsriptions/subscription.service"; +import { InviteService } from "projects/social_platform/src/app/api/invite/invite.service"; import { ClickOutsideModule } from "ng-click-outside"; import { Router, RouterLink } from "@angular/router"; import { TagComponent } from "@ui/components/tag/tag.component"; import { YearsFromBirthdayPipe } from "@corelib"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { TruncatePipe } from "projects/core/src/lib/pipes/formatters/truncate.pipe"; /** * Компонент карточки информации с разным наполнением, в зависимости от контекста diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.html b/projects/social_platform/src/app/ui/components/invite-card/invite-card.component.html similarity index 100% rename from projects/social_platform/src/app/office/features/invite-card/invite-card.component.html rename to projects/social_platform/src/app/ui/components/invite-card/invite-card.component.html diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.scss b/projects/social_platform/src/app/ui/components/invite-card/invite-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/invite-card/invite-card.component.scss rename to projects/social_platform/src/app/ui/components/invite-card/invite-card.component.scss diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.spec.ts b/projects/social_platform/src/app/ui/components/invite-card/invite-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/features/invite-card/invite-card.component.spec.ts rename to projects/social_platform/src/app/ui/components/invite-card/invite-card.component.spec.ts diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts b/projects/social_platform/src/app/ui/components/invite-card/invite-card.component.ts similarity index 93% rename from projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts rename to projects/social_platform/src/app/ui/components/invite-card/invite-card.component.ts index 8afa54445..5c5e21e5b 100644 --- a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts +++ b/projects/social_platform/src/app/ui/components/invite-card/invite-card.component.ts @@ -3,13 +3,13 @@ import { Component, EventEmitter, Input, OnInit, Output, signal } from "@angular/core"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { Invite } from "@models/invite.model"; +import { ErrorMessage } from "projects/core/src/lib/models/error/error-message"; +import { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; import { IconComponent, ButtonComponent, SelectComponent, InputComponent } from "@ui/components"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { TruncatePipe } from "projects/core/src/lib/pipes/formatters/truncate.pipe"; /** * Компонент карточки приглашения в команду или проект diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.html b/projects/social_platform/src/app/ui/components/members-filters/members-filters.component.html similarity index 100% rename from projects/social_platform/src/app/office/members/filters/members-filters.component.html rename to projects/social_platform/src/app/ui/components/members-filters/members-filters.component.html diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.scss b/projects/social_platform/src/app/ui/components/members-filters/members-filters.component.scss similarity index 100% rename from projects/social_platform/src/app/office/members/filters/members-filters.component.scss rename to projects/social_platform/src/app/ui/components/members-filters/members-filters.component.scss diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.spec.ts b/projects/social_platform/src/app/ui/components/members-filters/members-filters.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/members/filters/members-filters.component.spec.ts rename to projects/social_platform/src/app/ui/components/members-filters/members-filters.component.spec.ts diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.ts b/projects/social_platform/src/app/ui/components/members-filters/members-filters.component.ts similarity index 92% rename from projects/social_platform/src/app/office/members/filters/members-filters.component.ts rename to projects/social_platform/src/app/ui/components/members-filters/members-filters.component.ts index 11a96157d..9877076c0 100644 --- a/projects/social_platform/src/app/office/members/filters/members-filters.component.ts +++ b/projects/social_platform/src/app/ui/components/members-filters/members-filters.component.ts @@ -10,15 +10,15 @@ import { signal, } from "@angular/core"; import { RangeInputComponent } from "@ui/components/range-input/range-input.component"; -import { MembersComponent } from "@office/members/members.component"; import { ReactiveFormsModule } from "@angular/forms"; import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { Specialization } from "@office/models/specialization"; -import { SpecializationsService } from "@office/services/specializations.service"; -import { SkillsService } from "@office/services/skills.service"; -import { Skill } from "@office/models/skill"; +import { Specialization } from "projects/social_platform/src/app/domain/specializations/specialization"; import { ActivatedRoute, Router } from "@angular/router"; -import { CheckboxComponent } from "../../../ui/components/checkbox/checkbox.component"; +import { CheckboxComponent } from "../checkbox/checkbox.component"; +import { MembersComponent } from "@ui/pages/members/members.component"; +import { Skill } from "../../../domain/skills/skill"; +import { SkillsService } from "../../../api/skills/skills.service"; +import { SpecializationsService } from "../../../api/specializations/specializations.service"; /** * Компонент фильтров для списка участников diff --git a/projects/social_platform/src/app/office/features/message-input/message-input.component.html b/projects/social_platform/src/app/ui/components/message-input/message-input.component.html similarity index 100% rename from projects/social_platform/src/app/office/features/message-input/message-input.component.html rename to projects/social_platform/src/app/ui/components/message-input/message-input.component.html diff --git a/projects/social_platform/src/app/office/features/message-input/message-input.component.scss b/projects/social_platform/src/app/ui/components/message-input/message-input.component.scss similarity index 99% rename from projects/social_platform/src/app/office/features/message-input/message-input.component.scss rename to projects/social_platform/src/app/ui/components/message-input/message-input.component.scss index f9f689b14..ad62c820d 100644 --- a/projects/social_platform/src/app/office/features/message-input/message-input.component.scss +++ b/projects/social_platform/src/app/ui/components/message-input/message-input.component.scss @@ -216,7 +216,7 @@ $button-size: 40px; padding: 36px; background-color: var(--white); border: 2px dashed var(--accent); - border-radius: 5px; + border-radius: var(--rounded-md); transform: translate(-50%, -50%); } diff --git a/projects/social_platform/src/app/office/features/message-input/message-input.component.spec.ts b/projects/social_platform/src/app/ui/components/message-input/message-input.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/features/message-input/message-input.component.spec.ts rename to projects/social_platform/src/app/ui/components/message-input/message-input.component.spec.ts diff --git a/projects/social_platform/src/app/office/features/message-input/message-input.component.ts b/projects/social_platform/src/app/ui/components/message-input/message-input.component.ts similarity index 97% rename from projects/social_platform/src/app/office/features/message-input/message-input.component.ts rename to projects/social_platform/src/app/ui/components/message-input/message-input.component.ts index ccabd06e8..f67c49963 100644 --- a/projects/social_platform/src/app/office/features/message-input/message-input.component.ts +++ b/projects/social_platform/src/app/ui/components/message-input/message-input.component.ts @@ -10,15 +10,15 @@ import { Output, } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { ChatMessage } from "@models/chat-message.model"; +import { ChatMessage } from "projects/social_platform/src/app/domain/chat/chat-message.model"; import { fromEvent, map, Subscription } from "rxjs"; -import { FileService } from "@core/services/file.service"; +import { FileService } from "projects/core/src/lib/services/file/file.service"; import { FileTypePipe } from "@ui/pipes/file-type.pipe"; import { AutosizeModule } from "ngx-autosize"; import { NgxMaskModule } from "ngx-mask"; import { IconComponent } from "@ui/components"; -import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; import { UpperCasePipe } from "@angular/common"; +import { FormatedFileSizePipe } from "projects/core/src/lib/pipes/transformers/formatted-file-size.pipe"; /** * Компонент ввода сообщений для чата diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.html b/projects/social_platform/src/app/ui/components/modal/modal.component.html index 27a13b455..2f3e96920 100644 --- a/projects/social_platform/src/app/ui/components/modal/modal.component.html +++ b/projects/social_platform/src/app/ui/components/modal/modal.component.html @@ -1,7 +1,7 @@ -
- + > @if (listType === 'projects') {
diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/detail/list/list.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/list/list.component.scss diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts rename to projects/social_platform/src/app/ui/pages/program/detail/list/list.component.spec.ts diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts similarity index 93% rename from projects/social_platform/src/app/office/program/detail/list/list.component.ts rename to projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts index 99d94ae11..d5bd3f848 100644 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts @@ -26,27 +26,28 @@ import { switchMap, tap, } from "rxjs"; -import { ProjectsFilterComponent } from "@office/program/detail/list/projects-filter/projects-filter.component"; import Fuse from "fuse.js"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { SearchComponent } from "@ui/components/search/search.component"; -import { User } from "@auth/models/user.model"; -import { Project } from "@office/models/project.model"; -import { RatingCardComponent } from "@office/program/shared/rating-card/rating-card.component"; -import { ProgramService } from "@office/program/services/program.service"; -import { ProjectRatingService } from "@office/program/services/project-rating.service"; -import { AuthService } from "@auth/services"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { ApiPagination } from "@models/api-pagination.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { SubscriptionService } from "projects/social_platform/src/app/api/subsriptions/subscription.service"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { CheckboxComponent, ButtonComponent, IconComponent } from "@ui/components"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; -import { tagsFilter } from "projects/core/src/consts/filters/tags-filter.const"; +import { ProgramProjectsFilterComponent } from "@ui/components/program-projects-filter/program-projects-filter.component"; +import { RatingCardComponent } from "@ui/shared/rating-card/rating-card.component"; +import { InfoCardComponent } from "@ui/components/info-card/info-card.component"; +import { ButtonComponent } from "@ui/components"; +import { IconComponent } from "@uilib"; +import { ProgramService } from "projects/social_platform/src/app/api/program/program.service"; +import { ProgramDataService } from "@office/program/services/program-data.service"; +import { ProjectRatingService } from "projects/social_platform/src/app/api/project/project-rating.service"; +import { AuthService } from "projects/social_platform/src/app/api/auth"; import { ExportFileService } from "@office/services/export-file.service"; +import { PartnerProgramFields } from "projects/social_platform/src/app/domain/program/partner-program-fields.model"; +import { tagsFilter } from "projects/core/src/consts/filters/tags-filter.const"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; import { saveFile } from "@utils/helpers/export-file"; -import { ProgramDataService } from "@office/program/services/program-data.service"; @Component({ selector: "app-list", @@ -56,7 +57,7 @@ import { ProgramDataService } from "@office/program/services/program-data.servic CommonModule, ReactiveFormsModule, RouterModule, - ProjectsFilterComponent, + ProgramProjectsFilterComponent, SearchComponent, RatingCardComponent, InfoCardComponent, diff --git a/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/members.resolver.ts similarity index 87% rename from projects/social_platform/src/app/office/program/detail/list/members.resolver.ts rename to projects/social_platform/src/app/ui/pages/program/detail/list/members.resolver.ts index 4314a4d88..d974bae64 100644 --- a/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/members.resolver.ts @@ -2,9 +2,9 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { ProgramService } from "@office/program/services/program.service"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { User } from "projects/social_platform/src/app/domain/auth/user.model"; +import { ProgramService } from "projects/social_platform/src/app/api/program/program.service"; /** * Резолвер для предзагрузки участников программы diff --git a/projects/social_platform/src/app/office/program/detail/list/projects.resolver.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/projects.resolver.ts similarity index 87% rename from projects/social_platform/src/app/office/program/detail/list/projects.resolver.ts rename to projects/social_platform/src/app/ui/pages/program/detail/list/projects.resolver.ts index ec081377d..ea96f6c47 100644 --- a/projects/social_platform/src/app/office/program/detail/list/projects.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/projects.resolver.ts @@ -2,11 +2,11 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, ResolveFn, Router } from "@angular/router"; -import { ProgramService } from "@office/program/services/program.service"; -import { Project } from "@models/project.model"; -import { ApiPagination } from "@models/api-pagination.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; import { catchError, EMPTY } from "rxjs"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; +import { ProgramService } from "projects/social_platform/src/app/api/program/program.service"; /** * Резолвер для предзагрузки проектов программы diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html similarity index 97% rename from projects/social_platform/src/app/office/program/detail/main/main.component.html rename to projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html index 354e89f4c..55d9adf6a 100644 --- a/projects/social_platform/src/app/office/program/detail/main/main.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html @@ -45,15 +45,16 @@

о программе

@if (program.isUserManager) { } @for (n of news(); track n.id) { - + > }
diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/detail/main/main.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/main/main.component.scss diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/program/detail/main/main.component.spec.ts rename to projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts similarity index 88% rename from projects/social_platform/src/app/office/program/detail/main/main.component.ts rename to projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts index da41a2fa4..d8197a29c 100644 --- a/projects/social_platform/src/app/office/program/detail/main/main.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts @@ -8,7 +8,6 @@ import { signal, ViewChild, } from "@angular/core"; -import { ProgramService } from "@office/program/services/program.service"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { concatMap, @@ -21,27 +20,26 @@ import { tap, throttleTime, } from "rxjs"; -import { Program } from "@office/program/models/program.model"; -import { ProgramNewsService } from "@office/program/services/program-news.service"; -import { FeedNews } from "@office/projects/models/project-news.model"; import { expandElement } from "@utils/expand-element"; import { ParseBreaksPipe, ParseLinksPipe } from "projects/core"; -import { UserLinksPipe } from "@core/pipes/user-links.pipe"; -import { ProgramNewsCardComponent } from "../shared/news-card/news-card.component"; import { ButtonComponent, IconComponent } from "@ui/components"; -import { ApiPagination } from "@models/api-pagination.model"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; import { TagComponent } from "@ui/components/tag/tag.component"; -import { ProjectService } from "@office/services/project.service"; +import { ProjectService } from "projects/social_platform/src/app/api/project/project.service"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { MatProgressBarModule } from "@angular/material/progress-bar"; -import { LoadingService } from "@office/services/loading.service"; -import { ProjectAdditionalService } from "@office/projects/edit/services/project-additional.service"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; -import { NewsFormComponent } from "@office/features/news-form/news-form.component"; -import { AsyncPipe } from "@angular/common"; +import { LoadingService } from "@ui/services/loading/loading.service"; +import { SoonCardComponent } from "@ui/shared/soon-card/soon-card.component"; +import { NewsFormComponent } from "@ui/components/news-form/news-form.component"; import { AvatarComponent } from "@uilib"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { NewsCardComponent } from "@office/features/news-card/news-card.component"; +import { NewsCardComponent } from "@ui/components/news-card/news-card.component"; +import { TruncatePipe } from "projects/core/src/lib/pipes/formatters/truncate.pipe"; +import { UserLinksPipe } from "projects/core/src/lib/pipes/user/user-links.pipe"; +import { ProgramService } from "projects/social_platform/src/app/api/program/program.service"; +import { ProgramNewsService } from "projects/social_platform/src/app/api/program/program-news.service"; +import { ProjectAdditionalService } from "projects/social_platform/src/app/api/project/project-additional.service"; +import { FeedNews } from "projects/social_platform/src/app/domain/project/project-news.model"; +import { Program } from "projects/social_platform/src/app/domain/program/program.model"; @Component({ selector: "app-main", @@ -51,9 +49,7 @@ import { NewsCardComponent } from "@office/features/news-card/news-card.componen imports: [ IconComponent, ButtonComponent, - ProgramNewsCardComponent, UserLinksPipe, - AsyncPipe, ParseBreaksPipe, ParseLinksPipe, ModalComponent, @@ -236,7 +232,7 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { } @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; - @ViewChild(ProgramNewsCardComponent) ProgramNewsCardComponent?: ProgramNewsCardComponent; + @ViewChild(NewsCardComponent) ProgramNewsCardComponent?: NewsCardComponent; @ViewChild("descEl") descEl?: ElementRef; onNewsInVew(entries: IntersectionObserverEntry[]): void { diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.html b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.html similarity index 100% rename from projects/social_platform/src/app/office/program/detail/register/register.component.html rename to projects/social_platform/src/app/ui/pages/program/detail/register/register.component.html diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.scss similarity index 84% rename from projects/social_platform/src/app/office/program/detail/register/register.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/register/register.component.scss index e8f280db0..089025b06 100644 --- a/projects/social_platform/src/app/office/program/detail/register/register.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.scss @@ -7,7 +7,7 @@ .form { padding: 24px; background-color: var(--white); - border-radius: 5px; + border-radius: var(--rounded-md); &__fieldset { &:not(:last-child) { diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/program/detail/register/register.component.spec.ts rename to projects/social_platform/src/app/ui/pages/program/detail/register/register.component.spec.ts diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.ts similarity index 95% rename from projects/social_platform/src/app/office/program/detail/register/register.component.ts rename to projects/social_platform/src/app/ui/pages/program/detail/register/register.component.ts index cbf3bd75e..1562ee7eb 100644 --- a/projects/social_platform/src/app/office/program/detail/register/register.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.ts @@ -4,11 +4,11 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { map, Subscription } from "rxjs"; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ProgramDataSchema } from "@office/program/models/program.model"; import { ControlErrorPipe, ValidationService } from "projects/core"; -import { ProgramService } from "@office/program/services/program.service"; import { BarComponent, ButtonComponent, InputComponent } from "@ui/components"; import { KeyValuePipe } from "@angular/common"; +import { ProgramService } from "projects/social_platform/src/app/api/program/program.service"; +import { ProgramDataSchema } from "projects/social_platform/src/app/domain/program/program.model"; /** * Компонент регистрации в программе diff --git a/projects/social_platform/src/app/office/program/detail/register/register.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/program/detail/register/register.resolver.spec.ts rename to projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.spec.ts diff --git a/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts b/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.ts similarity index 90% rename from projects/social_platform/src/app/office/program/detail/register/register.resolver.ts rename to projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.ts index 4eebb752a..340d05f26 100644 --- a/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.ts @@ -2,8 +2,8 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { ProgramService } from "@office/program/services/program.service"; -import { ProgramDataSchema } from "@office/program/models/program.model"; +import { ProgramService } from "projects/social_platform/src/app/api/program/program.service"; +import { ProgramDataSchema } from "projects/social_platform/src/app/domain/program/program.model"; /** * Резолвер для получения схемы данных регистрации в программе diff --git a/projects/social_platform/src/app/office/program/main/main.component.html b/projects/social_platform/src/app/ui/pages/program/main/main.component.html similarity index 100% rename from projects/social_platform/src/app/office/program/main/main.component.html rename to projects/social_platform/src/app/ui/pages/program/main/main.component.html diff --git a/projects/social_platform/src/app/office/program/main/main.component.scss b/projects/social_platform/src/app/ui/pages/program/main/main.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/main/main.component.scss rename to projects/social_platform/src/app/ui/pages/program/main/main.component.scss diff --git a/projects/social_platform/src/app/office/program/main/main.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/main/main.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/program/main/main.component.spec.ts rename to projects/social_platform/src/app/ui/pages/program/main/main.component.spec.ts diff --git a/projects/social_platform/src/app/office/program/main/main.component.ts b/projects/social_platform/src/app/ui/pages/program/main/main.component.ts similarity index 94% rename from projects/social_platform/src/app/office/program/main/main.component.ts rename to projects/social_platform/src/app/ui/pages/program/main/main.component.ts index 7d14e73d3..b07d4c1b3 100644 --- a/projects/social_platform/src/app/office/program/main/main.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/main/main.component.ts @@ -11,15 +11,15 @@ import { Subscription, switchMap, } from "rxjs"; -import { Program } from "@office/program/models/program.model"; -import { NavService } from "@office/services/nav.service"; +import { NavService } from "@ui/services/nav/nav.service"; import Fuse from "fuse.js"; import { CheckboxComponent, SelectComponent } from "@ui/components"; import { generateOptionsList } from "@utils/generate-options-list"; import { ClickOutsideModule } from "ng-click-outside"; -import { ProgramCardComponent } from "../shared/program-card/program-card.component"; +import { ProgramCardComponent } from "../../../shared/program-card/program-card.component"; import { HttpParams } from "@angular/common/http"; -import { ProgramService } from "../services/program.service"; +import { ProgramService } from "projects/social_platform/src/app/api/program/program.service"; +import { Program } from "projects/social_platform/src/app/domain/program/program.model"; /** * Главный компонент списка программ diff --git a/projects/social_platform/src/app/office/program/main/main.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/program/main/main.resolver.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/program/main/main.resolver.spec.ts rename to projects/social_platform/src/app/ui/pages/program/main/main.resolver.spec.ts diff --git a/projects/social_platform/src/app/office/program/main/main.resolver.ts b/projects/social_platform/src/app/ui/pages/program/main/main.resolver.ts similarity index 85% rename from projects/social_platform/src/app/office/program/main/main.resolver.ts rename to projects/social_platform/src/app/ui/pages/program/main/main.resolver.ts index bf91ddbe9..5125e57c0 100644 --- a/projects/social_platform/src/app/office/program/main/main.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/program/main/main.resolver.ts @@ -1,10 +1,10 @@ /** @format */ import { inject } from "@angular/core"; -import { ProgramService } from "@office/program/services/program.service"; import { ResolveFn } from "@angular/router"; -import { ApiPagination } from "@models/api-pagination.model"; -import { Program } from "@office/program/models/program.model"; +import { ProgramService } from "projects/social_platform/src/app/api/program/program.service"; +import { ApiPagination } from "projects/social_platform/src/app/domain/other/api-pagination.model"; +import { Program } from "projects/social_platform/src/app/domain/program/program.model"; /** * Резолвер для предзагрузки списка программ diff --git a/projects/social_platform/src/app/office/program/program.component.html b/projects/social_platform/src/app/ui/pages/program/program.component.html similarity index 100% rename from projects/social_platform/src/app/office/program/program.component.html rename to projects/social_platform/src/app/ui/pages/program/program.component.html diff --git a/projects/social_platform/src/app/office/program/program.component.scss b/projects/social_platform/src/app/ui/pages/program/program.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/program.component.scss rename to projects/social_platform/src/app/ui/pages/program/program.component.scss diff --git a/projects/social_platform/src/app/office/program/program.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/program.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/program/program.component.spec.ts rename to projects/social_platform/src/app/ui/pages/program/program.component.spec.ts diff --git a/projects/social_platform/src/app/office/program/program.component.ts b/projects/social_platform/src/app/ui/pages/program/program.component.ts similarity index 96% rename from projects/social_platform/src/app/office/program/program.component.ts rename to projects/social_platform/src/app/ui/pages/program/program.component.ts index 6c8b51465..09c93dc47 100644 --- a/projects/social_platform/src/app/office/program/program.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/program.component.ts @@ -1,14 +1,14 @@ /** @format */ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { NavService } from "@services/nav.service"; +import { NavService } from "@ui/services/nav/nav.service"; import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { Subscription } from "rxjs"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { SearchComponent } from "@ui/components/search/search.component"; import { BarComponent } from "@ui/components"; -import { ProgramService } from "./services/program.service"; import { BackComponent } from "@uilib"; +import { ProgramService } from "../../../api/program/program.service"; /** * Основной компонент модуля "Программы" diff --git a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.html b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html similarity index 100% rename from projects/social_platform/src/app/office/projects/dashboard/dashboard.component.html rename to projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html diff --git a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.scss b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/dashboard/dashboard.component.scss rename to projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.scss diff --git a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts similarity index 89% rename from projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts rename to projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts index 374208b6a..0037e9842 100644 --- a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts @@ -3,10 +3,10 @@ import { CommonModule } from "@angular/common"; import { Component, inject, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { Project } from "@office/models/project.model"; import { Subscription } from "rxjs"; -import { DashboardItemComponent } from "./shared/dashboardItem/dashboardItem.component"; +import { DashboardItemComponent } from "../../../shared/dashboardItem/dashboardItem.component"; import { DashboardItem, dashboardItemBuilder } from "@utils/helpers/dashboardItemBuilder"; +import { Project } from "projects/social_platform/src/app/domain/project/project.model"; @Component({ selector: "app-dashboard", diff --git a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.html similarity index 95% rename from projects/social_platform/src/app/office/projects/detail/chat/chat.component.html rename to projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.html index c73d6fe13..8559be0ef 100644 --- a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.html @@ -1,5 +1,5 @@ -@if (project) { +@if (project()) {
@if (!messages.length) { @@ -30,7 +30,7 @@

участники