diff --git a/.eslintrc.json b/.eslintrc.json index 99e8d33e1..3c8d2da82 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,7 @@ "es2021": true, "jasmine": true }, - "extends": ["standard", "prettier", "plugin:@typescript-eslint/recommended"], + "extends": ["standard", "plugin:@typescript-eslint/recommended", "prettier"], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": "latest", @@ -14,6 +14,10 @@ "rules": { "no-useless-constructor": "off", "@typescript-eslint/no-empty-function": "off", - "dot-notation": "off" + "dot-notation": "off", + "lines-between-class-members": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-unused-expressions": "off" } } diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 0e1c44005..89d28f846 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -34,19 +34,7 @@ jobs: NETLIFY_PREVIEW_APP: true # or perhaps like this GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NETLIFY_PR_ID: ${{ github.event.pull_request.number }}-social - - name: Publish preview skills - uses: netlify/actions/cli@master - id: publish_preview_skills - with: - args: deploy --dir=./dist/skills - env: - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SKILLS_SITE_ID }} - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_ACCESS_TOKEN }} - NETLIFY_PREVIEW_APP: true # or perhaps like this - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NETLIFY_PR_ID: ${{ github.event.pull_request.number }}-skills - uses: mshick/add-pr-comment@v2 with: message: | Social platform url - ${{steps.publish_preview_social.outputs.NETLIFY_URL}} - Skills platform url - ${{steps.publish_preview_skills.outputs.NETLIFY_URL}} diff --git a/angular.json b/angular.json index 3593b57be..7683af089 100644 --- a/angular.json +++ b/angular.json @@ -89,6 +89,7 @@ "test": { "builder": "@angular-devkit/build-angular:karma", "options": { + "karmaConfig": "karma.conf.js", "polyfills": ["zone.js", "zone.js/testing"], "tsConfig": "projects/social_platform/tsconfig.spec.json", "inlineStyleLanguage": "scss", @@ -136,94 +137,6 @@ } } }, - "skills": { - "projectType": "application", - "schematics": { - "@schematics/angular:component": { - "style": "scss" - } - }, - "root": "projects/skills", - "sourceRoot": "projects/skills/src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "options": { - "outputPath": "dist/skills", - "index": "projects/skills/src/index.html", - "main": "projects/skills/src/main.ts", - "polyfills": ["zone.js"], - "tsConfig": "projects/skills/tsconfig.app.json", - "inlineStyleLanguage": "scss", - "assets": ["projects/skills/src/favicon.ico", "projects/skills/src/assets"], - "styles": ["projects/skills/src/styles.scss"], - "scripts": [], - "stylePreprocessorOptions": { - "includePaths": ["projects/skills/src"] - } - }, - "configurations": { - "production": { - "fileReplacements": [ - { - "replace": "projects/skills/src/environments/environment.ts", - "with": "projects/skills/src/environments/environment.prod.ts" - } - ], - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kb", - "maximumError": "1mb" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "6kb", - "maximumError": "12kb" - } - ], - "outputHashing": "all" - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "configurations": { - "production": { - "buildTarget": "skills:build:production" - }, - "development": { - "buildTarget": "skills:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", - "options": { - "buildTarget": "skills:build" - } - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "polyfills": ["zone.js", "zone.js/testing"], - "tsConfig": "projects/skills/tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": ["projects/skills/src/favicon.ico", "projects/skills/src/assets"], - "styles": ["projects/skills/src/styles.scss"], - "scripts": [] - } - } - } - }, "ui": { "projectType": "library", "root": "projects/ui", diff --git a/package.json b/package.json index a302369f7..22ec35819 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,12 @@ "scripts": { "ng": "ng", "start:social": "ng serve social_platform --open", - "start:skills": "ng serve skills --open", "build:social:dev": "npm run build:sprite && ng build social_platform --configuration=development", "build:social:prod": "npm run build:sprite && ng build social_platform --configuration=production", - "build:skills:prod": "ng build skills --configuration=production", - "build:skills:dev": "ng build skills --configuration=development", - "build:prod": "npm run build:social:prod && npm run build:skills:prod", + "build:prod": "npm run build:social:prod", "build:pr": "npm run build:social:dev", "watch": "ng build --watch social_platform --configuration=development", - "build:sprite-social": "svg-sprite -s --dest=projects/social_platform/src/assets/icons 'projects/social_platform/src/assets/icons/svg/**/*.svg'", - "build:sprite-skills": "svg-sprite -s --dest=projects/skills/src/assets/icons 'projects/skills/src/assets/icons/svg/**/*.svg'", - "build:sprite": "npm run build:sprite-skills && npm run build:sprite-social", + "build:sprite": "svg-sprite -s --dest=projects/social_platform/src/assets/icons 'projects/social_platform/src/assets/icons/svg/**/*.svg'", "test": "ng test", "test:ci": "ng test --browsers=Headless --no-watch", "lint:ts": "eslint projects/**/*.ts", 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/lists/trajectory-more-list.const.ts b/projects/core/src/consts/lists/trajectory-more-list.const.ts deleted file mode 100644 index 7a8861cbd..000000000 --- a/projects/core/src/consts/lists/trajectory-more-list.const.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** @format */ - -export const trajectoryMoreList = [ - { - label: "Работа с наставником", - }, - { - label: "Индивидуальный набор навыков", - }, - { - label: "Трекинг прогресса", - }, - { - label: "Действия > Обучение", - }, -]; diff --git a/projects/core/src/consts/navigation/nav-profile-items.const.ts b/projects/core/src/consts/navigation/nav-profile-items.const.ts index cba732394..aef430bb5 100644 --- a/projects/core/src/consts/navigation/nav-profile-items.const.ts +++ b/projects/core/src/consts/navigation/nav-profile-items.const.ts @@ -1,33 +1,35 @@ /** @format */ +import { EditStep } from "projects/social_platform/src/app/api/project/project-step.service"; + export const navProfileItems = [ { - step: "main", + step: "main" as EditStep, src: "main", label: "основные данные", }, { - step: "education", + step: "education" as EditStep, src: "in-search", label: "образование", }, { - step: "experience", + step: "experience" as EditStep, src: "suitcase", label: "работа", }, { - step: "achievements", + step: "achievements" as EditStep, src: "medal", label: "достижения", }, { - step: "skills", + step: "skills" as EditStep, src: "squiz", label: "навыки", }, { - step: "settings", + step: "settings" as EditStep, src: "settings", label: "действия", }, 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..96bf1eef6 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"; /** * Элементы навигации для редактирования проекта @@ -9,26 +9,32 @@ import { EditStep } from "@office/projects/edit/services/project-step.service"; export const navProjectItems = [ { step: "main" as EditStep, // Идентификатор шага + src: "main", label: "основные данные", // Отображаемый текст }, { step: "contacts" as EditStep, + src: "contacts", label: "партнеры и ресурсы", }, { step: "achievements" as EditStep, + src: "achievements", label: "достижения", }, { step: "vacancies" as EditStep, + src: "vacancies", label: "вакансии", }, { step: "team" as EditStep, + src: "team", label: "команда", }, { step: "additional" as EditStep, + src: "additional", label: "данные для конкурсов", }, ]; 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/other/trajectory-more.const.ts b/projects/core/src/consts/other/trajectory-more.const.ts new file mode 100644 index 000000000..63f222cfa --- /dev/null +++ b/projects/core/src/consts/other/trajectory-more.const.ts @@ -0,0 +1,16 @@ +/** @format */ + +export const trajectoryMore = [ + { + label: "Работа с наставником", + }, + { + label: "Индивидуальный набор навыков", + }, + { + label: "Трекинг прогресса", + }, + { + 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 86% 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..4dd163328 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 { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { TokenService } from "../../services/tokens/token.service"; /** * Guard для проверки аутентификации пользователя @@ -22,14 +22,14 @@ import { TokenService } from "@corelib"; */ export const AuthRequiredGuard: CanActivateFn = () => { const tokenService = inject(TokenService); - const authService = inject(AuthService); + const authRepository = inject(AuthRepositoryPort); const router = inject(Router); if (tokenService.getTokens() === null) { return router.createUrlTree(["/auth/login"]); } - return authService.getProfile().pipe( + return authRepository.fetchProfile().pipe( map(profile => !!profile), catchError(() => { return router.navigateByUrl("/auth/login"); 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..1a2429561 --- /dev/null +++ b/projects/core/src/lib/guards/kanban/kanban.guard.ts @@ -0,0 +1,37 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; +import { User } from "@domain/auth/user.model"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { Collaborator } from "@domain/project/collaborator.model"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { catchError, map, Observable, of, switchMap } from "rxjs"; + +export const KanbanBoardGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot +): Observable => { + const router = inject(Router); + const projectRepository = inject(ProjectRepositoryPort); + const authRepository = inject(AuthRepositoryPort); + + const projectId = Number(route.parent?.params["projectId"]); + + if (!projectId) return of(router.createUrlTree([`/office/projects/${projectId}/`])); + + return authRepository.fetchProfile().pipe( + switchMap((user: User) => + projectRepository.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"]))) + ) + ), + catchError(() => of(router.createUrlTree(["/auth/login"]))) + ); +}; 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..aaec71665 --- /dev/null +++ b/projects/core/src/lib/guards/profile-edit/profile-edit.guard.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; + +export const ProfileEditRequiredGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot +): Observable => { + const router = inject(Router); + const authRepository = inject(AuthRepositoryPort); + + const profileId = Number(route.paramMap.get("id")); + + return authRepository.fetchProfile().pipe( + map(profile => + profile.id === profileId ? true : router.createUrlTree([`/office/profile/${profileId}/`]) + ), + catchError(() => of(router.createUrlTree(["/auth/login"]))) + ); +}; 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 81% 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..b51f01aa5 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 @@ -2,22 +2,22 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; import { Observable, of } from "rxjs"; import { catchError, map } from "rxjs/operators"; -import { ProjectService } from "@office/services/project.service"; export const ProjectEditRequiredGuard: CanActivateFn = ( route: ActivatedRouteSnapshot ): Observable => { const router = inject(Router); - const projectService = inject(ProjectService); + const projectRepository = inject(ProjectRepositoryPort); const projectId = Number(route.paramMap.get("projectId")); if (isNaN(projectId)) { return of(router.createUrlTree(["/office/projects/my"])); } - return projectService.getOne(projectId).pipe( + return projectRepository.getOne(projectId).pipe( map(project => { if (project.partnerProgram?.isSubmitted) { return router.createUrlTree([`/office/projects/${projectId}`]); 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/core/src/lib/interceptors/bearer-token.interceptor.ts b/projects/core/src/lib/interceptors/bearer-token.interceptor.ts index 22d47dac4..e6565bd20 100644 --- a/projects/core/src/lib/interceptors/bearer-token.interceptor.ts +++ b/projects/core/src/lib/interceptors/bearer-token.interceptor.ts @@ -10,7 +10,7 @@ import { } from "@angular/common/http"; import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from "rxjs"; import { Router } from "@angular/router"; -import { TokenService } from "../services"; +import { LoggerService, TokenService } from "../services"; /** * HTTP интерцептор для автоматического управления JWT токенами @@ -30,7 +30,11 @@ import { TokenService } from "../services"; */ @Injectable() export class BearerTokenInterceptor implements HttpInterceptor { - constructor(private readonly tokenService: TokenService, private readonly router: Router) {} + constructor( + private readonly tokenService: TokenService, + private readonly router: Router, + private readonly loggerService: LoggerService + ) {} /** Флаг предотвращения множественных запросов на обновление токена */ private isRefreshing = false; @@ -92,7 +96,7 @@ export class BearerTokenInterceptor implements HttpInterceptor { if (error.status === 401 && request.url.includes("/api/token/refresh")) { this.router .navigateByUrl("/auth/login") - .then(() => console.debug("Redirected to login: refresh token expired")); + .then(() => this.loggerService.debug("Redirected to login: refresh token expired")); } // Если 401 на другом endpoint - пытаемся обновить токен else if (error.status === 401 && !request.url.includes("/api/token/refresh")) { diff --git a/projects/core/src/lib/interceptors/camelcase.interceptor.ts b/projects/core/src/lib/interceptors/camelcase.interceptor.ts index 44daedd8b..e7b6700d2 100644 --- a/projects/core/src/lib/interceptors/camelcase.interceptor.ts +++ b/projects/core/src/lib/interceptors/camelcase.interceptor.ts @@ -11,6 +11,7 @@ import { import { map, type Observable } from "rxjs"; import * as snakecaseKeys from "snakecase-keys"; import camelcaseKeys from "camelcase-keys"; +import { LoggerService } from "../services/logger/logger.service"; /** * HTTP интерцептор для автоматического преобразования стиля именования ключей объектов @@ -31,7 +32,7 @@ import camelcaseKeys from "camelcase-keys"; */ @Injectable() export class CamelcaseInterceptor implements HttpInterceptor { - constructor() {} + constructor(private readonly loggerService: LoggerService) {} /** * Основной метод интерцептора @@ -46,7 +47,7 @@ export class CamelcaseInterceptor implements HttpInterceptor { let req: HttpRequest>; // Обрабатываем тело запроса если оно существует - if (request.body) { + if (request.body && !(request.body instanceof FormData)) { // Клонируем запрос с преобразованным телом (camelCase → snake_case) req = request.clone({ body: snakecaseKeys(request.body, { @@ -77,7 +78,10 @@ export class CamelcaseInterceptor implements HttpInterceptor { }), }); } catch (error) { - console.warn("CamelcaseInterceptor: Failed to transform response body", error); + this.loggerService.warn( + "CamelcaseInterceptor: Failed to transform response body", + error + ); return event; } } diff --git a/projects/core/src/lib/interceptors/index.ts b/projects/core/src/lib/interceptors/index.ts index 923562170..d7ec38e75 100644 --- a/projects/core/src/lib/interceptors/index.ts +++ b/projects/core/src/lib/interceptors/index.ts @@ -2,3 +2,4 @@ export * from "./bearer-token.interceptor"; export * from "./camelcase.interceptor"; +export * from "./logging.interceptor"; diff --git a/projects/core/src/lib/interceptors/logging.interceptor.ts b/projects/core/src/lib/interceptors/logging.interceptor.ts new file mode 100644 index 000000000..3edaf69ee --- /dev/null +++ b/projects/core/src/lib/interceptors/logging.interceptor.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { Injectable } from "@angular/core"; +import { + HttpErrorResponse, + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, + HttpResponse, +} from "@angular/common/http"; +import { Observable, tap } from "rxjs"; +import { LoggerService } from "../services"; + +@Injectable() +export class LoggingInterceptor implements HttpInterceptor { + constructor(private readonly logger: LoggerService) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + const started = Date.now(); + + return next.handle(request).pipe( + tap({ + next: event => { + if (event instanceof HttpResponse) { + const elapsed = Date.now() - started; + this.logger.debug( + `[HTTP] ${request.method} ${request.urlWithParams} ${event.status} ${elapsed}ms` + ); + } + }, + error: (error: HttpErrorResponse) => { + const elapsed = Date.now() - started; + this.logger.error( + `[HTTP] ${request.method} ${request.urlWithParams} ${error.status} ${elapsed}ms`, + error.message + ); + }, + }) + ); + } +} 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 98% 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 index 8b901cade..88ff734c7 100644 --- a/projects/core/src/lib/pipes/years-from-birthday.pipe.ts +++ b/projects/core/src/lib/pipes/formatters/years-from-birthday.pipe.ts @@ -1,7 +1,7 @@ /** @format */ import { Pipe, type PipeTransform } from "@angular/core"; -import { PluralizePipe } from "projects/core"; +import { PluralizePipe } from "./pluralize.pipe"; import * as RelativeTime from "dayjs/plugin/relativeTime"; import * as dayjs from "dayjs"; 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/core/src/lib/pipes/user/user-role.pipe.spec.ts b/projects/core/src/lib/pipes/user/user-role.pipe.spec.ts new file mode 100644 index 000000000..aa9989c75 --- /dev/null +++ b/projects/core/src/lib/pipes/user/user-role.pipe.spec.ts @@ -0,0 +1,15 @@ +/** @format */ + +import { AuthService } from "projects/social_platform/src/app/api/auth"; +import { UserRolePipe } from "../user-role.pipe"; +import { of } from "rxjs"; + +describe("UserRolePipe", () => { + it("create an instance", () => { + const authSpy = { + roles: of([]), + }; + const pipe = new UserRolePipe(authSpy as unknown as AuthService); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/projects/core/src/lib/pipes/user/user-role.pipe.ts b/projects/core/src/lib/pipes/user/user-role.pipe.ts new file mode 100644 index 000000000..a0ef2c925 --- /dev/null +++ b/projects/core/src/lib/pipes/user/user-role.pipe.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { Pipe, PipeTransform } from "@angular/core"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { map, Observable, shareReplay } from "rxjs"; + +/** + * Пайп для преобразования ID роли пользователя в название роли + * Используется в шаблонах Angular для отображения названия роли вместо её ID + * + * Пример использования в шаблоне: {{ userId | userRole | async }} + */ +@Pipe({ + name: "userRole", + standalone: true, +}) +export class UserRolePipe implements PipeTransform { + private readonly roles$ = this.authRepository.fetchUserRoles().pipe(shareReplay(1)); + + constructor(private readonly authRepository: AuthRepositoryPort) {} + + /** + * Преобразует числовой ID роли в название роли + * + * @param value - ID роли (число) + * @returns Observable - Observable с названием роли или undefined, если роль не найдена + */ + transform(value: number): Observable { + return this.roles$.pipe(map(roles => roles.find(role => role.id === value)?.name)); + } +} 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 81% rename from projects/core/src/lib/services/api.service.ts rename to projects/core/src/lib/services/api/api.service.ts index 02df1d320..27b11033b 100644 --- a/projects/core/src/lib/services/api.service.ts +++ b/projects/core/src/lib/services/api/api.service.ts @@ -2,8 +2,9 @@ import { Inject, Injectable } from "@angular/core"; import { HttpClient, HttpParams } from "@angular/common/http"; -import { first, Observable } from "rxjs"; -import { API_URL } from "../providers"; +import { first, Observable, retry, throwError, timer } from "rxjs"; +import { API_URL } from "../../providers"; +import { exponentialBackoff } from "@utils/exponentialBackoff"; /** * Базовый сервис для работы с REST API @@ -30,6 +31,8 @@ export class ApiService { @Inject(API_URL) private readonly apiUrl: string ) {} + private RETRY_COUNT = 3; + /** * Выполняет GET запрос к API * @param path - Относительный путь к ресурсу (будет добавлен к базовому URL) @@ -41,7 +44,9 @@ export class ApiService { * apiService.get('/users', new HttpParams().set('page', '1')) */ get(path: string, params?: HttpParams, options?: object): Observable { - return this.http.get(this.apiUrl + path, { params, ...options }).pipe(first()) as Observable; + return this.http + .get(this.apiUrl + path, { params, ...options }) + .pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable; } getFile(path: string, params?: HttpParams): Observable { @@ -50,7 +55,7 @@ export class ApiService { params, responseType: "blob", }) - .pipe(first()) as Observable; + .pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable; } /** @@ -63,7 +68,9 @@ export class ApiService { * apiService.put('/users/1', { name: 'John', email: 'john@example.com' }) */ put(path: string, body: object): Observable { - return this.http.put(this.apiUrl + path, body).pipe(first()) as Observable; + return this.http + .put(this.apiUrl + path, body) + .pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable; } /** @@ -76,7 +83,9 @@ export class ApiService { * apiService.patch('/users/1', { name: 'John' }) // обновляет только имя */ patch(path: string, body: object): Observable { - return this.http.patch(this.apiUrl + path, body).pipe(first()) as Observable; + return this.http + .patch(this.apiUrl + path, body) + .pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable; } /** @@ -89,7 +98,9 @@ export class ApiService { * apiService.post('/users', { name: 'John', email: 'john@example.com' }) */ post(path: string, body: object): Observable { - return this.http.post(this.apiUrl + path, body).pipe(first()) as Observable; + return this.http + .post(this.apiUrl + path, body) + .pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable; } /** @@ -103,6 +114,8 @@ export class ApiService { * apiService.delete('/users', new HttpParams().set('ids', '1,2,3')) */ delete(path: string, params?: HttpParams): Observable { - return this.http.delete(this.apiUrl + path, { params }).pipe(first()) as Observable; + return this.http + .delete(this.apiUrl + path, { params }) + .pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable; } } diff --git a/projects/core/src/lib/services/skillsApi.service.ts b/projects/core/src/lib/services/api/skillsApi.service.ts similarity index 97% rename from projects/core/src/lib/services/skillsApi.service.ts rename to projects/core/src/lib/services/api/skillsApi.service.ts index 00cf8e2bc..80e38001f 100644 --- a/projects/core/src/lib/services/skillsApi.service.ts +++ b/projects/core/src/lib/services/api/skillsApi.service.ts @@ -2,8 +2,8 @@ import { Inject, Injectable } from "@angular/core"; import { HttpClient } from "@angular/common/http"; +import { SKILLS_API_URL } from "../../providers/api-url.provide"; import { ApiService } from "./api.service"; -import { 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 85% 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..435c23873 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,8 @@ import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { ErrorCode } from "../models/error-code"; +import { ErrorCode } from "../../models/error/error-code"; +import { LoggerService } from "../logger/logger.service"; /** * Сервис для обработки и навигации к страницам ошибок @@ -30,7 +31,7 @@ import { ErrorCode } from "../models/error-code"; providedIn: "root", }) export class ErrorService { - constructor(private readonly router: Router) {} + constructor(private readonly router: Router, private readonly loggerService: LoggerService) {} /** * Навигация на страницу ошибки 404 @@ -54,6 +55,8 @@ export class ErrorService { * @returns Promise - промис завершения навигации с логированием */ private throwError(type: ErrorCode): Promise { - return this.router.navigateByUrl(`/error/${type}`).then(() => console.debug("Route Changed")); + return this.router + .navigateByUrl(`/error/${type}`) + .then(() => this.loggerService.debug("Route Changed")); } } 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/core/src/lib/services/error/global-error-handler.service.ts b/projects/core/src/lib/services/error/global-error-handler.service.ts new file mode 100644 index 000000000..df5040d19 --- /dev/null +++ b/projects/core/src/lib/services/error/global-error-handler.service.ts @@ -0,0 +1,48 @@ +/** @format */ + +import { ErrorHandler, inject, Injectable, NgZone } from "@angular/core"; +import { ErrorService } from "./error.service"; +import { LoggerService } from "../logger/logger.service"; + +/** + * Глобальный обработчик ошибок приложения + * + * Назначение: + * - Перехватывает все необработанные ошибки в приложении + * - Реализует интерфейс ErrorHandler от Angular + * - Обеспечивает централизованную обработку ошибок + * + * Функциональность: + * - Обрабатывает как синхронные, так и асинхронные ошибки (Promise rejections) + * - Логирует ошибки в консоль для отладки + * - Может перенаправлять на страницы ошибок (закомментированный код) + * + * Принимает: + * - err: any - любая ошибка, возникшая в приложении + * + * Возвращает: void + * + * Зависимости: + * - ErrorService - для навигации на страницы ошибок + * - NgZone - для выполнения операций в Angular зоне + * + * Примечание: + * - Код для обработки HTTP ошибок закомментирован + * - Можно расширить для специфической обработки разных типов ошибок + */ +@Injectable() +export class GlobalErrorHandlerService implements ErrorHandler { + private readonly logger = inject(LoggerService); + + constructor(private readonly errorService: ErrorService, private readonly zone: NgZone) {} + + handleError(err: any): void { + const error = err.rejection ? err.rejection : err; + + if (error instanceof Error) { + this.logger.error(`[GlobalError] ${error.name}: ${error.message}`, error.stack); + } else { + this.logger.error("[GlobalError] Unknown error", error); + } + } +} 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/core/src/lib/services/file/file.service.ts b/projects/core/src/lib/services/file/file.service.ts new file mode 100644 index 000000000..4d9de08db --- /dev/null +++ b/projects/core/src/lib/services/file/file.service.ts @@ -0,0 +1,50 @@ +/** @format */ + +import { Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { HttpParams } from "@angular/common/http"; +import { ApiService } from "../api/api.service"; + +/** + * Сервис для работы с файлами + * Предоставляет методы для загрузки и удаления файлов через API + * Использует ApiService + HttpClient, поэтому авторизация, retry и camelCase + * обрабатываются интерсепторами автоматически + */ +@Injectable({ + providedIn: "root", +}) +export class FileService { + private readonly FILES_URL = "/files"; + + constructor(private readonly apiService: ApiService) {} + + /** + * Загружает файл на сервер + * + * @param file - объект File для загрузки + * @returns Observable<{ url: string }> - Observable с URL загруженного файла + * + * Использует HttpClient через ApiService — интерсепторы (bearer token, retry, logging) + * работают автоматически. CamelcaseInterceptor пропускает FormData без преобразования. + */ + uploadFile(file: File): Observable<{ url: string }> { + const formData = new FormData(); + formData.append("file", file); + + return this.apiService.post<{ url: string }>(`${this.FILES_URL}/`, formData); + } + + /** + * Удаляет файл с сервера по URL + * + * @param fileUrl - URL файла для удаления + * @returns Observable<{ success: true }> - Observable с результатом операции + * + * Передает URL файла как query параметр 'link' + */ + deleteFile(fileUrl: string): Observable<{ success: true }> { + const params = new HttpParams({ fromObject: { link: fileUrl } }); + return this.apiService.delete(`${this.FILES_URL}/`, params); + } +} diff --git a/projects/core/src/lib/services/index.ts b/projects/core/src/lib/services/index.ts index 538be53ff..bc241117f 100644 --- a/projects/core/src/lib/services/index.ts +++ b/projects/core/src/lib/services/index.ts @@ -1,8 +1,9 @@ /** @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"; +export * from "./logger/logger.service"; diff --git a/projects/core/src/lib/services/logger/logger.service.ts b/projects/core/src/lib/services/logger/logger.service.ts new file mode 100644 index 000000000..fd40c18f0 --- /dev/null +++ b/projects/core/src/lib/services/logger/logger.service.ts @@ -0,0 +1,129 @@ +/** @format */ + +import { Inject, Injectable, Optional } from "@angular/core"; + +/** + * Уровни логирования + */ +enum LogLevel { + DEBUG = "DEBUG", + INFO = "INFO", + WARN = "WARN", + ERROR = "ERROR", +} + +/** + * Сервис логирования с поддержкой уровней и форматирования + * + * Особенности: + * - DEBUG логи пишутся только в development режиме + * - Все логи включают timestamp и уровень логирования + * - Поддержка дополнительного контекста (метаданные) + * - Форматированный вывод для лучшей читаемости + */ +@Injectable({ providedIn: "root" }) +export class LoggerService { + private isDev = this.isDevMode(); + + constructor( + @Optional() + @Inject("PRODUCTION") + private production?: boolean + ) { + // Если PRODUCTION inject предоставлен, используем его, иначе проверяем окружение + if (this.production !== undefined) { + this.isDev = !this.production; + } + } + + /** + * Логирование на уровне DEBUG + * Видно только в development режиме + * + * @param message - сообщение логирования + * @param data - дополнительные данные/контекст + */ + // eslint-disable-next-line no-console + debug(message: string, data?: unknown): void { + if (this.isDev) { + console.debug(this.formatLog(LogLevel.DEBUG, message, data)); + } + } + + /** + * Логирование на уровне INFO + * Видно в обоих режимах + * + * @param message - сообщение логирования + * @param data - дополнительные данные/контекст + */ + // eslint-disable-next-line no-console + info(message: string, data?: unknown): void { + console.info(this.formatLog(LogLevel.INFO, message, data)); + } + + /** + * Логирование на уровне WARN + * Видно в обоих режимах + * + * @param message - сообщение логирования + * @param data - дополнительные данные/контекст + */ + // eslint-disable-next-line no-console + warn(message: string, data?: unknown): void { + console.warn(this.formatLog(LogLevel.WARN, message, data)); + } + + /** + * Логирование на уровне ERROR + * Видно в обоих режимах + * + * @param message - сообщение логирования + * @param error - объект ошибки или дополнительные данные + */ + // eslint-disable-next-line no-console + error(message: string, error?: unknown): void { + console.error(this.formatLog(LogLevel.ERROR, message, error)); + } + + /** + * Форматирует лог с временем, уровнем и метаданными + * + * Формат: + * [HH:MM:SS.sss] [LEVEL] Message: { data } + */ + private formatLog(level: LogLevel, message: string, data?: unknown): string { + const timestamp = this.getTimestamp(); + const dataStr = data ? ` ${JSON.stringify(data)}` : ""; + return `[${timestamp}] [${level}] ${message}${dataStr}`; + } + + /** + * Возвращает текущее время в формате HH:MM:SS.sss + */ + private getTimestamp(): string { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + const ms = String(now.getMilliseconds()).padStart(3, "0"); + return `${hours}:${minutes}:${seconds}.${ms}`; + } + + /** + * Проверяет, находимся ли мы в development режиме + */ + private isDevMode(): boolean { + return !this.isProduction(); + } + + /** + * Проверяет, находимся ли мы в production режиме + */ + private isProduction(): boolean { + // Проверяем разные способы определения production + return ( + typeof process !== "undefined" && process.env && process.env["NODE_ENV"] === "production" + ); + } +} 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 94% rename from projects/core/src/lib/services/token.service.ts rename to projects/core/src/lib/services/tokens/token.service.ts index 06f74b0e0..2f9d909a4 100644 --- a/projects/core/src/lib/services/token.service.ts +++ b/projects/core/src/lib/services/tokens/token.service.ts @@ -2,11 +2,12 @@ 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"; +import { PRODUCTION } from "../../providers/production.provide"; +import { ApiService } from "../api/api.service"; /** * Сервис для управления JWT токенами аутентификации 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 98% rename from projects/social_platform/src/app/core/services/websocket.service.ts rename to projects/core/src/lib/services/websockets/websocket.service.ts index a99043f9b..019ec9181 100644 --- a/projects/social_platform/src/app/core/services/websocket.service.ts +++ b/projects/core/src/lib/services/websockets/websocket.service.ts @@ -4,7 +4,7 @@ import { filter, map, Observable, Observer, retry, Subject } from "rxjs"; import { environment } from "@environment"; import * as snakecaseKeys from "snakecase-keys"; import camelcaseKeys from "camelcase-keys"; -import { TokenService } from "@corelib"; +import { TokenService } from "../tokens/token.service"; /** * Сервис для работы с WebSocket соединениями diff --git a/projects/skills/README.md b/projects/skills/README.md deleted file mode 100644 index c6e260df0..000000000 --- a/projects/skills/README.md +++ /dev/null @@ -1,228 +0,0 @@ - - -# Skills Management Platform - -Комплексное приложение Angular для управления навыками, траекторией обучения и отслеживания прогресса пользователей. Эта платформа обеспечивает интерактивный процесс обучения с различными типами заданий, системами оценки и рекомендациями по карьерной траектории. - -## 🏗️ Project Structure - -\`\`\` -src/ -├── app/ # Основное приложение -│ ├── profile/ # Модуль профиля пользователя -│ ├── skills/ # Модуль управления навыками -│ ├── task/ # Модуль выполнения заданий -│ ├── rating/ # Модуль рейтингов -│ ├── trajectories/ # Модуль траекторий обучения -│ ├── subscription/ # Модуль подписок -│ ├── webinars/ # Модуль вебинаров -│ └── shared/ # Общие компоненты -├── models/ # TypeScript модели данных -├── assets/ # Статические ресурсы -├── styles/ # Глобальные стили -└── environments/ # Environment configurations -\`\`\` - -## 🚀 Features - -### Core Modules - -1. **Управление профилем** (`/profile`) - - - Отображение и редактирование профиля пользователя - - Выбор и управление навыками - - Отслеживание прогресса - - Управление студентами для наставников - -2. **Система навыков** (`/skills`) - - - Просмотр и выбор навыков - - Подробная информация о навыках - - Отслеживание выполнения задач - - Визуализация прогресса - -3. **Система задач** (`/task`) - - - Интерактивные учебные задачи - - Несколько типов вопросов: - - Вопросы с одним вариантом ответа - - Задачи на сопоставление/соответствие - - Задачи на исключение - - Письменные ответы - - Информационные слайды - - Отслеживание прогресса в режиме реального времени - - Результаты и обратная связь - -4. **Система рейтингов** (`/rating`) - - - Рейтинги пользователей - - Рейтинги по навыкам - - Отслеживание достижений - -5. **Траектории** (`/trajectories`) - - - Профессиональное ориентирование - - Планирование бизнес-траектории - - Назначение наставника - - Этапы прогресса - -6. **Управление подписками** (`/subscription`) - - - Планы подписки - - Обработка платежей - - Контроль доступа - -7. **Вебинары** (`/webinars`) - - Прямые и записанные сессии - - Система регистрации - - Управление контентом - -### Additional Features - -1. **Skills Management** - - - Просмотр доступных навыков - - Выбор персональных навыков для изучения - - Отслеживание прогресса по каждому навыку - - Система уровней сложности - -2. **Interactive Tasks** - - - Различные типы вопросов (одиночный выбор, соединение, исключение, письменные ответы) - - Информационные слайды с обучающим контентом - - Система подсказок и обратной связи - - Прогресс-бар выполнения заданий - -3. **Learning Trajectories** - - - Структурированные программы развития карьеры - - Назначение менторов - - Отслеживание прогресса по месяцам - - Индивидуальные навыки от менторов - -4. **Rating System** - - - Общий рейтинг пользователей - - Рейтинг по навыкам - - Система очков и достижений - -5. **User Profile** - - Персональная информация - - История обучения - - Статистика прогресса - - Управление подпиской - -## 🛠️ Technical Stack - -- **Frontend**: Angular 17+ (Standalone Components) -- **Styling**: SCSS с модульной архитектурой -- **State Management**: Angular Signals -- **HTTP**: Angular HttpClient -- **Routing**: Angular Router с lazy loading -- **Typing**: TypeScript - -## 📱 Responsive Design - -Приложение полностью адаптивно благодаря: - -- Подходу «Mobile-first» (мобильные устройства в приоритете) -- Адаптивным макетам для планшетов и настольных компьютеров -- Интерактивным элементам, удобным для сенсорного управления -- Оптимизированной производительности на всех устройствах - -## 🔧 Настройка разработки - -1. Установите зависимости: - \`\`\`bash - npm install - \`\`\` - -2. Запустите сервер разработки: - \`\`\`bash - ng serve - \`\`\` - -3. Сборка для производства: - \`\`\`bash - ng build --prod - \`\`\` - -## 🎨 Система дизайна - -Приложение использует настраиваемую систему дизайна с: - -- Единой цветовой палитрой -- Типографской шкалой с использованием семейства шрифтов Mont -- Библиотекой повторно используемых компонентов -- Стандартизированными шаблонами интервалов и макетов - -## 🔐 Аутентификация и авторизация - -- Аутентификация на основе JWT -- Контроль доступа на основе ролей (студент, наставник, администратор) -- Защищенные маршруты и компоненты -- Управление сессиями - -## 📊 Модели данных - -Ключевые структуры данных включают: - -- Профили пользователей и аутентификация -- Навыки и компетенции -- Траектории обучения -- Определения задач и ответы -- Показатели рейтинга и прогресса -- Данные о подписке и оплате - -### Основные интерфейсы - -- `Skill` - Модель навыка -- `Task` - Модель задания -- `Trajectory` - Модель траектории обучения -- `UserData` - Данные пользователя -- `Profile` - Профиль пользователя - -## 🔧 Configuration - -The application uses environment files for configuration: - -- `environment.ts` - настройки для разработки -- `environment.prod.ts` - настройки для продакшена - -## 🔧 API Integration - -The application integrates with backend API through the service `SkillsApiService` from the library `@corelib`. - -### Основные эндпоинты: - -- `/progress/` - данные профиля и прогресса -- `/courses/` - навыки и задания -- `/trajectories/` - траектории обучения -- `/questions/` - интерактивные вопросы -- `/subscription/` - управление подписками - -## 🔧 Детали реализации - -### Отложенная загрузка - -Все модули загружаются по требованию для оптимизации производительности. - -### Реактивное управление состоянием - -Для реактивного управления состоянием используются сигналы Angular. - -### Обработка ошибок - -Централизованная обработка ошибок с перенаправлением на страницу входа в систему. - -## 🌐 Развертывание - -Приложение настроено для развертывания на Netlify с использованием файла `_redirects` для маршрутизации SPA. - -## 📝 Вклад - -1. Следуйте руководству по стилю Angular. -2. Используйте стандартные коммиты. -3. Напишите тесты для новых функций. -4. Обновите документацию. -5. Следуйте процессу проверки кода. diff --git a/projects/skills/src/_redirects b/projects/skills/src/_redirects deleted file mode 100644 index 7797f7c6a..000000000 --- a/projects/skills/src/_redirects +++ /dev/null @@ -1 +0,0 @@ -/* /index.html 200 diff --git a/projects/skills/src/app/app.component.html b/projects/skills/src/app/app.component.html deleted file mode 100644 index 37f22b118..000000000 --- a/projects/skills/src/app/app.component.html +++ /dev/null @@ -1,91 +0,0 @@ - - -
-
-
- -
- - @if (mobileMenuOpen) { - - } - -
-
-
- -
-

Платформа создана компанией ООО «Молодежный форсайт»

-

Политика обработки персональных данных

-

2022

-
-
- -
- @if (userData() !== undefined) { - - } - - -
-
-
-
-
-
diff --git a/projects/skills/src/app/app.component.scss b/projects/skills/src/app/app.component.scss deleted file mode 100644 index 5ca25a593..000000000 --- a/projects/skills/src/app/app.component.scss +++ /dev/null @@ -1,189 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.app { - position: relative; - display: flex; - height: 100%; - background-color: var(--white); - - &__wrapper { - display: flex; - flex-direction: column; - flex-grow: 1; - min-width: 0; - } - - &__sidebar { - display: none; - - @include responsive.apply-desktop { - display: block; - flex-shrink: 0; - width: 234px; - height: 100vh; - - ::ng-deep { - .sidebar__logo { - margin-bottom: 17px; - } - } - } - - &--text { - display: flex; - flex-direction: column; - gap: 2px; - margin-top: 17px; - color: var(--dark-grey); - } - } - - .nav-bar { - display: block; - padding: 25px 0 10px; - background-color: var(--light-gray); - - @include responsive.apply-desktop { - display: none; - padding: 20px 0; - background-color: transparent; - } - - &__top { - display: flex; - align-items: center; - justify-content: space-between; - padding-right: 50px; - margin-bottom: 40px; - } - - &__notifications-toggle { - position: relative; - display: flex; - gap: 20px; - align-items: center; - justify-content: center; - width: 60px; - height: 40px; - color: var(--black); - cursor: pointer; - border-radius: var(--rounded-sm); - - >.attention { - top: 9px; - right: 11px; - } - - &--active { - color: var(--white); - background-color: var(--accent); - } - } - - &__toggle { - position: relative; - z-index: 100; - display: block; - cursor: pointer; - - >.attention { - top: 8px; - right: 2px; - } - - @include responsive.apply-desktop { - display: none; - } - } - - &__mobile-menu { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 50; - display: flex; - flex-direction: column; - padding: 25px 15px 30px; - background-color: var(--white); - } - - &__mobile-logo { - display: block; - } - - &__profile { - display: flex; - justify-content: flex-start; - margin-top: auto; - } - } - - .attention { - position: absolute; - width: 7px; - height: 7px; - background-color: var(--red); - border-radius: 50%; - } - - &__body { - display: flex; - flex-grow: 1; - justify-content: center; - padding: 0 200px; - overflow-y: auto; - - @media (max-width: 1600px) { - padding: 0 150px; - } - - @media (max-width: 1400px) { - padding: 0 100px; - } - - @media (max-width: 1200px) { - padding: 0 50px; - } - - @media (max-width: 992px) { - padding: 0 20px; - } - - @media (max-width: 768px) { - padding: 0 15px; - } - } - - &__header { - display: none; - background-color: var(--white); - - @include responsive.apply-desktop { - display: block; - } - } - - &__inner { - display: flex; - width: 100%; - height: 100%; - - &--wrapper { - display: grid; - grid-template-columns: 2fr 10fr; - width: 100%; - - @media (max-width: 992px) { - grid-template-columns: 1fr; - } - } - - &--content { - flex-grow: 1; - min-width: 0; - } - } -} diff --git a/projects/skills/src/app/app.component.spec.ts b/projects/skills/src/app/app.component.spec.ts deleted file mode 100644 index 15f223da2..000000000 --- a/projects/skills/src/app/app.component.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { AppComponent } from "./app.component"; - -describe("AppComponent", () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AppComponent], - }).compileComponents(); - }); - - it("should create the app", () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it(`should have the 'skills' title`, () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app.title).toEqual("skills"); - }); - - it("should render title", () => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector("h1")?.textContent).toContain("Hello, skills"); - }); -}); diff --git a/projects/skills/src/app/app.component.ts b/projects/skills/src/app/app.component.ts deleted file mode 100644 index 939837e33..000000000 --- a/projects/skills/src/app/app.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** @format */ - -import { Component, inject, type OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { Router, RouterLink, RouterOutlet } from "@angular/router"; -import { IconComponent, ProfileControlPanelComponent, SidebarComponent } from "@uilib"; -import { SidebarProfileComponent } from "./shared/sidebar-profile/sidebar-profile.component"; -import type { UserData } from "../models/profile.model"; -import { AuthService } from "@auth/services"; -import { SnackbarComponent } from "@ui/components/snackbar/snackbar.component"; - -/** - * Корневой компонент приложения, который служит основным контейнером макета - * - * Функции: - * - Основная навигационная боковая панель - * - Отображение профиля пользователя - * - Обработка мобильного меню - * - Управление состоянием аутентификации - * - Рендеринг контента на основе маршрутов - * - * Компонент инициализирует данные пользователя при запуске и обрабатывает - * перенаправления аутентификации, если пользователь не авторизован должным образом. - */ -@Component({ - selector: "app-root", - standalone: true, - imports: [ - CommonModule, - RouterOutlet, - RouterLink, - SidebarComponent, - SidebarProfileComponent, - IconComponent, - ProfileControlPanelComponent, - SnackbarComponent, - ], - templateUrl: "./app.component.html", - styleUrl: "./app.component.scss", -}) -export class AppComponent implements OnInit { - // Внедренные сервисы для управления профилем и аутентификацией - authService = inject(AuthService); - router = inject(Router); - - // Управление состоянием UI - mobileMenuOpen = false; - notificationsOpen = false; - - // Конфигурация приложения - title = "skills"; - - /** - * Конфигурация элементов навигации - * Каждый элемент представляет основной раздел приложения - */ - navItems = [ - { name: "Мой профиль", icon: "person", link: "profile" }, - { name: "Рейтинг", icon: "growth", link: "rating" }, - // { name: "Траектории", icon: "receipt", link: "trackCar" }, - ]; - - // Реактивное состояние с использованием Angular signals - userData = signal(null); - logout = signal(false); - - /** - * Инициализация компонента - * Получает данные пользователя и синхронизирует профиль при запуске - * Перенаправляет на страницу входа при ошибке аутентификации - */ - ngOnInit(): void {} - - onLogout() { - this.authService.logout().subscribe({ - next: () => { - location.href = "https://app.procollab.ru/auth/login"; - }, - }); - } -} diff --git a/projects/skills/src/app/app.config.ts b/projects/skills/src/app/app.config.ts deleted file mode 100644 index a981b9ffb..000000000 --- a/projects/skills/src/app/app.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { ApplicationConfig } from "@angular/core"; -import { provideRouter } from "@angular/router"; -import { provideAnimations } from "@angular/platform-browser/animations"; -import { routes } from "./app.routes"; -import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; -import { - API_URL, - BearerTokenInterceptor, - CamelcaseInterceptor, - PRODUCTION, - SKILLS_API_URL, -} from "@corelib"; -import { environment } from "../environments/environment"; - -export const appConfig: ApplicationConfig = { - providers: [ - provideRouter(routes), - { provide: HTTP_INTERCEPTORS, multi: true, useClass: BearerTokenInterceptor }, - { provide: HTTP_INTERCEPTORS, multi: true, useClass: CamelcaseInterceptor }, - { provide: API_URL, useValue: environment.apiUrl }, - { provide: SKILLS_API_URL, useValue: environment.skillsApiUrl }, - { provide: PRODUCTION, useValue: environment.production }, - provideHttpClient(withInterceptorsFromDi()), - provideAnimations(), - ], -}; diff --git a/projects/skills/src/app/app.routes.ts b/projects/skills/src/app/app.routes.ts deleted file mode 100644 index edfe580ae..000000000 --- a/projects/skills/src/app/app.routes.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** @format */ - -import type { Routes } from "@angular/router"; - -/** - * Основная конфигурация маршрутизации приложения - * - * Использует ленивую загрузку для всех функциональных модулей для оптимизации размера начального бандла - * Каждый маршрут загружает свой модуль только при обращении, что улучшает производительность - * - * Структура маршрутов: - * - / (корень) -> перенаправляет на профиль - * - /profile -> Управление профилем пользователя - * - /skills -> Просмотр и управление навыками - * - /rating -> Рейтинги пользователей и таблицы лидеров - * - /task -> Интерактивные обучающие задания - * - /trackBuss -> Бизнес-траектория (в настоящее время отключена) - * - /trackCar -> Карьерная траектория - * - /subscription -> Управление подписками - * - /webinars -> Система вебинаров - */ -export const routes: Routes = [ - { - path: "", - pathMatch: "full", - redirectTo: "trackCar", // Маршрут по умолчанию перенаправляет на профиль пользователя - }, - { - path: "trackCar", - loadChildren: () => - import("./trajectories/track-career/track-career.routes").then(c => c.TRACK_CAREER_ROUTES), - }, -]; diff --git a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html deleted file mode 100644 index 977e5ddc4..000000000 --- a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.scss b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.scss deleted file mode 100644 index 8f83de7d6..000000000 --- a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.scss +++ /dev/null @@ -1,121 +0,0 @@ -@use "styles/responsive"; - -:host { - display: block; - padding: 19px 20px 15px; - margin-top: auto; -} - -.control-panel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - - &__actions { - z-index: 2; - display: flex; - align-items: center; - justify-content: space-around; - width: 85%; - margin-bottom: -16px; - } - - &__action { - display: flex; - align-items: center; - justify-content: center; - width: 41px; - height: 41px; - color: var(--accent); - cursor: pointer; - background-color: var(--light-gray); - border: 4px solid var(--white); - border-radius: 50%; - } - - &__notifications { - position: absolute; - right: 100%; - z-index: 20; - width: 380px; - max-height: 600px; - transform: translate(100%, -100%); - } - - &__bell { - position: relative; - width: 20px; - - > .attention { - position: absolute; - top: 0; - right: 0; - width: 7px; - height: 7px; - background-color: var(--red); - border-radius: 50%; - } - } -} - -.user { - display: flex; - flex-direction: column; - padding: 26px 10px 10px; - cursor: pointer; - background-color: var(--light-gray); - - &__avatar { - display: block; - margin-right: 12px; - } - - &__row { - display: flex; - align-items: center; - padding-bottom: 20px; - } - - &__logout { - display: flex; - align-items: center; - margin-left: auto; - margin-left: 20px; - color: var(--red); - cursor: pointer; - transition: color 0.2s; - - i { - margin-left: 5px; - } - - &:hover { - color: var(--red-dark); - } - - @include responsive.apply-desktop { - display: none; - } - } - - &__name { - color: var(--black); - } - - &__email, - &__verified, - &__not-verified { - text-decoration: underline; - text-underline-offset: 4px; - } - - &__email, - &__verified { - color: var(--gray); - } - - &__not-verified { - color: var(--red); - } -} diff --git a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.spec.ts b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.spec.ts deleted file mode 100644 index bd9abfd29..000000000 --- a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { SidebarProfileComponent } from "./sidebar-profile.component"; - -describe("SidebarProfileComponent", () => { - let component: SidebarProfileComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SidebarProfileComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SidebarProfileComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts deleted file mode 100644 index 9a88135b4..000000000 --- a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, inject, type OnInit, Output, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { AvatarComponent, IconComponent } from "@uilib"; -import { DayjsPipe } from "@corelib"; -import { RouterLink } from "@angular/router"; -import type { UserData } from "projects/skills/src/models/profile.model"; - -/** - * Компонент профиля пользователя в боковой панели - * - * Отображает информацию о текущем пользователе в боковой панели приложения. - * Загружает данные пользователя при инициализации и предоставляет возможность выхода из системы. - * - * @example - * - */ -@Component({ - selector: "app-sidebar-profile", - standalone: true, - imports: [CommonModule, IconComponent, AvatarComponent, DayjsPipe, RouterLink], - templateUrl: "./sidebar-profile.component.html", - styleUrl: "./sidebar-profile.component.scss", -}) -export class SidebarProfileComponent implements OnInit { - /** - * Событие выхода из системы - * Эмитится когда пользователь нажимает на кнопку выхода - */ - @Output() logout = new EventEmitter(); - - /** - * Сигнал с данными пользователя - * Содержит информацию о текущем авторизованном пользователе или null если данные не загружены - */ - user = signal(null); - - /** - * Инициализация компонента - * - * Загружает данные пользователя при создании компонента. - * В случае ошибки перенаправляет на страницу авторизации. - * - * @returns void - */ - ngOnInit(): void {} -} diff --git a/projects/skills/src/app/shared/task-card/task-card.component.html b/projects/skills/src/app/shared/task-card/task-card.component.html deleted file mode 100644 index 4b5ed00e4..000000000 --- a/projects/skills/src/app/shared/task-card/task-card.component.html +++ /dev/null @@ -1,25 +0,0 @@ - - -
-
- prize -
-

{{ task.name }}

-
Задание {{ task.level }}
-
-
-
- @if (!status?.isDone) { - - - - } @else { - - } -
-
diff --git a/projects/skills/src/app/shared/task-card/task-card.component.scss b/projects/skills/src/app/shared/task-card/task-card.component.scss deleted file mode 100644 index ffadbbfa8..000000000 --- a/projects/skills/src/app/shared/task-card/task-card.component.scss +++ /dev/null @@ -1,45 +0,0 @@ -.task { - display: flex; - justify-content: space-between; - padding: 10px 15px; - border: 1px solid var(--grey-button); - border-radius: 10px; - - &--next { - border-color: var(--accent); - } - - &__left { - display: flex; - gap: 3px; - align-items: center; - } - - &__prize { - width: 48px; - height: 48px; - filter: grayscale(1); - - &--colored { - filter: unset; - } - } - - &__level { - color: var(--dark-grey); - text-decoration: underline; - } - - &__button { - display: block; - - i { - transform: rotate(90deg); - } - } - - &__check { - display: flex; - color: var(--green); - } -} diff --git a/projects/skills/src/app/shared/task-card/task-card.component.spec.ts b/projects/skills/src/app/shared/task-card/task-card.component.spec.ts deleted file mode 100644 index e55c9ccb3..000000000 --- a/projects/skills/src/app/shared/task-card/task-card.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { TaskCardComponent } from "./task-card.component"; - -describe("TaskCardComponent", () => { - let component: TaskCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TaskCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TaskCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/shared/task-card/task-card.component.ts b/projects/skills/src/app/shared/task-card/task-card.component.ts deleted file mode 100644 index 22ffa9144..000000000 --- a/projects/skills/src/app/shared/task-card/task-card.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; -import type { - Task, - TasksResponse, -} from "../../../../../social_platform/src/app/office/models/skill.model"; - -/** - * Компонент карточки задачи - * - * Отображает информацию о задаче в виде карточки с деталями задачи и её статусом. - * Используется для представления задач в списке или сетке задач. - * - * @example - * - * - */ -@Component({ - selector: "app-task-card", - standalone: true, - imports: [CommonModule, ButtonComponent, IconComponent], - templateUrl: "./task-card.component.html", - styleUrl: "./task-card.component.scss", -}) -export class TaskCardComponent { - /** - * Данные задачи для отображения - * - * Обязательное свойство, содержащее всю информацию о задаче: - * название, описание, сложность и другие параметры. - */ - @Input({ required: true }) task!: Task; - - /** - * Статус выполнения задачи - * - * Обязательное свойство, содержащее информацию о статусе задачи - * из статистики недель. Используется для отображения прогресса - * и текущего состояния выполнения задачи. - */ - @Input({ required: true }) status!: TasksResponse["statsOfWeeks"][0]; -} diff --git a/projects/skills/src/app/skills/services/skill.service.ts b/projects/skills/src/app/skills/services/skill.service.ts deleted file mode 100644 index 9c2d23dd6..000000000 --- a/projects/skills/src/app/skills/services/skill.service.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** @format */ - -import { inject, Injectable } from "@angular/core"; -import { SkillsApiService } from "@corelib"; -import { ApiPagination } from "../../../models/api-pagination.model"; -import { - Skill, - TasksResponse, -} from "../../../../../social_platform/src/app/office/models/skill.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * Сервис навыков - * - * Управляет всеми операциями, связанными с навыками, включая: - * - Обнаружение и просмотр навыков - * - Детали навыков и управление заданиями - * - Отслеживание прогресса пользователя - * - Управление состоянием выбора навыков - * - * Этот сервис предоставляет интерфейс между фронтендом и бэкендом - * для всей функциональности, связанной с навыками на платформе обучения. - */ -@Injectable({ - providedIn: "root", -}) -export class SkillService { - private readonly COURSES_URL = "/courses"; - - apiService = inject(SkillsApiService); - - // Локальное управление состоянием для текущего выбора навыка - private skillId: number | null = null; - private storageKey = "skillId"; - - /** - * Получает все доступные навыки с пагинацией - * - * @returns Observable> Пагинированный список всех навыков в системе - */ - getAll() { - return this.apiService.get>(`${this.COURSES_URL}/all-skills/`); - } - - /** - * Получает навыки, которые отмечены/выбраны для текущего пользователя - * - * @param limit - Количество навыков для получения на страницу (по умолчанию: 5) - * @param offset - Количество навыков для пропуска для пагинации (по умолчанию: 0) - * @returns Observable> Пагинированный список выбранных навыков пользователя - */ - getAllMarked(limit = 5, offset = 0) { - return this.apiService.get>( - `${this.COURSES_URL}/choose-skills/`, - new HttpParams({ - fromObject: { - limit, - offset, - }, - }) - ); - } - - /** - * Получает подробную информацию о конкретном навыке - * - * @param skillId - Уникальный идентификатор навыка - * @returns Observable Полная информация о навыке, включая описание и требования - */ - getDetail(skillId: number) { - return this.apiService.get(`${this.COURSES_URL}/skill-details/${skillId}`); - } - - /** - * Получает все задания, связанные с конкретным навыком - * - * @param skillId - Уникальный идентификатор навыка - * @returns Observable Задания, статистика прогресса и статус завершения - */ - getTasks(skillId: number) { - return this.apiService.get(`${this.COURSES_URL}/tasks-of-skill/${skillId}`); - } - - /** - * Устанавливает ID текущего выбранного навыка в памяти и localStorage - * - * Этот метод используется для поддержания состояния при навигации между - * страницами и компонентами, связанными с навыками. - * - * @param id - ID навыка для установки как текущий выбор - */ - setSkillId(id: number) { - this.skillId = id; - localStorage.setItem(this.storageKey, JSON.stringify(id)); - } - - /** - * Получает ID текущего выбранного навыка - * - * Проверяет localStorage для сохранения между сессиями браузера. - * - * @returns number | null - ID текущего выбранного навыка или null, если ничего не выбрано - */ - getSkillId() { - const skillValue = localStorage.getItem(this.storageKey); - return skillValue ? JSON.parse(skillValue) : null; - } -} diff --git a/projects/skills/src/app/trajectories/track-career/detail/info/guards/trajectory-info.guard.ts b/projects/skills/src/app/trajectories/track-career/detail/info/guards/trajectory-info.guard.ts deleted file mode 100644 index c118c9f83..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/info/guards/trajectory-info.guard.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** @format */ - -import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; -import { inject } from "@angular/core"; -import { catchError, map, Observable, of } from "rxjs"; -import { TrajectoriesService } from "../../../../trajectories.service"; - -export const TrajectoryInfoRequiredGuard: CanActivateFn = ( - route: ActivatedRouteSnapshot -): Observable => { - const router = inject(Router); - const trajectoriesService = inject(TrajectoriesService); - - const trajectoryId = Number(route.paramMap.get("trackId")); - if (isNaN(trajectoryId)) { - return of(router.createUrlTree(["/trackCar/all"])); - } - - return trajectoriesService.getOne(trajectoryId).pipe( - map(trajectory => { - if (trajectory.isActiveForUser) { - return true; - } - return router.createUrlTree(["/trackCar/all"]); - }), - catchError(() => { - return of(router.createUrlTree(["/trackCar/all"])); - }) - ); -}; diff --git a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html deleted file mode 100644 index 61d39ccff..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html +++ /dev/null @@ -1,71 +0,0 @@ - - -@if (trajectory) { -
-
-
-
- -
- -
-
-

прогресс по курсу

-

55%

-
-
- - @if (userTrajectory()?.availableSkills; as availableSkills) { -
- @for (skill of availableSkills; track skill.id) { - - } -
- } -
- -
-
-
-

о курсе

- -
- @if (trajectory.description) { -
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "cкрыть" : "подробнее" }} -
- } -
- } -
-
-
-
- - -
-

ты прошел модуль!

- - complete module image - - отлично -
-
-
-} diff --git a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.scss b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.scss deleted file mode 100644 index 14cf4cbf8..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.scss +++ /dev/null @@ -1,173 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.trajectory { - padding-top: 12px; - padding-bottom: 100px; - - @include responsive.apply-desktop { - padding-bottom: 0; - } - - &__main { - display: grid; - grid-template-columns: 1fr; - } - - &__right { - display: flex; - flex-direction: column; - max-height: 226px; - } - - &__left { - max-width: 157px; - } - - &__section { - padding: 24px; - margin-bottom: 14px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - } - - &__info { - @include responsive.apply-desktop { - grid-column: span 3; - } - } - - &__details { - display: grid; - grid-template-columns: 2fr 5fr 3fr; - grid-gap: 20px; - } - - &__progress { - position: relative; - height: 48px; - padding: 10px; - text-align: center; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - - &--cover { - position: absolute; - top: 0%; - left: 0%; - height: 48px; - background-color: var(--accent); - border-radius: var(--rounded-lg); - opacity: 0.15; - - &--complete { - background-color: var(--green-dark); - } - } - - &--percent { - color: var(--green-dark); - } - - &--complete { - color: var(--black); - } - } - - &__skills { - display: flex; - flex-direction: column; - gap: 20px; - margin-top: 18px; - } -} - -.about { - padding: 24px; - background-color: var(--light-white); - border-radius: var(--rounded-lg); - - &__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); - - &--icon { - color: var(--accent); - } - } - - &__title { - margin-bottom: 8px; - color: var(--accent); - } - - /* stylelint-disable value-no-vendor-prefix */ - &__text { - p { - display: -webkit-box; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } - } - /* stylelint-enable value-no-vendor-prefix */ - - &__read-full { - margin-top: 8px; - color: var(--accent); - cursor: pointer; - } -} - -.read-more { - margin-top: 8px; - color: var(--accent); - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - color: var(--accent-dark); - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - max-height: calc(100vh - 40px); - padding: 0 200px; - - &__text { - color: var(--dark-grey); - text-align: center; - } - - &__img { - margin: 30px 0; - } -} diff --git a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.ts b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.ts deleted file mode 100644 index bdf022a4e..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** @format */ - -import { - type AfterViewInit, - ChangeDetectorRef, - Component, - type ElementRef, - inject, - type OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; -import { IconComponent } from "@uilib"; -import { expandElement } from "@utils/expand-element"; -import { map, type Observable, type Subscription } from "rxjs"; -import { CommonModule } from "@angular/common"; -import type { Trajectory, UserTrajectory } from "projects/skills/src/models/trajectory.model"; -import { TrajectoriesService } from "../../../trajectories.service"; -import { BreakpointObserver } from "@angular/cdk/layout"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; -// import { SkillCardComponent } from "projects/skills/src/app/shared/skill-card/skill-card.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ButtonComponent } from "@ui/components"; - -/** - * Компонент детальной информации о траектории - * Отображает полную информацию о выбранной траектории пользователя: - * - Основную информацию (название, изображение, описание) - * - Временную шкалу траектории - * - Информацию о наставнике - * - Навыки (персональные, текущие, будущие, пройденные) - * - * Поддерживает навигацию к отдельным навыкам и взаимодействие с наставником - */ -@Component({ - selector: "app-detail", - standalone: true, - imports: [ - IconComponent, - RouterModule, - ParseBreaksPipe, - ParseLinksPipe, - CommonModule, - SoonCardComponent, - // SkillCardComponent, - ModalComponent, - ButtonComponent, - ], - templateUrl: "./info.component.html", - styleUrl: "./info.component.scss", -}) -export class TrajectoryInfoComponent implements OnInit, AfterViewInit { - route = inject(ActivatedRoute); - router = inject(Router); - - cdRef = inject(ChangeDetectorRef); - - trajectoryService = inject(TrajectoriesService); - breakpointObserver = inject(BreakpointObserver); - - subscriptions$: Subscription[] = []; - - trajectory!: Trajectory; - userTrajectory = signal(null); - - isCompleteModule = signal(false); - - @ViewChild("descEl") descEl?: ElementRef; - - desktopMode$: Observable = this.breakpointObserver - .observe("(min-width: 920px)") - .pipe(map(result => result.matches)); - - /** - * Инициализация компонента - * Загружает данные траектории, пользовательскую информацию и настраивает навыки - */ - ngOnInit(): void { - this.desktopMode$.subscribe(_ => {}); - - this.route.parent?.data.pipe(map(r => r["data"])).subscribe(r => { - this.trajectory = r[0]; - this.userTrajectory.set({ ...r[1], individualSkills: r[2] }); - // this.isCompleteModule.set(this.userTrajectory()!.completedSkills.some(skill => skill.isDone)); - }); - } - - descriptionExpandable?: boolean; - readFullDescription!: boolean; - - /** - * Проверка возможности расширения описания после инициализации представления - */ - ngAfterViewInit(): void { - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - - this.cdRef.detectChanges(); - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** - * Переключение развернутого/свернутого состояния описания - * @param elem - HTML элемент описания - * @param expandedClass - CSS класс для развернутого состояния - * @param isExpanded - текущее состояние (развернуто/свернуто) - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - /** - * Обработчик клика по навыку - * Устанавливает ID навыка в сервисе и переходит к странице навыка - * @param skillId - ID выбранного навыка - */ - onSkillClick(skillId: number) { - this.router.navigate(["skills", skillId]); - } -} diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.html b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.html deleted file mode 100644 index 8c3bec611..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.html +++ /dev/null @@ -1,46 +0,0 @@ - - -
-
- @if(trajectory()) { -
-
- -
- - @if (!isTaskDetail()) { -

{{ trajectory()?.name }}

- } -
-
- -
-
- {{ isTaskDetail() ? "назад к модулю?" : "назад" }} - - вернуться в программу -
-
- - - } -
-
diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.scss b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.scss deleted file mode 100644 index ebab1169d..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.scss +++ /dev/null @@ -1,102 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -$detail-bar-height: 63px; -$detail-bar-mb: 12px; - -.detail { - display: flex; - flex-direction: column; - height: 100%; - max-height: 100%; - padding-top: 20px; - - &__body { - flex-grow: 1; - max-height: calc(100% - #{$detail-bar-height} - #{$detail-bar-mb}); - padding-bottom: 12px; - } -} - -.info { - $body-slide: 15px; - - position: relative; - padding: 0; - background-color: transparent; - border: none; - border-radius: $body-slide; - - &__cover { - position: relative; - height: 136px; - background: linear-gradient(var(--accent), var(--lime)); - border-radius: 15px 15px 0 0; - } - - &__body { - position: relative; - z-index: 2; - } - - &__avatar { - position: absolute; - bottom: -10px; - left: 50%; - z-index: 100; - display: block; - cursor: pointer; - background-color: var(--white); - border-radius: 50%; - - &--program { - bottom: 15px; - } - - @include responsive.apply-desktop { - transform: translate(-50%, 50%); - } - } - - &__row { - display: flex; - gap: 20px; - align-items: center; - justify-content: center; - margin-top: 2px; - - @include responsive.apply-desktop { - justify-content: unset; - margin-top: 0; - } - } - - &__title { - margin-top: 10px; - overflow: hidden; - color: var(--black); - text-align: center; - text-overflow: ellipsis; - - &--project { - transform: translateX(-31%); - } - } - - &__text { - color: var(--dark-grey); - } - - &__actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 180px; - align-items: center; - padding: 24px 0 30px; - - &--disabled { - cursor: not-allowed; - opacity: 0.5; - } - } -} diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.spec.ts b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.spec.ts deleted file mode 100644 index f79e3c7d4..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { VacanciesDetailComponent } from "./trajectory-detail.component"; - -describe("VacanciesDetailComponent", () => { - let component: VacanciesDetailComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [VacanciesDetailComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(VacanciesDetailComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.ts b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.ts deleted file mode 100644 index 264bd1cf8..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.component.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, inject, signal, type OnDestroy, type OnInit } from "@angular/core"; -import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import type { Trajectory } from "projects/skills/src/models/trajectory.model"; -import { filter, map, type Subscription } from "rxjs"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { ButtonComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; - -/** - * Компонент детального просмотра траектории - * Отображает навигационную панель и служит контейнером для дочерних компонентов - * Управляет состоянием выбранной траектории и ID траектории из URL - */ -@Component({ - selector: "app-trajectory-detail", - standalone: true, - imports: [CommonModule, RouterOutlet, AvatarComponent, ButtonComponent, ModalComponent], - templateUrl: "./trajectory-detail.component.html", - styleUrl: "./trajectory-detail.component.scss", -}) -export class TrajectoryDetailComponent implements OnInit, OnDestroy { - route = inject(ActivatedRoute); - router = inject(Router); - destroyRef = inject(DestroyRef); - - subscriptions$: Subscription[] = []; - - trajectory = signal(undefined); - isDisabled = signal(false); - isTaskDetail = signal(false); - - /** - * Инициализация компонента - * Подписывается на параметры маршрута и данные траектории - */ - ngOnInit(): void { - this.route.data - .pipe( - map(data => data["data"]), - filter(trajectory => !!trajectory) - ) - .subscribe({ - next: trajectory => { - this.trajectory.set(trajectory[0]); - }, - }); - - this.router.events - .pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(() => { - this.isTaskDetail.set(this.router.url.includes("task")); - }); - } - - /** - * Очистка ресурсов при уничтожении компонента - * Отписывается от всех активных подписок - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** - * Перенаправляет на страницу с информацией в завивисимости от listType - */ - redirectDetailInfo(trackId?: number): void { - if (this.trajectory()) { - this.router.navigateByUrl(`/trackCar/${trackId}`); - } else { - this.router.navigateByUrl("/trackCar/all"); - } - } -} diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.resolver.ts b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.resolver.ts deleted file mode 100644 index bc007dc92..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.resolver.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import type { ActivatedRouteSnapshot } from "@angular/router"; -import { TrajectoriesService } from "../../trajectories.service"; -import { forkJoin } from "rxjs"; - -/** - * Резолвер для загрузки детальной информации о траектории - * Загружает данные траектории, информацию о пользователе и индивидуальные навыки - * @param route - снимок активного маршрута с параметрами - * @returns Observable с массивом данных [траектория, пользовательская информация, навыки] - */ - -/** - * Функция-резолвер для получения детальной информации о траектории - * @param route - снимок маршрута содержащий параметр trackId - * @returns Observable с объединенными данными о траектории - */ -export const TrajectoryDetailResolver = (route: ActivatedRouteSnapshot) => { - const trajectoryService = inject(TrajectoriesService); - const trajectoryId = route.params["trackId"]; - - return forkJoin([ - trajectoryService.getOne(trajectoryId), - trajectoryService.getUserTrajectoryInfo(), - trajectoryService.getIndividualSkills(), - ]); -}; diff --git a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.routes.ts b/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.routes.ts deleted file mode 100644 index 26572c9e7..000000000 --- a/projects/skills/src/app/trajectories/track-career/detail/trajectory-detail.routes.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { TrajectoryInfoRequiredGuard } from "./info/guards/trajectory-info.guard"; -import { TrajectoryInfoComponent } from "./info/info.component"; -import { TrajectoryDetailComponent } from "./trajectory-detail.component"; -import { TrajectoryDetailResolver } from "./trajectory-detail.resolver"; - -export const TRAJECTORY_DETAIL_ROUTES = [ - { - path: "", - component: TrajectoryDetailComponent, - canActivate: [TrajectoryInfoRequiredGuard], - resolve: { - data: TrajectoryDetailResolver, - }, - children: [ - { - path: "", - component: TrajectoryInfoComponent, - }, - // { - // path: "task", - // loadChildren: () => import("../../../task/task.routes").then(m => m.TASK_ROUTES), - // }, - ], - }, -]; diff --git a/projects/skills/src/app/trajectories/track-career/list/list.component.html b/projects/skills/src/app/trajectories/track-career/list/list.component.html deleted file mode 100644 index 7d4222623..000000000 --- a/projects/skills/src/app/trajectories/track-career/list/list.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
- @if (trajectoriesList().length) { -
- @for (trajectory of trajectoriesList(); track trajectory.id) { - - - - } -
- } -
diff --git a/projects/skills/src/app/trajectories/track-career/list/list.component.scss b/projects/skills/src/app/trajectories/track-career/list/list.component.scss deleted file mode 100644 index ef3eb00e7..000000000 --- a/projects/skills/src/app/trajectories/track-career/list/list.component.scss +++ /dev/null @@ -1,9 +0,0 @@ -.trajectories { - margin-top: 20px; - - &__list { - display: grid; - grid-template-columns: 4fr 4fr; - grid-gap: 20px; - } -} diff --git a/projects/skills/src/app/trajectories/track-career/list/list.component.spec.ts b/projects/skills/src/app/trajectories/track-career/list/list.component.spec.ts deleted file mode 100644 index 8b9a839e4..000000000 --- a/projects/skills/src/app/trajectories/track-career/list/list.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ListComponent } from "@office/program/detail/rate-projects/list/list.component"; - -describe("ListComponent", () => { - let component: ListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ListComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/trajectories/track-career/list/list.component.ts b/projects/skills/src/app/trajectories/track-career/list/list.component.ts deleted file mode 100644 index 5c1de0690..000000000 --- a/projects/skills/src/app/trajectories/track-career/list/list.component.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** @format */ - -import { - type AfterViewInit, - ChangeDetectorRef, - Component, - inject, - type OnDestroy, - type OnInit, - signal, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { concatMap, fromEvent, map, noop, of, Subscription, tap, throttleTime } from "rxjs"; -import { TrajectoriesService } from "../../trajectories.service"; -import { TrajectoryComponent } from "../shared/trajectory/trajectory.component"; -import { Trajectory } from "projects/skills/src/models/trajectory.model"; - -/** - * Компонент списка траекторий - * Отображает список доступных траекторий с поддержкой пагинации - * Поддерживает два режима: "all" (все траектории) и "my" (пользовательские) - * Реализует бесконечную прокрутку для загрузки дополнительных элементов - */ -@Component({ - selector: "app-list", - standalone: true, - imports: [CommonModule, RouterModule, TrajectoryComponent], - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", -}) -export class TrajectoriesListComponent implements OnInit, AfterViewInit, OnDestroy { - router = inject(Router); - route = inject(ActivatedRoute); - trajectoriesService = inject(TrajectoriesService); - cdRef = inject(ChangeDetectorRef); - - totalItemsCount = signal(0); - trajectoriesList = signal([]); - trajectoriesPage = signal(1); - perFetchTake = signal(20); - - subscriptions$ = signal([]); - - /** - * Инициализация компонента - * Определяет тип списка (all/my) и загружает начальные данные - */ - ngOnInit(): void { - this.route.data.pipe(map(r => r["data"])).subscribe(data => { - this.trajectoriesList.set(data as Trajectory[]); - this.totalItemsCount.set((data as Trajectory[]).length); - }); - } - - /** - * Настройка обработчика прокрутки после инициализации представления - * Подписывается на события прокрутки для реализации бесконечной загрузки - */ - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(500) - ) - .subscribe(noop); - this.subscriptions$().push(scrollEvents$); - } - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$().forEach(s => s.unsubscribe()); - } - - /** - * Обработчик события прокрутки - * Проверяет достижение конца списка и загружает дополнительные элементы - * @returns Observable с результатом загрузки или пустой объект - */ - onScroll() { - if (this.totalItemsCount() && this.trajectoriesList().length >= this.totalItemsCount()) - return of({}); - - const target = document.querySelector(".office__body"); - if (!target) return of({}); - - const diff = target.scrollTop - target.scrollHeight + target.clientHeight; - - if (diff > 0) { - return this.onFetch(this.trajectoriesPage() * this.perFetchTake(), this.perFetchTake()).pipe( - tap((trajectoryChunk: Trajectory[]) => { - this.trajectoriesPage.update(page => page + 1); - this.trajectoriesList.update(items => [...items, ...trajectoryChunk]); - }) - ); - } - - return of({}); - } - - /** - * Загрузка дополнительных траекторий - * @param offset - смещение для пагинации - * @param limit - количество элементов для загрузки - * @returns Observable с данными траекторий - */ - onFetch(offset: number, limit: number) { - return this.trajectoriesService.getTrajectories(limit, offset).pipe( - tap((res: any) => { - this.totalItemsCount.set(res.count); - this.trajectoriesList.update(items => [...items, ...res.results]); - }), - map(res => res) - ); - } -} diff --git a/projects/skills/src/app/trajectories/track-career/list/list.resolver.spec.ts b/projects/skills/src/app/trajectories/track-career/list/list.resolver.spec.ts deleted file mode 100644 index 358a69b0d..000000000 --- a/projects/skills/src/app/trajectories/track-career/list/list.resolver.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { ResolveFn } from "@angular/router"; - -import { listResolver } from "./records.resolver"; - -describe("listResolver", () => { - const executeResolver: ResolveFn = (...resolverParameters) => - TestBed.runInInjectionContext(() => listResolver(...resolverParameters)); - - beforeEach(() => { - TestBed.configureTestingModule({}); - }); - - it("should be created", () => { - expect(executeResolver).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.html b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.html deleted file mode 100644 index 7ce78cd10..000000000 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.html +++ /dev/null @@ -1,49 +0,0 @@ - - -@if(trajectory) { -
-
- - - - {{ - isMember() - ? "для участников программы" - : isSubs() - ? "доступно по подписке" - : "доступно всем пользователям" - }} - -
- -
-

{{ trajectory.name | truncate: 50 }}

-

- {{ - isDates() - ? "16.02.2026 - 16.04.2026" - : isDate() - ? "16.02.2026" - : isEnded() - ? "курс завершен" - : "доступен до 16.04.2026" - }} -

-
- -
- @if (!isStarted()) { - - } @else { -

- {{ isStarted() ? "начать" : "продолжить обучение" }} -

- - } -
-
-} diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss deleted file mode 100644 index 513120114..000000000 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss +++ /dev/null @@ -1,76 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.trajectory { - position: relative; - width: 100%; - max-width: 333px; - overflow: hidden; - cursor: pointer; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - - &__cover { - padding: 35px 24px; - background: linear-gradient(var(--accent), var(--lime)); - border-radius: var(--rounded-lg); - - app-button { - ::ng-deep { - .button { - position: absolute; - top: 15px; - right: 24px; - width: 166px; - padding: 0; - } - } - } - - app-avatar { - ::ng-deep { - .avatar { - img { - box-shadow: 0 0 8px rgba($color: #333, $alpha: 30%); - } - } - } - } - } - - &__info { - display: flex; - flex-direction: column; - gap: 2px; - padding: 12px 24px; - - &--date { - color: var(--grey-for-text) !important; - } - } - - &__action { - position: absolute; - right: 14px; - bottom: 16px; - - &--started { - color: var(--green) !important; - } - } - - &__rocket { - position: absolute; - top: -46%; - right: -13%; - z-index: 0; - width: 175px; - transform: rotate(-53deg); - } - - p, - i { - color: var(--black); - } -} diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.spec.ts b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.spec.ts deleted file mode 100644 index 53fbb50d5..000000000 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { TrajectoryComponent } from "./trajectory.component"; - -describe("TrajectoryComponent", () => { - let component: TrajectoryComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TrajectoryComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TrajectoryComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); 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 deleted file mode 100644 index 6a73d4d11..000000000 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input, signal } from "@angular/core"; -import { Router, RouterModule } from "@angular/router"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { Trajectory } from "projects/skills/src/models/trajectory.model"; -import { IconComponent, ButtonComponent } from "@ui/components"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; - -/** - * Компонент отображения карточки траектории - * Показывает информацию о траектории: название, описание, навыки, длительность - * Поддерживает различные модальные окна для взаимодействия с пользователем - * Обрабатывает выбор траектории и навигацию к детальной информации - * - * @Input trajectory - объект траектории для отображения - */ -@Component({ - selector: "app-trajectory", - standalone: true, - imports: [ - CommonModule, - RouterModule, - TruncatePipe, - IconComponent, - AvatarComponent, - ButtonComponent, - IconComponent, - ], - templateUrl: "./trajectory.component.html", - styleUrl: "./trajectory.component.scss", -}) -export class TrajectoryComponent { - @Input() trajectory!: Trajectory; - - router = inject(Router); - - protected readonly isStarted = signal(false); - protected readonly isDates = signal(false); - protected readonly isDate = signal(false); - protected readonly isEnded = signal(false); - - protected readonly isMember = signal(false); - protected readonly isSubs = signal(false); -} diff --git a/projects/skills/src/app/trajectories/track-career/track-career.component.html b/projects/skills/src/app/trajectories/track-career/track-career.component.html deleted file mode 100644 index a89e50624..000000000 --- a/projects/skills/src/app/trajectories/track-career/track-career.component.html +++ /dev/null @@ -1,18 +0,0 @@ - - -
- - -
- - - - - -
-
diff --git a/projects/skills/src/app/trajectories/track-career/track-career.component.scss b/projects/skills/src/app/trajectories/track-career/track-career.component.scss deleted file mode 100644 index 53c48f8bc..000000000 --- a/projects/skills/src/app/trajectories/track-career/track-career.component.scss +++ /dev/null @@ -1,10 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.profile { - &__info { - display: grid; - grid-template-columns: 8fr 2fr; - column-gap: 20px; - } -} diff --git a/projects/skills/src/app/trajectories/track-career/track-career.component.spec.ts b/projects/skills/src/app/trajectories/track-career/track-career.component.spec.ts deleted file mode 100644 index 6ff4be267..000000000 --- a/projects/skills/src/app/trajectories/track-career/track-career.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { TrackCareerComponent } from "./track-career.component"; - -describe("TrackCareerComponent", () => { - let component: TrackCareerComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TrackCareerComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TrackCareerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/skills/src/app/trajectories/track-career/track-career.component.ts b/projects/skills/src/app/trajectories/track-career/track-career.component.ts deleted file mode 100644 index 56f31b633..000000000 --- a/projects/skills/src/app/trajectories/track-career/track-career.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject } from "@angular/core"; -import { RouterModule } from "@angular/router"; -import { BackComponent } from "@uilib"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; - -/** - * Главный компонент модуля отслеживания карьерных траекторий - * Служит контейнером для дочерних компонентов и маршрутизации - * Отображает навигационную панель с вкладками "Траектории" и "Моя траектория" - */ -@Component({ - selector: "app-track-career", - standalone: true, - imports: [ - CommonModule, - RouterModule, - BackComponent, - SearchComponent, - ReactiveFormsModule, - SoonCardComponent, - ], - templateUrl: "./track-career.component.html", - styleUrl: "./track-career.component.scss", -}) -export class TrackCareerComponent { - private readonly fb = inject(FormBuilder); - - constructor() { - this.searchForm = this.fb.group({ - search: [""], - }); - } - - searchForm: FormGroup; -} diff --git a/projects/skills/src/app/trajectories/track-career/track-career.resolver.ts b/projects/skills/src/app/trajectories/track-career/track-career.resolver.ts deleted file mode 100644 index 1ef71e309..000000000 --- a/projects/skills/src/app/trajectories/track-career/track-career.resolver.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { TrajectoriesService } from "../trajectories.service"; - -/** - * Резолвер для загрузки списка всех доступных траекторий - * Выполняется перед активацией маршрута для предзагрузки данных - * @returns Observable с массивом траекторий (20 элементов с offset 0) - */ - -/** - * Функция-резолвер для получения списка траекторий - * @returns Promise/Observable с данными траекторий - */ -export const TrajectoriesResolver = () => { - const trajectoriesService = inject(TrajectoriesService); - - return trajectoriesService.getTrajectories(20, 0); -}; diff --git a/projects/skills/src/app/trajectories/track-career/track-career.routes.ts b/projects/skills/src/app/trajectories/track-career/track-career.routes.ts deleted file mode 100644 index 84c84ea52..000000000 --- a/projects/skills/src/app/trajectories/track-career/track-career.routes.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { TrackCareerComponent } from "./track-career.component"; -import { TrajectoriesListComponent } from "./list/list.component"; -import { TrajectoriesResolver } from "./track-career.resolver"; - -/** - * Конфигурация маршрутов для модуля карьерных траекторий - * Определяет структуру навигации: - * - "" - редирект на "all" - * - "all" - список всех доступных траекторий - * - "my" - пользовательская траектория - * - ":trackId" - детальная информация о конкретной траектории - */ - -export const TRACK_CAREER_ROUTES: Routes = [ - { - path: "", - component: TrackCareerComponent, - children: [ - { - path: "", - redirectTo: "all", - pathMatch: "full", - }, - { - path: "all", - component: TrajectoriesListComponent, - resolve: { - data: TrajectoriesResolver, - }, - }, - ], - }, - { - path: ":trackId", - loadChildren: () => - import("./detail/trajectory-detail.routes").then(c => c.TRAJECTORY_DETAIL_ROUTES), - }, -]; diff --git a/projects/skills/src/app/trajectories/trajectories.service.ts b/projects/skills/src/app/trajectories/trajectories.service.ts deleted file mode 100644 index b563e7f17..000000000 --- a/projects/skills/src/app/trajectories/trajectories.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** @format */ - -import { HttpParams } from "@angular/common/http"; -import { Injectable } from "@angular/core"; -import { SkillsApiService } from "@corelib"; -import { catchError, map, of } from "rxjs"; -import { Student, Trajectory, UserTrajectory } from "../../models/trajectory.model"; - -/** - * Сервис траекторий - * - * Управляет всеми операциями, связанными с траекториями, включая: - * - Обнаружение траекторий и регистрацию - * - Отслеживание прогресса пользователя по траектории - * - Управление отношениями ментор-студент - * - Индивидуальные назначения навыков - * - Обновления статуса встреч - * - * Траектории представляют структурированные пути обучения, которые направляют пользователей - * через серию навыков и этапов для достижения карьерных целей. - */ -@Injectable({ - providedIn: "root", -}) -export class TrajectoriesService { - private readonly TRAJECTORY_URL = "/trajectories"; - - constructor(private readonly apiService: SkillsApiService) {} - - /** - * Получает доступные траектории с пагинацией - * - * @param limit - Количество траекторий для получения на страницу - * @param offset - Количество траекторий для пропуска для пагинации - * @returns Observable - Список доступных траекторий - */ - getTrajectories(limit: number, offset: number) { - const params = new HttpParams(); - params.set("limit", limit); - params.set("offset", offset); - - return this.apiService.get(this.TRAJECTORY_URL); - } - - /** - * Получает подробную информацию о конкретной траектории - * - * @param id - Уникальный идентификатор траектории - * @returns Observable - Полная информация о траектории - */ - getOne(id: number) { - return this.apiService.get(`${this.TRAJECTORY_URL}/${id}`); - } - - /** - * Получает информацию о регистрации текущего пользователя в траектории - * - * Включает прогресс, назначение ментора, статус встреч и категоризацию навыков. - * - * @returns Observable - Полные данные регистрации пользователя в траектории - */ - getUserTrajectoryInfo() { - return this.apiService.get(`${this.TRAJECTORY_URL}/user-trajectory/`); - } - - /** - * Получает всех студентов, назначенных текущему ментору - * - * Используется менторами для просмотра и управления прогрессом назначенных им студентов. - * - * @returns Observable - Список студентов с их прогрессом по траектории - */ - getMentorStudents() { - return this.apiService.get(`${this.TRAJECTORY_URL}/mentor/students/`); - } - - /** - * Получает индивидуальные навыки, назначенные специально текущему пользователю - * - * Индивидуальные навыки - это пользовательские назначения, которые могут не быть частью - * стандартной учебной программы траектории. - * - * @returns Observable - Список индивидуально назначенных навыков - */ - getIndividualSkills() { - return this.apiService - .get(`${this.TRAJECTORY_URL}/individual-skills/`) - .pipe( - map(response => { - // Обработка различных форматов ответов от API - if (Array.isArray(response) && response.length > 0) { - return response[0].skills || []; - } - return []; - }), - catchError(error => { - console.log("Ошибка при получении индивидуальных навыков", error); - return of([]); // Возвращает пустой массив при ошибке - }) - ); - } - - /** - * Обновляет статус встречи для отношений ментор-студент - * - * @param id - ID записи встречи - * @param initialMeeting - Была ли завершена первоначальная встреча - * @param finalMeeting - Была ли завершена финальная встреча - * @returns Observable - Ответ, подтверждающий обновление - */ - updateMeetings(id: number, initialMeeting: boolean, finalMeeting: boolean) { - const body = { - meeting_id: id, - initial_meeting: initialMeeting, - final_meeting: finalMeeting, - }; - - return this.apiService.post(`${this.TRAJECTORY_URL}/meetings/update/`, body); - } - - /** - * Регистрирует текущего пользователя в конкретной траектории - * - * Создает новую регистрацию пользователя в траектории и назначает ментора. - * - * @param trajectoryId - ID траектории для регистрации - * @returns Observable - Ответ, подтверждающий регистрацию - */ - activateTrajectory(trajectoryId: number) { - return this.apiService.post(`${this.TRAJECTORY_URL}/user-trajectory/create/`, { - trajectory_id: trajectoryId, - }); - } -} diff --git a/projects/skills/src/assets/.gitkeep b/projects/skills/src/assets/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/projects/skills/src/assets/font/Mont-Black.eot b/projects/skills/src/assets/font/Mont-Black.eot deleted file mode 100644 index c5ce8fd92..000000000 Binary files a/projects/skills/src/assets/font/Mont-Black.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Black.ttf b/projects/skills/src/assets/font/Mont-Black.ttf deleted file mode 100644 index 43b4026c0..000000000 Binary files a/projects/skills/src/assets/font/Mont-Black.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Black.woff b/projects/skills/src/assets/font/Mont-Black.woff deleted file mode 100644 index dbbe6246f..000000000 Binary files a/projects/skills/src/assets/font/Mont-Black.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Black.woff2 b/projects/skills/src/assets/font/Mont-Black.woff2 deleted file mode 100644 index 0aefce335..000000000 Binary files a/projects/skills/src/assets/font/Mont-Black.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-BlackItalic.eot b/projects/skills/src/assets/font/Mont-BlackItalic.eot deleted file mode 100644 index 692763158..000000000 Binary files a/projects/skills/src/assets/font/Mont-BlackItalic.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-BlackItalic.ttf b/projects/skills/src/assets/font/Mont-BlackItalic.ttf deleted file mode 100644 index 792420b18..000000000 Binary files a/projects/skills/src/assets/font/Mont-BlackItalic.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-BlackItalic.woff b/projects/skills/src/assets/font/Mont-BlackItalic.woff deleted file mode 100644 index b9c790560..000000000 Binary files a/projects/skills/src/assets/font/Mont-BlackItalic.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-BlackItalic.woff2 b/projects/skills/src/assets/font/Mont-BlackItalic.woff2 deleted file mode 100644 index cf1bbbef8..000000000 Binary files a/projects/skills/src/assets/font/Mont-BlackItalic.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Bold.eot b/projects/skills/src/assets/font/Mont-Bold.eot deleted file mode 100644 index 4b2c0eb46..000000000 Binary files a/projects/skills/src/assets/font/Mont-Bold.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Bold.ttf b/projects/skills/src/assets/font/Mont-Bold.ttf deleted file mode 100644 index 4952bac44..000000000 Binary files a/projects/skills/src/assets/font/Mont-Bold.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Bold.woff b/projects/skills/src/assets/font/Mont-Bold.woff deleted file mode 100644 index f5fc0b368..000000000 Binary files a/projects/skills/src/assets/font/Mont-Bold.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Bold.woff2 b/projects/skills/src/assets/font/Mont-Bold.woff2 deleted file mode 100644 index 1093fe0da..000000000 Binary files a/projects/skills/src/assets/font/Mont-Bold.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-BoldItalic.eot b/projects/skills/src/assets/font/Mont-BoldItalic.eot deleted file mode 100644 index 120034db0..000000000 Binary files a/projects/skills/src/assets/font/Mont-BoldItalic.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-BoldItalic.ttf b/projects/skills/src/assets/font/Mont-BoldItalic.ttf deleted file mode 100644 index 713b73fcd..000000000 Binary files a/projects/skills/src/assets/font/Mont-BoldItalic.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-BoldItalic.woff b/projects/skills/src/assets/font/Mont-BoldItalic.woff deleted file mode 100644 index febb9b5c4..000000000 Binary files a/projects/skills/src/assets/font/Mont-BoldItalic.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-BoldItalic.woff2 b/projects/skills/src/assets/font/Mont-BoldItalic.woff2 deleted file mode 100644 index c2f08b243..000000000 Binary files a/projects/skills/src/assets/font/Mont-BoldItalic.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ExtraLight.eot b/projects/skills/src/assets/font/Mont-ExtraLight.eot deleted file mode 100644 index 3020d40c0..000000000 Binary files a/projects/skills/src/assets/font/Mont-ExtraLight.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ExtraLight.ttf b/projects/skills/src/assets/font/Mont-ExtraLight.ttf deleted file mode 100644 index bc557ed80..000000000 Binary files a/projects/skills/src/assets/font/Mont-ExtraLight.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ExtraLight.woff b/projects/skills/src/assets/font/Mont-ExtraLight.woff deleted file mode 100644 index 696d98c0f..000000000 Binary files a/projects/skills/src/assets/font/Mont-ExtraLight.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ExtraLight.woff2 b/projects/skills/src/assets/font/Mont-ExtraLight.woff2 deleted file mode 100644 index 9c1b82b16..000000000 Binary files a/projects/skills/src/assets/font/Mont-ExtraLight.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ExtraLightItalic.eot b/projects/skills/src/assets/font/Mont-ExtraLightItalic.eot deleted file mode 100644 index c8b196fc3..000000000 Binary files a/projects/skills/src/assets/font/Mont-ExtraLightItalic.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ExtraLightItalic.ttf b/projects/skills/src/assets/font/Mont-ExtraLightItalic.ttf deleted file mode 100644 index 63f2d489e..000000000 Binary files a/projects/skills/src/assets/font/Mont-ExtraLightItalic.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ExtraLightItalic.woff b/projects/skills/src/assets/font/Mont-ExtraLightItalic.woff deleted file mode 100644 index 92daa148d..000000000 Binary files a/projects/skills/src/assets/font/Mont-ExtraLightItalic.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ExtraLightItalic.woff2 b/projects/skills/src/assets/font/Mont-ExtraLightItalic.woff2 deleted file mode 100644 index 9c7e86432..000000000 Binary files a/projects/skills/src/assets/font/Mont-ExtraLightItalic.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Heavy.eot b/projects/skills/src/assets/font/Mont-Heavy.eot deleted file mode 100644 index ace30f7ad..000000000 Binary files a/projects/skills/src/assets/font/Mont-Heavy.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Heavy.ttf b/projects/skills/src/assets/font/Mont-Heavy.ttf deleted file mode 100644 index 5bea880d7..000000000 Binary files a/projects/skills/src/assets/font/Mont-Heavy.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Heavy.woff b/projects/skills/src/assets/font/Mont-Heavy.woff deleted file mode 100644 index 477129403..000000000 Binary files a/projects/skills/src/assets/font/Mont-Heavy.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Heavy.woff2 b/projects/skills/src/assets/font/Mont-Heavy.woff2 deleted file mode 100644 index e3defdb4e..000000000 Binary files a/projects/skills/src/assets/font/Mont-Heavy.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-HeavyItalic.eot b/projects/skills/src/assets/font/Mont-HeavyItalic.eot deleted file mode 100644 index d8e607fe6..000000000 Binary files a/projects/skills/src/assets/font/Mont-HeavyItalic.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-HeavyItalic.ttf b/projects/skills/src/assets/font/Mont-HeavyItalic.ttf deleted file mode 100644 index 59ac4d809..000000000 Binary files a/projects/skills/src/assets/font/Mont-HeavyItalic.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-HeavyItalic.woff b/projects/skills/src/assets/font/Mont-HeavyItalic.woff deleted file mode 100644 index 33a14d6ad..000000000 Binary files a/projects/skills/src/assets/font/Mont-HeavyItalic.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-HeavyItalic.woff2 b/projects/skills/src/assets/font/Mont-HeavyItalic.woff2 deleted file mode 100644 index b5c524bfe..000000000 Binary files a/projects/skills/src/assets/font/Mont-HeavyItalic.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Light.eot b/projects/skills/src/assets/font/Mont-Light.eot deleted file mode 100644 index b8ee7d641..000000000 Binary files a/projects/skills/src/assets/font/Mont-Light.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Light.ttf b/projects/skills/src/assets/font/Mont-Light.ttf deleted file mode 100644 index 86bfb2580..000000000 Binary files a/projects/skills/src/assets/font/Mont-Light.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Light.woff b/projects/skills/src/assets/font/Mont-Light.woff deleted file mode 100644 index 9d0ed8498..000000000 Binary files a/projects/skills/src/assets/font/Mont-Light.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Light.woff2 b/projects/skills/src/assets/font/Mont-Light.woff2 deleted file mode 100644 index a73f94592..000000000 Binary files a/projects/skills/src/assets/font/Mont-Light.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-LightItalic.eot b/projects/skills/src/assets/font/Mont-LightItalic.eot deleted file mode 100644 index fa6e9e4ed..000000000 Binary files a/projects/skills/src/assets/font/Mont-LightItalic.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-LightItalic.ttf b/projects/skills/src/assets/font/Mont-LightItalic.ttf deleted file mode 100644 index 4b4d7f530..000000000 Binary files a/projects/skills/src/assets/font/Mont-LightItalic.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-LightItalic.woff b/projects/skills/src/assets/font/Mont-LightItalic.woff deleted file mode 100644 index c24d78b69..000000000 Binary files a/projects/skills/src/assets/font/Mont-LightItalic.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-LightItalic.woff2 b/projects/skills/src/assets/font/Mont-LightItalic.woff2 deleted file mode 100644 index 5b4103107..000000000 Binary files a/projects/skills/src/assets/font/Mont-LightItalic.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Regular.eot b/projects/skills/src/assets/font/Mont-Regular.eot deleted file mode 100644 index c90748244..000000000 Binary files a/projects/skills/src/assets/font/Mont-Regular.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Regular.ttf b/projects/skills/src/assets/font/Mont-Regular.ttf deleted file mode 100644 index ee599fc44..000000000 Binary files a/projects/skills/src/assets/font/Mont-Regular.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Regular.woff b/projects/skills/src/assets/font/Mont-Regular.woff deleted file mode 100644 index b39fea5ca..000000000 Binary files a/projects/skills/src/assets/font/Mont-Regular.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Regular.woff2 b/projects/skills/src/assets/font/Mont-Regular.woff2 deleted file mode 100644 index 0b682c4d2..000000000 Binary files a/projects/skills/src/assets/font/Mont-Regular.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-RegularItalic.eot b/projects/skills/src/assets/font/Mont-RegularItalic.eot deleted file mode 100644 index c8ba21ba3..000000000 Binary files a/projects/skills/src/assets/font/Mont-RegularItalic.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-RegularItalic.ttf b/projects/skills/src/assets/font/Mont-RegularItalic.ttf deleted file mode 100644 index 9a05be178..000000000 Binary files a/projects/skills/src/assets/font/Mont-RegularItalic.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-RegularItalic.woff b/projects/skills/src/assets/font/Mont-RegularItalic.woff deleted file mode 100644 index 7c1a23c1e..000000000 Binary files a/projects/skills/src/assets/font/Mont-RegularItalic.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-RegularItalic.woff2 b/projects/skills/src/assets/font/Mont-RegularItalic.woff2 deleted file mode 100644 index da3f1053d..000000000 Binary files a/projects/skills/src/assets/font/Mont-RegularItalic.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-SemiBold.eot b/projects/skills/src/assets/font/Mont-SemiBold.eot deleted file mode 100644 index 758cd6f77..000000000 Binary files a/projects/skills/src/assets/font/Mont-SemiBold.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-SemiBold.ttf b/projects/skills/src/assets/font/Mont-SemiBold.ttf deleted file mode 100644 index 5ca92c503..000000000 Binary files a/projects/skills/src/assets/font/Mont-SemiBold.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-SemiBold.woff b/projects/skills/src/assets/font/Mont-SemiBold.woff deleted file mode 100644 index b18fe0f1b..000000000 Binary files a/projects/skills/src/assets/font/Mont-SemiBold.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-SemiBold.woff2 b/projects/skills/src/assets/font/Mont-SemiBold.woff2 deleted file mode 100644 index 7c2fb176b..000000000 Binary files a/projects/skills/src/assets/font/Mont-SemiBold.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-SemiBoldItalic.eot b/projects/skills/src/assets/font/Mont-SemiBoldItalic.eot deleted file mode 100644 index 4bfae3740..000000000 Binary files a/projects/skills/src/assets/font/Mont-SemiBoldItalic.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-SemiBoldItalic.ttf b/projects/skills/src/assets/font/Mont-SemiBoldItalic.ttf deleted file mode 100644 index e44a23b47..000000000 Binary files a/projects/skills/src/assets/font/Mont-SemiBoldItalic.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-SemiBoldItalic.woff b/projects/skills/src/assets/font/Mont-SemiBoldItalic.woff deleted file mode 100644 index 73654a944..000000000 Binary files a/projects/skills/src/assets/font/Mont-SemiBoldItalic.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-SemiBoldItalic.woff2 b/projects/skills/src/assets/font/Mont-SemiBoldItalic.woff2 deleted file mode 100644 index a65c6296a..000000000 Binary files a/projects/skills/src/assets/font/Mont-SemiBoldItalic.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Thin.eot b/projects/skills/src/assets/font/Mont-Thin.eot deleted file mode 100644 index 535a8e0fc..000000000 Binary files a/projects/skills/src/assets/font/Mont-Thin.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Thin.ttf b/projects/skills/src/assets/font/Mont-Thin.ttf deleted file mode 100644 index 34d2faed3..000000000 Binary files a/projects/skills/src/assets/font/Mont-Thin.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Thin.woff b/projects/skills/src/assets/font/Mont-Thin.woff deleted file mode 100644 index 5cb2794c2..000000000 Binary files a/projects/skills/src/assets/font/Mont-Thin.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-Thin.woff2 b/projects/skills/src/assets/font/Mont-Thin.woff2 deleted file mode 100644 index 5634eb7b3..000000000 Binary files a/projects/skills/src/assets/font/Mont-Thin.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ThinItalic.eot b/projects/skills/src/assets/font/Mont-ThinItalic.eot deleted file mode 100644 index 162bf8a59..000000000 Binary files a/projects/skills/src/assets/font/Mont-ThinItalic.eot and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ThinItalic.ttf b/projects/skills/src/assets/font/Mont-ThinItalic.ttf deleted file mode 100644 index 0e4ca5596..000000000 Binary files a/projects/skills/src/assets/font/Mont-ThinItalic.ttf and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ThinItalic.woff b/projects/skills/src/assets/font/Mont-ThinItalic.woff deleted file mode 100644 index d6c596573..000000000 Binary files a/projects/skills/src/assets/font/Mont-ThinItalic.woff and /dev/null differ diff --git a/projects/skills/src/assets/font/Mont-ThinItalic.woff2 b/projects/skills/src/assets/font/Mont-ThinItalic.woff2 deleted file mode 100644 index c17514854..000000000 Binary files a/projects/skills/src/assets/font/Mont-ThinItalic.woff2 and /dev/null differ diff --git a/projects/skills/src/assets/icons/svg/arrow-no-body.svg b/projects/skills/src/assets/icons/svg/arrow-no-body.svg deleted file mode 100644 index 8086d5782..000000000 --- a/projects/skills/src/assets/icons/svg/arrow-no-body.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/bell.svg b/projects/skills/src/assets/icons/svg/bell.svg deleted file mode 100644 index 16e745a68..000000000 --- a/projects/skills/src/assets/icons/svg/bell.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/check.svg b/projects/skills/src/assets/icons/svg/check.svg deleted file mode 100644 index 3fbc5cde1..000000000 --- a/projects/skills/src/assets/icons/svg/check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/circle-check.svg b/projects/skills/src/assets/icons/svg/circle-check.svg deleted file mode 100644 index e1c485043..000000000 --- a/projects/skills/src/assets/icons/svg/circle-check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/cross.svg b/projects/skills/src/assets/icons/svg/cross.svg deleted file mode 100644 index 451c5b7a6..000000000 --- a/projects/skills/src/assets/icons/svg/cross.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/folder.svg b/projects/skills/src/assets/icons/svg/folder.svg deleted file mode 100644 index bc5130c5c..000000000 --- a/projects/skills/src/assets/icons/svg/folder.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/geo-point.svg b/projects/skills/src/assets/icons/svg/geo-point.svg deleted file mode 100644 index 145b904f9..000000000 --- a/projects/skills/src/assets/icons/svg/geo-point.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/growth.svg b/projects/skills/src/assets/icons/svg/growth.svg deleted file mode 100644 index dc10ddf4e..000000000 --- a/projects/skills/src/assets/icons/svg/growth.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/hint.svg b/projects/skills/src/assets/icons/svg/hint.svg deleted file mode 100644 index 42dc91c30..000000000 --- a/projects/skills/src/assets/icons/svg/hint.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/projects/skills/src/assets/icons/svg/left-arrow.svg b/projects/skills/src/assets/icons/svg/left-arrow.svg deleted file mode 100644 index 89f75442b..000000000 --- a/projects/skills/src/assets/icons/svg/left-arrow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/lib.svg b/projects/skills/src/assets/icons/svg/lib.svg deleted file mode 100644 index 58bcb2209..000000000 --- a/projects/skills/src/assets/icons/svg/lib.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/lock.svg b/projects/skills/src/assets/icons/svg/lock.svg deleted file mode 100644 index 3e17fbe86..000000000 --- a/projects/skills/src/assets/icons/svg/lock.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/logout.svg b/projects/skills/src/assets/icons/svg/logout.svg deleted file mode 100644 index a7c74d0b0..000000000 --- a/projects/skills/src/assets/icons/svg/logout.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/projects/skills/src/assets/icons/svg/logout2.svg b/projects/skills/src/assets/icons/svg/logout2.svg deleted file mode 100644 index ddda850b6..000000000 --- a/projects/skills/src/assets/icons/svg/logout2.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/logout3.svg b/projects/skills/src/assets/icons/svg/logout3.svg deleted file mode 100644 index b7c27c199..000000000 --- a/projects/skills/src/assets/icons/svg/logout3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/menu-burger.svg b/projects/skills/src/assets/icons/svg/menu-burger.svg deleted file mode 100644 index 22b276789..000000000 --- a/projects/skills/src/assets/icons/svg/menu-burger.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/menu-cross.svg b/projects/skills/src/assets/icons/svg/menu-cross.svg deleted file mode 100644 index 02dc703ec..000000000 --- a/projects/skills/src/assets/icons/svg/menu-cross.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/pdf.svg b/projects/skills/src/assets/icons/svg/pdf.svg deleted file mode 100644 index 7c5a1c7a9..000000000 --- a/projects/skills/src/assets/icons/svg/pdf.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/person.svg b/projects/skills/src/assets/icons/svg/person.svg deleted file mode 100644 index 8746fa9c3..000000000 --- a/projects/skills/src/assets/icons/svg/person.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/receipt.svg b/projects/skills/src/assets/icons/svg/receipt.svg deleted file mode 100644 index 698630e87..000000000 --- a/projects/skills/src/assets/icons/svg/receipt.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/projects/skills/src/assets/icons/svg/search-sidebar.svg b/projects/skills/src/assets/icons/svg/search-sidebar.svg deleted file mode 100644 index 3aef51d2c..000000000 --- a/projects/skills/src/assets/icons/svg/search-sidebar.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/projects/skills/src/assets/icons/svg/search.svg b/projects/skills/src/assets/icons/svg/search.svg deleted file mode 100644 index 78d113336..000000000 --- a/projects/skills/src/assets/icons/svg/search.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/projects/skills/src/assets/icons/svg/trackbuss.svg b/projects/skills/src/assets/icons/svg/trackbuss.svg deleted file mode 100644 index 3ad8d2ac0..000000000 --- a/projects/skills/src/assets/icons/svg/trackbuss.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/icons/svg/trackcar.svg b/projects/skills/src/assets/icons/svg/trackcar.svg deleted file mode 100644 index 067c302d1..000000000 --- a/projects/skills/src/assets/icons/svg/trackcar.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/icons/svg/triangle.svg b/projects/skills/src/assets/icons/svg/triangle.svg deleted file mode 100644 index c98333c02..000000000 --- a/projects/skills/src/assets/icons/svg/triangle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/icons/svg/webinars.svg b/projects/skills/src/assets/icons/svg/webinars.svg deleted file mode 100644 index e7617fcc4..000000000 --- a/projects/skills/src/assets/icons/svg/webinars.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg b/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg deleted file mode 100644 index bf5d7f495..000000000 --- a/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/projects/skills/src/assets/images/profile/instruction.svg b/projects/skills/src/assets/images/profile/instruction.svg deleted file mode 100644 index 916a80ee3..000000000 --- a/projects/skills/src/assets/images/profile/instruction.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/projects/skills/src/assets/images/profile/olive-branch.svg b/projects/skills/src/assets/images/profile/olive-branch.svg deleted file mode 100644 index 4e73c5c6e..000000000 --- a/projects/skills/src/assets/images/profile/olive-branch.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/projects/skills/src/assets/images/profile/soon.svg b/projects/skills/src/assets/images/profile/soon.svg deleted file mode 100644 index 91b8e1279..000000000 --- a/projects/skills/src/assets/images/profile/soon.svg +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/profile/trajectory.svg b/projects/skills/src/assets/images/profile/trajectory.svg deleted file mode 100644 index c69501425..000000000 --- a/projects/skills/src/assets/images/profile/trajectory.svg +++ /dev/null @@ -1,683 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/rating/top-rating.png b/projects/skills/src/assets/images/rating/top-rating.png deleted file mode 100644 index f0518905f..000000000 Binary files a/projects/skills/src/assets/images/rating/top-rating.png and /dev/null differ diff --git a/projects/skills/src/assets/images/shared/logo.svg b/projects/skills/src/assets/images/shared/logo.svg deleted file mode 100644 index 37bad9f3b..000000000 --- a/projects/skills/src/assets/images/shared/logo.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/shared/prize.png b/projects/skills/src/assets/images/shared/prize.png deleted file mode 100644 index 15e68c9d8..000000000 Binary files a/projects/skills/src/assets/images/shared/prize.png and /dev/null differ diff --git a/projects/skills/src/assets/images/subscription/stars.svg b/projects/skills/src/assets/images/subscription/stars.svg deleted file mode 100644 index b012ef889..000000000 --- a/projects/skills/src/assets/images/subscription/stars.svg +++ /dev/null @@ -1,198 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/subscription/wave.svg b/projects/skills/src/assets/images/subscription/wave.svg deleted file mode 100644 index 26d25f958..000000000 --- a/projects/skills/src/assets/images/subscription/wave.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/projects/skills/src/assets/images/task/character.svg b/projects/skills/src/assets/images/task/character.svg deleted file mode 100644 index 64f0ea9dd..000000000 --- a/projects/skills/src/assets/images/task/character.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/task/check.svg b/projects/skills/src/assets/images/task/check.svg deleted file mode 100644 index 9794f77de..000000000 --- a/projects/skills/src/assets/images/task/check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/images/task/complete.svg b/projects/skills/src/assets/images/task/complete.svg deleted file mode 100644 index e97c7693a..000000000 --- a/projects/skills/src/assets/images/task/complete.svg +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/task/fire.svg b/projects/skills/src/assets/images/task/fire.svg deleted file mode 100644 index f08467b6c..000000000 --- a/projects/skills/src/assets/images/task/fire.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/projects/skills/src/assets/images/task/wave_cropped.svg b/projects/skills/src/assets/images/task/wave_cropped.svg deleted file mode 100644 index 81f01837e..000000000 --- a/projects/skills/src/assets/images/task/wave_cropped.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/completeAll.png b/projects/skills/src/assets/images/trajectories/completeAll.png deleted file mode 100644 index bfb842a66..000000000 Binary files a/projects/skills/src/assets/images/trajectories/completeAll.png and /dev/null differ diff --git a/projects/skills/src/assets/images/trajectories/confirm.svg b/projects/skills/src/assets/images/trajectories/confirm.svg deleted file mode 100644 index 793da4194..000000000 --- a/projects/skills/src/assets/images/trajectories/confirm.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/explaining/explaining1.svg b/projects/skills/src/assets/images/trajectories/explaining/explaining1.svg deleted file mode 100644 index d38d23655..000000000 --- a/projects/skills/src/assets/images/trajectories/explaining/explaining1.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/explaining/explaining2.svg b/projects/skills/src/assets/images/trajectories/explaining/explaining2.svg deleted file mode 100644 index 5565aa3e7..000000000 --- a/projects/skills/src/assets/images/trajectories/explaining/explaining2.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/explaining/explaining3.svg b/projects/skills/src/assets/images/trajectories/explaining/explaining3.svg deleted file mode 100644 index 027517930..000000000 --- a/projects/skills/src/assets/images/trajectories/explaining/explaining3.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/explaining/explaining4.svg b/projects/skills/src/assets/images/trajectories/explaining/explaining4.svg deleted file mode 100644 index 6aeac0650..000000000 --- a/projects/skills/src/assets/images/trajectories/explaining/explaining4.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/more.svg b/projects/skills/src/assets/images/trajectories/more.svg deleted file mode 100644 index f7992761e..000000000 --- a/projects/skills/src/assets/images/trajectories/more.svg +++ /dev/null @@ -1,299 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/rocket.svg b/projects/skills/src/assets/images/trajectories/rocket.svg deleted file mode 100644 index 75422e0ec..000000000 --- a/projects/skills/src/assets/images/trajectories/rocket.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/trajectories_cover.svg b/projects/skills/src/assets/images/trajectories/trajectories_cover.svg deleted file mode 100644 index aa49f0c79..000000000 --- a/projects/skills/src/assets/images/trajectories/trajectories_cover.svg +++ /dev/null @@ -1,283 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/trajectories/vertical_line.svg b/projects/skills/src/assets/images/trajectories/vertical_line.svg deleted file mode 100644 index ac72e9e99..000000000 --- a/projects/skills/src/assets/images/trajectories/vertical_line.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/projects/skills/src/assets/images/webinars/bro.svg b/projects/skills/src/assets/images/webinars/bro.svg deleted file mode 100644 index 5abba7b38..000000000 --- a/projects/skills/src/assets/images/webinars/bro.svg +++ /dev/null @@ -1,219 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/projects/skills/src/assets/images/webinars/smart-people-active.svg b/projects/skills/src/assets/images/webinars/smart-people-active.svg deleted file mode 100644 index 056f5c636..000000000 --- a/projects/skills/src/assets/images/webinars/smart-people-active.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/projects/skills/src/assets/images/webinars/smart-people.svg b/projects/skills/src/assets/images/webinars/smart-people.svg deleted file mode 100644 index 20c0cb008..000000000 --- a/projects/skills/src/assets/images/webinars/smart-people.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/projects/skills/src/environments/environment.prod.ts b/projects/skills/src/environments/environment.prod.ts deleted file mode 100644 index 35e1db1ce..000000000 --- a/projects/skills/src/environments/environment.prod.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** @format */ - -export const environment = { - production: true, - apiUrl: "https://api.procollab.ru", - skillsApiUrl: "https://api.skills.procollab.ru", -}; diff --git a/projects/skills/src/environments/environment.ts b/projects/skills/src/environments/environment.ts deleted file mode 100644 index 5ac33172d..000000000 --- a/projects/skills/src/environments/environment.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** @format */ - -export const environment = { - production: false, - apiUrl: "https://dev.procollab.ru", - skillsApiUrl: "https://skills.dev.procollab.ru", -}; diff --git a/projects/skills/src/favicon.ico b/projects/skills/src/favicon.ico deleted file mode 100644 index f4dce8662..000000000 Binary files a/projects/skills/src/favicon.ico and /dev/null differ diff --git a/projects/skills/src/index.html b/projects/skills/src/index.html deleted file mode 100644 index 06f631293..000000000 --- a/projects/skills/src/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Skills - - - - - - - - diff --git a/projects/skills/src/main.ts b/projects/skills/src/main.ts deleted file mode 100644 index b288c95b7..000000000 --- a/projects/skills/src/main.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** @format */ - -import { bootstrapApplication } from "@angular/platform-browser"; -import { appConfig } from "./app/app.config"; -import { AppComponent } from "./app/app.component"; - -bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); diff --git a/projects/skills/src/models/api-pagination.model.ts b/projects/skills/src/models/api-pagination.model.ts deleted file mode 100644 index 0d26cee6f..000000000 --- a/projects/skills/src/models/api-pagination.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -/** - * Универсальный интерфейс для пагинированных ответов API - * - * Используется для всех эндпоинтов, которые возвращают списки данных - * с поддержкой постраничной навигации. Следует стандарту Django REST Framework. - * - * @template T - Тип элементов в массиве results - */ -export interface ApiPagination { - /** Общее количество элементов во всей коллекции */ - count: number; - - /** Массив элементов текущей страницы */ - results: T[]; - - /** URL для получения следующей страницы (null если это последняя страница) */ - next: string; - - /** URL для получения предыдущей страницы (null если это первая страница) */ - previous: string; -} diff --git a/projects/skills/src/models/profile.model.ts b/projects/skills/src/models/profile.model.ts deleted file mode 100644 index 0adb28f91..000000000 --- a/projects/skills/src/models/profile.model.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** @format */ - -/** - * Интерфейс для основных данных пользователя - * - * Содержит персональную информацию, статистику и статус - * пользователя в системе обучения - */ -export interface UserData { - /** Уникальный идентификатор пользователя */ - id: number; - - /** Имя пользователя */ - firstName: string; - - /** Фамилия пользователя */ - lastName: string; - - /** Ссылка на основной файл профиля (резюме, портфолио) */ - fileLink: string; - - /** URL аватара пользователя */ - avatar: string; - - /** Возраст пользователя */ - age: number; - - /** Профессиональная специализация */ - specialization: string; - - /** Географическое местоположение */ - geoPosition: string; - - /** Дата верификации профиля в формате строки */ - verificationDate: string; - - /** Общее количество накопленных баллов */ - points: number; - - /** Статус ментора - может ли пользователь обучать других */ - isMentor: boolean; -} - -/** - * Интерфейс для навыка пользователя - * - * Представляет конкретный навык с информацией о прогрессе - * и текущем уровне владения - */ -export interface Skill { - /** Уникальный идентификатор навыка */ - skillId: number; - - /** Название навыка */ - skillName: string; - - /** Текущий уровень владения навыком (1-10) */ - skillLevel: number; - - /** Прогресс изучения навыка в процентах (0-100) */ - skillProgress: number; - - /** Ссылка на дополнительные материалы по навыку */ - fileLink: string; -} - -/** - * Интерфейс для месячной активности - * - * Отслеживает активность пользователя по месяцам - * для построения календаря достижений - */ -export interface Month { - /** Название месяца */ - month: string; - - /** Был ли месяц успешно завершен (выполнены цели) */ - successfullyDone: boolean; - - /** Год (опционально, для исторических данных) */ - year?: number; -} - -/** - * Основной интерфейс профиля пользователя - * - * Объединяет всю информацию о пользователе: - * персональные данные, навыки и историю активности - */ -export interface Profile { - /** Основные данные пользователя */ - userData: UserData; - - /** Массив навыков пользователя с прогрессом */ - skills: Skill[]; - - /** История месячной активности */ - months: Month[]; -} diff --git a/projects/skills/src/models/rating.model.ts b/projects/skills/src/models/rating.model.ts deleted file mode 100644 index 114f37457..000000000 --- a/projects/skills/src/models/rating.model.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -/** - * Интерфейс для общего рейтинга пользователей - * - * Представляет информацию о пользователе в системе рейтингов, - * включая персональные данные, профессиональную информацию - * и достижения в обучении - */ -export interface GeneralRating { - /** Имя пользователя для отображения в рейтинге */ - userName: string; - - /** Возраст пользователя */ - age: number; - - /** Профессиональная специализация или область деятельности */ - specialization: string; - - /** Географическое местоположение пользователя */ - geoPosition: string; - - /** Количество набранных баллов/очков в системе рейтинга */ - scoreCount: number; - - /** - * Ссылка на файл аватара пользователя - * null - аватар не установлен, используется заглушка - */ - file: string | null; -} diff --git a/projects/skills/src/models/trajectory.model.ts b/projects/skills/src/models/trajectory.model.ts deleted file mode 100644 index 9d2f9eae8..000000000 --- a/projects/skills/src/models/trajectory.model.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** @format */ - -import type { UserData } from "./profile.model"; -import type { Skill } from "../../../social_platform/src/app/office/models/skill.model"; - -/** - * Информация о навыке в контексте траектории - * Упрощенная версия интерфейса Skill для отображения траектории - */ -interface SkillInfo { - fileLink: string | null; // URL к ресурсам навыка - name: string; // Отображаемое название навыка -} - -/** - * Определение траектории обучения - * - * Траектории - это структурированные пути обучения, которые направляют пользователей через - * серию навыков и компетенций для достижения конкретных карьерных или - * бизнес-целей. - */ -export interface Trajectory { - id: number; - name: string; // Отображаемое название траектории - description: string; // Подробное описание того, что охватывает траектория - isActiveForUser: boolean; // Зарегистрирован ли текущий пользователь в этой траектории - avatar: string | null; // URL логотипа/аватара траектории - mentors: number[]; // Массив ID пользователей-менторов, назначенных на эту траекторию - skills: SkillInfo[]; // Навыки, включенные в эту траекторию - backgroundColor: string; // Пользовательский цвет фона для темизации UI - buttonColor: string; // Пользовательский цвет кнопки для темизации UI - selectButtonColor: string; // Пользовательский цвет кнопки выбора - textColor: string; // Пользовательский цвет текста для темизации UI - company: string; // Компания или организация, предлагающая эту траекторию - durationMonths: number; // Ожидаемая продолжительность в месяцах -} - -/** - * Навыки, категоризированные по статусу доступности в рамках траектории - * Помогает пользователям понимать их прогресс и что доступно далее - */ -export interface TrajectorySkills { - availableSkills: Skill[]; // Навыки, к которым пользователь может получить доступ в настоящее время - unavailableSkills: Skill[]; // Навыки, заблокированные до выполнения предварительных условий - completedSkills: Skill[]; // Навыки, которые пользователь успешно завершил -} - -/** - * Регистрация и прогресс пользователя в конкретной траектории - * - * Содержит всю информацию о путешествии пользователя через траекторию, - * включая назначение ментора, статус встреч и прогресс по навыкам. - */ -export interface UserTrajectory { - trajectoryId: number; // ID зарегистрированной траектории - startDate: string; // ISO строка даты начала регистрации - endDate: string; // ISO строка даты ожидаемого завершения - isActive: boolean; // Активна ли регистрация в настоящее время - mentorFirstName: string; // Имя назначенного ментора - mentorLastName: string; // Фамилия назначенного ментора - mentorAvatar: string | null; // URL фотографии профиля назначенного ментора - mentorId: number; // ID пользователя назначенного ментора - firstMeetingDone: boolean; // Состоялась ли первоначальная встреча с ментором - finalMeetingDone: boolean; // Состоялась ли финальная оценочная встреча - availableSkills: Skill[]; // Навыки, доступные пользователю в настоящее время - unavailableSkills: Skill[]; // Навыки, заблокированные в ожидании предварительных условий - completedSkills: Skill[]; // Навыки, успешно завершенные пользователем - individualSkills: Skill[]; // Пользовательские навыки, назначенные специально этому пользователю - activeMonth: number; // Текущий месяц траектории (начиная с 1) - durationMonths: number; // Общая продолжительность траектории в месяцах -} - -/** - * Информация о студенте для панели ментора - * - * Используется менторами для отслеживания прогресса назначенных им студентов - * и управления менторскими отношениями. - */ -export interface Student { - trajectory: Trajectory; // Траектория, в которой зарегистрирован студент - finalMeeting: boolean; // Была ли завершена финальная встреча - initialMeeting: boolean; // Была ли завершена первоначальная встреча - remainingDays: number; // Дни, оставшиеся в траектории - userTrajectoryId: number; // ID регистрации пользователя в траектории - meetingId: number; // ID записи встречи - student: UserData; // Полная информация профиля студента - mentorId: number; // ID назначенного ментора -} diff --git a/projects/skills/src/models/webinars.model.ts b/projects/skills/src/models/webinars.model.ts deleted file mode 100644 index 327a50b22..000000000 --- a/projects/skills/src/models/webinars.model.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** @format */ - -/** - * Интерфейс для представления спикера вебинара - * - * Содержит основную информацию о ведущем вебинара, - * включая персональные данные и профессиональную информацию - */ -interface Speaker { - /** Полное имя спикера (имя и фамилия) */ - fullName: string; - /** URL фотографии спикера для отображения в интерфейсе */ - photo: string; - /** Должность или профессиональная позиция спикера */ - position: string; -} - -/** - * Модель вебинара - * - * Представляет собой онлайн-семинар или презентацию с полной информацией - * о событии, включая временные рамки, статус регистрации и данные спикера - */ -export class Webinar { - /** Уникальный идентификатор вебинара в системе */ - id!: number; - - /** Название/заголовок вебинара */ - title!: string; - - /** Подробное описание содержания и целей вебинара */ - description!: string; - - /** Дата и время начала вебинара */ - datetimeStart!: Date; - - /** Продолжительность вебинара в минутах */ - duration!: number; - - /** - * Статус регистрации текущего пользователя на вебинар - * null - статус неизвестен - * true - пользователь зарегистрирован - * false - пользователь не зарегистрирован - */ - isRegistrated!: boolean | null; - - /** - * Ссылка на запись вебинара (для завершенных вебинаров) - * null - запись недоступна - * true/false - статус доступности записи - */ - recordingLink!: boolean | null; - - /** Информация о спикере, ведущем вебинар */ - speaker!: Speaker; -} diff --git a/projects/skills/src/styles.scss b/projects/skills/src/styles.scss deleted file mode 100644 index 5f3eec7e4..000000000 --- a/projects/skills/src/styles.scss +++ /dev/null @@ -1,36 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ - -/** @format */ -@import "styles/colors.scss"; -@import "styles/global.scss"; -@import "styles/responsive.scss"; -@import "styles/rounded.scss"; -@import "styles/typography.scss"; - -// COMPONENTS - -@import "styles/components/key-skills.scss"; -@import "styles/components/nav.scss"; -@import "styles/components/contact-link.scss"; - -// PAGES - -@import "styles/pages/members.scss"; -@import "styles/pages/auth.scss"; -@import "styles/pages/project-detail.scss"; - -:root { - --app-height: 100vh; -} - -html, -body { - min-width: 280px; - height: var(--app-height, 100%); -} - -body { - margin: 0; - overflow-x: hidden; - font-family: Roboto, "Helvetica Neue", sans-serif; -} diff --git a/projects/skills/src/styles/_colors.scss b/projects/skills/src/styles/_colors.scss deleted file mode 100644 index 5f321991a..000000000 --- a/projects/skills/src/styles/_colors.scss +++ /dev/null @@ -1,35 +0,0 @@ -/** @format */ - -@use "sass:color"; - -:root { - // ACCENT - --gradient: linear-gradient(90deg, #242424 0%, #8a63e6 50%); - --gradient-mild: linear-gradient(90deg, #501d53 0.3%, #3b1f72 22%, #8a63e6 100%); - --accent: #8a63e6; - --accent-dark: #{color.adjust(#8a63e6, $blackness: 20%)}; - --accent-mild: #{color.adjust(#6c27ff, $alpha: -0.4)}; - --accent-light: #9a80e6; - - // GOLD - --gold: #e5b25d; - --gold-dark: #c69849; - - // GRAY - --light-white: #fff; - --white: #fafafa; - --black: #333; - --dark-grey: #a59fb9; - --gray: #d3d3d3; - --light-gray: #f9f9f9; - --grey-button: #e5e5e5e5; - --medium-grey-for-outline: #eee; - --grey-for-text: #827e80; - - // FUNCTIONAL - --green: #88c9a1; - --green-dark: #297373; - --lime: #d6ff54; - --red: #d48a9e; - --red-dark: #{color.adjust(#d48a9e, $blackness: 10%)}; -} diff --git a/projects/skills/src/styles/_global.scss b/projects/skills/src/styles/_global.scss deleted file mode 100644 index e29a38145..000000000 --- a/projects/skills/src/styles/_global.scss +++ /dev/null @@ -1,94 +0,0 @@ -/** @format */ - -@import "typography"; - -/** @format */ - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -// html, -// body { -// overflow-y: hidden; -// } - -ul, -li, -ol { - list-style-type: none; -} - -fieldset { - border: none; - outline: none; -} - -img { - display: block; - max-width: 100%; -} - -a, -.link { - color: var(--accent); - text-decoration: none; - transition: color 0.2s; - - &:hover { - color: var(--accent-hover); - } -} - -.edit-link { - position: relative; - - &__remove { - position: absolute; - top: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - width: 50px; - color: var(--white); - cursor: pointer; - background-color: var(--gray); - border-radius: 0 8px 8px 0; - } -} - -button { - background-color: transparent; - border: none; - outline: none; -} - -label.field-label { - display: block; - margin-bottom: 6px; - margin-left: 12px; - color: var(--black); - - @include body-12; -} - -.error { - display: block; - gap: 10px; - margin-top: 6px; - color: var(--red) !important; - - i { - color: var(--red) !important; - } - - p { - &:not(:last-child) { - margin-bottom: 10px; - } - } -} diff --git a/projects/skills/src/styles/_responsive.scss b/projects/skills/src/styles/_responsive.scss deleted file mode 100644 index 274eab993..000000000 --- a/projects/skills/src/styles/_responsive.scss +++ /dev/null @@ -1,10 +0,0 @@ -/** @format */ - -$container-md: 1280px; -$container-sm: 920px; - -@mixin apply-desktop { - @media screen and (min-width: #{$container-sm}) { - @content; - } -} diff --git a/projects/skills/src/styles/_rounded.scss b/projects/skills/src/styles/_rounded.scss deleted file mode 100644 index b2602fac5..000000000 --- a/projects/skills/src/styles/_rounded.scss +++ /dev/null @@ -1,9 +0,0 @@ -/** @format */ - -:root { - --rounded-sm: 3px; - --rounded-md: 5px; - --rounded-lg: 8px; - --rounded-xl: 15px; - --rounded-xxl: 45px; -} diff --git a/projects/skills/src/styles/_typography.scss b/projects/skills/src/styles/_typography.scss deleted file mode 100644 index e39bad451..000000000 --- a/projects/skills/src/styles/_typography.scss +++ /dev/null @@ -1,258 +0,0 @@ -/** @format */ - -@font-face { - font-family: Mont; - font-style: normal; - font-weight: 900; - src: - local(""), - url("../assets/font/Mont-Black.eot?#iefix") format("embedded-opentype"), - url("../assets/font/Mont-Black.woff2") format("woff2"), - url("../assets/font/Mont-Black.woff") format("woff"), - url("../assets/font/Mont-Black.ttf") format("truetype"); -} - -@font-face { - font-family: Mont; - font-style: normal; - font-weight: 800; - src: url("../assets/font/Mont-Heavy.eot"); - src: - local(""), - url("../assets/font/Mont-Heavy.eot?#iefix") format("embedded-opentype"), - url("../assets/font/Mont-Heavy.woff2") format("woff2"), - url("../assets/font/Mont-Heavy.woff") format("woff"), - url("../assets/font/Mont-Heavy.ttf") format("truetype"); -} - -@font-face { - font-family: Mont; - font-style: normal; - font-weight: bold; - src: url("../assets/font/Mont-Bold.eot"); - src: - local(""), - url("../assets/font/Mont-Bold.eot?#iefix") format("embedded-opentype"), - url("../assets/font/Mont-Bold.woff2") format("woff2"), - url("../assets/font/Mont-Bold.woff") format("woff"), - url("../assets/font/Mont-Bold.ttf") format("truetype"); -} - -@font-face { - font-family: Mont; - font-style: normal; - font-weight: 600; - src: url("../assets/font/Mont-SemiBold.eot"); - src: - local(""), - url("../assets/font/Mont-SemiBold.eot?#iefix") format("embedded-opentype"), - url("../assets/font/Mont-SemiBold.woff2") format("woff2"), - url("../assets/font/Mont-SemiBold.woff") format("woff"), - url("../assets/font/Mont-SemiBold.ttf") format("truetype"); -} - -@font-face { - font-family: Mont; - font-style: normal; - font-weight: normal; - src: url("../assets/font/Mont-Regular.eot"); - src: - local(""), - url("../assets/font/Mont-Regular.eot?#iefix") format("embedded-opentype"), - url("../assets/font/Mont-Regular.woff2") format("woff2"), - url("../assets/font/Mont-Regular.woff") format("woff"), - url("../assets/font/Mont-Regular.ttf") format("truetype"); -} - -@mixin heading-1 { - font-family: Mont, sans-serif; - font-size: 18px; - font-style: normal; - font-weight: 400; - line-height: 150%; -} - -.text-heading-1 { - @include heading-1; -} - -@mixin heading-1-bold { - font-family: Mont, sans-serif; - font-size: 18px; - font-style: normal; - font-weight: 600; - line-height: 150%; -} - -.text-heading-1-bold { - @include heading-1-bold; -} - -@mixin heading-2 { - font-family: Mont, sans-serif; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: 150%; -} - -.text-heading-2 { - @include heading-2; -} - -@mixin heading-2-bold { - font-family: Mont, sans-serif; - font-size: 16px; - font-style: normal; - font-weight: 600; - line-height: 150%; -} - -.text-heading-2-bold { - @include heading-2-bold; -} - -@mixin heading-3 { - font-family: Mont, sans-serif; - font-size: 24px; - font-style: normal; - font-weight: 700; - line-height: 150%; -} - -.text-heading-3 { - @include heading-3; -} - -@mixin heading-4 { - font-family: Mont, sans-serif; - font-size: 18px; - font-style: normal; - font-weight: 700; - line-height: 150%; -} - -.text-heading-4 { - @include heading-4; -} - -@mixin body-18 { - font-family: Mont, sans-serif; - font-size: 18px; - font-style: normal; - font-weight: 600; - line-height: 150%; -} - -.text-body-18 { - @include body-18; -} - -@mixin body-bold-18 { - font-family: Mont, sans-serif; - font-size: 18px; - font-style: normal; - font-weight: 800; - line-height: 130%; -} - -.text-body-bold-18 { - @include body-18; -} - -@mixin bold-body-16 { - font-family: Mont, sans-serif; - font-size: 16px; - font-style: normal; - font-weight: 700; - line-height: 150%; -} - -.text-bold-body-16 { - @include bold-body-16; -} - -@mixin body-16 { - font-family: Mont, sans-serif; - font-size: 16px; - font-style: normal; - font-weight: 600; - line-height: 150%; -} - -.text-body-16 { - @include body-16; -} - -@mixin bold-body-14 { - font-family: Mont, sans-serif; - font-size: 14px; - font-style: normal; - font-weight: 700; - line-height: 150%; -} - -.text-body-bold-14 { - @include bold-body-14; -} - -@mixin body-14 { - font-family: Mont, sans-serif; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 150%; -} - -.text-body-14 { - @include body-14; -} - -@mixin body-12 { - font-family: Mont, sans-serif; - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 130%; -} - -.text-body-12 { - @include body-12; -} - -@mixin body-bold-12 { - font-family: Mont, sans-serif; - font-size: 12px; - font-style: normal; - font-weight: 600; - line-height: 130%; -} - -.text-body-bold-12 { - @include body-bold-12; -} - -@mixin body-10 { - font-family: Mont, sans-serif; - font-size: 10px; - font-style: normal; - font-weight: 400; - line-height: 130%; - color: var(--grey-button); -} - -.text-body-10 { - @include body-10; -} - -@mixin body-6 { - font-family: Mont, sans-serif; - font-size: 6px; - font-style: normal; - font-weight: 400; - line-height: 130%; -} - -.text-body-6 { - @include body-6; -} diff --git a/projects/skills/src/styles/components/_contact-link.scss b/projects/skills/src/styles/components/_contact-link.scss deleted file mode 100644 index 85bd1644a..000000000 --- a/projects/skills/src/styles/components/_contact-link.scss +++ /dev/null @@ -1,25 +0,0 @@ -.contact-link { - color: var(--accent); - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } - - &__link { - display: flex; - align-items: center; - } - - &__icon { - display: flex; - flex-shrink: 0; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - margin-right: 12px; - border: 1px solid var(--grey-button); - border-radius: 6px; - } -} diff --git a/projects/skills/src/styles/components/_key-skills.scss b/projects/skills/src/styles/components/_key-skills.scss deleted file mode 100644 index 987af772f..000000000 --- a/projects/skills/src/styles/components/_key-skills.scss +++ /dev/null @@ -1,31 +0,0 @@ -.tag-list { - display: flex; - flex-wrap: wrap; - - &__item { - display: block; - margin-bottom: 12px; - - &:not(:last-child) { - margin-right: 10px; - } - } - - &__remove-icon { - color: var(--red); - cursor: pointer; - } -} - -.tag-list-form { - display: flex; - - &__input { - flex-grow: 1; - margin-right: 6px; - } - - &__button { - width: 52px; - } -} diff --git a/projects/skills/src/styles/components/_nav.scss b/projects/skills/src/styles/components/_nav.scss deleted file mode 100644 index 9d734df68..000000000 --- a/projects/skills/src/styles/components/_nav.scss +++ /dev/null @@ -1,50 +0,0 @@ -/** @format */ - -.nav { - &--vertical { - .nav__list { - flex-direction: column; - align-items: flex-start; - } - - .nav__link { - &:not(:last-child) { - margin-right: 0; - margin-bottom: 30px; - } - } - } - - &__list { - display: flex; - align-items: center; - } - - &__link { - color: var(--dark-grey); - transition: color 0.2s; - - &:not(:last-child) { - margin-right: 38px; - } - - &:hover { - color: var(--black); - } - - &--active { - color: var(--black); - } - } - - &__item { - display: flex; - align-items: center; - color: inherit; - cursor: pointer; - - i { - margin-right: 12px; - } - } -} diff --git a/projects/skills/src/styles/components/_underlined-link.scss b/projects/skills/src/styles/components/_underlined-link.scss deleted file mode 100644 index 77631ee61..000000000 --- a/projects/skills/src/styles/components/_underlined-link.scss +++ /dev/null @@ -1,11 +0,0 @@ -.underlined-link { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } -} diff --git a/projects/skills/src/styles/pages/_members.scss b/projects/skills/src/styles/pages/_members.scss deleted file mode 100644 index 561a32728..000000000 --- a/projects/skills/src/styles/pages/_members.scss +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -@use "styles/responsive"; - -.members { - padding-bottom: 100px; - - &__bar { - padding: 20px; - margin-bottom: 12px; - background-color: var(--white); - } - - &__list { - display: grid; - grid-template-columns: 1fr; - grid-gap: 16px; - align-items: start; - - @include responsive.apply-desktop { - grid-template-columns: repeat(3, 1fr); - } - } -} diff --git a/projects/skills/src/styles/pages/_project-detail.scss b/projects/skills/src/styles/pages/_project-detail.scss deleted file mode 100644 index 7c67ec426..000000000 --- a/projects/skills/src/styles/pages/_project-detail.scss +++ /dev/null @@ -1,52 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.unsubscribe-modal { - display: flex; - flex-direction: column; - align-items: center; - margin: 4px 20px; - - h3 { - margin-bottom: 40px; - text-align: center; - } - - app-avatar { - margin-bottom: 30px; - } - - app-button ::ng-deep .button--inline { - min-height: 38px; - - @include responsive.apply-desktop { - min-height: 52px; - } - } - - &__buttons { - display: flex; - flex-direction: column; - gap: 15px; - justify-content: space-between; - width: 100%; - - &--additional { - justify-content: center; - } - - @include responsive.apply-desktop { - flex-direction: row; - } - } -} - -.unsubscribe-modal-btn { - &__typography { - @include typography.bold-body-14; - - @include responsive.apply-desktop { - @include typography.bold-body-16; - } - } -} diff --git a/projects/skills/src/styles/pages/auth.scss b/projects/skills/src/styles/pages/auth.scss deleted file mode 100644 index b82a86567..000000000 --- a/projects/skills/src/styles/pages/auth.scss +++ /dev/null @@ -1,144 +0,0 @@ -/** @format */ - -@use "styles/responsive"; -@use "styles/typography"; - -.auth { - padding: 100px 0; - - &__logo { - position: fixed; - top: 45px; - left: 18px; - display: block; - width: 95px; - - @include responsive.apply-desktop { - top: 120px; - left: 200px; - width: 157px; - height: 30px; - } - } - - &__greeting { - max-width: 510px; - margin-bottom: 20px; - - @include responsive.apply-desktop { - margin-bottom: 25px; - } - } - - &__wrapper { - @include responsive.apply-desktop { - max-width: 333px; - } - } - - &__title { - margin-bottom: 6px; - color: var(--black); - - &--register { - @include typography.heading-4; - } - - @include typography.heading-1; - - @include responsive.apply-desktop { - &--register { - @include typography.heading-2; - } - } - } - - &__info { - @include typography.heading-2; - } - - &__field { - margin-bottom: 12px; - - app-input, - app-select { - display: block; - } - } - - &__row { - display: flex; - - fieldset { - width: 50%; - - &:not(:last-child) { - margin-right: 15px; - } - } - } - - &__agreement { - display: flex; - align-items: center; - margin-bottom: 30px; - cursor: pointer; - } - - &__checkbox { - display: flex; - align-items: center; - justify-content: center; - margin-right: 10px; - } - - &__agreement-text { - color: var(--gray-100); - } - - &__button { - display: block; - width: 100%; - margin-top: 35px; - - &-typography { - @include typography.bold-body-14; - - @include responsive.apply-desktop { - @include typography.bold-body-16; - } - } - - @include responsive.apply-desktop { - margin-top: 30px; - } - } - - &__button-icon { - display: none !important; - margin-left: 10px; - transform: rotate(90deg); - - @include responsive.apply-desktop { - display: block !important; - } - } - - &__toggle { - @include typography.body-12; - - margin-top: 4vh; - } - - a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } -} diff --git a/projects/skills/tsconfig.app.json b/projects/skills/tsconfig.app.json deleted file mode 100644 index 741196093..000000000 --- a/projects/skills/tsconfig.app.json +++ /dev/null @@ -1,10 +0,0 @@ -/* To learn more about this file see: https://angular.io/config/tsconfig. */ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "../../out-tsc/app", - "types": [] - }, - "files": ["src/main.ts"], - "include": ["src/**/*.d.ts"] -} diff --git a/projects/skills/tsconfig.spec.json b/projects/skills/tsconfig.spec.json deleted file mode 100644 index 063a6f02c..000000000 --- a/projects/skills/tsconfig.spec.json +++ /dev/null @@ -1,9 +0,0 @@ -/* To learn more about this file see: https://angular.io/config/tsconfig. */ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "../../out-tsc/spec", - "types": ["jasmine"] - }, - "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] -} 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 95% 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..4160ffc73 100644 --- a/projects/social_platform/src/app/office/services/advert.service.ts +++ b/projects/social_platform/src/app/api/advert/advert.service.ts @@ -2,8 +2,8 @@ import { Injectable } from "@angular/core"; import { map, Observable } from "rxjs"; -import { New } from "@models/article.model"; -import { ApiService } from "projects/core"; +import { New } from "@domain/news/article.model"; +import { ApiService } from "@corelib"; import { plainToInstance } from "class-transformer"; /** diff --git a/projects/social_platform/src/app/api/auth/auth.service.spec.ts b/projects/social_platform/src/app/api/auth/auth.service.spec.ts new file mode 100644 index 000000000..6da3c03bc --- /dev/null +++ b/projects/social_platform/src/app/api/auth/auth.service.spec.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; + +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; + +describe("AuthRepository", () => { + let service: AuthRepository; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [AuthRepository], + }); + service = TestBed.inject(AuthRepository); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); 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..6401674b0 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-email.service.ts @@ -0,0 +1,85 @@ +/** @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 { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { ResendEmailUseCase } from "../use-cases/resend-email.use-case"; + +@Injectable() +export class AuthEmailService { + private readonly tokenService = inject(TokenService); + private readonly route = inject(ActivatedRoute); + private readonly resendEmailUseCase = inject(ResendEmailUseCase); + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + + 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(() => this.logger.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.resendEmailUseCase + .execute(this.userEmail()!) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + 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-info.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-info.service.ts new file mode 100644 index 000000000..8859f7a7e --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-info.service.ts @@ -0,0 +1,41 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { User, UserRole } from "@domain/auth/user.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Project } from "@domain/project/project.model"; + +@Injectable({ providedIn: "root" }) +export class AuthInfoService { + private readonly authRepository = inject(AuthRepositoryPort); + + readonly profile = this.authRepository.profile; + readonly roles = this.authRepository.roles; + readonly changeableRoles = this.authRepository.changeableRoles; + + fetchProfile(): Observable { + return this.authRepository.fetchProfile(); + } + + fetchUser(id: number): Observable { + return this.authRepository.fetchUser(id); + } + + fetchLeaderProjects(): Observable> { + return this.authRepository.fetchLeaderProjects(); + } + + fetchUserRoles(): Observable { + return this.authRepository.fetchUserRoles(); + } + + fetchChangeableRoles(): Observable { + return this.authRepository.fetchChangeableRoles(); + } + + logout(): Observable { + return this.authRepository.logout(); + } +} 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..8298d72eb --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-login.service.ts @@ -0,0 +1,63 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { TokenService, ValidationService } from "@corelib"; +import { Subject, takeUntil, tap } from "rxjs"; +import { AuthUIInfoService } from "./ui/auth-ui-info.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { LoginUseCase } from "../use-cases/login.use-case"; +import { toAsyncState } from "@domain/shared/to-async-state"; +import { LoginResult, LoginError } from "@domain/auth/results/login.result"; + +@Injectable() +export class AuthLoginService { + private readonly tokenService = inject(TokenService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly validationService = inject(ValidationService); + private readonly authUIInfoService = inject(AuthUIInfoService); + private readonly loginUseCase = inject(LoginUseCase); + private readonly logger = inject(LoggerService); + + private readonly destroy$ = new Subject(); + + private readonly loginForm = this.authUIInfoService.loginForm; + + // Login Component + readonly showPassword = this.authUIInfoService.showPassword; + readonly login$ = this.authUIInfoService.login$; + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSubmit() { + const redirectType = this.route.snapshot.queryParams["redirect"]; + + if ( + !this.validationService.getFormValidation(this.loginForm) || + this.login$().status === "loading" + ) { + return; + } + + this.loginUseCase + .execute(this.loginForm.getRawValue()) + .pipe( + tap(result => { + if (result.ok) { + this.tokenService.memTokens(result.value.tokens); + const url = redirectType === "program" ? "/office/program" : "/office"; + this.router + .navigateByUrl(url) + .then(() => this.logger.debug("Route changed from LoginComponent")); + } + }), + toAsyncState(), + takeUntil(this.destroy$) + ) + .subscribe(state => this.login$.set(state)); + } +} 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..3d00805e2 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts @@ -0,0 +1,110 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { ValidationService } from "@corelib"; +import { map, Subject, takeUntil, tap } from "rxjs"; +import { AuthUIInfoService } from "./ui/auth-ui-info.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ResetPasswordUseCase } from "../use-cases/reset-password.use-case"; +import { SetPasswordUseCase } from "../use-cases/set-password.use-case"; +import { toAsyncState } from "@domain/shared/to-async-state"; +import { PasswordError } from "@domain/auth/results/password.result"; + +@Injectable() +export class AuthPasswordService { + private readonly route = inject(ActivatedRoute); + private readonly resetPasswordUseCase = inject(ResetPasswordUseCase); + private readonly setPasswordUseCase = inject(SetPasswordUseCase); + private readonly router = inject(Router); + private readonly validationService = inject(ValidationService); + private readonly authUIInfoService = inject(AuthUIInfoService); + private readonly logger = inject(LoggerService); + + private readonly passwordForm = this.authUIInfoService.passwordForm; + private readonly resetForm = this.authUIInfoService.resetForm; + + private readonly destroy$ = new Subject(); + + // ResetPassword-Confirmation Component + readonly email = this.route.queryParams.pipe( + map(r => r["email"]), + takeUntil(this.destroy$) + ); + + // ResetPassword Component + readonly password$ = this.authUIInfoService.password$; + + readonly credsSubmitInitiated = this.authUIInfoService.credsSubmitInitiated; + + init(): void { + const token = this.route.snapshot.queryParamMap.get("token"); + if (!token) { + // Handle the case where token is not present + this.logger.error("Token is missing"); + } + } + + // ResetPassword Component + onSubmitResetPassword(): void { + if ( + !this.validationService.getFormValidation(this.resetForm) || + this.password$().status === "loading" + ) + return; + + this.resetPasswordUseCase + .execute(this.resetForm.value.email!) + .pipe( + tap(result => { + if (result.ok) { + this.router + .navigate(["/auth/reset_password/confirm"], { + queryParams: { email: this.resetForm.value.email }, + }) + .then(() => this.logger.debug("ResetPasswordComponent")); + } else { + this.resetForm.reset(); + } + }), + toAsyncState(), + takeUntil(this.destroy$) + ) + .subscribe({ next: result => this.password$.set(result) }); + } + + // SetPassword Component + onSubmitSetPassword(): void { + this.credsSubmitInitiated.set(true); + const token = this.route.snapshot.queryParamMap.get("token"); + + if ( + !token || + !this.validationService.getFormValidation(this.passwordForm) || + this.password$().status === "loading" + ) + return; + + this.setPasswordUseCase + .execute(this.passwordForm.value.password!, token) + .pipe( + tap(result => { + if (result.ok) { + this.router + .navigateByUrl("/auth/login") + .then(() => this.logger.debug("SetPasswordComponent")); + } else { + this.logger.error("Error setting password:", result.error); + } + }), + toAsyncState(), + takeUntil(this.destroy$) + ) + .subscribe({ next: result => this.password$.set(result) }); + } + + 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..b52355633 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/auth-register.service.ts @@ -0,0 +1,73 @@ +/** @format */ + +import { computed, inject, Injectable } from "@angular/core"; +import { Router } from "@angular/router"; +import { Subject, takeUntil, tap } from "rxjs"; +import { AuthUIInfoService } from "./ui/auth-ui-info.service"; +import { ValidationService } from "@corelib"; +import { RegisterUseCase } from "../use-cases/register.use-case"; +import { toAsyncState } from "@domain/shared/to-async-state"; +import { RegisterError } from "@domain/auth/results/register.result"; +import { isFailure } from "@domain/shared/async-state"; + +@Injectable() +export class AuthRegisterService { + private readonly registerUseCase = inject(RegisterUseCase); + private readonly router = inject(Router); + private readonly validationService = inject(ValidationService); + private readonly authUIInfoService = inject(AuthUIInfoService); + + private readonly registerForm = this.authUIInfoService.registerForm; + + readonly registerAgreement = this.authUIInfoService.registerAgreement; + readonly ageAgreement = this.authUIInfoService.ageAgreement; + readonly credsSubmitInitiated = this.authUIInfoService.credsSubmitInitiated; + readonly infoSubmitInitiated = this.authUIInfoService.infoSubmitInitiated; + + readonly showPassword = this.authUIInfoService.showPassword; + readonly showPasswordRepeat = this.authUIInfoService.showPasswordRepeat; + + readonly register$ = this.authUIInfoService.register$; + + readonly step = this.authUIInfoService.step; + + // Вычисляемое из AsyncState — автоматически обновляется при смене состояния + readonly serverErrors = computed(() => { + const state = this.register$(); + if (isFailure(state) && state.error.kind === "validation_error") { + return Object.values(state.error.errors).flat(); + } + return []; + }); + + private readonly destroy$ = new Subject(); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSendForm(): void { + if ( + !this.validationService.getFormValidation(this.registerForm) || + this.register$().status === "loading" + ) { + return; + } + + const form = this.authUIInfoService.prepareFormValues(this.registerForm); + + this.registerUseCase + .execute(form) + .pipe( + tap(result => { + if (result.ok) { + this.router.navigateByUrl("/auth/verification/email?adress=" + form.email); + } + }), + toAsyncState(), + takeUntil(this.destroy$) + ) + .subscribe(state => this.register$.set(state)); + } +} 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..60640e811 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/facades/ui/auth-ui-info.service.ts @@ -0,0 +1,151 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, FormGroup, Validators } from "@angular/forms"; +import { ValidationService } from "@corelib"; +import dayjs from "dayjs"; +import { LoginError, LoginResult } from "@domain/auth/results/login.result"; +import { PasswordError } from "@domain/auth/results/password.result"; +import { RegisterError } from "@domain/auth/results/register.result"; +import { AsyncState, initial, isFailure, isLoading } from "@domain/shared/async-state"; + +@Injectable() +export class AuthUIInfoService { + private readonly fb = inject(FormBuilder); + private readonly validationService = inject(ValidationService); + + // login + readonly showPasswordRepeat = signal(false); + readonly showPassword = signal(false); + + readonly login$ = signal>(initial()); + readonly loginIsSubmitting = computed(() => isLoading(this.login$())); + readonly errorWrongAuth = computed(() => isFailure(this.login$())); + + // password + readonly credsSubmitInitiated = signal(false); + readonly password$ = signal>(initial()); + readonly isSubmitting = computed(() => isLoading(this.password$())); + readonly errorServer = computed(() => { + const s = this.password$(); + return isFailure(s) && s.error.kind === "server_error"; + }); + readonly errorRequest = computed(() => isFailure(this.password$())); + + // register + readonly registerAgreement = signal(false); + readonly ageAgreement = signal(false); + readonly infoSubmitInitiated = signal(false); + + readonly register$ = signal>(initial()); + readonly registerIsSubmitting = computed(() => isLoading(this.register$())); + readonly isUserCreationModalError = computed(() => isFailure(this.register$())); + 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/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 94% 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 ae1363520..f568bfe24 100644 --- a/projects/social_platform/src/app/auth/services/profile.service.ts +++ b/projects/social_platform/src/app/api/auth/profile.service.ts @@ -1,11 +1,11 @@ /** @format */ import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { Achievement } from "../models/user.model"; +import { ApiService } from "@corelib"; +import { Achievement } from "@domain/auth/user.model"; import { map, Observable } from "rxjs"; import { plainToInstance } from "class-transformer"; -import { Approve } from "@office/models/skill.model"; +import { Approve } from "@domain/skills/skill"; /** * Сервис управления профилем пользователя diff --git a/projects/social_platform/src/app/api/auth/use-cases/download-cv.use-case.ts b/projects/social_platform/src/app/api/auth/use-cases/download-cv.use-case.ts new file mode 100644 index 000000000..d5178c39f --- /dev/null +++ b/projects/social_platform/src/app/api/auth/use-cases/download-cv.use-case.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class DownloadCvUseCase { + private readonly authRepositoryPort = inject(AuthRepositoryPort); + + execute(): Observable> { + return this.authRepositoryPort.downloadCV().pipe( + map(file => ok(file)), + catchError(error => of(fail({ kind: "download_cv_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/auth/use-cases/login.use-case.ts b/projects/social_platform/src/app/api/auth/use-cases/login.use-case.ts new file mode 100644 index 000000000..51b5f0018 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/use-cases/login.use-case.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { LoginCommand } from "@domain/auth/commands/login.command"; +import { LoginError, LoginResult } from "@domain/auth/results/login.result"; + +@Injectable({ providedIn: "root" }) +export class LoginUseCase { + private readonly authRepositoryPort = inject(AuthRepositoryPort); + + execute(command: LoginCommand): Observable> { + return this.authRepositoryPort.login(command).pipe( + map(tokens => ok({ tokens })), + catchError(error => { + if (error.status === 401) { + return of(fail({ kind: "wrong_credentials" })); + } + + return of(fail({ kind: "unknown" })); + }) + ); + } +} diff --git a/projects/social_platform/src/app/api/auth/use-cases/register.use-case.ts b/projects/social_platform/src/app/api/auth/use-cases/register.use-case.ts new file mode 100644 index 000000000..7c05ddb7b --- /dev/null +++ b/projects/social_platform/src/app/api/auth/use-cases/register.use-case.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { RegisterCommand } from "@domain/auth/commands/register.command"; +import { RegisterError, RegisterFieldErrors } from "@domain/auth/results/register.result"; + +@Injectable({ providedIn: "root" }) +export class RegisterUseCase { + private readonly authRepositoryPort = inject(AuthRepositoryPort); + + execute(command: RegisterCommand): Observable> { + return this.authRepositoryPort.register(command).pipe( + map(() => ok(undefined)), + catchError(error => { + if (error.status === 500) { + return of(fail({ kind: "server_error" })); + } + + if (error.status === 400) { + return of( + fail({ + kind: "validation_error", + errors: (error.error ?? {}) as RegisterFieldErrors, + }) + ); + } + + return of(fail({ kind: "unknown", cause: error })); + }) + ); + } +} diff --git a/projects/social_platform/src/app/api/auth/use-cases/resend-email.use-case.ts b/projects/social_platform/src/app/api/auth/use-cases/resend-email.use-case.ts new file mode 100644 index 000000000..5ce07a381 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/use-cases/resend-email.use-case.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ResendEmailUseCase { + private readonly authRepositoryPort = inject(AuthRepositoryPort); + + execute(email: string): Observable> { + return this.authRepositoryPort.resendEmail(email).pipe( + map(() => ok(undefined)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/auth/use-cases/reset-password.use-case.ts b/projects/social_platform/src/app/api/auth/use-cases/reset-password.use-case.ts new file mode 100644 index 000000000..cf7c9c5b2 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/use-cases/reset-password.use-case.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ResetPasswordUseCase { + private readonly authRepositoryPort = inject(AuthRepositoryPort); + + execute(email: string): Observable> { + return this.authRepositoryPort.resetPassword(email).pipe( + map(() => ok(undefined)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/auth/use-cases/set-password.use-case.ts b/projects/social_platform/src/app/api/auth/use-cases/set-password.use-case.ts new file mode 100644 index 000000000..63f866630 --- /dev/null +++ b/projects/social_platform/src/app/api/auth/use-cases/set-password.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; + +@Injectable({ providedIn: "root" }) +export class SetPasswordUseCase { + private readonly authRepositoryPort = inject(AuthRepositoryPort); + + execute( + password: string, + token: string + ): Observable> { + return this.authRepositoryPort.setPassword(password, token).pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "unknown" as const, cause: error }))) + ); + } +} 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 100% 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..b36920552 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,8 +2,8 @@ import { TestBed } from "@angular/core/testing"; -import { ChatDirectService } from "./chat-direct.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { ChatDirectService } from "./chat-direct.service"; describe("ChatDirectService", () => { let service: 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..42e8aafbe 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 @@ -1,12 +1,12 @@ /** @format */ import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; +import { ApiService } from "@corelib"; 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 "@domain/other/api-pagination.model"; +import { ChatMessage } from "@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 100% 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..d49ec0878 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,8 +2,8 @@ import { TestBed } from "@angular/core/testing"; -import { ChatProjectService } from "./chat-project.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { ChatProjectService } from "./chat-project.service"; describe("ChatProjectService", () => { let service: 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 89% 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..ff59bf436 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 @@ -1,9 +1,9 @@ /** @format */ import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; +import { ApiService } from "@corelib"; 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 100% 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..a99f8c5cf 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,8 +2,8 @@ import { TestBed } from "@angular/core/testing"; -import { ChatService } from "./chat.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { ChatService } from "./chat.service"; describe("ChatService", () => { let service: 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..b6ded1adf 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,9 @@ 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 "@core/lib/services/websockets/websocket.service"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ChatFile, ChatMessage } from "@domain/chat/chat-message.model"; import { ChatEventType, DeleteChatMessageDto, @@ -18,9 +18,9 @@ import { SendChatMessageDto, TypingInChatDto, TypingInChatEventDto, -} from "@models/chat.model"; +} from "@domain/chat/chat.model"; import { plainToInstance } from "class-transformer"; -import { ApiService, TokenService } from "projects/core"; +import { ApiService, TokenService } from "@corelib"; import { BehaviorSubject, map, Observable } from "rxjs"; /** @@ -73,7 +73,7 @@ export class ChatService { * @param status - статус онлайн (true - онлайн, false - оффлайн) */ setOnlineStatus(userId: number, status: boolean) { - this.userOnlineStatusCache.next({ ...this.userOnlineStatusCache, [userId]: status }); + this.userOnlineStatusCache.next({ ...this.userOnlineStatusCache.value, [userId]: status }); } /** 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..fbfacdc05 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/facedes/chat-direct-info.service.ts @@ -0,0 +1,253 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +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 { ChatDirectUIInfoService } from "./ui/chat-direct-ui-info.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { ApiPagination } from "@domain/other/api-pagination.model"; + +@Injectable() +export class ChatDirectInfoService { + private readonly route = inject(ActivatedRoute); + private readonly authRepository = inject(AuthRepositoryPort); + 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 { + this.chatService + .loadProjectFiles(Number(this.route.parent?.snapshot.paramMap.get("projectId"))) + .pipe(takeUntil(this.destroy$)) + .subscribe(files => { + this.chatFiles.set(files); + }); + } + + private initializationProfile(): void { + this.authRepository.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..0a2521291 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/facedes/chat-info.service.ts @@ -0,0 +1,85 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { NavService } from "@ui/services/nav/nav.service"; +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"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; + +@Injectable() +export class ChatInfoService { + private readonly navService = inject(NavService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly authRepository = inject(AuthRepositoryPort); + private readonly chatService = inject(ChatService); + private readonly chatUIInfoService = inject(ChatUIInfoService); + private readonly logger = inject(LoggerService); + + private readonly destroy$ = new Subject(); + + private readonly chatsData = this.chatUIInfoService.chatsData; + + readonly chats: Observable = combineLatest([ + this.authRepository.profile, + toObservable(this.chatsData), + ]).pipe( + map(([profile, chats]) => + chats.map(chat => ({ + ...chat, + isUnread: profile.id !== chat.lastMessage.author.id && !chat.lastMessage.isRead, + })) + ), + map(chats => chats.sort((a, b) => Number(b.isUnread) - Number(a.isUnread))), + 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(() => this.logger.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..7545bc505 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/facedes/ui/chat-direct-ui-info.service.ts @@ -0,0 +1,103 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ChatWindowComponent } from "@ui/widgets/chat-window/chat-window.component"; +import { ChatItem } from "@domain/chat/chat-item.model"; +import { ChatFile, ChatMessage } from "@domain/chat/chat-message.model"; +import { + OnChatMessageDto, + OnDeleteChatMessageDto, + OnEditChatMessageDto, + OnReadChatMessageDto, +} from "@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 isAsideMobileShown = 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(); + } + + /** Переключение боковой панели на мобильных устройствах */ + onToggleMobileAside(): void { + this.isAsideMobileShown.set(!this.isAsideMobileShown()); + } +} 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..4029e5bbf --- /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 "@domain/chat/chat-item.model"; +import { OnChatMessageDto } from "@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/chat/use-cases/check-unreads.use-case.ts b/projects/social_platform/src/app/api/chat/use-cases/check-unreads.use-case.ts new file mode 100644 index 000000000..456bd2073 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/use-cases/check-unreads.use-case.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { ChatRepositoryPort } from "@domain/chat/ports/chat.repository.port"; + +export type CheckUnreadsError = { kind: "server_error" }; + +@Injectable({ providedIn: "root" }) +export class CheckUnreadsUseCase { + private readonly chatRepository = inject(ChatRepositoryPort); + + execute(): Observable> { + return this.chatRepository.hasUnreads().pipe( + map(hasUnreads => ok(hasUnreads)), + catchError(() => of(fail({ kind: "server_error" }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/chat/use-cases/delete-message.use-case.ts b/projects/social_platform/src/app/api/chat/use-cases/delete-message.use-case.ts new file mode 100644 index 000000000..24ca0c7b8 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/use-cases/delete-message.use-case.ts @@ -0,0 +1,14 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ChatRealtimePort } from "@domain/chat/ports/chat-realtime.port"; +import { DeleteChatMessageDto } from "@domain/chat/chat.model"; + +@Injectable({ providedIn: "root" }) +export class DeleteMessageUseCase { + private readonly chatRealtime = inject(ChatRealtimePort); + + execute(message: DeleteChatMessageDto): void { + this.chatRealtime.deleteMessage(message); + } +} diff --git a/projects/social_platform/src/app/api/chat/use-cases/edit-message.use-case.ts b/projects/social_platform/src/app/api/chat/use-cases/edit-message.use-case.ts new file mode 100644 index 000000000..79aa6946d --- /dev/null +++ b/projects/social_platform/src/app/api/chat/use-cases/edit-message.use-case.ts @@ -0,0 +1,14 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ChatRealtimePort } from "@domain/chat/ports/chat-realtime.port"; +import { EditChatMessageDto } from "@domain/chat/chat.model"; + +@Injectable({ providedIn: "root" }) +export class EditMessageUseCase { + private readonly chatRealtime = inject(ChatRealtimePort); + + execute(message: EditChatMessageDto): void { + this.chatRealtime.editMessage(message); + } +} diff --git a/projects/social_platform/src/app/api/chat/use-cases/load-messages.use-case.ts b/projects/social_platform/src/app/api/chat/use-cases/load-messages.use-case.ts new file mode 100644 index 000000000..727edf4c4 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/use-cases/load-messages.use-case.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { ChatRepositoryPort } from "@domain/chat/ports/chat.repository.port"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ChatMessage } from "@domain/chat/chat-message.model"; + +export type LoadMessagesError = { kind: "server_error" }; + +@Injectable({ providedIn: "root" }) +export class LoadMessagesUseCase { + private readonly chatRepository = inject(ChatRepositoryPort); + + execute( + projectId: number, + offset?: number, + limit?: number + ): Observable, LoadMessagesError>> { + return this.chatRepository.loadMessages(projectId, offset, limit).pipe( + map(page => ok>(page)), + catchError(() => of(fail({ kind: "server_error" }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/chat/use-cases/load-project-files.use-case.ts b/projects/social_platform/src/app/api/chat/use-cases/load-project-files.use-case.ts new file mode 100644 index 000000000..66063c0bc --- /dev/null +++ b/projects/social_platform/src/app/api/chat/use-cases/load-project-files.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { ChatRepositoryPort } from "@domain/chat/ports/chat.repository.port"; +import { ChatFile } from "@domain/chat/chat-message.model"; + +export type LoadProjectFilesError = { kind: "server_error" }; + +@Injectable({ providedIn: "root" }) +export class LoadProjectFilesUseCase { + private readonly chatRepository = inject(ChatRepositoryPort); + + execute(projectId: number): Observable> { + return this.chatRepository.loadProjectFiles(projectId).pipe( + map(files => ok(files)), + catchError(() => of(fail({ kind: "server_error" }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/chat/use-cases/read-message.use-case.ts b/projects/social_platform/src/app/api/chat/use-cases/read-message.use-case.ts new file mode 100644 index 000000000..770d6b7eb --- /dev/null +++ b/projects/social_platform/src/app/api/chat/use-cases/read-message.use-case.ts @@ -0,0 +1,14 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ChatRealtimePort } from "@domain/chat/ports/chat-realtime.port"; +import { ReadChatMessageDto } from "@domain/chat/chat.model"; + +@Injectable({ providedIn: "root" }) +export class ReadMessageUseCase { + private readonly chatRealtime = inject(ChatRealtimePort); + + execute(message: ReadChatMessageDto): void { + this.chatRealtime.readMessage(message); + } +} diff --git a/projects/social_platform/src/app/api/chat/use-cases/send-message.use-case.ts b/projects/social_platform/src/app/api/chat/use-cases/send-message.use-case.ts new file mode 100644 index 000000000..004f10656 --- /dev/null +++ b/projects/social_platform/src/app/api/chat/use-cases/send-message.use-case.ts @@ -0,0 +1,14 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ChatRealtimePort } from "@domain/chat/ports/chat-realtime.port"; +import { SendChatMessageDto } from "@domain/chat/chat.model"; + +@Injectable({ providedIn: "root" }) +export class SendMessageUseCase { + private readonly chatRealtime = inject(ChatRealtimePort); + + execute(message: SendChatMessageDto): void { + this.chatRealtime.sendMessage(message); + } +} diff --git a/projects/social_platform/src/app/api/courses/facades/course-detail-info.service.ts b/projects/social_platform/src/app/api/courses/facades/course-detail-info.service.ts new file mode 100644 index 000000000..013ae034c --- /dev/null +++ b/projects/social_platform/src/app/api/courses/facades/course-detail-info.service.ts @@ -0,0 +1,76 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; +import { filter, map, Subject, takeUntil } from "rxjs"; +import { CourseDetail, CourseStructure } from "@domain/courses/courses.model"; +import { CourseDetailUIInfoService } from "./ui/course-detail-ui-info.service"; +import { loading, success } from "@domain/shared/async-state"; + +@Injectable() +export class CourseDetailInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly courseDetailUIInfoService = inject(CourseDetailUIInfoService); + + private readonly destroy$ = new Subject(); + + init(): void { + this.loadCourseData(); + this.trackNavigation(); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + redirectDetailInfo(courseId?: number): void { + if (courseId != null) { + this.router.navigateByUrl(`/office/courses/${courseId}`); + } else { + this.router.navigateByUrl("/office/courses/all"); + } + } + + redirectToProgram(): void { + const course = this.courseDetailUIInfoService.course(); + if (!course) return; + + this.router.navigate([`/office/program/${course.partnerProgramId}`], { + queryParams: { courseId: course.id }, + }); + } + + private loadCourseData(): void { + this.courseDetailUIInfoService.courseDetail$.set(loading()); + this.courseDetailUIInfoService.courseStructure$.set(loading()); + + this.route.data + .pipe( + map(data => data["data"]), + filter(data => !!data), + takeUntil(this.destroy$) + ) + .subscribe(([detail, structure]: [CourseDetail | null, CourseStructure | null]) => { + if (!detail || !structure) return; + + this.courseDetailUIInfoService.courseDetail$.set(success(detail)); + this.courseDetailUIInfoService.courseStructure$.set(success(structure)); + this.courseDetailUIInfoService.applyCourseData(structure); + }); + } + + private trackNavigation(): void { + this.courseDetailUIInfoService.isTaskDetail.set(this.router.url.includes("lesson")); + + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.courseDetailUIInfoService.isTaskDetail.set(this.router.url.includes("lesson")); + }); + } +} diff --git a/projects/social_platform/src/app/api/courses/facades/courses-list-info.service.ts b/projects/social_platform/src/app/api/courses/facades/courses-list-info.service.ts new file mode 100644 index 000000000..f1b9b7e10 --- /dev/null +++ b/projects/social_platform/src/app/api/courses/facades/courses-list-info.service.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { map, Subject, takeUntil } from "rxjs"; +import { CoursesListUIInfoService } from "./ui/courses-list-ui-info.service"; +import { loading, success } from "@domain/shared/async-state"; + +@Injectable() +export class CoursesListInfoService { + private readonly route = inject(ActivatedRoute); + private readonly coursesListUIInfoService = inject(CoursesListUIInfoService); + + private readonly destroy$ = new Subject(); + + init(): void { + this.coursesListUIInfoService.courses$.set(loading()); + + this.route.data + .pipe( + map(r => r["data"]), + takeUntil(this.destroy$) + ) + .subscribe(courses => { + this.coursesListUIInfoService.courses$.set(success(courses)); + }); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/social_platform/src/app/api/courses/facades/lesson-info.service.ts b/projects/social_platform/src/app/api/courses/facades/lesson-info.service.ts new file mode 100644 index 000000000..6ce48cd09 --- /dev/null +++ b/projects/social_platform/src/app/api/courses/facades/lesson-info.service.ts @@ -0,0 +1,174 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; +import { filter, map, Subject, takeUntil } from "rxjs"; +import { CourseLesson, Task } from "@domain/courses/courses.model"; +import { LessonUIInfoService } from "./ui/lesson-ui-info.service"; +import { SubmitTaskAnswerUseCase } from "../use-cases/submit-task-answer.use-case"; +import { SnackbarService } from "@ui/services/snackbar/snackbar.service"; +import { failure, loading, success } from "@domain/shared/async-state"; + +@Injectable() +export class LessonInfoService { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly submitTaskAnswerUseCase = inject(SubmitTaskAnswerUseCase); + private readonly snackbarService = inject(SnackbarService); + private readonly lessonUIInfoService = inject(LessonUIInfoService); + + private readonly destroy$ = new Subject(); + + init(): void { + this.loadLessonData(); + this.trackNavigation(); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onAnswerChange(value: any): void { + this.lessonUIInfoService.answerBody.set(value); + } + + onSubmitAnswer(): void { + const task = this.lessonUIInfoService.currentTask(); + if (!task) return; + + this.lessonUIInfoService.loader.set(true); + this.lessonUIInfoService.submitAnswer$.set(loading()); + + const body = this.lessonUIInfoService.answerBody(); + const isTextFile = task.answerType === "text_and_files"; + const answerText = task.answerType === "text" || isTextFile ? body?.text : undefined; + const optionIds = + task.answerType === "single_choice" || task.answerType === "multiple_choice" + ? body + : undefined; + const fileIds = task.answerType === "files" ? body : isTextFile ? body?.fileUrls : undefined; + + this.submitTaskAnswerUseCase + .execute(task.id, answerText, optionIds, fileIds) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + this.lessonUIInfoService.loader.set(false); + + if (!result.ok) { + this.lessonUIInfoService.hasError.set(true); + this.lessonUIInfoService.submitAnswer$.set(failure("submit_error")); + this.snackbarService.error("неверный ответ, попробуйте еще раз!"); + return; + } + + const res = result.value; + this.lessonUIInfoService.submitAnswer$.set(success(undefined)); + + if (res.isCorrect) { + this.lessonUIInfoService.success.set(true); + this.lessonUIInfoService.hasError.set(false); + this.lessonUIInfoService.markTaskCompleted(task.id); + this.snackbarService.success("правильный ответ, продолжайте дальше"); + } else { + this.lessonUIInfoService.hasError.set(true); + this.lessonUIInfoService.success.set(false); + this.snackbarService.error("неверный ответ, попробуйте еще раз!"); + setTimeout(() => this.lessonUIInfoService.hasError.set(false), 1000); + return; + } + + if (!res.canContinue) return; + + setTimeout(() => { + const nextId = res.nextTaskId ?? this.getNextTask()?.id ?? null; + + if (nextId) { + this.lessonUIInfoService.currentTaskId.set(nextId); + this.lessonUIInfoService.success.set(false); + this.lessonUIInfoService.answerBody.set(null); + } else { + this.router.navigate(["results"], { relativeTo: this.route }); + } + }, 1000); + }, + error: () => { + this.lessonUIInfoService.loader.set(false); + this.lessonUIInfoService.hasError.set(true); + this.lessonUIInfoService.submitAnswer$.set(failure("submit_error")); + this.snackbarService.error("неверный ответ, попробуйте еще раз!"); + }, + }); + } + + private loadLessonData(): void { + this.route.data + .pipe( + map(data => data["data"] as CourseLesson | null), + filter((lessonInfo): lessonInfo is CourseLesson => !!lessonInfo), + takeUntil(this.destroy$) + ) + .subscribe({ + next: lessonInfo => { + this.lessonUIInfoService.loading.set(true); + this.lessonUIInfoService.lesson$.set(success(lessonInfo)); + + if (lessonInfo.progressStatus === "completed") { + setTimeout(() => { + this.lessonUIInfoService.loading.set(false); + this.router.navigate(["results"], { relativeTo: this.route }); + }, 500); + return; + } + + const nextTaskId = + lessonInfo.currentTaskId ?? + lessonInfo.tasks.find(t => t.isAvailable && !t.isCompleted)?.id ?? + null; + + const allCompleted = lessonInfo.tasks.every(t => t.isCompleted); + const onResultsPage = this.router.url.includes("results"); + + if (onResultsPage && !allCompleted) { + this.lessonUIInfoService.currentTaskId.set(nextTaskId); + setTimeout(() => { + this.lessonUIInfoService.loading.set(false); + this.router.navigate(["./"], { relativeTo: this.route }); + }, 500); + } else if (nextTaskId === null && allCompleted) { + setTimeout(() => { + this.lessonUIInfoService.loading.set(false); + this.router.navigate(["results"], { relativeTo: this.route }); + }, 500); + } else { + this.lessonUIInfoService.currentTaskId.set(nextTaskId); + setTimeout(() => this.lessonUIInfoService.loading.set(false), 500); + } + }, + complete: () => { + setTimeout(() => this.lessonUIInfoService.loading.set(false), 500); + }, + }); + } + + private trackNavigation(): void { + this.lessonUIInfoService.isComplete.set(this.router.url.includes("results")); + + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.lessonUIInfoService.isComplete.set(this.router.url.includes("results")); + }); + } + + private getNextTask(): Task | null { + const currentId = this.lessonUIInfoService.currentTaskId(); + const allTasks = this.lessonUIInfoService.tasks(); + const currentIndex = allTasks.findIndex(t => t.id === currentId); + return allTasks.slice(currentIndex + 1).find(t => t.isAvailable && !t.isCompleted) ?? null; + } +} diff --git a/projects/social_platform/src/app/api/courses/facades/ui/course-detail-ui-info.service.ts b/projects/social_platform/src/app/api/courses/facades/ui/course-detail-ui-info.service.ts new file mode 100644 index 000000000..6d7545b5d --- /dev/null +++ b/projects/social_platform/src/app/api/courses/facades/ui/course-detail-ui-info.service.ts @@ -0,0 +1,67 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { CourseDetail, CourseStructure } from "@domain/courses/courses.model"; +import { AsyncState, initial, isLoading, isSuccess } from "@domain/shared/async-state"; + +@Injectable() +export class CourseDetailUIInfoService { + readonly courseDetail$ = signal>(initial()); + readonly courseStructure$ = signal>(initial()); + + readonly loading = computed(() => { + const detail = this.courseDetail$(); + const structure = this.courseStructure$(); + return ( + detail.status === "initial" || + isLoading(detail) || + structure.status === "initial" || + isLoading(structure) + ); + }); + + readonly course = computed(() => { + const state = this.courseDetail$(); + return isSuccess(state) ? state.data : undefined; + }); + + readonly courseStructure = computed(() => { + const state = this.courseStructure$(); + return isSuccess(state) ? state.data : undefined; + }); + + readonly courseModules = computed(() => this.courseStructure()?.modules ?? []); + + readonly isDisabled = computed(() => { + const course = this.course(); + return course ? !course.partnerProgramId : false; + }); + + readonly isTaskDetail = signal(false); + readonly isCompleteModule = signal(false); + readonly isCourseCompleted = signal(false); + + applyCourseData(structure: CourseStructure): void { + this.checkCompletedModules(structure); + } + + private checkCompletedModules(structure: CourseStructure): void { + const completedModuleIds = structure.modules + .filter(m => m.progressStatus === "completed") + .map(m => m.id); + + const unseenModule = completedModuleIds.find( + id => !localStorage.getItem(`course_${structure.courseId}_module_${id}_complete_seen`) + ); + + if (unseenModule) { + const allModulesCompleted = structure.modules.every(m => m.progressStatus === "completed"); + this.isCourseCompleted.set(allModulesCompleted); + this.isCompleteModule.set(true); + localStorage.setItem( + `course_${structure.courseId}_module_${unseenModule}_complete_seen`, + "true" + ); + } + } +} diff --git a/projects/social_platform/src/app/api/courses/facades/ui/courses-list-ui-info.service.ts b/projects/social_platform/src/app/api/courses/facades/ui/courses-list-ui-info.service.ts new file mode 100644 index 000000000..188fb2931 --- /dev/null +++ b/projects/social_platform/src/app/api/courses/facades/ui/courses-list-ui-info.service.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { CourseCard } from "@domain/courses/courses.model"; +import { AsyncState, initial, isLoading, isSuccess } from "@domain/shared/async-state"; + +@Injectable() +export class CoursesListUIInfoService { + readonly courses$ = signal>(initial()); + + readonly loading = computed(() => { + const state = this.courses$(); + return state.status === "initial" || isLoading(state); + }); + + readonly coursesList = computed(() => { + const state = this.courses$(); + return isSuccess(state) ? state.data : []; + }); +} diff --git a/projects/social_platform/src/app/api/courses/facades/ui/lesson-ui-info.service.ts b/projects/social_platform/src/app/api/courses/facades/ui/lesson-ui-info.service.ts new file mode 100644 index 000000000..213abe6e1 --- /dev/null +++ b/projects/social_platform/src/app/api/courses/facades/ui/lesson-ui-info.service.ts @@ -0,0 +1,68 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { CourseLesson, Task } from "@domain/courses/courses.model"; +import { AsyncState, initial, isSuccess } from "@domain/shared/async-state"; + +@Injectable() +export class LessonUIInfoService { + readonly lesson$ = signal>(initial()); + readonly submitAnswer$ = signal>(initial()); + + readonly lessonInfo = computed(() => { + const state = this.lesson$(); + return isSuccess(state) ? state.data : undefined; + }); + + readonly isComplete = signal(false); + readonly currentTaskId = signal(null); + + /** Transition loading — управляется фасадом вручную (с setTimeout delay) */ + readonly loading = signal(false); + readonly loader = signal(false); + readonly success = signal(false); + readonly hasError = signal(false); + + readonly answerBody = signal(null); + readonly completedTaskIds = signal>(new Set()); + + readonly tasks = computed(() => this.lessonInfo()?.tasks ?? []); + + readonly currentTask = computed(() => { + const id = this.currentTaskId(); + return this.tasks().find(t => t.id === id) ?? null; + }); + + readonly isLastTask = computed(() => { + const allTasksLength = this.tasks().length; + return allTasksLength === this.currentTask()?.order; + }); + + readonly isSubmitDisabled = computed(() => { + const task = this.currentTask(); + const body = this.answerBody(); + if (!task) return true; + + switch (task.answerType) { + case "text": + return !body || (typeof body === "string" && !body.trim()); + case "text_and_files": + return !body?.text?.trim() || !body?.fileUrls?.length; + case "single_choice": + case "multiple_choice": + return !body || (Array.isArray(body) && body.length === 0); + case "files": + return !body || (Array.isArray(body) && body.length === 0); + default: + return false; + } + }); + + markTaskCompleted(taskId: number): void { + this.completedTaskIds.update(ids => new Set([...ids, taskId])); + } + + isDone(task: Task): boolean { + return task.isCompleted || this.completedTaskIds().has(task.id); + } +} diff --git a/projects/social_platform/src/app/api/courses/use-cases/get-course-detail.use-case.ts b/projects/social_platform/src/app/api/courses/use-cases/get-course-detail.use-case.ts new file mode 100644 index 000000000..77f387903 --- /dev/null +++ b/projects/social_platform/src/app/api/courses/use-cases/get-course-detail.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { CoursesRepositoryPort } from "@domain/courses/ports/courses.repository.port"; +import { CourseDetail } from "@domain/courses/courses.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetCourseDetailUseCase { + private readonly coursesRepository = inject(CoursesRepositoryPort); + + execute( + courseId: number + ): Observable> { + return this.coursesRepository.getCourseDetail(courseId).pipe( + map(detail => ok(detail)), + catchError(error => of(fail({ kind: "get_course_detail_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/courses/use-cases/get-course-lesson.use-case.ts b/projects/social_platform/src/app/api/courses/use-cases/get-course-lesson.use-case.ts new file mode 100644 index 000000000..4ebc4d614 --- /dev/null +++ b/projects/social_platform/src/app/api/courses/use-cases/get-course-lesson.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { CoursesRepositoryPort } from "@domain/courses/ports/courses.repository.port"; +import { CourseLesson } from "@domain/courses/courses.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetCourseLessonUseCase { + private readonly coursesRepository = inject(CoursesRepositoryPort); + + execute( + lessonId: number + ): Observable> { + return this.coursesRepository.getCourseLesson(lessonId).pipe( + map(lesson => ok(lesson)), + catchError(error => of(fail({ kind: "get_course_lesson_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/courses/use-cases/get-course-structure.use-case.ts b/projects/social_platform/src/app/api/courses/use-cases/get-course-structure.use-case.ts new file mode 100644 index 000000000..be53f5677 --- /dev/null +++ b/projects/social_platform/src/app/api/courses/use-cases/get-course-structure.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { CoursesRepositoryPort } from "@domain/courses/ports/courses.repository.port"; +import { CourseStructure } from "@domain/courses/courses.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetCourseStructureUseCase { + private readonly coursesRepository = inject(CoursesRepositoryPort); + + execute( + courseId: number + ): Observable> { + return this.coursesRepository.getCourseStructure(courseId).pipe( + map(structure => ok(structure)), + catchError(error => of(fail({ kind: "get_course_structure_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/courses/use-cases/get-courses.use-case.ts b/projects/social_platform/src/app/api/courses/use-cases/get-courses.use-case.ts new file mode 100644 index 000000000..3b298ebd3 --- /dev/null +++ b/projects/social_platform/src/app/api/courses/use-cases/get-courses.use-case.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { CoursesRepositoryPort } from "@domain/courses/ports/courses.repository.port"; +import { CourseCard } from "@domain/courses/courses.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetCoursesUseCase { + private readonly coursesRepository = inject(CoursesRepositoryPort); + + execute(): Observable> { + return this.coursesRepository.getCourses().pipe( + map(courses => ok(courses)), + catchError(error => of(fail({ kind: "get_courses_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/courses/use-cases/submit-task-answer.use-case.ts b/projects/social_platform/src/app/api/courses/use-cases/submit-task-answer.use-case.ts new file mode 100644 index 000000000..a57a7f3ac --- /dev/null +++ b/projects/social_platform/src/app/api/courses/use-cases/submit-task-answer.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { CoursesRepositoryPort } from "@domain/courses/ports/courses.repository.port"; +import { TaskAnswerResponse } from "@domain/courses/courses.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class SubmitTaskAnswerUseCase { + private readonly coursesRepository = inject(CoursesRepositoryPort); + + execute( + taskId: number, + answerText?: any, + optionIds?: number[], + fileIds?: number[] + ): Observable> { + return this.coursesRepository.postAnswerQuestion(taskId, answerText, optionIds, fileIds).pipe( + map(response => ok(response)), + catchError(error => of(fail({ kind: "submit_answer_error" as const, cause: error }))) + ); + } +} 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/office/services/export-file.service.ts b/projects/social_platform/src/app/api/export-file/export-file.service.ts similarity index 100% rename from projects/social_platform/src/app/office/services/export-file.service.ts rename to projects/social_platform/src/app/api/export-file/export-file.service.ts diff --git a/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.ts b/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.ts new file mode 100644 index 000000000..06152f715 --- /dev/null +++ b/projects/social_platform/src/app/api/export-file/facades/export-file-info.service.ts @@ -0,0 +1,81 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ExportFileService } from "../export-file.service"; +import { saveFile } from "@utils/export-file"; +import { ProgramDetailMainUIInfoService } from "../../program/facades/detail/ui/program-detail-main-ui-info.service"; +import { Subject, takeUntil } from "rxjs"; +import { LoggerService } from "@corelib"; +import { AsyncState, failure, initial, loading, success } from "@domain/shared/async-state"; + +@Injectable() +export class ExportFileInfoService { + private readonly exportFileService = inject(ExportFileService); + private readonly programDetailMainUIInfoService = inject(ProgramDetailMainUIInfoService); + private readonly loggerService = inject(LoggerService); + + private readonly destroy$ = new Subject(); + + private readonly program = this.programDetailMainUIInfoService.program; + + readonly loadingExports$ = signal>(initial()); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + downloadProjects(): void { + this.loadingExports$.set(loading()); + + this.exportFileService + .exportAllProjects(this.program()!.id) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: blob => { + saveFile(blob, "all", this.program()?.name); + this.loadingExports$.set(success(undefined)); + }, + error: err => { + this.loggerService.error(err); + this.loadingExports$.set(failure(`export_file_${err}`)); + }, + }); + } + + downloadSubmittedProjects(): void { + this.loadingExports$.set(loading()); + + this.exportFileService + .exportSubmittedProjects(this.program()!.id) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: blob => { + saveFile(blob, "submitted", this.program()?.name); + this.loadingExports$.set(success(undefined)); + }, + error: err => { + this.loadingExports$.set(failure(`export_file_${err}`)); + }, + }); + } + + downloadRates(): void { + this.loadingExports$.set(loading()); + + this.exportFileService + .exportProgramRates(this.program()!.id) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: blob => { + saveFile(blob, "rates", this.program()?.name); + this.loadingExports$.set(success(undefined)); + }, + error: err => { + this.loadingExports$.set(failure(`export_file_${err}`)); + }, + }); + } + + downloadCalculations(): void {} +} 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..0f5fb5f2b --- /dev/null +++ b/projects/social_platform/src/app/api/feed/facades/feed-info.service.ts @@ -0,0 +1,245 @@ +/** @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 { FeedItem, FeedItemType } from "@domain/feed/feed-item.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedUIInfoService } from "./ui/feed-ui-info.service"; +import { FetchFeedUseCase } from "../use-cases/fetch-feed.use-case"; +import { ReadFeedNewsUseCase } from "../use-cases/read-feed-news.use-case"; +import { ToggleFeedLikeUseCase } from "../use-cases/toggle-feed-like.use-case"; +import { isSuccess, loading, success } from "@domain/shared/async-state"; + +const DEFAULT_FEED_TYPES: FeedItemType[] = ["vacancy", "project", "news"]; +const FILTER_SPLIT_SYMBOL = "|"; + +@Injectable() +export class FeedInfoService { + private readonly route = inject(ActivatedRoute); + private readonly fetchFeedUseCase = inject(FetchFeedUseCase); + private readonly readFeedNewsUseCase = inject(ReadFeedNewsUseCase); + private readonly toggleFeedLikeUseCase = inject(ToggleFeedLikeUseCase); + 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); + + const prev = this.feedUIInfoService.feedItems(); + this.feedUIInfoService.feedItems$.set(loading(prev)); + + return this.onFetch( + 0, + this.feedUIInfoService.perFetchTake(), + includes ?? DEFAULT_FEED_TYPES + ); + }), + 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.feedUIInfoService.feedItems$.update(state => + isSuccess(state) + ? success([...state.data, ...uniqueNewItems]) + : success(uniqueNewItems) + ); + queueMicrotask(() => this.observeFeedItems()); + } + }) + ); + } + + 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[] = DEFAULT_FEED_TYPES) { + const type = + includes.length === 0 + ? DEFAULT_FEED_TYPES.join(FILTER_SPLIT_SYMBOL) + : includes.join(FILTER_SPLIT_SYMBOL); + + return this.fetchFeedUseCase.execute(offset, limit, type).pipe( + tap(result => { + this.feedUIInfoService.totalItemsCount.set(result.ok ? result.value.count : 0); + }), + map(result => (result.ok ? result.value.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.readFeedNewsUseCase + .execute("project", news.content.contentObject.id, [news.content.id]) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + }); + + profileNews.forEach(news => { + if (news.typeModel !== "news") return; + this.readFeedNewsUseCase + .execute("profile", 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.toggleFeedLikeUseCase + .execute( + "profile", + item.content.contentObject.id as unknown as string, + newsId, + !item.content.isUserLiked + ) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + this.feedUIInfoService.applyLikeNews(itemIdx); + }); + } else if ("leader" in item.content.contentObject) { + this.toggleFeedLikeUseCase + .execute( + "project", + item.content.contentObject.id as unknown as string, + newsId, + !item.content.isUserLiked + ) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + 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..07837d0e2 --- /dev/null +++ b/projects/social_platform/src/app/api/feed/facades/ui/feed-ui-info.service.ts @@ -0,0 +1,57 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedItem } from "@domain/feed/feed-item.model"; +import { AsyncState, initial, isLoading, isSuccess, success } from "@domain/shared/async-state"; + +@Injectable() +export class FeedUIInfoService { + readonly feedItems$ = signal>(initial()); + + readonly feedItems = computed(() => { + const state = this.feedItems$(); + if (isSuccess(state)) return state.data; + if (isLoading(state)) return state.previous ?? []; + return []; + }); + + readonly totalItemsCount = signal(0); + readonly feedPage = signal(0); + readonly perFetchTake = signal(20); + + applyInitializationFeedNewsEvent(feed: ApiPagination): void { + this.feedItems$.set(success(feed.results)); + this.totalItemsCount.set(feed.count); + this.feedPage.set(feed.results.length); + } + + applyFeedFilters(feed: FeedItem[]): void { + this.feedItems$.set(success(feed)); + this.feedPage.set(feed.length); + } + + applyLikeNews(itemIdx: number): void { + this.feedItems$.update(state => { + if (!isSuccess(state)) return state; + + const items = state.data; + const item = items[itemIdx]; + + if (item.typeModel !== "news") return state; + + const updated: FeedItem = { + ...item, + content: { + ...item.content, + likesCount: item.content.isUserLiked + ? item.content.likesCount - 1 + : item.content.likesCount + 1, + isUserLiked: !item.content.isUserLiked, + }, + }; + + return success(items.map((it, i) => (i === itemIdx ? updated : it))); + }); + } +} diff --git a/projects/social_platform/src/app/api/feed/use-cases/fetch-feed.use-case.ts b/projects/social_platform/src/app/api/feed/use-cases/fetch-feed.use-case.ts new file mode 100644 index 000000000..4f7663455 --- /dev/null +++ b/projects/social_platform/src/app/api/feed/use-cases/fetch-feed.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { FeedRepositoryPort } from "@domain/feed/ports/feed.repository.port"; +import { FeedItem } from "@domain/feed/feed-item.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class FetchFeedUseCase { + private readonly feedRepositoryPort = inject(FeedRepositoryPort); + + execute( + offset: number, + limit: number, + type: string + ): Observable, { kind: "fetch_feed_error"; cause?: unknown }>> { + return this.feedRepositoryPort.fetchFeed(offset, limit, type).pipe( + map(feed => ok>(feed)), + catchError(error => of(fail({ kind: "fetch_feed_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/feed/use-cases/read-feed-news.use-case.ts b/projects/social_platform/src/app/api/feed/use-cases/read-feed-news.use-case.ts new file mode 100644 index 000000000..1ad610c00 --- /dev/null +++ b/projects/social_platform/src/app/api/feed/use-cases/read-feed-news.use-case.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ReadFeedNewsUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + ownerType: "project" | "profile", + ownerId: number, + newsIds: number[] + ): Observable> { + const request$ = + ownerType === "profile" + ? this.profileNewsRepositoryPort.readNews(ownerId, newsIds) + : this.projectNewsRepositoryPort.readNews(ownerId, newsIds); + + return request$.pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "read_feed_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/feed/use-cases/toggle-feed-like.use-case.ts b/projects/social_platform/src/app/api/feed/use-cases/toggle-feed-like.use-case.ts new file mode 100644 index 000000000..c073dc2f3 --- /dev/null +++ b/projects/social_platform/src/app/api/feed/use-cases/toggle-feed-like.use-case.ts @@ -0,0 +1,30 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ToggleFeedLikeUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + ownerType: "project" | "profile", + ownerId: string, + newsId: number, + state: boolean + ): Observable> { + const request$ = + ownerType === "profile" + ? this.profileNewsRepositoryPort.toggleLike(ownerId, newsId, state) + : this.projectNewsRepositoryPort.toggleLike(ownerId, newsId, state); + + return request$.pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "toggle_feed_like_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/industry/facades/industry-info.service.ts b/projects/social_platform/src/app/api/industry/facades/industry-info.service.ts new file mode 100644 index 000000000..0588ca2a3 --- /dev/null +++ b/projects/social_platform/src/app/api/industry/facades/industry-info.service.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { Industry } from "@domain/industry/industry.model"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; + +@Injectable({ providedIn: "root" }) +export class IndustryInfoService { + private readonly industryRepository = inject(IndustryRepositoryPort); + + readonly industries = this.industryRepository.industries; + + getAll(): Observable { + return this.industryRepository.getAll(); + } + + getOne(industryId: number): Industry | undefined { + return this.industryRepository.getOne(industryId); + } +} diff --git a/projects/social_platform/src/app/api/invite/use-cases/accept-invite.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/accept-invite.use-case.ts new file mode 100644 index 000000000..d6c38e97f --- /dev/null +++ b/projects/social_platform/src/app/api/invite/use-cases/accept-invite.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { acceptInvite } from "@domain/invite/events/accept-invite.event"; + +@Injectable({ providedIn: "root" }) +export class AcceptInviteUseCase { + private readonly inviteRepositoryPort = inject(InviteRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute(inviteId: number): Observable> { + return this.inviteRepositoryPort.acceptInvite(inviteId).pipe( + tap(invite => + this.eventBus.emit(acceptInvite(invite.id, invite.project.id, invite.user.id, invite.role)) + ), + map(() => ok(undefined)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/invite/use-cases/get-my-invites.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/get-my-invites.use-case.ts new file mode 100644 index 000000000..a38d0feef --- /dev/null +++ b/projects/social_platform/src/app/api/invite/use-cases/get-my-invites.use-case.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { Invite } from "@domain/invite/invite.model"; + +@Injectable({ providedIn: "root" }) +export class GetMyInvitesUseCase { + private readonly inviteRepositoryPort = inject(InviteRepositoryPort); + + execute(): Observable> { + return this.inviteRepositoryPort.getMy().pipe( + map(invites => ok(invites)), + catchError(error => of(fail({ kind: "get_invites_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/invite/use-cases/get-project-invites.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/get-project-invites.use-case.ts new file mode 100644 index 000000000..8b7b59636 --- /dev/null +++ b/projects/social_platform/src/app/api/invite/use-cases/get-project-invites.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { Invite } from "@domain/invite/invite.model"; + +@Injectable({ providedIn: "root" }) +export class GetProjectInvitesUseCase { + private readonly InviteRepositoryPort = inject(InviteRepositoryPort); + + execute( + projectId: number + ): Observable> { + return this.InviteRepositoryPort.getByProject(projectId).pipe( + map(invites => ok(invites)), + catchError(error => of(fail({ kind: "get_project_invites_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/invite/use-cases/reject-invite.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/reject-invite.use-case.ts new file mode 100644 index 000000000..d6a8b00bf --- /dev/null +++ b/projects/social_platform/src/app/api/invite/use-cases/reject-invite.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { rejectInvite } from "@domain/invite/events/reject-invite.event"; + +@Injectable({ providedIn: "root" }) +export class RejectInviteUseCase { + private readonly inviteRepositoryPort = inject(InviteRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute(inviteId: number): Observable> { + return this.inviteRepositoryPort.rejectInvite(inviteId).pipe( + tap(invite => this.eventBus.emit(rejectInvite(invite.id, invite.project.id, invite.user.id))), + map(() => ok(undefined)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/invite/use-cases/revoke-invite.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/revoke-invite.use-case.ts new file mode 100644 index 000000000..19ddb10ff --- /dev/null +++ b/projects/social_platform/src/app/api/invite/use-cases/revoke-invite.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { revokeInvite } from "@domain/invite/events/revoke-invite.event"; + +@Injectable({ providedIn: "root" }) +export class RevokeInviteUseCase { + private readonly inviteRepositoryPort = inject(InviteRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + invitationId: number + ): Observable> { + return this.inviteRepositoryPort.revokeInvite(invitationId).pipe( + tap(invite => this.eventBus.emit(revokeInvite(invite.id, invite.project.id, invite.user.id))), + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "revoke_invite_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts new file mode 100644 index 000000000..51455fb90 --- /dev/null +++ b/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { SendForUserCommand } from "@domain/invite/commands/send-for-user.command"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { Invite } from "@domain/invite/invite.model"; + +@Injectable({ providedIn: "root" }) +export class SendForUserUseCase { + private readonly inviteRepositoryPort = inject(InviteRepositoryPort); + + execute({ + userId, + projectId, + role, + specialization, + }: SendForUserCommand): Observable> { + return this.inviteRepositoryPort.sendForUser(userId, projectId, role, specialization).pipe( + map(invite => ok(invite)), + catchError(error => of(fail({ kind: "invite_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/invite/use-cases/update-invite.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/update-invite.use-case.ts new file mode 100644 index 000000000..48dd9a110 --- /dev/null +++ b/projects/social_platform/src/app/api/invite/use-cases/update-invite.use-case.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { UpdateInviteCommand } from "@domain/invite/commands/update-invite.command"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class UpdateInviteUseCase { + private readonly inviteRepositoryPort = inject(InviteRepositoryPort); + + execute({ + inviteId, + role, + specialization, + }: UpdateInviteCommand): Observable< + Result + > { + return this.inviteRepositoryPort.updateInvite(inviteId, role, specialization).pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "update_invite_error" as const, cause: error }))) + ); + } +} 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..14c769752 --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/dto/comment.model.dto.ts @@ -0,0 +1,11 @@ +/** @format */ + +import { User } from "@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..b4104010d --- /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 "@domain/auth/user.model"; +import { filter, Observable, of, Subject } from "rxjs"; +import { ActivatedRoute, Router } from "@angular/router"; +import { ProjectsDetailUIInfoService } from "../project/facades/detail/ui/projects-detail-ui.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; + +@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 authRepository = inject(AuthRepositoryPort); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly router = inject(Router); + readonly route = inject(ActivatedRoute); + + private deleteTaskSubject = new Subject(); + + constructor() { + this.authRepository.profile + .pipe(filter(Boolean)) + .subscribe(profile => this.currentUser.set(profile)); + } + + setTaskDetailInfo(detail: TaskDetail | undefined) { + this.taskDetail.set(detail); + } + + leaderId = this.projectsDetailUIInfoService.leaderId; + collaborators = this.projectsDetailUIInfoService.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..be5d2cabf --- /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..f83992909 --- /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/kanban/use-cases/get-board.use-case.ts b/projects/social_platform/src/app/api/kanban/use-cases/get-board.use-case.ts new file mode 100644 index 000000000..060b6ac7d --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/use-cases/get-board.use-case.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { KanbanRepositoryPort } from "@domain/kanban/ports/kanban.repository.port"; +import { Board } from "@domain/kanban/board.model"; + +export type GetBoardError = { kind: "not_found" } | { kind: "server_error" }; + +@Injectable({ providedIn: "root" }) +export class GetBoardUseCase { + private readonly kanbanRepository = inject(KanbanRepositoryPort); + + execute(projectId: number): Observable> { + return this.kanbanRepository.getBoardByProjectId(projectId).pipe( + map(board => ok(board)), + catchError(error => { + if (error.status === 404) { + return of(fail({ kind: "not_found" })); + } + return of(fail({ kind: "server_error" })); + }) + ); + } +} diff --git a/projects/social_platform/src/app/api/kanban/use-cases/get-column-tasks.use-case.ts b/projects/social_platform/src/app/api/kanban/use-cases/get-column-tasks.use-case.ts new file mode 100644 index 000000000..3b2cc76b0 --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/use-cases/get-column-tasks.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { KanbanRepositoryPort } from "@domain/kanban/ports/kanban.repository.port"; +import { Column } from "@domain/kanban/column.model"; + +export type GetColumnTasksError = { kind: "server_error" }; + +@Injectable({ providedIn: "root" }) +export class GetColumnTasksUseCase { + private readonly kanbanRepository = inject(KanbanRepositoryPort); + + execute(columnId: number): Observable> { + return this.kanbanRepository.getTasksByColumnId(columnId).pipe( + map(column => ok(column)), + catchError(() => of(fail({ kind: "server_error" }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/kanban/use-cases/get-task.use-case.ts b/projects/social_platform/src/app/api/kanban/use-cases/get-task.use-case.ts new file mode 100644 index 000000000..16d98389d --- /dev/null +++ b/projects/social_platform/src/app/api/kanban/use-cases/get-task.use-case.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { KanbanRepositoryPort } from "@domain/kanban/ports/kanban.repository.port"; +import { TaskDetail } from "@domain/kanban/task.model"; + +export type GetTaskError = { kind: "not_found" } | { kind: "server_error" }; + +@Injectable({ providedIn: "root" }) +export class GetTaskUseCase { + private readonly kanbanRepository = inject(KanbanRepositoryPort); + + execute(taskId: number): Observable> { + return this.kanbanRepository.getTaskById(taskId).pipe( + map(task => ok(task)), + catchError(error => { + if (error.status === 404) { + return of(fail({ kind: "not_found" })); + } + return of(fail({ kind: "server_error" })); + }) + ); + } +} 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..2d8f3b48c --- /dev/null +++ b/projects/social_platform/src/app/api/member/facades/members-info.service.ts @@ -0,0 +1,251 @@ +/** @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 { User } from "@domain/auth/user.model"; +import { AbstractControl } from "@angular/forms"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { MembersUIInfoService } from "./ui/members-ui-info.service"; +import { NavigationService } from "../../paths/navigation.service"; +import { ProjectsDetailUIInfoService } from "../../project/facades/detail/ui/projects-detail-ui.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { GetMembersUseCase } from "../use-case/get-members.use-case"; +import { isSuccess, loading, success } from "@domain/shared/async-state"; + +@Injectable() +export class MembersInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly navService = inject(NavService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly membersUIInfoService = inject(MembersUIInfoService); + private readonly authRepository = inject(AuthRepositoryPort); + private readonly navigationService = inject(NavigationService); + private readonly logger = inject(LoggerService); + private readonly getMembersUseCase = inject(GetMembersUseCase); + + private readonly searchParams = signal>({}); // Signal для параметров поиска + + private readonly membersTake = this.membersUIInfoService.membersTake; // Количество участников на странице + private readonly profileId = this.projectsDetailUIInfoService.profileId; + + private readonly members = this.membersUIInfoService.members; // Массив участников для отображения + + private readonly searchForm = this.membersUIInfoService.searchForm; + private readonly filterForm = this.membersUIInfoService.filterForm; + + private readonly destroy$ = new Subject(); + + /** + * Инициализация компонента + * + * Выполняет: + * - Очистку URL параметров + * - Установку заголовка навигации + * - Загрузку начальных данных из резолвера + * - Настройку подписок на изменения форм и URL параметровК + */ + initializationMembers(): void { + // Устанавливаем заголовок страницы + this.navService.setNavTitle("Участники"); + + this.initializationProfile(); + + this.initializationControls(); + + // Подписываемся на изменения URL параметров для обновления списка участников + // (skip(1) пропускает начальное значение — данные уже загружены resolver'ом) + this.initializationQueryParams(); + } + + private initializationProfile(): void { + this.authRepository.profile + .pipe( + filter(user => !!user), + takeUntil(this.destroy$) + ) + .subscribe({ + next: user => { + this.projectsDetailUIInfoService.applySetLoggedUserId("profile", user.id); + }, + }); + } + + 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); + + const prev = this.membersUIInfoService.members(); + this.membersUIInfoService.members$.set(loading(prev)); + + return this.onFetch(0, 20, fetchParams); + }) + ) + .subscribe(members => { + this.membersUIInfoService.members$.set(success(members.results)); + }); + } + + 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.membersUIInfoService.members().length, + this.membersTake(), + this.searchParams() + ).pipe( + tap(membersChunk => { + this.membersUIInfoService.members$.update(state => + isSuccess(state) + ? success([...state.data, ...membersChunk.results]) + : success(membersChunk.results) + ); + }) + ); + } + + 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(() => this.logger.debug("QueryParams changed from MembersComponent")); + }); + } + + /** + * Выполняет запрос на получение участников с заданными параметрами + * + * @param skip - Количество записей для пропуска (для пагинации) + * @param take - Количество записей для получения + * @param params - Дополнительные параметры фильтрации + * @returns Observable - Массив участников + */ + private onFetch(skip: number, take: number, params?: Record) { + return this.getMembersUseCase.execute(skip, take, params).pipe( + map(result => (result.ok ? result.value : this.emptyMembersPagination())), + takeUntil(this.destroy$) + ); + } + + redirectToProfile(): void { + this.navigationService.profileRedirect(this.profileId()); + } + + private emptyMembersPagination(): ApiPagination { + return { + count: 0, + results: [], + next: "", + previous: "", + }; + } +} 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..2c42753d7 --- /dev/null +++ b/projects/social_platform/src/app/api/member/facades/ui/members-ui-info.service.ts @@ -0,0 +1,43 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { User } from "@domain/auth/user.model"; +import { AsyncState, initial, isLoading, isSuccess, success } from "@domain/shared/async-state"; + +@Injectable() +export class MembersUIInfoService { + private readonly fb = inject(FormBuilder); + + readonly membersTotalCount = signal(undefined); // Общее количество участников + readonly membersTake = signal(20); // Количество участников на странице + private readonly membersPage = signal(1); // Текущая страница для пагинации + + readonly members$ = signal>(initial()); // Массив участников для отображения + + readonly members = computed(() => { + const state = this.members$(); + if (isSuccess(state)) return state.data; + if (isLoading(state)) return state.previous ?? []; + return []; + }); + + // Форма поиска с обязательным полем для ввода имени + readonly searchForm = this.fb.group({ + search: ["", [Validators.required]], + }); + + // Форма фильтрации с полями для различных критериев + readonly filterForm = this.fb.group({ + keySkill: ["", Validators.required], // Ключевой навык + speciality: ["", Validators.required], // Специальность + age: [[null, null]], // Диапазон возраста [от, до] + isMosPolytechStudent: [false], // Является ли студентом МосПолитеха + }); + + applyMembersPagination(members: ApiPagination) { + this.membersTotalCount.set(members.count); + this.members$.set(success(members.results)); + } +} diff --git a/projects/social_platform/src/app/api/member/facades/ui/mentors-ui-info.service.ts b/projects/social_platform/src/app/api/member/facades/ui/mentors-ui-info.service.ts new file mode 100644 index 000000000..91be9e489 --- /dev/null +++ b/projects/social_platform/src/app/api/member/facades/ui/mentors-ui-info.service.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { User } from "@domain/auth/user.model"; + +@Injectable() +export class MentorsUIInfoService { + readonly mentorsTake = signal(20); + readonly mentorsTotalCount = signal(undefined); + readonly members = signal([]); + + applyMentorsPagination(data: ApiPagination): void { + this.mentorsTotalCount.set(data.count); + this.members.set(data.results); + } + + applyMentorsChunk(data: ApiPagination): void { + this.members.update(list => [...list, ...(data.results || [])]); + this.mentorsTotalCount.set(data.count); + } +} diff --git a/projects/social_platform/src/app/api/member/use-case/get-members.use-case.ts b/projects/social_platform/src/app/api/member/use-case/get-members.use-case.ts new file mode 100644 index 000000000..11585190e --- /dev/null +++ b/projects/social_platform/src/app/api/member/use-case/get-members.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { User } from "@domain/auth/user.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { MemberRepositoryPort } from "@domain/member/ports/member.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetMembersUseCase { + private readonly memberRepositoryPort = inject(MemberRepositoryPort); + + execute( + skip: number, + take: number, + params?: Record + ): Observable, { kind: "get_members_error"; cause?: unknown }>> { + return this.memberRepositoryPort.getMembers(skip, take, params).pipe( + map(members => ok>(members)), + catchError(error => of(fail({ kind: "get_members_error" as const, cause: error }))) + ); + } +} 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..d6243a6e6 --- /dev/null +++ b/projects/social_platform/src/app/api/news/news-info.service.ts @@ -0,0 +1,71 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { FeedNews } from "@domain/project/project-news.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { AsyncState, initial, isLoading, isSuccess, success } from "@domain/shared/async-state"; + +@Injectable({ providedIn: "root" }) +export class NewsInfoService { + readonly news$ = signal>(initial()); // Массив новостей + + readonly news = computed(() => { + const state = this.news$(); + if (isSuccess(state)) return state.data; + if (isLoading(state)) return state.previous ?? []; + return []; + }); + + applySetNews( + news: + | ApiPagination + | { + results: never[]; + count: number; + } + ): void { + this.news$.set(success(news.results)); + } + + applyAddNews(newsRes: FeedNews): void { + this.news$.update(state => + isSuccess(state) ? success([newsRes, ...state.data]) : success([newsRes]) + ); + } + + applyUpdateNews(results: FeedNews[]): void { + this.news$.update(state => + isSuccess(state) ? success([...state.data, ...results]) : success(results) + ); + } + + applyDeleteNews(newsId: number): void { + this.news$.update(state => + isSuccess(state) ? success(state.data.filter(n => n.id !== newsId)) : state + ); + } + + applyEditNews(resNews: FeedNews): void { + this.news$.update(state => + isSuccess(state) ? success(state.data.map(n => (n.id === resNews.id ? resNews : n))) : state + ); + } + + applyLikeNews(newsId: number): void { + this.news$.update(list => + isSuccess(list) + ? success( + list.data.map(n => + n.id === newsId + ? { + ...n, + isUserLiked: !n.isUserLiked, + likesCount: n.isUserLiked ? n.likesCount - 1 : n.likesCount + 1, + } + : n + ) + ) + : list + ); + } +} 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..15023a502 --- /dev/null +++ b/projects/social_platform/src/app/api/office/facades/office-info.service.ts @@ -0,0 +1,164 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { map, Subject, takeUntil, tap } from "rxjs"; +import { ActivatedRoute, Router } from "@angular/router"; +import { ChatService } from "../../chat/chat.service"; +import { Invite } from "@domain/invite/invite.model"; +import { OfficeUIInfoService } from "./ui/office-ui-info.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { RejectInviteUseCase } from "../../invite/use-cases/reject-invite.use-case"; +import { AcceptInviteUseCase } from "../../invite/use-cases/accept-invite.use-case"; + +@Injectable() +export class OfficeInfoService { + private readonly industryRepository = inject(IndustryRepositoryPort); + private readonly route = inject(ActivatedRoute); + private readonly authRepository = inject(AuthRepositoryPort); + + private readonly rejectInviteUseCase = inject(RejectInviteUseCase); + private readonly acceptInviteUseCase = inject(AcceptInviteUseCase); + + private readonly router = inject(Router); + private readonly chatService = inject(ChatService); + private readonly officeUIInfoService = inject(OfficeUIInfoService); + private readonly logger = inject(LoggerService); + + readonly invites = this.officeUIInfoService.invites; + + private readonly destroy$ = new Subject(); + + initializationOffice(): void { + this.industryRepository.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.authRepository.profile + .pipe( + tap(profile => { + this.officeUIInfoService.applyCreateNavItems(profile.id); + + if (!profile?.doesCompleted()) { + this.router + .navigateByUrl("/office/onboarding") + .then(() => this.logger.debug("Route changed from OfficeComponent")); + } else if ( + profile?.verificationDate === null && + localStorage.getItem("waitVerificationAccepted") !== "true" + ) { + this.officeUIInfoService.applyOpenVerificationModal(); + } + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + 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.rejectInviteUseCase + .execute(inviteId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (!result.ok) { + this.officeUIInfoService.applyOpenInviteErrorModal(); + return; + } + + this.invites.update(invites => invites.filter(invite => invite.id !== inviteId)); + }, + }); + } + + onAcceptInvite(inviteId: number): void { + const invite = this.invites().find(i => i.id === inviteId); + if (!invite) return; + + this.acceptInviteUseCase + .execute(inviteId) + .pipe( + tap(result => { + if (!result.ok) { + this.officeUIInfoService.applyOpenInviteErrorModal(); + return; + } + + this.invites.update(invites => invites.filter(invite => invite.id !== inviteId)); + + this.router + .navigateByUrl(`/office/projects/${invite.project.id}`) + .then(() => this.logger.debug("Route changed from SidebarComponent")); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + onLogout() { + this.authRepository + .logout() + .pipe( + tap(() => { + this.router + .navigateByUrl("/auth") + .then(() => this.logger.debug("Route changed from OfficeComponent")); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } +} 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..b6ab60f6c --- /dev/null +++ b/projects/social_platform/src/app/api/office/facades/ui/office-ui-info.service.ts @@ -0,0 +1,55 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { Invite } from "@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: "courses" }, + { 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..994cd0fa2 --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/onboarding-info.service.ts @@ -0,0 +1,63 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; +import { OnboardingService } from "../onboarding.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; + +@Injectable() +export class OnboardingInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly onboardingService = inject(OnboardingService); + private readonly logger = inject(LoggerService); + + 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(() => this.logger.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(() => this.logger.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(() => this.logger.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..d9fb989f7 --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-one-info.service.ts @@ -0,0 +1,104 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { Specialization } from "@domain/specializations/specialization"; +import { concatMap, map, Observable, Subject, take, takeUntil } from "rxjs"; +import { OnboardingService } from "../../onboarding.service"; +import { ValidationService } from "@corelib"; +import { ActivatedRoute, 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"; +import { SpecializationsGroup } from "@domain/specializations/specializations-group"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { failure, initial, loading } from "@domain/shared/async-state"; + +@Injectable() +export class OnboardingStageOneInfoService { + private readonly authRepository = inject(AuthRepositoryPort); + private readonly onboardingService = inject(OnboardingService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly validationService = inject(ValidationService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + 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; + + readonly nestedSpecializations$: Observable = this.route.data.pipe( + map(r => r["data"]) + ); + + 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(loading()); + + this.authRepository + .updateProfile(this.stageForm.value) + .pipe( + concatMap(() => this.authRepository.updateOnboardingStage(2)), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => this.completeRegistration(2), + error: () => this.stageSubmitting.set(failure("submit_error")), + }); + } + + 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(loading()); + this.onboardingService.setFormValue(this.stageForm.value); + this.router.navigateByUrl( + stage === 2 ? `/office/onboarding/stage-${stage}` : "/office/onboarding/stage-3" + ); + this.skipSubmitting.set(initial()); + } +} 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..3417f526e --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-three-info.service.ts @@ -0,0 +1,61 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { concatMap, Subject, take, takeUntil, tap } from "rxjs"; +import { OnboardingService } from "../../onboarding.service"; +import { Router } from "@angular/router"; +import { OnboardingUIInfoService } from "./ui/onboarding-ui-info.service"; +import { OnboardingStageThreeUIInfoService } from "./ui/onboarding-stage-three-ui-info.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { loading } from "@domain/shared/async-state"; + +@Injectable() +export class OnboardingStageThreeInfoService { + private readonly onboardingService = inject(OnboardingService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly onboardingStageThreeUIInfoService = inject(OnboardingStageThreeUIInfoService); + private readonly authRepository = inject(AuthRepositoryPort); + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + + 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(loading()); + + this.authRepository + .updateProfile({ userType: this.userRole() }) + .pipe( + concatMap(() => this.authRepository.updateOnboardingStage(null)), + tap(() => { + this.router + .navigateByUrl("/office") + .then(() => this.logger.debug("Route changed from OnboardingStageTwo")); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } +} 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..cdea64b6b --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-two-info.service.ts @@ -0,0 +1,115 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Router } from "@angular/router"; +import { concatMap, Subject, take, takeUntil } from "rxjs"; +import { ValidationService } from "@corelib"; +import { OnboardingService } from "../../onboarding.service"; +import { Skill } from "@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"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { failure, initial, loading } from "@domain/shared/async-state"; + +@Injectable() +export class OnboardingStageTwoInfoService { + private readonly authRepository = inject(AuthRepositoryPort); + 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(loading()); + + const { skills } = this.stageForm.getRawValue(); + + this.authRepository + .updateProfile({ skillsIds: skills.map((skill: Skill) => skill.id) }) + .pipe( + concatMap(() => this.authRepository.updateOnboardingStage(2)), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => this.completeRegistration(3), + error: err => { + this.stageSubmitting.set(failure("submit_error")); + 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(loading()); + this.onboardingService.setFormValue(this.stageForm.value); + this.router.navigateByUrl(`/office/onboarding/stage-${stage}`); + this.skipSubmitting.set(initial()); + } +} 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..1ccbb1177 --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.ts @@ -0,0 +1,122 @@ +/** @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 { OnboardingStageZeroUIInfoService } from "./ui/onboarding-stage-zero-ui-info.service"; +import { User } from "@domain/auth/user.model"; +import { OnboardingUIInfoService } from "./ui/onboarding-ui-info.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { failure, initial, loading } from "@domain/shared/async-state"; + +@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 authRepository = inject(AuthRepositoryPort); + 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.authRepository.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(loading()); + this.authRepository + .updateProfile(onboardingSkipInfo as Partial) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => this.completeRegistration(3), + error: error => { + this.skipSubmitting.set(failure("skip_error")); + 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(loading()); + this.authRepository + .updateProfile(newStageForm as Partial) + .pipe( + concatMap(() => this.authRepository.updateOnboardingStage(1)), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => this.completeRegistration(1), + error: error => { + this.stageSubmitting.set(failure("submit_error")); + this.onboardingStageZeroUIInfoService.applySubmitModalError(error); + }, + }); + } + + private completeRegistration(stage: number): void { + this.skipSubmitting.set(loading()); + this.onboardingService.setFormValue(this.stageForm.value as Partial); + this.router.navigateByUrl( + stage === 1 ? "/office/onboarding/stage-1" : "/office/onboarding/stage-3" + ); + this.skipSubmitting.set(initial()); + } +} 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..f60f30530 --- /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 "@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..bedf68f6e --- /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 "@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..988bda1fe --- /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 "@domain/auth/user.model"; +import { Skill } from "@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..45b2555ec --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service.ts @@ -0,0 +1,512 @@ +/** @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/transformYear"; +import { yearRangeValidators } from "@utils/yearRangeValidators"; +import { + educationUserLevel, + educationUserType, +} from "@core/consts/lists/education-info-list.const"; +import { languageLevelsList, languageNamesList } from "@core/consts/lists/language-info-list.const"; +import { User } from "@domain/auth/user.model"; + +@Injectable() +export class OnboardingStageZeroUIInfoService { + private readonly fb = inject(FormBuilder); + + 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..5c5ac74ca --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-ui-info.service.ts @@ -0,0 +1,15 @@ +/** @format */ + +import { computed, Injectable, signal } from "@angular/core"; +import { AsyncState, initial, isLoading } from "@domain/shared/async-state"; + +@Injectable() +export class OnboardingUIInfoService { + readonly stageSubmitting$ = signal>(initial()); + readonly stageSubmitting = computed(() => isLoading(this.stageSubmitting$())); + + readonly skipSubmitting$ = signal>(initial()); + readonly skipSubmitting = computed(() => isLoading(this.skipSubmitting$())); + + 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 76% 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..1543d9188 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 { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; describe("OnboardingService", () => { let service: OnboardingService; @@ -13,7 +13,7 @@ describe("OnboardingService", () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [{ provide: AuthService, useValue: authSpy }], + providers: [{ provide: AuthRepository, useValue: authSpy }], }); service = TestBed.inject(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 92% 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..44241a875 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 "@domain/auth/user.model"; import { BehaviorSubject, take } from "rxjs"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; /** * СЕРВИС УПРАВЛЕНИЯ СОСТОЯНИЕМ ОНБОРДИНГА @@ -42,8 +42,8 @@ import { BehaviorSubject, take } from "rxjs"; providedIn: "root", }) export class OnboardingService { - constructor(private authService: AuthService) { - this.authService.profile.pipe(take(1)).subscribe(p => { + constructor(private authRepository: AuthRepositoryPort) { + this.authRepository.profile.pipe(take(1)).subscribe(p => { this._formValue$.next({ avatar: p.avatar, city: p.city, 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..a962eab58 --- /dev/null +++ b/projects/social_platform/src/app/api/paths/navigation.service.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { Router } from "@angular/router"; + +@Injectable({ providedIn: "root" }) +export class NavigationService { + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + + profileRedirect(profileId?: number): void { + if (!profileId) return; + + this.router + .navigateByUrl(`/office/profile/${profileId}`) + .then(() => this.logger.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..c1d3f1201 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-info.service.ts @@ -0,0 +1,203 @@ +/** @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 { 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"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { AddProfileNewsUseCase } from "../../use-cases/add-profile-news.use-case"; +import { DeleteProfileNewsUseCase } from "../../use-cases/delete-profile-news.use-case"; +import { EditProfileNewsUseCase } from "../../use-cases/edit-profile-news.use-case"; +import { FetchProfileNewsUseCase } from "../../use-cases/fetch-profile-news.use-case"; +import { ReadProfileNewsUseCase } from "../../use-cases/read-profile-news.use-case"; +import { ToggleProfileNewsLikeUseCase } from "../../use-cases/toggle-profile-news-like.use-case"; + +@Injectable() +export class ProfileDetailInfoService { + private readonly route = inject(ActivatedRoute); + private readonly authRepository = inject(AuthRepositoryPort); + private readonly addProfileNewsUseCase = inject(AddProfileNewsUseCase); + private readonly deleteProfileNewsUseCase = inject(DeleteProfileNewsUseCase); + private readonly editProfileNewsUseCase = inject(EditProfileNewsUseCase); + private readonly fetchProfileNewsUseCase = inject(FetchProfileNewsUseCase); + private readonly readProfileNewsUseCase = inject(ReadProfileNewsUseCase); + private readonly toggleProfileNewsLikeUseCase = inject(ToggleProfileNewsLikeUseCase); + 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.addProfileNewsUseCase.execute(this.route.snapshot.params["id"], news).pipe( + tap(result => { + if (!result.ok) return; + + this.newsInfoService.applyAddNews(result.value); + }), + takeUntil(this.destroy$) + ); + } + + /** + * Удаление новости из профиля + * @param newsId - идентификатор удаляемой новости + */ + onDeleteNews(newsId: number): void { + this.newsInfoService.applyDeleteNews(newsId); + + this.deleteProfileNewsUseCase + .execute(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.toggleProfileNewsLikeUseCase + .execute(this.route.snapshot.params["id"], newsId, !item.isUserLiked) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + this.newsInfoService.applyLikeNews(newsId); + }); + } + + /** + * Редактирование существующей новости + * @param news - обновленные данные новости + * @param newsItemId - идентификатор редактируемой новости + */ + onEditNews(news: ProfileNews, newsItemId: number) { + return this.editProfileNewsUseCase + .execute(this.route.snapshot.params["id"], newsItemId, news) + .pipe( + tap(result => { + if (!result.ok) return; + + this.newsInfoService.applyEditNews(result.value); + }) + ); + } + + /** + * Обработчик появления новостей в области видимости + * Отмечает новости как просмотренные при скролле + * @param entries - массив элементов, попавших в область видимости + */ + onNewsInView(entries: IntersectionObserverEntry[]): void { + const ids = entries.map(e => { + return Number((e.target as HTMLElement).dataset["id"]); + }); + + this.readProfileNewsUseCase + .execute(Number(this.route.snapshot.params["id"]), ids) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + private initializationProfileVields(): void { + this.authRepository.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.fetchProfileNewsUseCase.execute(Number(userId))), + takeUntil(this.destroy$) + ) + .subscribe(result => { + if (!result.ok) return; + + this.newsInfoService.applySetNews(result.value); + + 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..6d90bf7cb --- /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..bc53dd6de --- /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/directionItemBuilder"; +import { User } from "@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..9f0502340 --- /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/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..275ca8434 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-education-info.service.ts @@ -0,0 +1,205 @@ +/** @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/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(); + } + + private normalizeYear(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + if (typeof value === "number") { + return value; + } + + if (typeof value === "string") { + return transformYearStringToNumber(value); + } + + return null; + } + + private resetSelectedIds(): void { + this.selectedEntryYearEducationId.set(undefined); + this.selectedComplitionYearEducationId.set(undefined); + this.selectedEducationStatusId.set(undefined); + this.selectedEducationLevelId.set(undefined); + } + + /** + * Добавление записи об образовании + * Валидирует форму и добавляет новую запись в массив образования + */ + addEducation() { + if (!this.showEducationFields()) { + this.showEducationFields.set(true); + this.resetSelectedIds(); + 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 = this.normalizeYear(this.profileForm.get("entryYear")?.value); + const completionYear = this.normalizeYear(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.resetSelectedIds(); + } + this.editEducationClick.set(false); + } + + /** + * Редактирование записи об образовании + * @param index - индекс записи в массиве образования + */ + editEducation(index: number) { + this.editEducationClick.set(true); + this.showEducationFields.set(true); + this.resetSelectedIds(); + + const educationItem = this.education.value[index]; + const entryYear = this.normalizeYear(educationItem.entryYear); + const completionYear = this.normalizeYear(educationItem.completionYear); + + this.yearListEducation.forEach(entryYearWork => { + if (transformYearStringToNumber(entryYearWork.value as string) === entryYear) { + this.selectedEntryYearEducationId.set(entryYearWork.id); + } + }); + + this.yearListEducation.forEach(completionYearWork => { + if (transformYearStringToNumber(completionYearWork.value as string) === 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, + 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..e85d6a2b3 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-experience-info.service.ts @@ -0,0 +1,175 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { transformYearStringToNumber } from "@utils/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(); + } + + private normalizeYear(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + if (typeof value === "number") { + return value; + } + + if (typeof value === "string") { + return transformYearStringToNumber(value); + } + + return null; + } + + private resetSelectedIds(): void { + this.selectedEntryYearWorkId.set(undefined); + this.selectedComplitionYearWorkId.set(undefined); + } + + /** + * Добавление записи об опыте работы + * Валидирует форму и добавляет новую запись в массив опыта работы + */ + addWork() { + if (!this.showWorkFields()) { + this.showWorkFields.set(true); + this.resetSelectedIds(); + 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 = this.normalizeYear(this.profileForm.get("entryYearWork")?.value); + const completionYear = this.normalizeYear(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.resetSelectedIds(); + } + this.editWorkClick.set(false); + } + + editWork(index: number) { + this.editWorkClick.set(true); + this.showWorkFields.set(true); + this.resetSelectedIds(); + const workItem = this.workExperience.value[index]; + + if (workItem) { + const entryYear = this.normalizeYear(workItem.entryYearWork ?? workItem.entryYear); + const completionYear = this.normalizeYear( + workItem.completionYearWork ?? workItem.completionYear + ); + + this.yearListEducation.forEach(entryYearWork => { + if (transformYearStringToNumber(entryYearWork.value as string) === entryYear) { + this.selectedEntryYearWorkId.set(entryYearWork.id); + } + }); + + this.yearListEducation.forEach(complitionYearWork => { + if (transformYearStringToNumber(complitionYearWork.value as string) === completionYear) { + this.selectedComplitionYearWorkId.set(complitionYearWork.id); + } + }); + + this.profileForm.patchValue({ + organization: workItem.organization || workItem.organizationName, + entryYearWork: entryYear, + completionYearWork: 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..1c9225f54 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/edit/profile-edit-info.service.ts @@ -0,0 +1,221 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { concatMap, Subject, takeUntil } from "rxjs"; +import { ProfileFormService } from "./profile-form.service"; +import { Achievement } from "@domain/auth/user.model"; +import dayjs from "dayjs"; +import { Skill } from "@domain/skills/skill"; +import { NavigationService } from "../../../paths/navigation.service"; +import { EditStep, ProjectStepService } from "../../../project/project-step.service"; +import { NavService } from "@ui/services/nav/nav.service"; +import { ActivatedRoute } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { AsyncState, failure, initial, loading, success } from "@domain/shared/async-state"; + +@Injectable() +export class ProfileEditInfoService { + private readonly profileFormService = inject(ProfileFormService); + private readonly authRepository = inject(AuthRepositoryPort); + private readonly route = inject(ActivatedRoute); + private readonly navigationService = inject(NavigationService); + private readonly navService = inject(NavService); + private readonly projectStepService = inject(ProjectStepService); + + private readonly destroy$ = new Subject(); + + private readonly profileForm = this.profileFormService.getForm(); + + readonly editIndex = signal(null); + + readonly profileFormSubmitting$ = signal>(initial()); + + readonly openGroupIndex = signal(null); + + readonly isModalErrorSkillsChoose = signal(false); + readonly isModalErrorSkillChooseText = signal(""); + + private readonly typeSpecific = this.profileFormService.typeSpecific; + private readonly achievements = this.profileFormService.achievements; + 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(); + } + + initializationEditInfo(): void { + this.navService.setNavTitle("Редактирование профиля"); + + // Получение текущего шага редактирования из query параметров + this.setupEditingStep(); + } + + private setupEditingStep(): void { + const stepFromUrl = this.route.snapshot.queryParams["editingStep"] as EditStep; + if (stepFromUrl) { + this.projectStepService.setStepFromRoute(stepFromUrl); + } + } + + onGroupToggled(index: number, isOpen: boolean): void { + this.openGroupIndex.set(isOpen ? index : null); + } + + isGroupDisabled(index: number): boolean { + return this.openGroupIndex() !== null && this.openGroupIndex() !== index; + } + + /** + * Сохранение профиля пользователя + * Валидирует всю форму и отправляет данные на сервер + */ + 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$().status === "loading") { + this.isModalErrorSkillsChoose.set(true); + return; + } + + this.profileFormSubmitting$.set(loading()); + + 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: any = { + id: this.profileId(), + first_name: this.profileForm.value.firstName, + last_name: this.profileForm.value.lastName, + email: this.profileForm.value.email, + user_type: this.profileForm.value.userType, + city: this.profileForm.value.city, + about_me: this.profileForm.value.aboutMe || "", + avatar: this.profileForm.value.avatar || null, + cover_image_address: this.profileForm.value.coverImageAddress || null, + phone_number: + typeof this.profileForm.value.phoneNumber === "string" + ? this.profileForm.value.phoneNumber.replace(/^([87])/, "+7") + : this.profileForm.value.phoneNumber, + speciality: this.profileForm.value.speciality, + skills_ids: this.profileForm.value.skills?.map((s: Skill) => s.id) || [], + }; + + // Добавляем birthday если он указан + if (this.profileForm.value.birthday) { + newProfile.birthday = dayjs(this.profileForm.value.birthday, "DD.MM.YYYY").format( + "YYYY-MM-DD" + ); + } + + // Добавляем специфичные для типа пользователя поля + if (this.userTypeMap[this.profileForm.value.userType]) { + newProfile[this.userTypeMap[this.profileForm.value.userType]] = this.typeSpecific.value; + } + + // Добавляем связанные данные если они были отредактированы + if (this.achievements.length > 0) { + newProfile.achievements = achievements; + } + if (this.profileForm.value.education?.length > 0) { + newProfile.education = this.profileForm.value.education; + } + if (this.profileForm.value.workExperience?.length > 0) { + newProfile.work_experience = this.profileForm.value.workExperience; + } + if (this.profileForm.value.userLanguages?.length > 0) { + newProfile.user_languages = this.profileForm.value.userLanguages; + } + + this.authRepository + .updateProfile(newProfile) + .pipe( + concatMap(() => this.authRepository.fetchProfile()), + takeUntil(this.destroy$) + ) + .subscribe({ + next: profile => { + this.profileFormSubmitting$.set(success(undefined)); + this.navigationService.profileRedirect(profile.id); + }, + error: error => { + this.profileFormSubmitting$.set(failure("profile_edit_error")); + 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 if (error.error?.[0]) { + this.isModalErrorSkillChooseText.set(error.error[0]); + } else { + this.isModalErrorSkillChooseText.set("Ошибка при сохранении профиля"); + } + }, + }); + } +} 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..23c63f4e7 --- /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 { catchError, concatMap, first, map, Observable, skip, Subject, takeUntil } from "rxjs"; +import dayjs from "dayjs"; +import { yearRangeValidators } from "@utils/yearRangeValidators"; +import { User } from "@domain/auth/user.model"; +import { Specialization } from "@domain/specializations/specialization"; +import { SelectComponent } from "@ui/primitives"; +import { generateOptionsList } from "@utils/generate-options-list"; +import { + educationUserLevel, + educationUserType, +} from "@core/consts/lists/education-info-list.const"; +import { languageLevelsList, languageNamesList } from "@core/consts/lists/language-info-list.const"; +import { error } from "console"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; + +@Injectable({ providedIn: "root" }) +export class ProfileFormService { + private readonly fb = inject(FormBuilder); + private readonly authRepository = inject(AuthRepositoryPort); + + 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.authRepository.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.authRepository.updateAvatar(url)), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + public initializeProfileData(): void { + this.authRepository.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.authRepository + .updateProfile({ + 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/api/profile/use-cases/add-profile-news.use-case.ts b/projects/social_platform/src/app/api/profile/use-cases/add-profile-news.use-case.ts new file mode 100644 index 000000000..16ea4438c --- /dev/null +++ b/projects/social_platform/src/app/api/profile/use-cases/add-profile-news.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProfileNews } from "@domain/profile/profile-news.model"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class AddProfileNewsUseCase { + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + userId: string, + news: { text: string; files: string[] } + ): Observable> { + return this.profileNewsRepositoryPort.addNews(userId, news).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "add_profile_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/profile/use-cases/delete-profile-news.use-case.ts b/projects/social_platform/src/app/api/profile/use-cases/delete-profile-news.use-case.ts new file mode 100644 index 000000000..b923ad97e --- /dev/null +++ b/projects/social_platform/src/app/api/profile/use-cases/delete-profile-news.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class DeleteProfileNewsUseCase { + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + userId: string, + newsId: number + ): Observable> { + return this.profileNewsRepositoryPort.delete(userId, newsId).pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "delete_profile_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/profile/use-cases/edit-profile-news.use-case.ts b/projects/social_platform/src/app/api/profile/use-cases/edit-profile-news.use-case.ts new file mode 100644 index 000000000..dbaecde8e --- /dev/null +++ b/projects/social_platform/src/app/api/profile/use-cases/edit-profile-news.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProfileNews } from "@domain/profile/profile-news.model"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class EditProfileNewsUseCase { + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + userId: string, + newsId: number, + news: Partial + ): Observable> { + return this.profileNewsRepositoryPort.editNews(userId, newsId, news).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "edit_profile_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/profile/use-cases/fetch-profile-news.use-case.ts b/projects/social_platform/src/app/api/profile/use-cases/fetch-profile-news.use-case.ts new file mode 100644 index 000000000..52ea1d9e1 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/use-cases/fetch-profile-news.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProfileNews } from "@domain/profile/profile-news.model"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class FetchProfileNewsUseCase { + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + userId: number + ): Observable< + Result, { kind: "fetch_profile_news_error"; cause?: unknown }> + > { + return this.profileNewsRepositoryPort.fetchNews(userId).pipe( + map(news => ok>(news)), + catchError(error => of(fail({ kind: "fetch_profile_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/profile/use-cases/get-profile-news-detail.use-case.ts b/projects/social_platform/src/app/api/profile/use-cases/get-profile-news-detail.use-case.ts new file mode 100644 index 000000000..1554614cb --- /dev/null +++ b/projects/social_platform/src/app/api/profile/use-cases/get-profile-news-detail.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProfileNews } from "@domain/profile/profile-news.model"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProfileNewsDetailUseCase { + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + userId: string, + newsId: string + ): Observable> { + return this.profileNewsRepositoryPort.fetchNewsDetail(userId, newsId).pipe( + map(news => ok(news)), + catchError(error => + of(fail({ kind: "get_profile_news_detail_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/profile/use-cases/read-profile-news.use-case.ts b/projects/social_platform/src/app/api/profile/use-cases/read-profile-news.use-case.ts new file mode 100644 index 000000000..4f1b78e8f --- /dev/null +++ b/projects/social_platform/src/app/api/profile/use-cases/read-profile-news.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ReadProfileNewsUseCase { + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + userId: number, + newsIds: number[] + ): Observable> { + return this.profileNewsRepositoryPort.readNews(userId, newsIds).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "read_profile_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/profile/use-cases/toggle-profile-news-like.use-case.ts b/projects/social_platform/src/app/api/profile/use-cases/toggle-profile-news-like.use-case.ts new file mode 100644 index 000000000..448d173e2 --- /dev/null +++ b/projects/social_platform/src/app/api/profile/use-cases/toggle-profile-news-like.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ToggleProfileNewsLikeUseCase { + private readonly profileNewsRepositoryPort = inject(ProfileNewsRepositoryPort); + + execute( + userId: string, + newsId: number, + state: boolean + ): Observable> { + return this.profileNewsRepositoryPort.toggleLike(userId, newsId, state).pipe( + map(() => ok(undefined)), + catchError(error => + of(fail({ kind: "toggle_profile_news_like_error" as const, cause: error })) + ) + ); + } +} 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..7982a51be --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-list-info.service.ts @@ -0,0 +1,470 @@ +/** @format */ + +import { ElementRef, inject, Injectable } from "@angular/core"; +import { + catchError, + concatMap, + distinctUntilChanged, + EMPTY, + fromEvent, + map, + of, + Subject, + switchMap, + takeUntil, + tap, + throttleTime, +} from "rxjs"; +import { ActivatedRoute, Router } from "@angular/router"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProgramDetailListUIInfoService } from "./ui/program-detail-list-ui-info.service"; +import { ProjectRate } from "@domain/project/project-rate"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { CreateProgramFiltersUseCase } from "../../use-cases/create-program-filters.use-case"; +import { GetAllProjectsUseCase } from "../../use-cases/get-all-projects.use-case"; +import { GetAllMembersUseCase } from "../../use-cases/get-all-members.use-case"; +import { GetProjectSubscriptionsUseCase } from "../../../project/use-case/get-project-subscriptions.use-case"; +import { FilterProjectRatingsUseCase } from "../../use-cases/filter-project-ratings.use-case"; +import { GetProjectRatingsUseCase } from "../../use-cases/get-project-ratings.use-case"; +import Fuse from "fuse.js"; +import { Project } from "@domain/project/project.model"; +import { User } from "@domain/auth/user.model"; +import { isSuccess, loading, success } from "@domain/shared/async-state"; + +@Injectable() +export class ProgramDetailListInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + private readonly createProgramFiltersUseCase = inject(CreateProgramFiltersUseCase); + private readonly getAllProjectsUseCase = inject(GetAllProjectsUseCase); + private readonly getAllMembersUseCase = inject(GetAllMembersUseCase); + private readonly getProjectSubscriptionsUseCase = inject(GetProjectSubscriptionsUseCase); + private readonly filterProjectRatingsUseCase = inject(FilterProjectRatingsUseCase); + private readonly getProjectRatingsUseCase = inject(GetProjectRatingsUseCase); + + private readonly authRepository = inject(AuthRepositoryPort); + + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + private readonly logger = inject(LoggerService); + + private readonly destroy$ = new Subject(); + + private readonly listType = this.programDetailListUIInfoService.listType; + private readonly searchParamName = this.programDetailListUIInfoService.searchParamName; + + 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.list$.set(success(data.results)); + }); + + this.setupSearch(); + + 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 => { + this.logger.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(() => this.logger.info("Query change from ProjectsComponent")); + } + + private setupSearch(): void { + this.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(() => this.logger.debug("QueryParams changed from ProgramListComponent")); + }); + + this.route.queryParams + .pipe( + map(q => q["search"]), + takeUntil(this.destroy$) + ) + .subscribe(search => { + this.programDetailListUIInfoService.searchedList.set(this.applySearch(search)); + }); + } + + setupProfile(): void { + this.authRepository.profile + .pipe( + switchMap(p => this.getProjectSubscriptionsUseCase.execute(p.id)), + takeUntil(this.destroy$) + ) + .subscribe({ + next: result => { + if (!result.ok) { + this.logger.error("Error loading profile subscriptions:", result.error); + return; + } + + this.programDetailListUIInfoService.applySetupProfile(result.value); + }, + }); + } + + private setupFilters(): void { + if (this.listType() === "members") return; + + this.route.queryParams + .pipe( + distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)), + concatMap(q => { + const prev = this.programDetailListUIInfoService.list(); + this.programDetailListUIInfoService.list$.set(loading(prev)); + + 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.filterProjectRatingsUseCase.execute(programId, filters, params).pipe( + map(result => { + if (!result.ok) { + this.logger.error("Error filtering rating projects:", result.error); + return this.emptyPage(); + } + + return result.value; + }) + ); + } + return this.getProjectRatingsUseCase.execute(programId, params).pipe( + map(result => { + if (!result.ok) { + this.logger.error("Error fetching rating projects:", result.error); + return this.emptyPage(); + } + + return result.value; + }) + ); + } + + if (Object.keys(filters).length > 0) { + return this.createProgramFiltersUseCase.execute(programId, filters, params).pipe( + map(result => { + if (!result.ok) { + this.logger.error("Error creating program filters:", result.error); + return this.emptyPage(); + } + + return result.value; + }) + ); + } + return this.getAllProjectsUseCase.execute(programId, params).pipe( + map(result => { + if (!result.ok) { + this.logger.error("Error fetching initial projects:", result.error); + return this.emptyPage(); + } + + return result.value; + }) + ); + }), + catchError(err => { + this.logger.error("Error in setupFilters:", err); + return of(this.emptyPage()); + }), + takeUntil(this.destroy$) + ) + .subscribe(result => { + if (!result) return; + + this.programDetailListUIInfoService.list$.set(success(result.results)); + 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.programDetailListUIInfoService.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.programDetailListUIInfoService.loadingMore.set(true); + 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": { + if (Object.keys(filters).length > 0) { + return this.filterProjectRatingsUseCase.execute(programId, filters, params).pipe( + tap(result => { + if (!result.ok) { + this.logger.error("Error fetching ratings:", result.error); + this.listPage.update(p => p - 1); + return; + } + + this.programDetailListUIInfoService.list$.update(state => + isSuccess(state) + ? success([...state.data, ...result.value.results]) + : success(result.value.results) + ); + this.programDetailListUIInfoService.loadingMore.set(false); + }), + catchError(err => { + this.logger.error("Error fetching ratings:", err); + this.listPage.update(p => p - 1); + return of(this.emptyPage()); + }), + takeUntil(this.destroy$) + ); + } + + return this.getProjectRatingsUseCase.execute(programId, params).pipe( + tap(result => { + if (!result.ok) { + this.logger.error("Error fetching ratings:", result.error); + this.listPage.update(p => p - 1); + return; + } + + this.programDetailListUIInfoService.list$.update(state => + isSuccess(state) + ? success([...state.data, ...result.value.results]) + : success(result.value.results) + ); + this.programDetailListUIInfoService.loadingMore.set(false); + }), + catchError(err => { + this.logger.error("Error fetching ratings:", err); + this.listPage.update(p => p - 1); + return of(this.emptyPage()); + }), + takeUntil(this.destroy$) + ); + } + + case "projects": { + const projectsRequest$ = + Object.keys(filters).length > 0 + ? this.createProgramFiltersUseCase.execute(programId, filters, params) + : this.getAllProjectsUseCase.execute(programId, params); + + return projectsRequest$.pipe( + tap(result => { + if (!result.ok) { + this.logger.error("Error fetching projects:", result.error); + this.listPage.update(p => p - 1); + return; + } + + this.programDetailListUIInfoService.list$.update(state => + isSuccess(state) + ? success([...state.data, ...result.value.results]) + : success(result.value.results) + ); + this.programDetailListUIInfoService.loadingMore.set(false); + }), + catchError(err => { + this.logger.error("Error fetching projects:", err); + this.listPage.update(p => p - 1); + return of(this.emptyPage()); + }), + takeUntil(this.destroy$) + ); + } + + case "members": { + return this.getAllMembersUseCase.execute(programId, offset, this.itemsPerPage()).pipe( + tap(result => { + if (!result.ok) { + this.logger.error("Error fetching members:", result.error); + this.listPage.update(p => p - 1); + return; + } + + this.programDetailListUIInfoService.list$.update(state => + isSuccess(state) + ? success([...state.data, ...result.value.results]) + : success(result.value.results) + ); + this.programDetailListUIInfoService.loadingMore.set(false); + }), + catchError(err => { + this.logger.error("Error fetching members:", err); + this.listPage.update(p => p - 1); + return of(this.emptyPage()); + }), + takeUntil(this.destroy$) + ); + } + + default: + return of(this.emptyPage()); + } + } + + // Построение запроса для фильтров (кроме участников) + 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.programDetailListUIInfoService.list(); + + const searchKeys = + this.listType() === "projects" || this.listType() === "rating" + ? ["name"] + : ["firstName", "lastName"]; + + const fuse = new Fuse(this.programDetailListUIInfoService.list(), { + keys: searchKeys, + }); + return fuse.search(search).map(r => r.item); + } + + private emptyPage(): ApiPagination { + return { + count: 0, + results: [], + next: "", + previous: "", + }; + } +} 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..b2a4e8ed4 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts @@ -0,0 +1,268 @@ +/** @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 { FeedNews } from "@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"; +import { FetchNewsUseCase } from "../../use-cases/fetch-news.use-case"; +import { ReadNewsUseCase } from "../../use-cases/read-news.use-case"; +import { ok } from "@domain/shared/result.type"; +import { AddNewsUseCase } from "../../use-cases/add-news.use-case"; +import { DeleteNewsUseCase } from "../../use-cases/delete-news.use-case"; +import { ToggleLikeUseCase } from "../../use-cases/toggle-like.use-case"; +import { EditNewsUseCase } from "../../use-cases/edit-news.use-case"; + +@Injectable() +export class ProgramDetailMainService { + 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 fetchNewsUseCase = inject(FetchNewsUseCase); + private readonly readNewsUseCase = inject(ReadNewsUseCase); + private readonly addNewsUseCase = inject(AddNewsUseCase); + private readonly deleteNewsUseCase = inject(DeleteNewsUseCase); + private readonly toggleLikeUseCase = inject(ToggleLikeUseCase); + private readonly editNewsUseCase = inject(EditNewsUseCase); + 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( + tap(param => { + if (param["access"] === "accessDenied") { + this.loadingService.hide(); + this.programDetailMainUIInfoService.applyInitProgramQueryParams(); + + this.router.navigate([], { + relativeTo: this.route, + queryParams: { access: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + 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( + ok({ + results: [], + count: 0, + }) + ); + } + }), + takeUntil(this.destroy$) + ) + .subscribe({ + next: result => { + if (!result.ok) { + this.loadingService.hide(); + this.programDetailMainUIInfoService.applyProgramOpenModal("error"); + return; + } + + const news = result.value; + + 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(result => { + if (!result.ok) return; + + this.totalNewsCount.set(result.value.count); + this.newsInfoService.applyUpdateNews(result.value.results); + + setTimeout(() => { + this.setupNewsObserver(); + }, 100); + }) + ); + } + + private fetchNews(offset: number, limit: number) { + const programId = this.route.snapshot.params["programId"]; + return this.fetchNewsUseCase.execute(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.readNewsUseCase + .execute(this.route.snapshot.params["programId"], ids) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + onAddNews(news: { text: string; files: string[] }) { + return this.addNewsUseCase.execute(this.route.snapshot.params["programId"], news).pipe( + tap(result => { + if (!result.ok) return; + this.newsInfoService.applyAddNews(result.value); + }) + ); + } + + onDelete(newsId: number) { + const item = this.news().find((n: any) => n.id === newsId); + if (!item) return; + + this.deleteNewsUseCase + .execute(this.route.snapshot.params["programId"], newsId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (!result.ok) return; + + this.newsInfoService.applyDeleteNews(result.value); + }, + }); + } + + onLike(newsId: number) { + const item = this.news().find((n: any) => n.id === newsId); + if (!item) return; + + this.toggleLikeUseCase + .execute(this.route.snapshot.params["programId"], newsId, !item.isUserLiked) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + this.newsInfoService.applyLikeNews(result.value); + }); + } + + onEdit(news: FeedNews, newsId: number) { + return this.editNewsUseCase.execute(this.route.snapshot.params["programId"], newsId, news).pipe( + tap(newsRes => { + if (!newsRes.ok) return; + + this.newsInfoService.applyEditNews(newsRes.value); + }) + ); + } + + 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..dd116883d --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-list-ui-info.service.ts @@ -0,0 +1,85 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { User } from "@domain/auth/user.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { ProjectRate } from "@domain/project/project-rate"; +import { Project } from "@domain/project/project.model"; +import { AsyncState, initial, isLoading, isSuccess } from "@domain/shared/async-state"; + +@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>(initial()); + readonly loadingMore = signal(false); + readonly searchedList = signal([]); + + readonly list = computed(() => { + const state = this.list$(); + if (isSuccess(state)) return state.data; + if (isLoading(state)) return state.previous ?? []; + return []; + }); + + readonly profileSubscriptions = signal([]); + readonly profileProjSubsIds = computed(() => this.profileSubscriptions().map(sub => sub.id)); + + readonly availableFilters = signal([]); + + 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: [""], + }); + + readonly isHintExpertsModal = signal(false); + + 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 }); + } + + applySetAvailableFilters(filters: PartnerProgramFields[]): void { + this.availableFilters.set(filters); + } + + applySetupProfile(subs: ApiPagination): void { + this.profileSubscriptions.set(subs.results); + } + + applyHintModalOpen(): void { + this.isHintExpertsModal.set(true); + } +} 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..3f8f865dc --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts @@ -0,0 +1,82 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Program } from "@domain/program/program.model"; +import { FeedNews } from "@domain/project/project-news.model"; + +@Injectable({ providedIn: "root" }) +export class ProgramDetailMainUIInfoService { + readonly program = signal(undefined); + readonly programId = signal(undefined); + + readonly totalNewsCount = signal(0); + + // Сигналы для работы с модальными окнами с текстом + readonly showProgramModal = signal(false); + readonly showProgramModalErrorMessage = signal(null); + readonly registeredProgramModal = signal(false); + + readonly registerDateExpired = signal(false); + + applyInitProgramQueryParams(): void { + this.applyProgramOpenModal("access"); + } + + applyInitProgram( + news: + | ApiPagination + | { + results: unknown[]; + count: number; + } + ): void { + if (news.results?.length) { + this.totalNewsCount.set(news.count); + } + } + + applyFormatingProgramData(program: Program): void { + this.program.set(program); + this.registerDateExpired.set(Date.now() > Date.parse(program.datetimeRegistrationEnds)); + if (program.isUserMember) { + const seen = this.hasSeenRegisteredProgramModal(program.id); + if (!seen) { + this.registeredProgramModal.set(true); + this.markSeenRegisteredProgramModal(program.id); + } + } + } + + applyProgramOpenModal(type: "access" | "error"): void { + const errorText = + type === "access" + ? "У вас не доступа к этой вкладке!" + : "Произошла ошибка при загрузке программы"; + + this.showProgramModal.set(true); + this.showProgramModalErrorMessage.set(errorText); + } + + applyProgramCloseModal(): void { + this.showProgramModal.set(false); + } + + private getRegisteredProgramSeenKey(programId: number): string { + return `program_registered_modal_seen_${programId}`; + } + + private hasSeenRegisteredProgramModal(programId: number): boolean { + try { + return !!localStorage.getItem(this.getRegisteredProgramSeenKey(programId)); + } catch (e) { + return false; + } + } + + private markSeenRegisteredProgramModal(programId: number): void { + try { + localStorage.setItem(this.getRegisteredProgramSeenKey(programId), "1"); + } catch (e) {} + } +} 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..d10ee2397 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/program-info.service.ts @@ -0,0 +1,47 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ActivatedRoute, Router } from "@angular/router"; +import { NavService } from "@ui/services/nav/nav.service"; +import { Subject, takeUntil } from "rxjs"; +import { ProgramMainUIInfoService } from "./ui/program-main-ui-info.service"; + +@Injectable() +export class ProgramInfoService { + private readonly route = inject(ActivatedRoute); + private readonly navService = inject(NavService); + private readonly router = inject(Router); + private readonly programMainUIInfoService = inject(ProgramMainUIInfoService); + private readonly loggerService = inject(LoggerService); + + private readonly destroy$ = new Subject(); + + private readonly searchForm = this.programMainUIInfoService.searchForm; + + initializationPrograms(): void { + this.navService.setNavTitle("Программы"); + + this.initilizationSearchValue(); + } + + private initilizationSearchValue(): void { + this.searchForm + .get("search") + ?.valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(search => { + this.router + .navigate([], { + queryParams: { search }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.loggerService.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..cec01bec4 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/program-main-info.service.ts @@ -0,0 +1,95 @@ +/** @format */ + +import { inject, Injectable } 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 { Program } from "@domain/program/program.model"; +import { HttpParams } from "@angular/common/http"; +import { ProgramMainUIInfoService } from "./ui/program-main-ui-info.service"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import Fuse from "fuse.js"; +import { ParticipatingProgramUseCase } from "../use-cases/participating-program.use-case"; + +@Injectable() +export class ProgramMainInfoService { + private readonly navService = inject(NavService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly participatingProgramUseCase = inject(ParticipatingProgramUseCase); + 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.participatingProgramUseCase + .execute(new HttpParams({ fromObject: filter })) + .pipe(map(result => ({ result, search }))); + }), + takeUntil(this.destroy$) + ) + .subscribe(({ result, search }) => { + if (!result.ok) { + return; + } + + const programs = this.applySearch(result.value, search); + + this.programMainUIInfoService.applyPrograms(programs, result.value.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..e4dc55df0 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/ui/program-main-ui-info.service.ts @@ -0,0 +1,32 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { generateOptionsList } from "@utils/generate-options-list"; +import { Program } from "@domain/program/program.model"; + +@Injectable() +export class ProgramMainUIInfoService { + private readonly fb = inject(FormBuilder); + + protected readonly programCount = signal(0); + readonly isPparticipating = signal(false); + + readonly programs = signal([]); + + readonly searchForm = this.fb.group({ + search: [""], + }); + + 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/use-cases/add-news.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/add-news.use-case.ts new file mode 100644 index 000000000..14565bdbb --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/add-news.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramNewsRepositoryPort } from "@domain/program/ports/program-news.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { FeedNews } from "@domain/project/project-news.model"; + +@Injectable({ providedIn: "root" }) +export class AddNewsUseCase { + private readonly programNewsRepositoryPort = inject(ProgramNewsRepositoryPort); + + execute( + programId: number, + news: { text: string; files: string[] } + ): Observable> { + return this.programNewsRepositoryPort.addNews(programId, news).pipe( + map(news => ok(news)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/apply-project-to-program.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/apply-project-to-program.use-case.ts new file mode 100644 index 000000000..3b105f6e5 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/apply-project-to-program.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ApplyProjectToProgramUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + programId: number, + body: any + ): Observable> { + return this.programRepositoryPort.applyProjectToProgram(programId, body).pipe( + map(result => ok(result)), + catchError(error => + of(fail({ kind: "apply_project_to_program_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/assign-project-program.ts b/projects/social_platform/src/app/api/program/use-cases/assign-project-program.ts new file mode 100644 index 000000000..d96bec3bc --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/assign-project-program.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { ProjectAssign } from "@domain/project/project-assign.model"; + +@Injectable({ providedIn: "root" }) +export class AssignProjectProgramUseCase { + private readonly projectProgramRepositoryPort = inject(ProjectProgramRepositoryPort); + + execute( + projectId: number, + partnerProgramId: number + ): Observable> { + return this.projectProgramRepositoryPort + .assignProjectToProgram(projectId, partnerProgramId) + .pipe( + map(project => ok(project)), + catchError(error => of(fail({ kind: "unknown" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/create-program-filters.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/create-program-filters.use-case.ts new file mode 100644 index 000000000..80939f9e8 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/create-program-filters.use-case.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Project } from "@domain/project/project.model"; + +@Injectable({ providedIn: "root" }) +export class CreateProgramFiltersUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + programId: number, + filters: Record, + params?: HttpParams + ): Observable, { kind: "unknown" }>> { + return this.programRepositoryPort.createProgramFilters(programId, filters, params).pipe( + map(project => ok>(project)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/delete-news.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/delete-news.use-case.ts new file mode 100644 index 000000000..aa24cb40f --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/delete-news.use-case.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramNewsRepositoryPort } from "@domain/program/ports/program-news.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class DeleteNewsUseCase { + private readonly programNewsRepositoryPort = inject(ProgramNewsRepositoryPort); + + execute(programId: number, newsId: number): Observable> { + return this.programNewsRepositoryPort.deleteNews(programId, newsId).pipe( + map(() => ok(newsId)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/edit-news.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/edit-news.use-case.ts new file mode 100644 index 000000000..efe8e0cef --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/edit-news.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramNewsRepositoryPort } from "@domain/program/ports/program-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; +import { FeedNews } from "@domain/project/project-news.model"; + +@Injectable({ providedIn: "root" }) +export class EditNewsUseCase { + private readonly programNewsRepositoryPort = inject(ProgramNewsRepositoryPort); + + execute( + programId: number, + newsId: number, + newsItem: Partial + ): Observable> { + return this.programNewsRepositoryPort.editNews(programId, newsId, newsItem).pipe( + map(news => ok(news)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/fetch-news.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/fetch-news.use-case.ts new file mode 100644 index 000000000..73c28caa7 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/fetch-news.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramNewsRepositoryPort } from "@domain/program/ports/program-news.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedNews } from "@domain/project/project-news.model"; + +@Injectable({ providedIn: "root" }) +export class FetchNewsUseCase { + private readonly programNewsRepositoryPort = inject(ProgramNewsRepositoryPort); + + execute( + limit: number, + offset: number, + programId: number + ): Observable, { kind: "unknown" }>> { + return this.programNewsRepositoryPort.fetchNews(limit, offset, programId).pipe( + map(news => ok(news)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/filter-project-ratings.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/filter-project-ratings.use-case.ts new file mode 100644 index 000000000..d4700d381 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/filter-project-ratings.use-case.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectRatingRepositoryPort } from "@domain/project/ports/project-rating.repository.port"; +import { ProjectRate } from "@domain/project/project-rate"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class FilterProjectRatingsUseCase { + private readonly projectRatingRepositoryPort = inject(ProjectRatingRepositoryPort); + + execute( + programId: number, + filters: Record, + params?: HttpParams + ): Observable< + Result, { kind: "filter_project_ratings_error"; cause?: unknown }> + > { + return this.projectRatingRepositoryPort.postFilters(programId, filters, params).pipe( + map(rating => ok>(rating)), + catchError(error => of(fail({ kind: "filter_project_ratings_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-actual-programs.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-actual-programs.use-case.ts new file mode 100644 index 000000000..c4e56bcc9 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-actual-programs.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { Program } from "@domain/program/program.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetActualProgramsUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute(): Observable< + Result, { kind: "get_actual_programs_error"; cause?: unknown }> + > { + return this.programRepositoryPort.getActualPrograms().pipe( + map(programs => ok>(programs)), + catchError(error => of(fail({ kind: "get_actual_programs_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-all-members.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-all-members.use-case.ts new file mode 100644 index 000000000..95d4a1485 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-all-members.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { User } from "@domain/auth/user.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; + +@Injectable({ providedIn: "root" }) +export class GetAllMembersUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + programId: number, + skip: number, + take: number + ): Observable, { kind: "unknown" }>> { + return this.programRepositoryPort.getAllMembers(programId, skip, take).pipe( + map(members => ok>(members)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-all-projects.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-all-projects.use-case.ts new file mode 100644 index 000000000..b17d7298a --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-all-projects.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { HttpParams } from "@angular/common/http"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Project } from "@domain/project/project.model"; + +@Injectable({ providedIn: "root" }) +export class GetAllProjectsUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + programId: number, + params?: HttpParams + ): Observable, { kind: "unknown" }>> { + return this.programRepositoryPort.getAllProjects(programId, params).pipe( + map(project => ok>(project)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-data-schema.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-data-schema.use-case.ts new file mode 100644 index 000000000..4d2a8c3dc --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-data-schema.use-case.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramDataSchema } from "@domain/program/program.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProgramDataSchemaUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + programId: number + ): Observable< + Result + > { + return this.programRepositoryPort.getDataSchema(programId).pipe( + map(schema => ok(schema)), + catchError(error => + of(fail({ kind: "get_program_data_schema_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-filters.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-filters.use-case.ts new file mode 100644 index 000000000..b1ab3ce8b --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-filters.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProgramFiltersUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + programId: number + ): Observable< + Result + > { + return this.programRepositoryPort.getProgramFilters(programId).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "get_program_filters_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program.use-case.ts new file mode 100644 index 000000000..90676702d --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { Program } from "@domain/program/program.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProgramUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + programId: number + ): Observable> { + return this.programRepositoryPort.getOne(programId).pipe( + map(program => ok(program)), + catchError(error => of(fail({ kind: "get_program_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-programs.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-programs.use-case.ts new file mode 100644 index 000000000..ac8ba4794 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-programs.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { Program } from "@domain/program/program.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProgramsUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + skip: number, + take: number + ): Observable, { kind: "get_programs_error"; cause?: unknown }>> { + return this.programRepositoryPort.getAll(skip, take).pipe( + map(programs => ok>(programs)), + catchError(error => of(fail({ kind: "get_programs_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-project-ratings.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-project-ratings.use-case.ts new file mode 100644 index 000000000..bfbcf066b --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-project-ratings.use-case.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectRatingRepositoryPort } from "@domain/project/ports/project-rating.repository.port"; +import { ProjectRate } from "@domain/project/project-rate"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectRatingsUseCase { + private readonly projectRatingRepositoryPort = inject(ProjectRatingRepositoryPort); + + execute( + programId: number, + params?: HttpParams + ): Observable< + Result, { kind: "get_project_ratings_error"; cause?: unknown }> + > { + return this.projectRatingRepositoryPort.getAll(programId, params).pipe( + map(rating => ok>(rating)), + catchError(error => of(fail({ kind: "get_project_ratings_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/participating-program.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/participating-program.use-case.ts new file mode 100644 index 000000000..e1679eef5 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/participating-program.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Program } from "@domain/program/program.model"; + +@Injectable({ providedIn: "root" }) +export class ParticipatingProgramUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute(filter?: HttpParams): Observable, { kind: "unknown" }>> { + return this.programRepositoryPort.getAll(0, 20, filter).pipe( + map(response => ok>(response)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts new file mode 100644 index 000000000..0c5f08be2 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/rate-project.use-case.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectRatingRepositoryPort } from "@domain/project/ports/project-rating.repository.port"; +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class RateProjectUseCase { + private readonly projectRatingRepositoryPort = inject(ProjectRatingRepositoryPort); + + execute( + projectId: number, + criteria: ProjectRatingCriterion[], + outputVals: Record + ): Observable> { + const dto = this.projectRatingRepositoryPort.formValuesToDTO(criteria, outputVals); + + return this.projectRatingRepositoryPort.rate(projectId, dto).pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "rate_project_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/read-news.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/read-news.use-case.ts new file mode 100644 index 000000000..2fdf22bec --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/read-news.use-case.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramNewsRepositoryPort } from "@domain/program/ports/program-news.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ReadNewsUseCase { + private readonly programNewsRepositoryPort = inject(ProgramNewsRepositoryPort); + + execute(programId: string, newsIds: number[]): Observable> { + return this.programNewsRepositoryPort.readNews(programId, newsIds).pipe( + map(() => ok(undefined)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/register-program.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/register-program.use-case.ts new file mode 100644 index 000000000..c2030f635 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/register-program.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramDataSchema } from "@domain/program/program.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class RegisterProgramUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + programId: number, + additionalData: Record + ): Observable> { + return this.programRepositoryPort.register(programId, additionalData).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "register_program_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/toggle-like.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/toggle-like.use-case.ts new file mode 100644 index 000000000..30783068e --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/toggle-like.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProgramNewsRepositoryPort } from "@domain/program/ports/program-news.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ToggleLikeUseCase { + private readonly programNewsRepositoryPort = inject(ProgramNewsRepositoryPort); + + execute( + programId: string, + newsId: number, + state: boolean + ): Observable> { + return this.programNewsRepositoryPort.toggleLike(programId, newsId, state).pipe( + map(() => ok(newsId)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} 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..ca12a6fa6 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/dashboard/projects-dashboard-info.service.ts @@ -0,0 +1,52 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { combineLatest, Subject, switchMap, takeUntil } from "rxjs"; +import { ActivatedRoute } from "@angular/router"; +import { ProjectsDashboardUIInfoService } from "./ui/projects-dashboard-ui-info.service"; +import { ProjectsService } from "../../projects.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { GetProjectSubscriptionsUseCase } from "../../use-case/get-project-subscriptions.use-case"; + +@Injectable() +export class ProjectsDashboardInfoService { + private readonly route = inject(ActivatedRoute); + private readonly authRepository = inject(AuthRepositoryPort); + private readonly getProjectSubscriptionsUseCase = inject(GetProjectSubscriptionsUseCase); + private readonly projectsDashboardUIInfoService = inject(ProjectsDashboardUIInfoService); + private readonly projectsService = inject(ProjectsService); + + private readonly destroy$ = new Subject(); + + initializationDashboardItems(): void { + const subscriptions$ = this.authRepository.profile.pipe( + switchMap(p => this.getProjectSubscriptionsUseCase.execute(p.id)) + ); + + combineLatest([this.route.data, subscriptions$]) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: ([ + { + data: { all, my }, + }, + subs, + ]) => { + this.projectsDashboardUIInfoService.applySetDashboardItems( + all, + my, + subs.ok ? subs.value : { count: 0, results: [], next: "", previous: "" } + ); + }, + }); + } + + addProject(): void { + this.projectsService.addProject(); + } + + 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..3923e93da --- /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/dashboardItemBuilder"; +import { Project } from "@domain/project/project.model"; +import { ProgramDetailListUIInfoService } from "../../../../program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ApiPagination } from "@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..6ddcbbc63 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts @@ -0,0 +1,277 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { filter, map, Observable, Subject, takeUntil, tap } from "rxjs"; +import { NavService } from "@ui/services/nav/nav.service"; +import { FeedNews } from "@domain/project/project-news.model"; +import { ActivatedRoute } from "@angular/router"; +import { Collaborator } from "@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"; +import { User } from "@domain/auth/user.model"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { RemoveProjectCollaboratorUseCase } from "../../use-case/remove-project-collaborator.use-case"; +import { TransferProjectOwnershipUseCase } from "../../use-case/transfer-project-ownership.use-case"; +import { FetchProjectNewsUseCase } from "../../use-case/fetch-project-news.use-case"; +import { ReadProjectNewsUseCase } from "../../use-case/read-project-news.use-case"; +import { AddProjectNewsUseCase } from "../../use-case/add-project-news.use-case"; +import { DeleteProjectNewsUseCase } from "../../use-case/delete-project-news.use-case"; +import { ToggleProjectNewsLikeUseCase } from "../../use-case/toggle-project-news-like.use-case"; +import { EditProjectNewsUseCase } from "../../use-case/edit-project-news.use-case"; + +@Injectable({ providedIn: "root" }) +export class ProjectsDetailService { + private readonly removeProjectCollaboratorUseCase = inject(RemoveProjectCollaboratorUseCase); + private readonly transferProjectOwnershipUseCase = inject(TransferProjectOwnershipUseCase); + private readonly fetchProjectNewsUseCase = inject(FetchProjectNewsUseCase); + private readonly readProjectNewsUseCase = inject(ReadProjectNewsUseCase); + private readonly addProjectNewsUseCase = inject(AddProjectNewsUseCase); + private readonly deleteProjectNewsUseCase = inject(DeleteProjectNewsUseCase); + private readonly toggleProjectNewsLikeUseCase = inject(ToggleProjectNewsLikeUseCase); + private readonly editProjectNewsUseCase = inject(EditProjectNewsUseCase); + private readonly projectsDetailUIService = inject(ProjectsDetailUIInfoService); + private readonly authRepository = inject(AuthRepositoryPort); + private readonly navService = inject(NavService); + private readonly route = inject(ActivatedRoute); // Сервис для работы с активным маршрутом + 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; + + readonly projSubscribers$?: Observable = this.route.parent?.data.pipe( + map(r => r["data"][1]) + ); + + destroy(): void { + this.observer?.disconnect(); + + this.destroy$.next(); + this.destroy$.complete(); + } + + initializationTeam(): void { + this.authRepository.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.fetchProjectNewsUseCase + .execute(String(this.project()?.id)) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) { + return; + } + + this.newsInfoService.applySetNews(result.value); + + // Настройка наблюдателя для отслеживания просмотра новостей + setTimeout(() => { + this.observer?.disconnect(); + this.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 => { + this.observer?.observe(e); + }); + }); + }); + } + + initializationProfile(): void { + this.authRepository.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.removeProjectCollaboratorUseCase + .execute(this.projectId()!, userId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (!result.ok) { + return; + } + + this.projectsDetailUIService.removeCollaborators(result.value); + }, + }); + } + + /** + * Обработчик появления новостей в области видимости + * Отмечает новости как просмотренные + * @param entries - массив элементов, попавших в область видимости + */ + onNewsInVew(entries: IntersectionObserverEntry[]): void { + const projectId = Number(this.project()?.id); + if (!projectId) { + return; + } + + const ids = entries + .map(e => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return Number(e.target.dataset.id); + }) + .filter(id => Number.isFinite(id)); + + if (!ids.length) { + return; + } + + this.readProjectNewsUseCase.execute(projectId, ids).pipe(takeUntil(this.destroy$)).subscribe(); + } + + /** + * Добавление новой новости + * @param news - объект с текстом и файлами новости + */ + onAddNews(news: { text: string; files: string[] }): Observable { + return this.addProjectNewsUseCase.execute(this.projectId()!.toString(), news).pipe( + tap(result => { + if (result.ok) { + this.newsInfoService.applyAddNews(result.value); + } + }), + filter(result => result.ok), + map(() => undefined), + takeUntil(this.destroy$) + ); + } + + /** + * Удаление новости + * @param newsId - ID удаляемой новости + */ + onDeleteNews(newsId: number): void { + this.deleteProjectNewsUseCase + .execute(this.projectId()!.toString(), newsId) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) { + return; + } + + this.newsInfoService.applyDeleteNews(result.value); + }); + } + + /** + * Переключение лайка новости + * @param newsId - ID новости для лайка + */ + onLike(newsId: number): void { + const item = this.news().find(n => n.id === newsId); + if (!item) return; + + this.toggleProjectNewsLikeUseCase + .execute(this.projectId()!.toString(), newsId, !item.isUserLiked) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) { + return; + } + + this.newsInfoService.applyLikeNews(result.value); + }); + } + + /** + * Редактирование новости + * @param news - обновленные данные новости + * @param newsItemId - ID редактируемой новости + */ + onEditNews(news: FeedNews, newsItemId: number): Observable { + return this.editProjectNewsUseCase.execute(this.projectId()!.toString(), newsItemId, news).pipe( + tap(result => { + if (result.ok) { + this.newsInfoService.applyEditNews(result.value); + } + }), + filter(result => result.ok), + map(() => undefined), + takeUntil(this.destroy$) + ); + } + + /** + * Удаление участника из проекта + * @param id - ID удаляемого участника + */ + onRemoveMember(id: Collaborator["userId"]) { + this.removeProjectCollaboratorUseCase + .execute(this.projectId()!, id) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) { + return; + } + + this.projectsDetailUIService.applyMembersManipulation(result.value); + }); + } + + /** + * Передача лидерства другому участнику + * @param id - ID нового лидера + */ + onTransferOwnership(id: Collaborator["userId"]) { + this.transferProjectOwnershipUseCase + .execute(this.projectId()!, id) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) { + return; + } + + this.projectsDetailUIService.applyMembersManipulation(result.value); + }); + } +} 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..d1b12c563 --- /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/directionItemBuilder"; +import { Project } from "@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..bf291ec56 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/detail/work-section/projects-detail-work-section-info.service.ts @@ -0,0 +1,73 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { map, Subject, takeUntil } from "rxjs"; +import { ActivatedRoute } from "@angular/router"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { ProjectsDetailWorkSectionUIInfoService } from "./ui/projects-detail-work-section-ui-info.service"; +import { AcceptResponseUseCase } from "../../../../vacancy/use-cases/accept-response.use-case"; +import { RejectResponseUseCase } from "../../../../vacancy/use-cases/reject-response.use-case"; + +@Injectable() +export class ProjectsDetailWorkSectionInfoService { + private readonly route = inject(ActivatedRoute); + private readonly acceptResponseUseCase = inject(AcceptResponseUseCase); + private readonly rejectResponseUseCase = inject(RejectResponseUseCase); + 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.acceptResponseUseCase + .execute(responseId) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + this.projectsDetailWorkSectionUIInfoService.applyFilterVacacnies(responseId); + }); + } + + /** + * Отклонение отклика на вакансию + * @param responseId - ID отклика для отклонения + */ + rejectResponse(responseId: number) { + this.rejectResponseUseCase + .execute(responseId) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + 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..e59c95ea0 --- /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 "@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/office/projects/edit/services/project-achievements.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-achievements.service.ts similarity index 88% rename from projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts rename to projects/social_platform/src/app/api/project/facades/edit/project-achievements.service.ts index d904fad70..fc33b21c1 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-achievements.service.ts @@ -1,6 +1,6 @@ /** @format */ -import { inject, Injectable, signal } from "@angular/core"; +import { computed, inject, Injectable, signal } from "@angular/core"; import { FormArray, FormBuilder, FormGroup } from "@angular/forms"; import { ProjectFormService } from "./project-form.service"; @@ -45,18 +45,25 @@ export class ProjectAchievementsService { } } + private readonly achievements = this.projectFormService.achievements; + + readonly hasAchievements = computed( + () => this.achievementsItems().length > 0 || this.achievements.length > 0 + ); + + private readonly projectForm = this.projectFormService.getForm(); + /** * Добавляет новое достижение или сохраняет изменения существующего. * @param achievementsFormArray FormArray, содержащий формы достижений - * @param projectForm основная форма проекта (FormGroup) */ - public addAchievement(achievementsFormArray: FormArray, projectForm: FormGroup): void { + public addAchievement(achievementsFormArray: FormArray): void { // Инициализируем сигнал при первом вызове this.initializeAchievementsItems(achievementsFormArray); // Считываем вводимые данные - const title = projectForm.get("title")?.value; - const status = projectForm.get("status")?.value; + const title = this.projectForm.get("title")?.value; + const status = this.projectForm.get("status")?.value; // Проверяем, что поля не пустые if (!title || !status || title.trim().length === 0 || status.trim().length === 0) { @@ -89,24 +96,19 @@ export class ProjectAchievementsService { } // Очищаем поля ввода формы проекта - projectForm.get("title")?.reset(); - projectForm.get("title")?.setValue(""); + this.projectForm.get("title")?.reset(); + this.projectForm.get("title")?.setValue(""); - projectForm.get("status")?.reset(); - projectForm.get("status")?.setValue(""); + this.projectForm.get("status")?.reset(); + this.projectForm.get("status")?.setValue(""); } /** * Инициализирует редактирование существующего достижения. * @param index индекс достижения в списке * @param achievementsFormArray FormArray достижений - * @param projectForm основная форма проекта */ - public editAchievement( - index: number, - achievementsFormArray: FormArray, - projectForm: FormGroup - ): void { + public editAchievement(index: number, achievementsFormArray: FormArray): void { // Инициализируем сигнал при необходимости this.initializeAchievementsItems(achievementsFormArray); @@ -114,7 +116,7 @@ export class ProjectAchievementsService { const source = achievementsFormArray.value[index]; // Заполняем поля формы проекта для редактирования - projectForm.patchValue({ + this.projectForm.patchValue({ achievementsName: source?.achievementsName || "", achievementsDate: source?.achievementsDate || "", }); diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts similarity index 75% rename from projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts rename to projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts index 8b27ae100..f3c6c81e9 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-additional.service.ts @@ -5,30 +5,31 @@ import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms" 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"; + ProjectNewAdditionalProgramFields, +} from "@domain/program/partner-program-fields.model"; import { Observable } from "rxjs"; +import { SendProjectAdditionalFieldsUseCase } from "../../use-case/send-project-additional-fields.use-case"; +import { SubmitCompetitiveProjectUseCase } from "../../use-case/submit-competitive-project.use-case"; +import { AsyncState, failure, initial, loading, success } from "@domain/shared/async-state"; /** * Сервис для управления дополнительными полями проекта в партнерской программе. * Предоставляет методы для инициализации формы, валидации, переключения значений, * подготовки к отправке и работы со статусами отправки и ошибок. */ -@Injectable({ providedIn: "root" }) +@Injectable() export class ProjectAdditionalService { private additionalForm!: FormGroup; - private partnerProgramFields: PartnerProgramFields[] = []; + readonly partnerProgramFields = signal([]); private partnerProgramFieldsValues: PartnerProgramFieldsValues[] = []; private readonly fb = inject(FormBuilder); - private readonly projectService = inject(ProjectService); - private readonly programService = inject(ProgramService); + private readonly sendProjectAdditionalFieldsUseCase = inject(SendProjectAdditionalFieldsUseCase); + private readonly submitCompetitiveProjectUseCase = inject(SubmitCompetitiveProjectUseCase); - private isSendingDecision = signal(false); - private isAssignProjectToProgramError = signal(false); - private errorAssignProjectToProgramModalMessage = signal<{ non_field_errors: string[] } | null>( + readonly isSend$ = signal>(initial()); + + readonly errorAssignProjectToProgramModalMessage = signal<{ non_field_errors: string[] } | null>( null ); @@ -44,13 +45,6 @@ export class ProjectAdditionalService { return this.additionalForm; } - /** - * Возвращает массив описаний полей партнерской программы. - */ - public getPartnerProgramFields(): PartnerProgramFields[] { - return this.partnerProgramFields; - } - /** * Возвращает массив сохраненных значений полей. */ @@ -58,27 +52,6 @@ export class ProjectAdditionalService { return this.partnerProgramFieldsValues; } - /** - * Возвращает сигнал, указывающий на процесс отправки. - */ - public getIsSendingDecision() { - return this.isSendingDecision; - } - - /** - * Возвращает сигнал, указывающий на ошибку при привязке к программе. - */ - public getIsAssignProjectToProgramError() { - return this.isAssignProjectToProgramError; - } - - /** - * Возвращает сообщение об ошибке привязки к программе. - */ - public getErrorAssignProjectToProgramModalMessage() { - return this.errorAssignProjectToProgramModalMessage; - } - /** * Инициализирует форму дополнительных полей согласно конфигурации и значениям. * @param fields описание полей партнерской программы @@ -88,14 +61,14 @@ export class ProjectAdditionalService { fields: PartnerProgramFields[], values: PartnerProgramFieldsValues[] = [] ): void { - this.partnerProgramFields = fields; + this.partnerProgramFields.set(fields); this.partnerProgramFieldsValues = values; // Создаем новую пустую форму this.additionalForm = this.fb.group({}); // Добавляем контролы для каждого поля - this.partnerProgramFields.forEach(field => { + this.partnerProgramFields().forEach(field => { this.getInitialValue(field, values); const validators = field.isRequired ? [Validators.required] : []; const initialValue = this.getInitialValue(field, values); @@ -133,11 +106,11 @@ export class ProjectAdditionalService { */ public validateRequiredFields(): boolean { this.additionalForm.updateValueAndValidity(); - this.partnerProgramFields + this.partnerProgramFields() .filter(f => f.isRequired) .forEach(f => this.additionalForm.get(f.name)?.markAsTouched()); - return this.partnerProgramFields + return this.partnerProgramFields() .filter(f => f.isRequired) .some(f => this.additionalForm.get(f.name)?.invalid); } @@ -146,7 +119,7 @@ export class ProjectAdditionalService { * Убирает валидаторы с заполненных обязательных полей перед отправкой. */ public prepareFieldsForSubmit(): void { - this.partnerProgramFields + this.partnerProgramFields() .filter(f => f.isRequired) .forEach(f => { const ctrl = this.additionalForm.get(f.name); @@ -163,10 +136,10 @@ export class ProjectAdditionalService { * @returns Observable результат запроса */ public sendAdditionalFieldsValues(projectId: number): Observable { - this.isSendingDecision.set(true); - const newFieldsFormValues: projectNewAdditionalProgramVields[] = []; + this.isSend$.set(loading()); + const newFieldsFormValues: ProjectNewAdditionalProgramFields[] = []; - this.partnerProgramFields.forEach((field: PartnerProgramFields) => { + this.partnerProgramFields().forEach((field: PartnerProgramFields) => { const fieldValue = this.additionalForm.get(field.name)?.value; newFieldsFormValues.push({ field_id: field.id, @@ -174,7 +147,7 @@ export class ProjectAdditionalService { }); }); - return this.projectService.sendNewProjectFieldsValues(projectId, newFieldsFormValues); + return this.sendProjectAdditionalFieldsUseCase.execute(projectId, newFieldsFormValues); } /** @@ -183,14 +156,14 @@ export class ProjectAdditionalService { * @returns Observable результат запроса */ public submitCompettetiveProject(relationId: number): Observable { - return this.programService.submitCompettetiveProject(relationId); + return this.submitCompetitiveProjectUseCase.execute(relationId); } /** * Сбрасывает флаг процесса отправки. */ public resetSendingState(): void { - this.isSendingDecision.set(false); + this.isSend$.set(initial()); } /** @@ -199,7 +172,7 @@ export class ProjectAdditionalService { */ public setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { this.errorAssignProjectToProgramModalMessage.set(error); - this.isAssignProjectToProgramError.set(true); + this.isSend$.set(failure("assign_error")); } /** @@ -207,7 +180,7 @@ export class ProjectAdditionalService { */ public clearAssignProjectToProgramError(): void { this.errorAssignProjectToProgramModalMessage.set(null); - this.isAssignProjectToProgramError.set(false); + this.isSend$.set(initial()); } /** @@ -242,7 +215,7 @@ export class ProjectAdditionalService { control.addValidators([Validators.maxLength(500)]); break; case "textarea": - control.addValidators([Validators.maxLength(500)]); + control.addValidators([Validators.maxLength(300)]); break; } } diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts similarity index 93% rename from projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts rename to projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts index 2290af62e..f7d0c8696 100644 --- a/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-contacts.service.ts @@ -1,6 +1,6 @@ /** @format */ -import { inject, Injectable, signal } from "@angular/core"; +import { computed, inject, Injectable, signal } from "@angular/core"; import { FormArray, FormBuilder, FormGroup, FormControl, Validators } from "@angular/forms"; import { ProjectFormService } from "./project-form.service"; @@ -40,11 +40,16 @@ export class ProjectContactsService { * Полезно вызывать после загрузки данных с сервера */ public syncLinksItems(linksFormArray: FormArray): void { - if (linksFormArray) { + if (linksFormArray && linksFormArray.length > 0) { this.linksItems.set(linksFormArray.value); + } else { + this.linksItems.set([]); } + this.initialized = true; } + readonly hasLinks = computed(() => this.linksItems().length > 0); + /** * Получает основную форму проекта */ @@ -74,7 +79,7 @@ export class ProjectContactsService { public addLink(linksFormArray: FormArray): void { this.initializeLinksItems(linksFormArray); linksFormArray.push(this.fb.control("", Validators.required)); - this.linksItems.update(items => [...items, ""]); + this.syncLinksItems(linksFormArray); } /** @@ -105,8 +110,8 @@ export class ProjectContactsService { */ public removeLink(index: number, linksFormArray: FormArray): void { // Удаляем из сигнала и из FormArray - this.linksItems.update(items => items.filter((_, i) => i !== index)); linksFormArray.removeAt(index); + this.syncLinksItems(linksFormArray); } /** 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..64025448d --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts @@ -0,0 +1,378 @@ +/** @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 "@domain/program/partner-program-fields.model"; +import { stripNullish } from "@utils/stripNull"; +import { concatMap, filter, Subject, takeUntil } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { UpdateFormUseCase } from "../../use-case/update-form.use-case"; +/** + * Сервис для управления основной формой проекта и формой дополнительных полей партнерской программы. + * Обеспечивает создание, инициализацию, валидацию, автосохранение, сброс и получение данных форм. + */ +@Injectable({ providedIn: "root" }) +export class ProjectFormService { + private projectForm!: FormGroup; + private additionalForm!: FormGroup; + private readonly fb = inject(FormBuilder); + private readonly route = inject(ActivatedRoute); + private readonly updateFormUseCase = inject(UpdateFormUseCase); + 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.updateFormUseCase.execute({ + id: Number(this.route.snapshot.params["projectId"]), + data: { + presentationAddress: "", + draft: true, + }, + }) + ), + filter(result => result.ok), + takeUntil(this.destroy$) + ) + .subscribe(); + + // Автосохранение при очистке coverImageAddress + this.coverImageAddress?.valueChanges + .pipe( + filter(value => !value), + concatMap(() => + this.updateFormUseCase.execute({ + id: Number(this.route.snapshot.params["projectId"]), + data: { + coverImageAddress: "", + draft: true, + }, + }) + ), + filter(result => result.ok), + 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..d15eb4608 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-goals.service.ts @@ -0,0 +1,336 @@ +/** @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 { Goal } from "@domain/project/goals.model"; +import { ProjectGoalsUIService } from "./ui/project-goals-ui.service"; +import { LoggerService } from "@corelib"; +import { GoalFormData } from "@infrastructure/adapters/project/dto/project-goal.dto"; +import { CreateGoalsUseCase } from "../../use-case/create-goals.use-case"; +import { UpdateGoalUseCase } from "../../use-case/update-goal.use-case"; +import { DeleteGoalUseCase } from "../../use-case/delete-goal.use-case"; + +/** + * Сервис для управления целями проекта + * Предоставляет полный набор методов для работы с целями: + * - инициализация, добавление, редактирование, удаление + * - валидация и очистка ошибок + * - управление состоянием модального окна выбора лидера + */ +@Injectable({ + providedIn: "root", +}) +export class ProjectGoalService { + private readonly fb = inject(FormBuilder); + private goalForm!: FormGroup; + private readonly projectFormService = inject(ProjectFormService); + private readonly projectGoalsUIService = inject(ProjectGoalsUIService); + private readonly loggerService = inject(LoggerService); + private readonly createGoalsUseCase = inject(CreateGoalsUseCase); + private readonly updateGoalUseCase = inject(UpdateGoalUseCase); + private readonly deleteGoalUseCase = inject(DeleteGoalUseCase); + + 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.deleteGoalUseCase + .execute(projectId, goalId) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) { + this.loggerService.error("Error deleting goal:", result.error.cause); + } + }); + } + + /** + * Получает выбранного лидера для конкретной цели + * @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[]) { + const goalIndexes = this.goals.controls + .map((_, idx) => idx) + .filter(idx => !this.goals.at(idx)?.get("id")?.value); + + const payload: GoalFormData[] = newGoals.map(goal => ({ + id: goal.id ?? null, + title: goal.title, + completionDate: goal.completionDate, + responsible: goal.responsible, + isDone: goal.isDone, + })); + + return this.createGoalsUseCase.execute(projectId, payload).pipe( + tap(result => { + if (!result.ok) { + this.loggerService.error("Error saving goals:", result.error.cause); + return; + } + + result.value.forEach((createdGoal: Goal, idx: number) => { + const formGroup = this.goals.at(goalIndexes[idx]); + if (formGroup && createdGoal?.id != null) { + formGroup.patchValue({ id: createdGoal.id }); + } + }); + }), + map(result => + result.ok ? result.value : { __error: true, err: result.error.cause, original: newGoals } + ), + catchError(err => of({ __error: true, err, original: newGoals })) + ); + } + + public editGoals(projectId: number, existingGoals: Goal[]) { + const requests = existingGoals.map((item, idx) => { + const payload: GoalFormData = { + id: item.id, + title: item.title, + completionDate: item.completionDate, + responsible: item.responsible, + isDone: item.isDone, + }; + + return this.updateGoalUseCase.execute(projectId, item.id, payload).pipe( + map(result => + result.ok + ? { res: result.value, idx } + : { __error: true, err: result.error.cause, original: item, 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..702be293f --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-partner.service.ts @@ -0,0 +1,317 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { catchError, forkJoin, map, of, Subject, takeUntil, tap } from "rxjs"; +import { Partner, PartnerDto } from "@domain/project/partner.model"; +import { LoggerService } from "@corelib"; +import { CreatePartnerUseCase } from "../../use-case/create-partner.use-case"; +import { DeletePartnerUseCase } from "../../use-case/delete-partner.use-case"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectPartnerService { + private readonly fb = inject(FormBuilder); + private partnerForm!: FormGroup; + private readonly loggerService = inject(LoggerService); + private readonly createPartnerUseCase = inject(CreatePartnerUseCase); + private readonly deletePartnerUseCase = inject(DeletePartnerUseCase); + + 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 && partnerFormArray.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); + + if (partnersId === null || partnersId === undefined) { + return; + } + + this.deletePartnerUseCase + .execute(projectId, partnersId) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) { + this.loggerService.error("Failed to delete partner", result.error.cause); + } + }); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах 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, idx) => ({ partner, idx })) + .filter(({ partner }) => partner.id === null) + .filter( + ({ partner }) => + !!partner.name && !!partner.inn && !!partner.contribution && !!partner.decisionMaker + ) + .map(({ partner, idx }) => { + const decisionMakerPath = String(partner.decisionMaker).split("/"); + const decisionMaker = Number(decisionMakerPath[decisionMakerPath.length - 1]); + + if (!Number.isFinite(decisionMaker) || decisionMaker <= 0) { + return of({ + __error: true, + err: new Error("Invalid decisionMaker id"), + original: partner, + idx, + }); + } + + const payload: PartnerDto = { + name: partner.name, + inn: partner.inn, + contribution: partner.contribution, + decisionMaker, + }; + + return this.createPartnerUseCase.execute(projectId, payload).pipe( + map(result => + result.ok + ? { res: result.value, idx } + : { __error: true, err: result.error.cause, original: partner, idx } + ), + catchError(err => of({ __error: true, err, original: partner, idx })) + ); + }); + + if (!requests.length) { + return of([]); + } + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + this.loggerService.error("Failed to post partner", r.err); + 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 { + this.loggerService.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..65f119718 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-resources.service.ts @@ -0,0 +1,326 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { catchError, forkJoin, map, of, Subject, takeUntil, tap } from "rxjs"; +import { Resource, ResourceDto } from "@domain/project/resource.model"; +import { LoggerService } from "@corelib"; +import { DeleteResourceUseCase } from "../../use-case/delete-resource.use-case"; +import { CreateResourceUseCase } from "../../use-case/create-resource.use-case"; +import { UpdateResourceUseCase } from "../../use-case/update-resource.use-case"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectResourceService { + private readonly fb = inject(FormBuilder); + private readonly loggerService = inject(LoggerService); + private readonly deleteResourceUseCase = inject(DeleteResourceUseCase); + private readonly createResourceUseCase = inject(CreateResourceUseCase); + private readonly updateResourceUseCase = inject(UpdateResourceUseCase); + + 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); + + if (resourceId === null || resourceId === undefined) { + return; + } + + this.deleteResourceUseCase + .execute(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, idx) => ({ resource, idx })) + .filter(({ resource }) => resource.id === null) + .filter(({ resource }) => !!resource.type && !!resource.description) + .map(({ resource, idx }) => { + const payload: Omit = { + type: resource.type, + description: resource.description, + partnerCompany: resource.partnerCompany ?? "запрос к рынку", + }; + + return this.createResourceUseCase.execute(projectId, payload).pipe( + map(result => + result.ok + ? { res: result.value, idx } + : { __error: true, err: result.error.cause, original: resource, idx } + ), + catchError(err => of({ __error: true, err, original: resource, idx })) + ); + }); + + if (!requests.length) { + return of([]); + } + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + this.loggerService.error("Failed to post resource", r.err); + 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, idx) => ({ resource, idx })) + .filter(({ resource }) => resource.id !== null && resource.id !== undefined) + .filter(({ resource }) => !!resource.type && !!resource.description) + .map(({ resource, idx }) => { + const payload: Omit = { + type: resource.type, + description: resource.description, + partnerCompany: resource.partnerCompany ?? "запрос к рынку", + }; + + return this.updateResourceUseCase.execute(projectId, resource.id, payload).pipe( + map(result => + result.ok + ? { res: result.value, idx } + : { __error: true, err: result.error.cause, original: resource, idx } + ), + catchError(err => of({ __error: true, err, original: resource, idx })) + ); + }); + + if (!requests.length) { + return of([]); + } + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + this.loggerService.error("Failed to add resource", r.err); + 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 { + this.loggerService.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..d13a405a0 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts @@ -0,0 +1,124 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ValidationService } from "@corelib"; +import { Subject, takeUntil } from "rxjs"; +import { ProjectTeamUIService } from "./ui/project-team-ui.service"; +import { SendForUserUseCase } from "../../../invite/use-cases/send-for-user.use-case"; +import { UpdateInviteUseCase } from "../../../invite/use-cases/update-invite.use-case"; +import { RevokeInviteUseCase } from "../../../invite/use-cases/revoke-invite.use-case"; +import { loading } from "@domain/shared/async-state"; + +/** + * Сервис для управления приглашениями участников команды проекта. + * Предоставляет функциональность для создания и валидации формы приглашения, + * отправки, редактирования и удаления приглашений, управления состоянием модального окна и ошибок. + */ +@Injectable() +export class ProjectTeamService { + private readonly projectTeamUIService = inject(ProjectTeamUIService); + private readonly validationService = inject(ValidationService); + + private readonly destroy$ = new Subject(); + + private readonly sendForUserUseCase = inject(SendForUserUseCase); + private readonly updateInviteUseCase = inject(UpdateInviteUseCase); + private readonly revokeInviteUseCase = inject(RevokeInviteUseCase); + + 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(loading()); + + // Извлечение profileId из URL ссылки + const linkUrl = new URL(this.inviteForm.get("link")?.value ?? ""); + const pathSegments = linkUrl.pathname.split("/"); + const profileId = Number(pathSegments[pathSegments.length - 1]); + + this.sendForUserUseCase + .execute({ + userId: profileId, + projectId, + role: this.inviteForm.get("role")?.value ?? "", + specialization: this.inviteForm.get("specialization")?.value ?? "", + }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (!result.ok) { + this.projectTeamUIService.applyErrorSubmitInvite(result.error.cause); + return; + } + + this.projectTeamUIService.applySubmitInvite(result.value); + }, + }); + } + + /** + * Обновляет параметры существующего приглашения. + * @param params объект с inviteId, role и specialization + */ + public editInvitation(params: { inviteId: number; role: string; specialization: string }): void { + const { inviteId, role, specialization } = params; + this.updateInviteUseCase + .execute({ inviteId, role, specialization }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (!result.ok) return; + + this.projectTeamUIService.applyEditInvitation(params); + }, + }); + } + + /** + * Удаляет приглашение по идентификатору. + * @param invitationId идентификатор приглашения + */ + public removeInvitation(invitationId: number): void { + this.revokeInviteUseCase + .execute(invitationId) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + this.projectTeamUIService.applyRemoveInvitation(invitationId); + }); + } + + /** + * Настроивает динамическую валидацию для поля 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..40578fd89 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-vacancy.service.ts @@ -0,0 +1,153 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Validators } from "@angular/forms"; +import { ValidationService } from "@corelib"; +import { Subject, takeUntil } from "rxjs"; +import { ProjectVacancyUIService } from "./ui/project-vacancy-ui.service"; +import { CreateVacancyDto } from "../../dto/create-vacancy.model"; +import { ProjectFormService } from "./project-form.service"; +import { UpdateVacancyUseCase } from "../../../vacancy/use-cases/update-vacancy.use-case"; +import { PostVacancyUseCase } from "../../../vacancy/use-cases/post-vacancy.use-case"; +import { DeleteVacancyUseCase } from "../../../vacancy/use-cases/delete-vacancy.use-case"; +import { failure, initial, loading } from "@domain/shared/async-state"; + +/** + * Сервис для управления вакансиями проекта. + * Обеспечивает создание, валидацию, отправку, + * редактирование и удаление вакансий, а также работу с формой вакансии + * и синхронизацию с API. + */ +@Injectable() +export class ProjectVacancyService { + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly validationService = inject(ValidationService); + private readonly projectFormService = inject(ProjectFormService); + private readonly updateVacancyUseCase = inject(UpdateVacancyUseCase); + private readonly postVacancyUseCase = inject(PostVacancyUseCase); + private readonly deleteVacancyUseCase = inject(DeleteVacancyUseCase); + + 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(loading()); + + 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, + }; + + const editIdx = this.projectFormService.editIndex(); + if (editIdx !== null) { + const editedVacancy = this.projectVacancyUIService.vacancies()[editIdx]; + if (!editedVacancy?.id) { + this.vacancyIsSubmitting.set(initial()); + return; + } + + this.updateVacancyUseCase + .execute(editedVacancy.id, payload) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (!result.ok) { + this.vacancyIsSubmitting.set(failure("vacancy_error")); + return; + } + + this.projectVacancyUIService.applyUpdateVacancy(result.value); + }, + }); + return; + } + + // Вызов API для создания вакансии + this.postVacancyUseCase + .execute(projectId, payload) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (!result.ok) { + this.vacancyIsSubmitting.set(failure("vacancy_error")); + return; + } + + this.projectVacancyUIService.applySubmitVacancy(result.value); + }, + }); + } + + /** + * Удаляет вакансию по её идентификатору с подтверждением пользователя. + * @param vacancyId идентификатор вакансии для удаления + */ + public removeVacancy(vacancyId: number): void { + if (!confirm("Вы точно хотите удалить вакансию?")) return; + this.deleteVacancyUseCase + .execute(vacancyId) + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (!result.ok) return; + + 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..89f007e50 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts @@ -0,0 +1,619 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { NavService } from "@ui/services/nav/nav.service"; +import { + EMPTY, + distinctUntilChanged, + forkJoin, + map, + Observable, + of, + Subject, + switchMap, + takeUntil, + tap, +} from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { ActivatedRoute, Router } from "@angular/router"; +import { Goal } from "@domain/project/goals.model"; +import { Partner } from "@domain/project/partner.model"; +import { Resource } from "@domain/project/resource.model"; +import { Invite } from "@domain/invite/invite.model"; +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 { toObservable } from "@angular/core/rxjs-interop"; +import { SkillsRepositoryPort as SkillsService } from "@domain/skills/ports/skills.repository.port"; +import { ProjectFormService } from "./project-form.service"; +import { ProjectGoalService } from "./project-goals.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"; +import { ProjectContactsService } from "./project-contacts.service"; +import { ProjectVacancyService } from "./project-vacancy.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { AssignProjectProgramUseCase } from "../../../program/use-cases/assign-project-program"; +import { DeleteProjectUseCase } from "../../use-case/delete-project.use-case"; +import { UpdateFormUseCase } from "../../use-case/update-form.use-case"; +import { + AsyncState, + failure, + initial, + isLoading, + loading, + success, +} from "@domain/shared/async-state"; + +@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 assignProjectProgramUseCase = inject(AssignProjectProgramUseCase); + private readonly deleteProjectUseCase = inject(DeleteProjectUseCase); + private readonly updateFormUseCase = inject(UpdateFormUseCase); + + private readonly projectTeamUIService = inject(ProjectTeamUIService); + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); + + private readonly industryRepository = inject(IndustryRepositoryPort); + private readonly skillsService = inject(SkillsService); + private readonly navService = inject(NavService); + private readonly validationService = inject(ValidationService); + private readonly snackBarService = inject(SnackbarService); + private readonly logger = inject(LoggerService); + + 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 projectContactsService = inject(ProjectContactsService); + + 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; + private readonly fromProgramOpen = this.projectsEditUIInfoService.fromProgramOpen; + + // Получаем форму проекта из сервиса + 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$ = toObservable(this.industryRepository.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 submitMode = signal<"draft" | "published" | null>(null); + + readonly projSubmitInitiated = signal(false); + readonly projFormIsSubmitting$ = signal>(initial()); + + readonly projFormIsSubmittingAsDraft = computed( + () => this.submitMode() === "draft" && isLoading(this.projFormIsSubmitting$()) + ); + readonly projFormIsSubmittingAsPublished = computed( + () => this.submitMode() === "published" && isLoading(this.projFormIsSubmitting$()) + ); + + readonly openGroupIds = signal>(new Set()); + + /** + * Проверяет, есть ли открытые группы навыков + */ + readonly hasOpenSkillsGroups = computed(() => this.openGroupIds().size > 0); + + 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.assignProjectProgramUseCase + .execute( + Number(this.route.snapshot.paramMap.get("projectId")), + this.projectForm.get("partnerProgramId")?.value + ) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: r => { + if (!r.ok) { + if (r.error.cause instanceof HttpErrorResponse) { + if (r.error.cause.status === 400) { + this.setAssignProjectToProgramError(r.error.cause.error); + } + } + return; + } + + this.projectsEditUIInfoService.applyOpenAssignProjectModal(r.value); + this.router.navigateByUrl( + `/office/projects/${r.value.newProjectId}/edit?editingStep=main` + ); + }, + }); + } + + 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 { + const programId = this.projectForm.get("partnerProgramId")?.value; + + this.deleteProjectUseCase + .execute(Number(this.route.snapshot.paramMap.get("projectId"))) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (!result.ok) { + return; + } + + if (this.fromProgram()) { + this.router.navigateByUrl(`/office/program/${programId}`); + } else { + this.router.navigateByUrl(`/office/projects/my`); + } + }, + }); + } + + /** + * Сохранение проекта как опубликованного с проверкой доп. полей + */ + saveProjectAsPublished(): void { + this.projectForm.get("draft")?.patchValue(false); + this.submitMode.set("published"); + + if (!this.isCompetitive()) { + this.projFormIsSubmitting$.set(loading()); + 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.projFormIsSubmitting$.set(loading()); + + 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.updateFormUseCase + .execute({ id: projectId, data: payload }) + .pipe( + switchMap(result => { + if (!result.ok) { + this.handleProjectSubmitError(result.error.cause); + return EMPTY; + } + + return this.saveOrEditGoals(projectId); + }), + switchMap(() => this.savePartners(projectId)), + switchMap(() => this.saveOrEditResources(projectId)) + ) + .subscribe({ + next: () => { + this.completeSubmitedProjectForm(projectId); + }, + error: err => { + this.submitMode.set(null); + this.projFormIsSubmitting$.set(failure("ошибка при сохранении данных")); + this.snackBarService.error("ошибка при сохранении данных"); + if (err.error["error"].includes("Срок подачи проектов в программу завершён.")) { + this.projectsEditUIInfoService.applyOpenSendDescisionLateModal(); + } + }, + }); + } + + closeSendingDescisionModal(): void { + this.projectsEditUIInfoService.applyCloseSendDescisionModal(); + + const projectId = Number(this.route.snapshot.params["projectId"]); + const relationId = this.relationId(); + + this.projFormIsSubmitting$.set(loading()); + 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.projectAchievementsService.syncAchievementsItems( + this.projectFormService.achievements + ); + this.projectGoalsService.initializeGoalsFromProject(goals); + this.projectPartnerService.initializePartnerFromProject(partners); + this.projectResourceService.initializeResourcesFromProject(resources); + this.projectTeamUIService.applySetInvites(invites); + this.projectTeamUIService.applySetCollaborators(project.collaborators); + + // Синхронизируем ссылки после инициализации данных проекта + this.projectContactsService.syncLinksItems(this.projectFormService.links); + + 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); + } + ); + } + + /** + * Поиск навыков + * @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"]); + + const seen = this.projectsEditUIInfoService.hasSeenFromProgramModal(); + if (this.fromProgram() && !seen) { + this.fromProgramOpen.set(true); + this.projectsEditUIInfoService.markSeenFromProgramModal(); + } else { + this.fromProgramOpen.set(false); + } + + 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.projFormIsSubmitting$.set(success(undefined)); + this.router.navigateByUrl(`/office/projects/${projectId}`); + } + + private handleProjectSubmitError(error?: unknown): void { + this.submitMode.set(null); + this.projFormIsSubmitting$.set(failure("ошибка при сохранении данных")); + this.snackBarService.error("ошибка при сохранении данных"); + + if ( + error instanceof HttpErrorResponse && + Array.isArray(error.error?.error) && + error.error.error.includes("Срок подачи проектов в программу завершён.") + ) { + this.projectsEditUIInfoService.applyOpenSendDescisionLateModal(); + } + } + + /** + * Отправка дополнительных полей через сервис + * @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: result => { + if (!result.ok) { + this.logger.error("Error sending additional fields:", result.error.cause); + this.projectAdditionalService.resetSendingState(); + this.submitMode.set("draft"); + return; + } + + if (!isDraft) { + this.projectAdditionalService + .submitCompettetiveProject(relationId) + .pipe(takeUntil(this.destroy$)) + .subscribe(submitResult => { + if (!submitResult.ok) { + this.logger.error( + "Error submitting competitive project:", + submitResult.error.cause + ); + this.projectAdditionalService.resetSendingState(); + this.submitMode.set("draft"); + return; + } + + this.projectAdditionalService.resetSendingState(); + this.submitProjectForm(); + }); + } else { + this.projectAdditionalService.resetSendingState(); + this.submitProjectForm(); + } + }, + error: error => { + this.logger.error("Error sending additional fields:", error); + this.projectAdditionalService.resetSendingState(); + this.submitMode.set("draft"); + }, + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-achievements-ui.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-achievements-ui.service.ts new file mode 100644 index 000000000..74387ce34 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-achievements-ui.service.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ProjectFormService } from "../project-form.service"; +import { ToggleFieldsInfoService } from "../../../../toggle-fields/toggle-fields-info.service"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectAchievementUIService { + private readonly projectFormService = inject(ProjectFormService); + private readonly toggleFieldsInfoService = inject(ToggleFieldsInfoService); + + private readonly editIndex = this.projectFormService.editIndex; + private readonly projectForm = this.projectFormService.getForm(); + + /** + * Скрывает поля ввода и очищает их + */ + hideFields(): void { + this.toggleFieldsInfoService.hideFields(); + this.clearInputFields(); + } + + private clearInputFields(): void { + this.projectForm.get("achievementsName")?.reset(); + this.projectForm.get("achievementsName")?.setValue(""); + + if (this.editIndex() !== null) { + this.projectFormService.editIndex.set(null); + } + } +} 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..07c668c53 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts @@ -0,0 +1,157 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Invite } from "@domain/invite/invite.model"; +import { Collaborator } from "@domain/project/collaborator.model"; +import { AsyncState, failure, initial } from "@domain/shared/async-state"; + +@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>(initial()); + + readonly isHintTeamModal = signal(false); + + readonly invitesFill = computed(() => this.invites().some(inv => inv.isAccepted === null)); + + /** + * Создает форму приглашения с контролами 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); + } + + applyOpenHintModal(): void { + this.isHintTeamModal.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(failure("invite_error")); + } + + 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(initial()); + } +} 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..cb980f416 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-vacancy-ui.service.ts @@ -0,0 +1,214 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; +import { rolesMembersList } from "@core/consts/lists/roles-members-list.const"; +import { workExperienceList } from "@core/consts/lists/work-experience-list.const"; +import { workFormatList } from "@core/consts/lists/work-format-list.const"; +import { workScheludeList } from "@core/consts/lists/work-schelude-list.const"; +import { Skill } from "@domain/skills/skill"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { AsyncState, initial, isLoading } from "@domain/shared/async-state"; +import { ProjectFormService } from "../project-form.service"; +import { ValidationService } from "@corelib"; +import { stripNullish } from "@utils/stripNull"; +import { ProjectsEditUIInfoService } from "./projects-edit-ui-info.service"; +import { ToggleFieldsInfoService } from "../../../../toggle-fields/toggle-fields-info.service"; + +@Injectable({ providedIn: "root" }) +export class ProjectVacancyUIService { + private readonly fb = inject(FormBuilder); + private readonly projectFormService = inject(ProjectFormService); + private readonly validationService = inject(ValidationService); + private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); + private readonly toggleFieldsInfoService = inject(ToggleFieldsInfoService); + + /** Константы для выпадающих списков */ + 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>(initial()); + readonly vacancyIsSubmittingFlag = computed(() => isLoading(this.vacancyIsSubmitting())); + + readonly vacancies = signal([]); + readonly onEditClicked = this.projectsEditUIInfoService.onEditClicked; + + readonly vacancyForm = this.fb.group({ + role: this.fb.control(null), + skills: this.fb.control([]), + description: this.fb.control("", [Validators.maxLength(3500)]), + 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(); + } + + applyUpdateVacancy(vacancy: Vacancy): void { + this.vacancies.update(list => list.map(item => (item.id === vacancy.id ? vacancy : item))); + this.applyResetVacancyForm(); + } + + /** + * Инициализирует редактирование вакансии по индексу в массиве: + * заполняет форму, выставляет сигналы и переключает режим редактирования. + * @param index индекс вакансии в списке vacancies + */ + applyEditVacancy(index: number): void { + const item = this.vacancies()[index]; + if (!item) { + return; + } + // Установка выбранных значений селектов по сопоставлению + 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); + this.toggleFieldsInfoService.showFields(); + } + + /** + * Сбрасывает форму вакансии к начальному состоянию: + * очищает значения, валидаторы и состояния контролов, + * сбрасывает сигналы выбранных селектов. + */ + 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.projectFormService.editIndex.set(null); + this.onEditClicked.set(false); + this.vacancyIsSubmitting.set(initial()); + } +} 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..d1b4e8803 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/projects-edit-ui-info.service.ts @@ -0,0 +1,91 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ProjectAssign } from "@domain/project/project-assign.model"; +import { ProjectsDetailUIInfoService } from "../../detail/ui/projects-detail-ui.service"; + +@Injectable() +export class ProjectsEditUIInfoService { + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + + // Id Лидера проекта + readonly leaderId = signal(0); + readonly fromProgram = signal(""); + readonly fromProgramOpen = signal(false); + readonly projectId = this.projectsDetailUIInfoService.projectId; + + // Маркер того является ли проект привязанный к конкурсной программе + readonly isCompetitive = signal(false); + readonly isProjectAssignToProgram = signal(false); + + // Состояние компонента + readonly isCompleted = signal(false); + readonly isSendDescisionLate = 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); + } + + private getFromProgramSeenKey(): string { + return `project_fromProgram_modal_seen_${this.projectId()}`; + } + + hasSeenFromProgramModal(): boolean { + try { + return !!localStorage.getItem(this.getFromProgramSeenKey()); + } catch (e) { + return false; + } + } + + markSeenFromProgramModal(): void { + try { + localStorage.setItem(this.getFromProgramSeenKey(), "1"); + } catch (e) {} + } + + closeFromProgramModal(): void { + this.fromProgramOpen.set(false); + this.markSeenFromProgramModal(); + } + + applyOpenSendDescisionLateModal(): void { + this.isSendDescisionLate.set(true); + } +} 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..2a55e37f6 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/list/projects-list-info.service.ts @@ -0,0 +1,277 @@ +/** @format */ + +import { computed, 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 { ProjectsInfoService } from "../projects-info.service"; +import { ProgramDetailListInfoService } from "../../../program/facades/detail/program-detail-list-info.service"; +import { inviteToProjectMapper } from "@utils/inviteToProjectMapper"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Project } from "@domain/project/project.model"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { GetAllProjectsUseCase } from "../../use-case/get-all-projects.use-case"; +import { GetMyProjectsUseCase } from "../../use-case/get-my-projects.use-case"; +import { + AsyncState, + initial, + isFailure, + isLoading, + isSuccess, + loading, + success, +} from "@domain/shared/async-state"; + +@Injectable() +export class ProjectsListInfoService { + private static readonly PROJECTS_PAGE_SIZE = 16; + + private readonly route = inject(ActivatedRoute); + private readonly navService = inject(NavService); + private readonly projectsInfoService = inject(ProjectsInfoService); + private readonly getAllProjectsUseCase = inject(GetAllProjectsUseCase); + private readonly getMyProjectsUseCase = inject(GetMyProjectsUseCase); + private readonly programDetailListInfoService = inject(ProgramDetailListInfoService); + private readonly logger = inject(LoggerService); + + private readonly destroy$ = new Subject(); + + private readonly projectsCount = signal(0); + private readonly currentPage = signal(1); + private readonly projectsPerFetch = signal(ProjectsListInfoService.PROJECTS_PAGE_SIZE); + + private readonly currentSearchQuery = signal(undefined); + private previousReqQuery = signal | null>(null); + + readonly projects$ = signal>(initial()); + + readonly projects = computed(() => { + const state = this.projects$(); + if (isSuccess(state)) return state.data; + if (isLoading(state)) return state.previous ?? []; + return []; + }); + + 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 (this.isAll()) { + this.route.queryParams + .pipe( + distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)), + concatMap(q => { + const prev = this.projects(); + this.projects$.set(loading(prev)); + + const reqQuery = this.buildFilterQuery(q); + + if ( + this.previousReqQuery() !== null && + JSON.stringify(reqQuery) === JSON.stringify(this.previousReqQuery()) + ) { + return EMPTY; + } + + this.previousReqQuery.set(reqQuery); + this.currentPage.set(1); + + return this.fetchAllProjects(reqQuery); + }), + takeUntil(this.destroy$) + ) + .subscribe(projects => { + this.projects$.set(success(projects.results)); + this.projectsCount.set(projects.count); + }); + } + + this.route.data + .pipe( + map(r => r["data"]), + takeUntil(this.destroy$) + ) + .subscribe(projects => { + if (this.isInvites()) { + this.projects$.set(success(inviteToProjectMapper(projects ?? []))); + this.projectsCount.set(projects?.length ?? 0); + return; + } + + this.projectsCount.set(projects.count); + this.projects$.set(success(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(state => + isSuccess(state) ? success([...state.data, ...chunk]) : success(chunk) + ); + }) + ); + } + + return EMPTY; + } + + private onFetch(skip: number, take: number) { + const queryParams = { + offset: skip, + limit: take, + ...this.buildFilterQuery(this.route.snapshot.queryParams), + }; + + if (this.isAll()) { + return this.fetchAllProjects(queryParams).pipe(map(projects => projects.results)); + } + + return this.fetchMyProjects({ offset: skip, limit: take }).pipe( + tap(projects => { + this.projectsCount.set(projects.count); + }), + map(projects => projects.results) + ); + } + + private fetchAllProjects(queryParams?: Record) { + const params = queryParams ? new HttpParams({ fromObject: queryParams }) : undefined; + + return this.getAllProjectsUseCase.execute(params).pipe( + map(result => { + if (!result.ok) { + this.logger.error("Error fetching all projects:", result.error); + return this.emptyProjectsPage(); + } + + return result.value; + }) + ); + } + + private fetchMyProjects(queryParams?: Record) { + const params = queryParams ? new HttpParams({ fromObject: queryParams }) : undefined; + + return this.getMyProjectsUseCase.execute(params).pipe( + map(result => { + if (!result.ok) { + this.logger.error("Error fetching my projects:", result.error); + return this.emptyProjectsPage(); + } + + return result.value; + }) + ); + } + + private emptyProjectsPage(): ApiPagination { + return { + count: 0, + results: [], + next: "", + previous: "", + }; + } + + sliceInvitesArray(inviteId: number): void { + this.projects$.update(state => + isSuccess(state) ? success(state.data.filter(p => p.inviteId !== inviteId)) : state + ); + 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..f18d7a061 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/projects-info.service.ts @@ -0,0 +1,93 @@ +/** @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, tap } from "rxjs"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ProjectsUIInfoService } from "./ui/projects-ui-info.service"; +import { CreateProjectUseCase } from "../use-case/create-project.use-case"; + +@Injectable() +export class ProjectsInfoService { + private readonly navService = inject(NavService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly projectsUIInfoService = inject(ProjectsUIInfoService); + private readonly createProjectUseCase = inject(CreateProjectUseCase); + + private readonly destroy$ = new Subject(); + private readonly logger = inject(LoggerService); + + 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(() => this.logger.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.createProjectUseCase + .execute() + .pipe( + tap(result => { + if (!result.ok) return; + + this.router + .navigate([`/office/projects/${result.value.id}/edit`], { + queryParams: { editingStep: "main", fromProgram }, + }) + .then(() => this.logger.debug("Route change from ProjectsComponent")); + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } +} 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..7fa4b08f1 --- /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/inviteToProjectMapper"; +import { Invite } from "@domain/invite/invite.model"; +import { Project } from "@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/api/project/project-form.service.ts b/projects/social_platform/src/app/api/project/project-form.service.ts new file mode 100644 index 000000000..47e5c3f69 --- /dev/null +++ b/projects/social_platform/src/app/api/project/project-form.service.ts @@ -0,0 +1,406 @@ +/** @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 "@domain/program/partner-program-fields.model"; +import { stripNullish } from "@utils/stripNull"; +import { concatMap, filter } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { optionalUrlOrMentionValidator } from "@utils/optionalUrl.validator"; +import { UpdateFormUseCase } from "./use-case/update-form.use-case"; +/** + * Сервис для управления основной формой проекта и формой дополнительных полей партнерской программы. + * Обеспечивает создание, инициализацию, валидацию, автосохранение, сброс и получение данных форм. + */ +@Injectable({ providedIn: "root" }) +export class ProjectFormService { + private projectForm!: FormGroup; + private additionalForm!: FormGroup; + private readonly fb = inject(FormBuilder); + private readonly route = inject(ActivatedRoute); + private readonly updateFormUseCase = inject(UpdateFormUseCase); + + public editIndex = signal(null); + public relationId = signal(0); + + constructor() { + this.initializeForm(); + } + + formModel = (this.projectForm = this.fb.group({ + imageAddress: [""], + name: ["", [Validators.required, Validators.maxLength(256)]], + region: ["", [Validators.required, Validators.maxLength(256)]], + implementationDeadline: [null], + trl: [null], + links: this.fb.array([]), + link: ["", optionalUrlOrMentionValidator], + industryId: [undefined], + description: ["", [Validators.maxLength(800)]], + presentationAddress: [""], + coverImageAddress: [""], + actuality: ["", [Validators.maxLength(1000)]], + targetAudience: ["", [Validators.maxLength(500)]], + problem: ["", [Validators.maxLength(1000)]], + partnerProgramId: [null], + achievements: this.fb.array([]), + title: [""], + status: [""], + + draft: [null], + })); + + /** + * Создает и настраивает основную форму проекта с набором контролов и валидаторов. + * Подписывается на изменения полей 'presentationAddress' и 'coverImageAddress' для автосохранения при очищении. + */ + private initializeForm(): void { + this.projectForm = this.fb.group({ + imageAddress: [""], + name: ["", [Validators.required, Validators.maxLength(256)]], + region: ["", [Validators.required, Validators.maxLength(256)]], + implementationDeadline: [null], + trl: [null], + links: this.fb.array([]), + link: ["", optionalUrlOrMentionValidator], + industryId: [undefined], + description: ["", [Validators.maxLength(800)]], + presentationAddress: [""], + coverImageAddress: [""], + actuality: ["", [Validators.maxLength(1000)]], + targetAudience: ["", [Validators.maxLength(500)]], + problem: ["", [Validators.maxLength(400)]], + partnerProgramId: [null], + achievements: this.fb.array([]), + title: [""], + status: [""], + + draft: [null], + }); + + // Автосохранение при очистке presentationAddress + this.presentationAddress?.valueChanges + .pipe( + filter(value => !value), + concatMap(() => + this.updateFormUseCase.execute({ + id: Number(this.route.snapshot.params["projectId"]), + data: { + presentationAddress: "", + draft: true, + }, + }) + ) + ) + .subscribe(); + + // Автосохранение при очистке coverImageAddress + this.coverImageAddress?.valueChanges + .pipe( + filter(value => !value), + concatMap(() => + this.updateFormUseCase.execute({ + id: Number(this.route.snapshot.params["projectId"]), + data: { + coverImageAddress: "", + draft: true, + }, + }) + ) + ) + .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, optionalUrlOrMentionValidator)); + }); + } + + /** + * Заполняет 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); + }); + } + + /** + * Возвращает основную форму проекта. + * @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 { + const value = stripNullish(this.projectForm.value); + + if (Array.isArray(value["links"])) { + value["links"] = value["links"].map((v: string) => v?.trim()).filter((v: string) => !!v); + } + + return 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 achievementsName(): FormArray { + return this.projectForm.get("achievementsName") as FormArray; + } + + public get achievementsDate(): FormArray { + return this.projectForm.get("achievementsDate") 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(500)); + 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/office/projects/edit/services/project-step.service.ts b/projects/social_platform/src/app/api/project/project-step.service.ts similarity index 82% 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 index 33df80461..402cf88d8 100644 --- 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 @@ -6,29 +6,31 @@ * @format */ -import { inject, Injectable, Signal, signal } from "@angular/core"; +import { inject, Injectable, signal } from "@angular/core"; import { Router } from "@angular/router"; /** Тип шага редактирования проекта */ -export type EditStep = "main" | "contacts" | "achievements" | "vacancies" | "team" | "additional"; +export type EditStep = + | "main" + | "contacts" + | "achievements" + | "vacancies" + | "team" + | "additional" + | "education" + | "experience" + | "skills" + | "settings"; @Injectable({ providedIn: "root", }) export class ProjectStepService { /** Сигнал, содержащий текущий шаг редактирования */ - private currentStep = signal("main"); + readonly currentStep = signal("main"); /** Ссылка на Router для изменения URL */ private readonly router = inject(Router); - /** - * Возвращает readonly-сигнал текущего шага. - * @returns Signal readonly-сигнал - */ - public getCurrentStep(): Signal { - return this.currentStep.asReadonly(); - } - /** * Устанавливает новый шаг и синхронизирует его с query-параметрами URL. * @param step новый шаг редактирования @@ -54,6 +56,10 @@ export class ProjectStepService { "vacancies", "team", "additional", + "education", + "experience", + "skills", + "settings", ]; if (step && validSteps.includes(step as EditStep)) { diff --git a/projects/social_platform/src/app/api/project/project.service.spec.ts b/projects/social_platform/src/app/api/project/project.service.spec.ts new file mode 100644 index 000000000..ffab42776 --- /dev/null +++ b/projects/social_platform/src/app/api/project/project.service.spec.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; + +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { ProjectRepository } from "./project.service"; + +describe("ProjectRepository", () => { + let service: ProjectRepository; + + beforeEach(() => { + const authSpy = jasmine.createSpyObj([{ profile: of({}) }]); + + TestBed.configureTestingModule({ + providers: [{ provide: AuthRepository, useValue: authSpy }], + imports: [HttpClientTestingModule], + }); + service = TestBed.inject(ProjectRepository); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/api/project/projects.service.ts b/projects/social_platform/src/app/api/project/projects.service.ts new file mode 100644 index 000000000..e89431a89 --- /dev/null +++ b/projects/social_platform/src/app/api/project/projects.service.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Router } from "@angular/router"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { CreateProjectUseCase } from "./use-case/create-project.use-case"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectsService { + private readonly createProjectUseCase = inject(CreateProjectUseCase); + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + + addProject(): void { + this.createProjectUseCase.execute().subscribe({ + next: result => { + if (!result.ok) return; + + this.router + .navigate([`/office/projects/${result.value.id}/edit`], { + queryParams: { editingStep: "main" }, + }) + .then(() => this.logger.debug("Route change from ProjectsComponent")); + }, + }); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/add-project-news.use-case.ts b/projects/social_platform/src/app/api/project/use-case/add-project-news.use-case.ts new file mode 100644 index 000000000..f70eaa242 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/add-project-news.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { FeedNews } from "@domain/project/project-news.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class AddProjectNewsUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + + execute( + projectId: string, + news: { text: string; files: string[] } + ): Observable> { + return this.projectNewsRepositoryPort.addNews(projectId, news).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "add_project_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/add-project-subscription.use-case.ts b/projects/social_platform/src/app/api/project/use-case/add-project-subscription.use-case.ts new file mode 100644 index 000000000..50ada8fc6 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/add-project-subscription.use-case.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { projectSubscribed } from "@domain/project/events/project-subscribed.event"; + +@Injectable({ providedIn: "root" }) +export class AddProjectSubscriptionUseCase { + private readonly projectSubscriptionRepositoryPort = inject(ProjectSubscriptionRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + projectId: number + ): Observable> { + return this.projectSubscriptionRepositoryPort.addSubscription(projectId).pipe( + tap(() => this.eventBus.emit(projectSubscribed(projectId))), + map(() => ok(undefined)), + catchError(error => + of(fail({ kind: "add_project_subscription_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/create-goals.use-case.ts b/projects/social_platform/src/app/api/project/use-case/create-goals.use-case.ts new file mode 100644 index 000000000..35dde4577 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/create-goals.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { Goal } from "@domain/project/goals.model"; +import { ProjectGoalsRepositoryPort } from "@domain/project/ports/project-goals.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { GoalFormData } from "@infrastructure/adapters/project/dto/project-goal.dto"; + +@Injectable({ providedIn: "root" }) +export class CreateGoalsUseCase { + private readonly projectGoalsRepositoryPort = inject(ProjectGoalsRepositoryPort); + + execute( + projectId: number, + goals: GoalFormData[] + ): Observable> { + return this.projectGoalsRepositoryPort.createGoal(projectId, goals).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "create_project_goals_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/create-partner.use-case.ts b/projects/social_platform/src/app/api/project/use-case/create-partner.use-case.ts new file mode 100644 index 000000000..f14921df0 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/create-partner.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { Partner, PartnerDto } from "@domain/project/partner.model"; +import { ProjectPartnerRepositoryPort } from "@domain/project/ports/project-partner.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class CreatePartnerUseCase { + private readonly projectPartnerRepositoryPort = inject(ProjectPartnerRepositoryPort); + + execute( + projectId: number, + partner: PartnerDto + ): Observable> { + return this.projectPartnerRepositoryPort.createPartner(projectId, partner).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "create_project_partner_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/create-project.use-case.ts b/projects/social_platform/src/app/api/project/use-case/create-project.use-case.ts new file mode 100644 index 000000000..4a633a3c2 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/create-project.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { Project } from "@domain/project/project.model"; +import { EventBus } from "@domain/shared/event-bus"; +import { projectCreated } from "@domain/project/events/project-created.event"; + +@Injectable({ providedIn: "root" }) +export class CreateProjectUseCase { + private readonly projectRepositoryPort = inject(ProjectRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute(): Observable> { + return this.projectRepositoryPort.postOne().pipe( + tap(project => this.eventBus.emit(projectCreated(project))), + map(project => ok(project)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/create-resource.use-case.ts b/projects/social_platform/src/app/api/project/use-case/create-resource.use-case.ts new file mode 100644 index 000000000..24079758d --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/create-resource.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectResourceRepositoryPort } from "@domain/project/ports/project-resource.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { Resource, ResourceDto } from "@domain/project/resource.model"; + +@Injectable({ providedIn: "root" }) +export class CreateResourceUseCase { + private readonly projectResourceRepositoryPort = inject(ProjectResourceRepositoryPort); + + execute( + projectId: number, + params: Omit + ): Observable> { + return this.projectResourceRepositoryPort.createResource(projectId, params).pipe( + map(resource => ok(resource)), + catchError(error => + of(fail({ kind: "create_project_resource_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/delete-goal.use-case.ts b/projects/social_platform/src/app/api/project/use-case/delete-goal.use-case.ts new file mode 100644 index 000000000..ebaed849e --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/delete-goal.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectGoalsRepositoryPort } from "@domain/project/ports/project-goals.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class DeleteGoalUseCase { + private readonly projectGoalsRepositoryPort = inject(ProjectGoalsRepositoryPort); + + execute( + projectId: number, + goalId: number + ): Observable> { + return this.projectGoalsRepositoryPort.deleteGoal(projectId, goalId).pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "delete_project_goal_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/delete-partner.use-case.ts b/projects/social_platform/src/app/api/project/use-case/delete-partner.use-case.ts new file mode 100644 index 000000000..95dc9794b --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/delete-partner.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectPartnerRepositoryPort } from "@domain/project/ports/project-partner.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class DeletePartnerUseCase { + private readonly projectPartnerRepositoryPort = inject(ProjectPartnerRepositoryPort); + + execute( + projectId: number, + partnerId: number + ): Observable> { + return this.projectPartnerRepositoryPort.deletePartner(projectId, partnerId).pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "delete_project_partner_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/delete-project-news.use-case.ts b/projects/social_platform/src/app/api/project/use-case/delete-project-news.use-case.ts new file mode 100644 index 000000000..418722066 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/delete-project-news.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class DeleteProjectNewsUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + + execute( + projectId: string, + newsId: number + ): Observable> { + return this.projectNewsRepositoryPort.delete(projectId, newsId).pipe( + map(() => ok(newsId)), + catchError(error => of(fail({ kind: "delete_project_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/delete-project-subscription.use-case.ts b/projects/social_platform/src/app/api/project/use-case/delete-project-subscription.use-case.ts new file mode 100644 index 000000000..117a8e3d4 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/delete-project-subscription.use-case.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { projectUnSubscribed } from "@domain/project/events/project-unsubsribed.event"; +import { EventBus } from "@domain/shared/event-bus"; + +@Injectable({ providedIn: "root" }) +export class DeleteProjectSubscriptionUseCase { + private readonly projectSubscriptionRepositoryPort = inject(ProjectSubscriptionRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + projectId: number + ): Observable> { + return this.projectSubscriptionRepositoryPort.deleteSubscription(projectId).pipe( + tap(() => this.eventBus.emit(projectUnSubscribed(projectId))), + map(() => ok(undefined)), + catchError(error => + of(fail({ kind: "delete_project_subscription_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/delete-project.use-case.ts b/projects/social_platform/src/app/api/project/use-case/delete-project.use-case.ts new file mode 100644 index 000000000..38ddfcf8b --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/delete-project.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { projectDeleted } from "@domain/project/events/project-deleted.event"; + +@Injectable({ providedIn: "root" }) +export class DeleteProjectUseCase { + private readonly projectRepositoryPort = inject(ProjectRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute(id: number): Observable> { + return this.projectRepositoryPort.deleteOne(id).pipe( + tap(() => this.eventBus.emit(projectDeleted(id))), + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "unknown" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/delete-resource.use-case.ts b/projects/social_platform/src/app/api/project/use-case/delete-resource.use-case.ts new file mode 100644 index 000000000..81ce934ea --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/delete-resource.use-case.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectResourceRepositoryPort } from "@domain/project/ports/project-resource.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class DeleteResourceUseCase { + private readonly projectResourceRepositoryPort = inject(ProjectResourceRepositoryPort); + + execute(projectId: number, resourceId: number): Observable> { + return this.projectResourceRepositoryPort.deleteResource(projectId, resourceId).pipe( + map(() => ok(undefined)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/edit-project-news.use-case.ts b/projects/social_platform/src/app/api/project/use-case/edit-project-news.use-case.ts new file mode 100644 index 000000000..d5e5669e9 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/edit-project-news.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { FeedNews } from "@domain/project/project-news.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class EditProjectNewsUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + + execute( + projectId: string, + newsId: number, + news: Partial + ): Observable> { + return this.projectNewsRepositoryPort.editNews(projectId, newsId, news).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "edit_project_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/fetch-project-news.use-case.ts b/projects/social_platform/src/app/api/project/use-case/fetch-project-news.use-case.ts new file mode 100644 index 000000000..3f76a2358 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/fetch-project-news.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { FeedNews } from "@domain/project/project-news.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class FetchProjectNewsUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + + execute( + projectId: string + ): Observable< + Result, { kind: "fetch_project_news_error"; cause?: unknown }> + > { + return this.projectNewsRepositoryPort.fetchNews(projectId).pipe( + map(news => ok>(news)), + catchError(error => of(fail({ kind: "fetch_project_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-all-projects.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-all-projects.use-case.ts new file mode 100644 index 000000000..e5863cada --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-all-projects.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { HttpParams } from "@angular/common/http"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { Project } from "@domain/project/project.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; + +@Injectable({ providedIn: "root" }) +export class GetAllProjectsUseCase { + private readonly projectRepositoryPort = inject(ProjectRepositoryPort); + + execute(params?: HttpParams): Observable, { kind: "unknown" }>> { + return this.projectRepositoryPort.getAll(params).pipe( + map(projects => ok>(projects)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-my-projects.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-my-projects.use-case.ts new file mode 100644 index 000000000..a69674917 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-my-projects.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { HttpParams } from "@angular/common/http"; +import { catchError, map, Observable, of } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetMyProjectsUseCase { + private readonly projectRepositoryPort = inject(ProjectRepositoryPort); + + execute(params?: HttpParams): Observable, { kind: "unknown" }>> { + return this.projectRepositoryPort.getMy(params).pipe( + map(projects => ok>(projects)), + catchError(() => of(fail({ kind: "unknown" as const }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-project-goals.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-project-goals.use-case.ts new file mode 100644 index 000000000..979b5e4ae --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-project-goals.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { Goal } from "@domain/project/goals.model"; +import { ProjectGoalsRepositoryPort } from "@domain/project/ports/project-goals.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectGoalsUseCase { + private readonly projectGoalsRepositoryPort = inject(ProjectGoalsRepositoryPort); + + execute( + projectId: number + ): Observable> { + return this.projectGoalsRepositoryPort.fetchAll(projectId).pipe( + map(goals => ok(goals)), + catchError(error => of(fail({ kind: "get_project_goals_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-project-news-detail.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-project-news-detail.use-case.ts new file mode 100644 index 000000000..69753aa6e --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-project-news-detail.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { FeedNews } from "@domain/project/project-news.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectNewsDetailUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + + execute( + projectId: string, + newsId: string + ): Observable> { + return this.projectNewsRepositoryPort.fetchNewsDetail(projectId, newsId).pipe( + map(news => ok(news)), + catchError(error => + of(fail({ kind: "get_project_news_detail_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-project-partners.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-project-partners.use-case.ts new file mode 100644 index 000000000..2e3f65fc2 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-project-partners.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { Partner } from "@domain/project/partner.model"; +import { ProjectPartnerRepositoryPort } from "@domain/project/ports/project-partner.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectPartnersUseCase { + private readonly projectPartnerRepositoryPort = inject(ProjectPartnerRepositoryPort); + + execute( + projectId: number + ): Observable> { + return this.projectPartnerRepositoryPort.fetchAll(projectId).pipe( + map(partners => ok(partners)), + catchError(error => of(fail({ kind: "get_project_partners_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-project-resources.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-project-resources.use-case.ts new file mode 100644 index 000000000..01c5de6ac --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-project-resources.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectResourceRepositoryPort } from "@domain/project/ports/project-resource.repository.port"; +import { Resource } from "@domain/project/resource.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectResourcesUseCase { + private readonly projectResourceRepositoryPort = inject(ProjectResourceRepositoryPort); + + execute( + projectId: number + ): Observable> { + return this.projectResourceRepositoryPort.fetchAll(projectId).pipe( + map(resources => ok(resources)), + catchError(error => of(fail({ kind: "get_project_resources_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-project-subscribers.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-project-subscribers.use-case.ts new file mode 100644 index 000000000..dde5511a1 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-project-subscribers.use-case.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { ProjectSubscriber } from "@domain/project/project-subscriber.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectSubscribersUseCase { + private readonly projectSubscriptionRepositoryPort = inject(ProjectSubscriptionRepositoryPort); + + execute( + projectId: number + ): Observable< + Result + > { + return this.projectSubscriptionRepositoryPort.getSubscribers(projectId).pipe( + map(subscribers => ok(subscribers)), + catchError(error => + of(fail({ kind: "get_project_subscribers_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-project-subscriptions.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-project-subscriptions.use-case.ts new file mode 100644 index 000000000..98f7d3235 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-project-subscriptions.use-case.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { Project } from "@domain/project/project.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectSubscriptionsUseCase { + private readonly projectSubscriptionRepositoryPort = inject(ProjectSubscriptionRepositoryPort); + + execute( + userId: number, + params?: HttpParams + ): Observable< + Result, { kind: "get_project_subscriptions_error"; cause?: unknown }> + > { + return this.projectSubscriptionRepositoryPort.getSubscriptions(userId, params).pipe( + map(subscriptions => ok>(subscriptions)), + catchError(error => + of(fail({ kind: "get_project_subscriptions_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/get-project.use-case.ts b/projects/social_platform/src/app/api/project/use-case/get-project.use-case.ts new file mode 100644 index 000000000..c2e29296d --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/get-project.use-case.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectUseCase { + private readonly projectRepositoryPort = inject(ProjectRepositoryPort); + + execute(id: number): Observable> { + return this.projectRepositoryPort.getOne(id).pipe( + map(project => ok(project)), + catchError(error => of(fail({ kind: "get_project_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/leave-project.use-case.ts b/projects/social_platform/src/app/api/project/use-case/leave-project.use-case.ts new file mode 100644 index 000000000..57012cf16 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/leave-project.use-case.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectCollaboratorsRepositoryPort } from "@domain/project/ports/project-collaborators.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class LeaveProjectUseCase { + private readonly projectCollaboratorsRepositoryPort = inject(ProjectCollaboratorsRepositoryPort); + + execute( + projectId: number + ): Observable> { + return this.projectCollaboratorsRepositoryPort.deleteLeave(projectId).pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "leave_project_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/read-project-news.use-case.ts b/projects/social_platform/src/app/api/project/use-case/read-project-news.use-case.ts new file mode 100644 index 000000000..5432a9c40 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/read-project-news.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ReadProjectNewsUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + + execute( + projectId: number, + newsIds: number[] + ): Observable> { + return this.projectNewsRepositoryPort.readNews(projectId, newsIds).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "read_project_news_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/remove-project-collaborator.use-case.ts b/projects/social_platform/src/app/api/project/use-case/remove-project-collaborator.use-case.ts new file mode 100644 index 000000000..3d985df34 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/remove-project-collaborator.use-case.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { ProjectCollaboratorsRepositoryPort } from "@domain/project/ports/project-collaborators.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { removeProjectCollaborator } from "@domain/project/events/remove-project-collaborator.event"; +import { EventBus } from "@domain/shared/event-bus"; + +@Injectable({ providedIn: "root" }) +export class RemoveProjectCollaboratorUseCase { + private readonly projectCollaboratorsRepositoryPort = inject(ProjectCollaboratorsRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + projectId: number, + userId: number + ): Observable> { + return this.projectCollaboratorsRepositoryPort.deleteCollaborator(projectId, userId).pipe( + tap(() => this.eventBus.emit(removeProjectCollaborator(projectId, userId))), + map(() => ok(userId)), + catchError(error => + of(fail({ kind: "remove_project_collaborator_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/send-project-additional-fields.use-case.ts b/projects/social_platform/src/app/api/project/use-case/send-project-additional-fields.use-case.ts new file mode 100644 index 000000000..0daf5554a --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/send-project-additional-fields.use-case.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; +import { Project } from "@domain/project/project.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class SendProjectAdditionalFieldsUseCase { + private readonly projectProgramRepositoryPort = inject(ProjectProgramRepositoryPort); + + execute( + projectId: number, + newValues: ProjectNewAdditionalProgramFields[] + ): Observable< + Result + > { + return this.projectProgramRepositoryPort.sendNewProjectFieldsValues(projectId, newValues).pipe( + map(project => ok(project)), + catchError(error => + of(fail({ kind: "send_project_additional_fields_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/submit-competitive-project.use-case.ts b/projects/social_platform/src/app/api/project/use-case/submit-competitive-project.use-case.ts new file mode 100644 index 000000000..271f12f4f --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/submit-competitive-project.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { Project } from "@domain/project/project.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class SubmitCompetitiveProjectUseCase { + private readonly programRepositoryPort = inject(ProgramRepositoryPort); + + execute( + relationId: number + ): Observable> { + return this.programRepositoryPort.submitCompettetiveProject(relationId).pipe( + map(project => ok(project)), + catchError(error => + of(fail({ kind: "submit_competitive_project_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/toggle-project-news-like.use-case.ts b/projects/social_platform/src/app/api/project/use-case/toggle-project-news-like.use-case.ts new file mode 100644 index 000000000..4f71696d5 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/toggle-project-news-like.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class ToggleProjectNewsLikeUseCase { + private readonly projectNewsRepositoryPort = inject(ProjectNewsRepositoryPort); + + execute( + projectId: string, + newsId: number, + state: boolean + ): Observable> { + return this.projectNewsRepositoryPort.toggleLike(projectId, newsId, state).pipe( + map(() => ok(newsId)), + catchError(error => + of(fail({ kind: "toggle_project_news_like_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/transfer-project-ownership.use-case.ts b/projects/social_platform/src/app/api/project/use-case/transfer-project-ownership.use-case.ts new file mode 100644 index 000000000..4a7dea420 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/transfer-project-ownership.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProjectCollaboratorsRepositoryPort } from "@domain/project/ports/project-collaborators.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class TransferProjectOwnershipUseCase { + private readonly projectCollaboratorsRepositoryPort = inject(ProjectCollaboratorsRepositoryPort); + + execute( + projectId: number, + userId: number + ): Observable> { + return this.projectCollaboratorsRepositoryPort.patchSwitchLeader(projectId, userId).pipe( + map(() => ok(userId)), + catchError(error => + of(fail({ kind: "transfer_project_ownership_error" as const, cause: error })) + ) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/update-form.use-case.ts b/projects/social_platform/src/app/api/project/use-case/update-form.use-case.ts new file mode 100644 index 000000000..239ff83b7 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/update-form.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { Project } from "@domain/project/project.model"; +import { UpdateFormCommand } from "@domain/project/commands/update-form.command"; + +@Injectable({ providedIn: "root" }) +export class UpdateFormUseCase { + private readonly projectRepositoryPort = inject(ProjectRepositoryPort); + + execute({ + id, + data, + }: UpdateFormCommand): Observable> { + return this.projectRepositoryPort.update(id, data).pipe( + map(project => ok(project)), + catchError(error => of(fail({ kind: "unknown" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/update-goal.use-case.ts b/projects/social_platform/src/app/api/project/use-case/update-goal.use-case.ts new file mode 100644 index 000000000..13488359c --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/update-goal.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { Goal } from "@domain/project/goals.model"; +import { ProjectGoalsRepositoryPort } from "@domain/project/ports/project-goals.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { GoalFormData } from "@infrastructure/adapters/project/dto/project-goal.dto"; + +@Injectable({ providedIn: "root" }) +export class UpdateGoalUseCase { + private readonly projectGoalsRepositoryPort = inject(ProjectGoalsRepositoryPort); + + execute( + projectId: number, + goalId: number, + goal: GoalFormData + ): Observable> { + return this.projectGoalsRepositoryPort.editGoal(projectId, goalId, goal).pipe( + map(result => ok(result)), + catchError(error => of(fail({ kind: "update_project_goal_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/project/use-case/update-resource.use-case.ts b/projects/social_platform/src/app/api/project/use-case/update-resource.use-case.ts new file mode 100644 index 000000000..d6c2f946f --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-case/update-resource.use-case.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { Resource, ResourceDto } from "@domain/project/resource.model"; +import { ProjectResourceRepositoryPort } from "@domain/project/ports/project-resource.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class UpdateResourceUseCase { + private readonly projectResourceRepositoryPort = inject(ProjectResourceRepositoryPort); + + execute( + projectId: number, + resourceId: number, + params: Omit + ): Observable> { + return this.projectResourceRepositoryPort.updateResource(projectId, resourceId, params).pipe( + map(resource => ok(resource)), + catchError(error => + of(fail({ kind: "update_project_resource_error" as const, cause: error })) + ) + ); + } +} 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..f6c1b8bf0 --- /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 { SpecializationsRepositoryPort as SpecializationsService } from "@domain/specializations/ports/specializations.repository.port"; + +@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..12aa8f84a --- /dev/null +++ b/projects/social_platform/src/app/api/skills/facades/skills-info.service.ts @@ -0,0 +1,72 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { Observable, Subject } from "rxjs"; +import { Skill } from "@domain/skills/skill"; +import { FormGroup } from "@angular/forms"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { SkillsGroup } from "@domain/skills/skills-group"; + +@Injectable({ providedIn: "root" }) +export class SkillsInfoService { + private readonly skillsRepository = inject(SkillsRepositoryPort); + + private readonly destroy$ = new Subject(); + + readonly inlineSkills = signal([]); + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + getSkillsNested(): Observable { + return this.skillsRepository.getSkillsNested(); + } + + /** + * Переключение навыка (добавление/удаление) + * @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.skillsRepository.getSkillsInline(query, 1000, 0).subscribe(({ results }) => { + this.inlineSkills.set(results); + }); + } +} diff --git a/projects/social_platform/src/app/api/skills/use-cases/get-skills-nested.use-case.ts b/projects/social_platform/src/app/api/skills/use-cases/get-skills-nested.use-case.ts new file mode 100644 index 000000000..8034c7f7a --- /dev/null +++ b/projects/social_platform/src/app/api/skills/use-cases/get-skills-nested.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { SkillsGroup } from "@domain/skills/skills-group"; + +export type GetSkillsNestedError = { kind: "server_error" }; + +@Injectable({ providedIn: "root" }) +export class GetSkillsNestedUseCase { + private readonly skillsRepository = inject(SkillsRepositoryPort); + + execute(): Observable> { + return this.skillsRepository.getSkillsNested().pipe( + map(groups => ok(groups)), + catchError(() => of(fail({ kind: "server_error" }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/skills/use-cases/search-skills.use-case.ts b/projects/social_platform/src/app/api/skills/use-cases/search-skills.use-case.ts new file mode 100644 index 000000000..36c4f2d32 --- /dev/null +++ b/projects/social_platform/src/app/api/skills/use-cases/search-skills.use-case.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { Skill } from "@domain/skills/skill"; +import { ApiPagination } from "@domain/other/api-pagination.model"; + +export type SearchSkillsError = { kind: "server_error" }; + +@Injectable({ providedIn: "root" }) +export class SearchSkillsUseCase { + private readonly skillsRepository = inject(SkillsRepositoryPort); + + execute( + search: string, + limit: number, + offset: number + ): Observable, SearchSkillsError>> { + return this.skillsRepository.getSkillsInline(search, limit, offset).pipe( + map(page => ok>(page)), + catchError(() => of(fail({ kind: "server_error" }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/specializations/facades/specializations-info.service.ts b/projects/social_platform/src/app/api/specializations/facades/specializations-info.service.ts new file mode 100644 index 000000000..5751f7860 --- /dev/null +++ b/projects/social_platform/src/app/api/specializations/facades/specializations-info.service.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Specialization } from "@domain/specializations/specialization"; +import { SpecializationsGroup } from "@domain/specializations/specializations-group"; +import { SpecializationsRepositoryPort } from "@domain/specializations/ports/specializations.repository.port"; + +@Injectable({ providedIn: "root" }) +export class SpecializationsInfoService { + private readonly specializationsRepository = inject(SpecializationsRepositoryPort); + + getSpecializationsNested(): Observable { + return this.specializationsRepository.getSpecializationsNested(); + } + + getSpecializationsInline( + search: string, + limit: number, + offset: number + ): Observable> { + return this.specializationsRepository.getSpecializationsInline(search, limit, offset); + } +} 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/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/toggle-fields/toggle-fields-info.service.ts b/projects/social_platform/src/app/api/toggle-fields/toggle-fields-info.service.ts new file mode 100644 index 000000000..da43b0e08 --- /dev/null +++ b/projects/social_platform/src/app/api/toggle-fields/toggle-fields-info.service.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; + +@Injectable() +export class ToggleFieldsInfoService { + readonly showInputFields = signal(false); + + /** + * Показывает поля для ввода достижения + */ + showFields(): void { + this.showInputFields.set(true); + } + + /** + * Скрывает поля ввода и очищает их + */ + hideFields(): void { + this.showInputFields.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..c3fd4fad5 --- /dev/null +++ b/projects/social_platform/src/app/api/tooltip/tooltip-info.service.ts @@ -0,0 +1,155 @@ +/** @format */ + +import { Injectable, signal } from "@angular/core"; + +@Injectable() +export class TooltipInfoService { + readonly isTooltipVisible = signal(false); + /** Позиция подсказки */ + readonly tooltipPosition: "left" | "right" = "right"; + + readonly haveHint = 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); + readonly isHintLoginVisible = signal(false); + readonly isHintTeamVisible = signal(false); + readonly isHintExpertsVisible = signal(false); + + /** Показать подсказку */ + showTooltip( + type: + | "base" + | "photo" + | "city" + | "education" + | "educationDescription" + | "work" + | "workName" + | "workDescription" + | "achievements" + | "language" + | "auth" + | "lib" + | "login" = "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" + | "login" + | "team" + | "experts" = "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; + case "team": + this.isHintTeamVisible.set(false); + break; + case "experts": + this.isHintExpertsVisible.set(false); + break; + + default: + this.isTooltipVisible.set(false); + break; + } + } + + toggleTooltip(): void { + this.isHintLoginVisible.set(!this.isHintLoginVisible()); + } +} 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..817ce2567 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/facades/ui/vacancy-detail-ui-info.service.ts @@ -0,0 +1,53 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Params } from "@angular/router"; +import { AsyncState, failure, initial, isLoading, success } from "@domain/shared/async-state"; +import { Vacancy } from "@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>(initial()); + readonly sendFormIsSubmittingFlag = computed(() => isLoading(this.sendFormIsSubmitting$())); + + // Создание формы отклика с валидацией + 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.applyResponseModalOpen(); + } + } + + applyResponseModalOpen(): void { + this.openModal.set(true); + } + + applySubmitVacancyResponse(): void { + this.sendFormIsSubmitting$.set(success(undefined)); + this.resultModal.set(true); + this.applyNoResponseCloseModal(); + } + + applyErrorFormSubmit(): void { + this.sendFormIsSubmitting$.set(failure("vacancy_form_error")); + } + + 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..65f0c0eb8 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/facades/ui/vacancy-ui-info.service.ts @@ -0,0 +1,99 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { AsyncState, initial, isLoading, isSuccess } from "@domain/shared/async-state"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { Vacancy } from "@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 vacancies$ = signal>(initial()); + readonly loadingMore = signal(false); + + readonly vacancyList = computed(() => { + const state = this.vacancies$(); + if (isSuccess(state)) return state.data; + if (isLoading(state)) return state.previous ?? []; + return []; + }); + + readonly responsesList = signal([]); + + readonly searchForm = this.fb.group({ + search: [""], + }); + + applyQueryParams(result: Vacancy[]): void { + this.applySetTotalItems(result); + this.vacancyPage.set(1); + } + + applySetTotalItems(vacancy: Vacancy[] | VacancyResponse[]): void { + if (!Array.isArray(vacancy)) { + this.totalItemsCount.set(0); + return; + } + + this.totalItemsCount.set(vacancy.length); + } + + 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..dad77e79e --- /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 { VacancyDetailUIInfoService } from "./ui/vacancy-detail-ui-info.service"; +import { ExpandService } from "../../expand/expand.service"; +import { SendVacancyResponseUseCase } from "../use-cases/send-vacancy-response.use-case"; +import { loading } from "@domain/shared/async-state"; + +@Injectable() +export class VacancyDetailInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly sendVacancyResponseUseCase = inject(SendVacancyResponseUseCase); + 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(loading()); + + this.sendVacancyResponseUseCase + .execute(Number(this.route.snapshot.paramMap.get("vacancyId")), this.sendForm.value as any) + .subscribe({ + next: result => { + if (!result.ok) { + this.vacancyDetailUIInfoService.applyErrorFormSubmit(); + return; + } + + this.vacancyDetailUIInfoService.applySubmitVacancyResponse(); + }, + }); + } + + 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..ede7c917b --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/facades/vacancy-info.service.ts @@ -0,0 +1,260 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; +import { + concatMap, + debounceTime, + distinctUntilChanged, + EMPTY, + filter, + fromEvent, + map, + Observable, + Subject, + switchMap, + takeUntil, + tap, + throttleTime, +} from "rxjs"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { VacancyUIInfoService } from "./ui/vacancy-ui-info.service"; +import { GetVacanciesUseCase } from "../use-cases/get-vacancies.use-case"; +import { failure, isSuccess, loading, success } from "@domain/shared/async-state"; + +@Injectable() +export class VacancyInfoService { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly getVacanciesUseCase = inject(GetVacanciesUseCase); + private readonly vacancyUIInfoService = inject(VacancyUIInfoService); + + private readonly destroy$ = new Subject(); + + readonly listType = this.vacancyUIInfoService.listType; + + // Переменные для работы с фильтрами + + 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.setupRouteListener(); + this.initializationSearchValueForm(); + } + + destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // ListType Section + // ---------------- + + updateListType(): void { + const segment = this.router.url.split("/").pop()?.split("?")[0]; + const newListType = segment as "all" | "my"; + + if (this.listType() !== newListType) { + this.listType.set(newListType); + + // Загружаем данные для нового типа + this.loadDataForCurrentType(); + } + } + + private setupRouteListener(): void { + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.updateListType(); + }); + + this.updateListType(); + } + + private loadDataForCurrentType(): void { + this.initializeListData(); + + if (this.listType() === "all") { + this.initializeQueryParams(); + } + + if (this.listType() === "my") { + this.vacancyUIInfoService.resetFilters(); + this.clearQueryParams(); + } + + this.vacancyUIInfoService.myModalSetup(); + } + + // 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({ + next: result => this.vacancyUIInfoService.vacancies$.set(success(result)), + error: () => this.vacancyUIInfoService.vacancies$.set(failure("fetch_error")), + }); + } + + // QueryParams Section + // ------------------- + + initializeQueryParams(): void { + let isFirstEmit = true; + + 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(params => { + // Пропускаем первый emit без фильтров — данные уже загружены resolver'ом + if (isFirstEmit) { + isFirstEmit = false; + const hasFilters = Object.values(params).some(v => v != null); + if (!hasFilters) return EMPTY; + } + + const prev = this.vacancyUIInfoService.vacancyList(); + this.vacancyUIInfoService.vacancies$.set(loading(prev)); + return this.onFetch(0, 20); + }) + ) + .subscribe({ + next: result => this.vacancyUIInfoService.vacancies$.set(success(result)), + error: () => this.vacancyUIInfoService.vacancies$.set(failure("fetch_error")), + }); + } + + // onScroll Section + // ------------------- + + onScroll(target: HTMLElement): Observable { + if ( + this.vacancyUIInfoService.totalItemsCount() && + this.vacancyUIInfoService.vacancyList().length >= this.vacancyUIInfoService.totalItemsCount() + ) + return EMPTY; + + if (!target) return EMPTY; + + const diff = target.scrollTop - target.scrollHeight + target.clientHeight; + + if (diff > 0) { + this.vacancyUIInfoService.loadingMore.set(true); + return this.onFetch( + this.vacancyUIInfoService.vacancyPage() * this.vacancyUIInfoService.perFetchTake(), + this.vacancyUIInfoService.perFetchTake() + ).pipe( + tap((result: Vacancy[]) => { + this.vacancyUIInfoService.vacancies$.update(state => + isSuccess(state) ? success([...state.data, ...result]) : success(result) + ); + this.vacancyUIInfoService.loadingMore.set(false); + }) + ); + } + + 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.getVacanciesUseCase + .execute({ + limit, + offset, + requiredExperience: this.requiredExperience(), + workFormat: this.workFormat(), + workSchedule: this.workSchedule(), + salary: this.salary(), + searchValue: this.roleContains(), + }) + .pipe(map(result => (result.ok ? result.value : []))); + } + + // 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/api/vacancy/use-cases/accept-response.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/accept-response.use-case.ts new file mode 100644 index 000000000..28c2bb16e --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/accept-response.use-case.ts @@ -0,0 +1,37 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { catchError, map, Observable, of, switchMap, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { acceptVacancyResponse } from "@domain/vacancy/events/accept-vacancy-response.event"; + +@Injectable({ providedIn: "root" }) +export class AcceptResponseUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + responseId: number + ): Observable> { + return this.vacancyRepositoryPort.acceptResponse(responseId).pipe( + switchMap(response => + this.vacancyRepositoryPort.getOne(response.vacancy).pipe( + tap(vacancy => + this.eventBus.emit( + acceptVacancyResponse( + response.id, + response.vacancy, + vacancy.project.id, + response.user.id + ) + ) + ), + map(() => ok(undefined)) + ) + ), + catchError(error => of(fail({ kind: "accept_response_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/delete-vacancy.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/delete-vacancy.use-case.ts new file mode 100644 index 000000000..1ead9bb60 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/delete-vacancy.use-case.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { vacancyDelete } from "@domain/vacancy/events/vacancy-deleted.event"; + +@Injectable({ providedIn: "root" }) +export class DeleteVacancyUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + vacancyId: number + ): Observable> { + return this.vacancyRepositoryPort.deleteVacancy(vacancyId).pipe( + tap(() => this.eventBus.emit(vacancyDelete(vacancyId))), + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "delete_vacancy_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/get-my-vacancies.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/get-my-vacancies.use-case.ts new file mode 100644 index 000000000..087ad3827 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/get-my-vacancies.use-case.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetMyVacanciesUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + + execute( + limit: number, + offset: number + ): Observable> { + return this.vacancyRepositoryPort.getMyVacancies(limit, offset).pipe( + map(responses => ok(responses)), + catchError(error => of(fail({ kind: "get_my_vacancies_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/get-project-responses.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/get-project-responses.use-case.ts new file mode 100644 index 000000000..cc325e251 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/get-project-responses.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetProjectResponsesUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + + execute( + projectId: number + ): Observable< + Result + > { + return this.vacancyRepositoryPort.responsesByProject(projectId).pipe( + map(responses => ok(responses)), + catchError(error => of(fail({ kind: "get_project_responses_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/get-vacancies.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/get-vacancies.use-case.ts new file mode 100644 index 000000000..09677f5ee --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/get-vacancies.use-case.ts @@ -0,0 +1,43 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +export interface GetVacanciesParams { + limit: number; + offset: number; + projectId?: number; + requiredExperience?: string; + workFormat?: string; + workSchedule?: string; + salary?: string; + searchValue?: string; +} + +@Injectable({ providedIn: "root" }) +export class GetVacanciesUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + + execute( + params: GetVacanciesParams + ): Observable> { + return this.vacancyRepositoryPort + .getForProject( + params.limit, + params.offset, + params.projectId, + params.requiredExperience, + params.workFormat, + params.workSchedule, + params.salary, + params.searchValue + ) + .pipe( + map(vacancies => ok(vacancies)), + catchError(error => of(fail({ kind: "get_vacancies_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/get-vacancy-detail.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/get-vacancy-detail.use-case.ts new file mode 100644 index 000000000..97946b6b9 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/get-vacancy-detail.use-case.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { catchError, map, Observable, of } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +@Injectable({ providedIn: "root" }) +export class GetVacancyDetailUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + + execute( + vacancyId: number + ): Observable> { + return this.vacancyRepositoryPort.getOne(vacancyId).pipe( + map(vacancy => ok(vacancy)), + catchError(error => of(fail({ kind: "get_vacancy_detail_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/post-vacancy.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/post-vacancy.use-case.ts new file mode 100644 index 000000000..8c185e4dc --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/post-vacancy.use-case.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { CreateVacancyDto } from "../../project/dto/create-vacancy.model"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { vacancyCreated } from "@domain/vacancy/events/vacancy-created.event"; + +@Injectable({ providedIn: "root" }) +export class PostVacancyUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + projectId: number, + vacancy: CreateVacancyDto + ): Observable> { + return this.vacancyRepositoryPort.postVacancy(projectId, vacancy).pipe( + tap(() => this.eventBus.emit(vacancyCreated(projectId, vacancy))), + map(createdVacancy => ok(createdVacancy)), + catchError(error => of(fail({ kind: "post_vacancy_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/reject-response.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/reject-response.use-case.ts new file mode 100644 index 000000000..4c541411a --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/reject-response.use-case.ts @@ -0,0 +1,37 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { catchError, map, Observable, of, switchMap, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { rejectVacancyResponse } from "@domain/vacancy/events/reject-vacancy-response.event"; + +@Injectable({ providedIn: "root" }) +export class RejectResponseUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + responseId: number + ): Observable> { + return this.vacancyRepositoryPort.rejectResponse(responseId).pipe( + switchMap(response => + this.vacancyRepositoryPort.getOne(response.vacancy).pipe( + tap(vacancy => + this.eventBus.emit( + rejectVacancyResponse( + response.id, + response.vacancy, + vacancy.project.id, + response.user.id + ) + ) + ), + map(() => ok(undefined)) + ) + ), + catchError(error => of(fail({ kind: "reject_response_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/send-vacancy-response.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/send-vacancy-response.use-case.ts new file mode 100644 index 000000000..442283ab7 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/send-vacancy-response.use-case.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { catchError, map, Observable, of, switchMap, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { sendVacancyResponse } from "@domain/vacancy/events/send-vacancy-response.event"; + +@Injectable({ providedIn: "root" }) +export class SendVacancyResponseUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + vacancyId: number, + body: { whyMe: string } + ): Observable> { + return this.vacancyRepositoryPort.sendResponse(vacancyId, body).pipe( + switchMap(response => + this.vacancyRepositoryPort.getOne(vacancyId).pipe( + tap(vacancy => + this.eventBus.emit( + sendVacancyResponse( + response.id, + vacancyId, + vacancy.project.id, + response.user.id, + response.isApproved ?? false + ) + ) + ), + map(() => ok(undefined)) + ) + ), + catchError(error => of(fail({ kind: "send_vacancy_response_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/api/vacancy/use-cases/update-vacancy.use-case.ts b/projects/social_platform/src/app/api/vacancy/use-cases/update-vacancy.use-case.ts new file mode 100644 index 000000000..9f40fc503 --- /dev/null +++ b/projects/social_platform/src/app/api/vacancy/use-cases/update-vacancy.use-case.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { CreateVacancyDto } from "../../project/dto/create-vacancy.model"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { catchError, map, Observable, of, tap } from "rxjs"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { EventBus } from "@domain/shared/event-bus"; +import { vacancyUpdated } from "@domain/vacancy/events/vacancy-updated.event"; + +@Injectable({ providedIn: "root" }) +export class UpdateVacancyUseCase { + private readonly vacancyRepositoryPort = inject(VacancyRepositoryPort); + private readonly eventBus = inject(EventBus); + + execute( + vacancyId: number, + vacancy: Partial | CreateVacancyDto + ): Observable> { + return this.vacancyRepositoryPort.updateVacancy(vacancyId, vacancy).pipe( + tap(() => this.eventBus.emit(vacancyUpdated(vacancyId, vacancy))), + map(updatedVacancy => ok(updatedVacancy)), + catchError(error => of(fail({ kind: "update_vacancy_error" as const, cause: error }))) + ); + } +} diff --git a/projects/social_platform/src/app/app.component.spec.ts b/projects/social_platform/src/app/app.component.spec.ts index 51502f6f2..16699d5d9 100644 --- a/projects/social_platform/src/app/app.component.spec.ts +++ b/projects/social_platform/src/app/app.component.spec.ts @@ -3,15 +3,15 @@ import { TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { AppComponent } from "./app.component"; -import { AuthService } from "@auth/services"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; describe("AppComponent", () => { beforeEach(async () => { - const authSpy = jasmine.createSpyObj("AuthService", ["getTokens"]); + const authSpy = jasmine.createSpyObj("AuthRepository", ["getTokens"]); await TestBed.configureTestingModule({ imports: [RouterTestingModule, AppComponent], - providers: [{ provide: AuthService, useValue: authSpy }], + providers: [{ provide: AuthRepository, useValue: authSpy }], }).compileComponents(); }); diff --git a/projects/social_platform/src/app/app.component.ts b/projects/social_platform/src/app/app.component.ts index 861f9e768..c594fa69f 100644 --- a/projects/social_platform/src/app/app.component.ts +++ b/projects/social_platform/src/app/app.component.ts @@ -1,8 +1,7 @@ /** @format */ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, inject, OnInit, DestroyRef } from "@angular/core"; import { ResolveEnd, ResolveStart, Router, RouterOutlet } from "@angular/router"; -import { AuthService } from "@auth/services"; import { debounceTime, filter, @@ -12,13 +11,15 @@ import { merge, noop, type Observable, - type Subscription, throttleTime, } from "rxjs"; 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 { LoggerService } from "@core/lib/services/logger/logger.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; /** * Корневой компонент приложения @@ -32,20 +33,23 @@ import { LoadingService } from "@office/services/loading.service"; styleUrls: ["./app.component.scss"], standalone: true, imports: [NgIf, MatProgressBarModule, RouterOutlet, AsyncPipe], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent implements OnInit, OnDestroy { +export class AppComponent implements OnInit { + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + constructor( - private authService: AuthService, + private authRepository: AuthRepositoryPort, private tokenService: TokenService, private router: Router, private loadingService: LoadingService ) {} ngOnInit(): void { - this.rolesSub$ = forkJoin([ - this.authService.getUserRoles(), - this.authService.getChangeableRoles(), - ]).subscribe(noop); + forkJoin([this.authRepository.fetchUserRoles(), this.authRepository.fetchChangeableRoles()]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(noop); const showLoaderEvents = this.router.events.pipe( filter(evt => evt instanceof ResolveStart), @@ -58,13 +62,15 @@ export class AppComponent implements OnInit, OnDestroy { map(() => false) ); - this.routerLoadingSub$ = merge(hideLoaderEvents, showLoaderEvents).subscribe(isLoading => { - if (isLoading) { - this.loadingService.show(); - } else { - this.loadingService.hide(); - } - }); + merge(hideLoaderEvents, showLoaderEvents) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(isLoading => { + if (isLoading) { + this.loadingService.show(); + } else { + this.loadingService.hide(); + } + }); this.isLoading$ = this.loadingService.isLoading$; @@ -72,36 +78,24 @@ export class AppComponent implements OnInit, OnDestroy { if (this.tokenService.getTokens() === null) { this.router .navigateByUrl("/auth/login") - .then(() => console.debug("Route changed from AppComponent")); + .then(() => this.logger.debug("Route changed from AppComponent")); } else { - console.debug("Route start changing from AppComponent"); + this.logger.debug("Route start changing from AppComponent"); this.router .navigateByUrl("/office") - .then(() => console.debug("Route changed From AppComponent")); + .then(() => this.logger.debug("Route changed From AppComponent")); } } - this.loadEvent = fromEvent(window, "load"); - this.resizeEvent = fromEvent(window, "resize").pipe(throttleTime(500)); - - this.appHeight$ = merge(this.loadEvent, this.resizeEvent).subscribe(() => { - document.documentElement.style.setProperty("--app-height", `${window.innerHeight}px`); - }); - } + const loadEvent = fromEvent(window, "load"); + const resizeEvent = fromEvent(window, "resize").pipe(throttleTime(500)); - ngOnDestroy(): void { - this.rolesSub$?.unsubscribe(); - this.routerLoadingSub$?.unsubscribe(); - this.appHeight$?.unsubscribe(); + merge(loadEvent, resizeEvent) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + document.documentElement.style.setProperty("--app-height", `${window.innerHeight}px`); + }); } - rolesSub$?: Subscription; - routerLoadingSub$?: Subscription; - - private loadEvent?: Observable; - private resizeEvent?: Observable; - - private appHeight$?: Subscription; - isLoading$?: Observable; } diff --git a/projects/social_platform/src/app/app.config.ts b/projects/social_platform/src/app/app.config.ts index 9679cff73..83084a42e 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 "@core/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"; @@ -14,12 +14,34 @@ import { API_URL, BearerTokenInterceptor, CamelcaseInterceptor, + LoggingInterceptor, PRODUCTION, SKILLS_API_URL, } from "@corelib"; import { environment } from "@environment"; import { registerLocaleData } from "@angular/common"; import localeRu from "@angular/common/locales/ru"; +import { AUTH_PROVIDERS } from "./infrastructure/di/auth.providers"; +import { FEED_PROVIDERS } from "./infrastructure/di/feed.providers"; +import { INDUSTRY_PROVIDERS } from "./infrastructure/di/industry.providers"; +import { INVITE_PROVIDERS } from "./infrastructure/di/invite.providers"; +import { MEMBER_PROVIDERS } from "./infrastructure/di/member.providers"; +import { PROFILE_NEWS_PROVIDERS } from "./infrastructure/di/profile-news.providers"; +import { PROGRAM_PROVIDERS } from "./infrastructure/di/program/program.providers"; +import { PROGRAM_NEWS_PROVIDERS } from "./infrastructure/di/program/program-news.providers"; +import { PROJECT_PROVIDERS } from "./infrastructure/di/project/project.providers"; +import { PROJECT_GOALS_PROVIDERS } from "./infrastructure/di/project/project-goals.providers"; +import { PROJECT_NEWS_PROVIDERS } from "./infrastructure/di/project/project-news.providers"; +import { PROJECT_PROGRAM_PROVIDERS } from "./infrastructure/di/project/project-program.providers"; +import { PROJECT_PARTNER_PROVIDERS } from "./infrastructure/di/project/project-partner.providers"; +import { PROJECT_RATING_PROVIDERS } from "./infrastructure/di/project/project-rating.providers"; +import { PROJECT_RESOURCES_PROVIDERS } from "./infrastructure/di/project/project-resources.providers"; +import { PROJECT_SUBSCRIPTION_PROVIDERS } from "./infrastructure/di/project/project-subscription.providers"; +import { PROJECT_COLLABORATORS_PROVIDERS } from "./infrastructure/di/project/project-collaborators.providers"; +import { SKILLS_PROVIDERS } from "./infrastructure/di/skills.providers"; +import { SPECIALIZATIONS_PROVIDERS } from "./infrastructure/di/specializations.providers"; +import { VACANCY_PROVIDERS } from "./infrastructure/di/vacancy.providers"; +import { COURSES_PROVIDERS } from "./infrastructure/di/courses/courses.providers"; registerLocaleData(localeRu, "ru-RU"); @@ -42,6 +64,11 @@ export const APP_CONFIG: ApplicationConfig = { useClass: BearerTokenInterceptor, multi: true, }, + { + provide: HTTP_INTERCEPTORS, + useClass: LoggingInterceptor, + multi: true, + }, { provide: API_URL, useValue: environment.apiUrl, @@ -63,5 +90,26 @@ export const APP_CONFIG: ApplicationConfig = { }) ), provideAnimations(), + ...AUTH_PROVIDERS, + ...FEED_PROVIDERS, + ...INDUSTRY_PROVIDERS, + ...INVITE_PROVIDERS, + ...MEMBER_PROVIDERS, + ...PROFILE_NEWS_PROVIDERS, + ...PROGRAM_PROVIDERS, + ...PROGRAM_NEWS_PROVIDERS, + ...PROJECT_PROVIDERS, + ...PROJECT_GOALS_PROVIDERS, + ...PROJECT_NEWS_PROVIDERS, + ...PROJECT_PARTNER_PROVIDERS, + ...PROJECT_PROGRAM_PROVIDERS, + ...PROJECT_RATING_PROVIDERS, + ...PROJECT_RESOURCES_PROVIDERS, + ...PROJECT_SUBSCRIPTION_PROVIDERS, + ...PROJECT_COLLABORATORS_PROVIDERS, + ...SKILLS_PROVIDERS, + ...SPECIALIZATIONS_PROVIDERS, + ...VACANCY_PROVIDERS, + ...COURSES_PROVIDERS, ], }; diff --git a/projects/social_platform/src/app/app.routes.ts b/projects/social_platform/src/app/app.routes.ts index fc1b1aa35..d114f479c 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 "@core/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/auth/auth.routes.ts b/projects/social_platform/src/app/auth/auth.routes.ts deleted file mode 100644 index ae80194fc..000000000 --- a/projects/social_platform/src/app/auth/auth.routes.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { AuthComponent } from "./auth.component"; -import { LoginComponent } from "./login/login.component"; -import { RegisterComponent } from "./register/register.component"; -import { EmailVerificationComponent } from "./email-verification/email-verification.component"; -import { ConfirmEmailComponent } from "./confirm-email/confirm-email.component"; -import { ResetPasswordComponent } from "@auth/reset-password/reset-password.component"; -import { SetPasswordComponent } from "@auth/set-password/set-password.component"; -import { ConfirmPasswordResetComponent } from "@auth/confirm-password-reset/confirm-password-reset.component"; - -/** - * Конфигурация маршрутов для модуля аутентификации - * - * Назначение: Определяет все маршруты для страниц аутентификации - * Принимает: Не принимает параметров - * Возвращает: Массив конфигураций маршрутов Angular - * - * Функциональность: - * - Настраивает маршруты для входа, регистрации, сброса пароля - * - Определяет дочерние маршруты для AuthComponent - * - Настраивает редиректы и компоненты для каждого пути - */ -export const AUTH_ROUTES: Routes = [ - { - path: "", - component: AuthComponent, - children: [ - { - path: "", - pathMatch: "full", - redirectTo: "login", - }, - { - path: "login", - component: LoginComponent, - }, - { - path: "register", - component: RegisterComponent, - }, - { - path: "verification/email", - component: EmailVerificationComponent, - }, - { - path: "reset_password/send_email", - component: ResetPasswordComponent, - }, - { - path: "reset_password", - component: SetPasswordComponent, - }, - { - path: "reset_password/confirm", - component: ConfirmPasswordResetComponent, - }, - ], - }, - { - path: "verification", - component: ConfirmEmailComponent, - }, -]; diff --git a/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.ts b/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.ts deleted file mode 100644 index 2564cc288..000000000 --- a/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** @format */ - -import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { TokenService } from "@corelib"; - -/** - * Компонент подтверждения email адреса - * - * Назначение: Обрабатывает подтверждение email через ссылку из письма - * Принимает: Access и refresh токены из query параметров URL - * Возвращает: Перенаправление в офис при успешном подтверждении - * - * Функциональность: - * - Получает токены из query параметров URL - * - Сохраняет токены в TokenService - * - Перенаправляет пользователя в офис при успешной аутентификации - * - Автоматически выполняется при переходе по ссылке из письма - */ -@Component({ - selector: "app-confirm-email", - templateUrl: "./confirm-email.component.html", - styleUrl: "./confirm-email.component.scss", - standalone: true, -}) -export class ConfirmEmailComponent implements OnInit { - constructor( - private tokenService: TokenService, - private route: ActivatedRoute, - private router: Router - ) {} - - ngOnInit(): void { - this.route.queryParams.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")); - } - }); - } -} diff --git a/projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.ts b/projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.ts deleted file mode 100644 index 1b2e656d0..000000000 --- a/projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** @format */ - -import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { map } from "rxjs"; -import { AsyncPipe } from "@angular/common"; - -/** - * Компонент подтверждения сброса пароля - * - * Назначение: Отображает страницу с инструкциями после запроса сброса пароля - * Принимает: email адрес через query параметры маршрута - * Возвращает: Информационное сообщение о отправке письма для сброса пароля - * - * Функциональность: - * - Получает email из query параметров - * - Отображает подтверждение отправки письма для сброса пароля - * - Информирует пользователя о следующих шагах - */ -@Component({ - selector: "app-confirm-password-reset", - templateUrl: "./confirm-password-reset.component.html", - styleUrl: "./confirm-password-reset.component.scss", - standalone: true, - imports: [AsyncPipe], -}) -export class ConfirmPasswordResetComponent implements OnInit { - constructor(private readonly route: ActivatedRoute) {} - - ngOnInit(): void {} - - email = this.route.queryParams.pipe(map(r => r["email"])); -} diff --git a/projects/social_platform/src/app/auth/email-verification/email-verification.component.ts b/projects/social_platform/src/app/auth/email-verification/email-verification.component.ts deleted file mode 100644 index 16a3319f3..000000000 --- a/projects/social_platform/src/app/auth/email-verification/email-verification.component.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { filter, interval, map, noop, Observable, Subscription } from "rxjs"; -import { AuthService } from "@auth/services"; -import { IconComponent } from "@ui/components"; - -/** - * Компонент подтверждения email адреса - * - * Назначение: Отображает страницу ожидания подтверждения email после регистрации - * Принимает: email адрес через query параметры маршрута - * Возвращает: Интерфейс с возможностью повторной отправки письма подтверждения - * - * Функциональность: - * - Показывает инструкции по подтверждению email - * - Реализует таймер для повторной отправки письма (60 секунд) - * - Позволяет отправить письмо подтверждения повторно - * - Получает email из query параметров маршрута - */ -@Component({ - selector: "app-email-verification", - templateUrl: "./email-verification.component.html", - styleUrl: "./email-verification.component.scss", - standalone: true, - imports: [IconComponent], -}) -export class EmailVerificationComponent implements OnInit, OnDestroy { - constructor(private route: ActivatedRoute, private readonly authService: AuthService) {} - - ngOnInit(): void { - const emailSub$ = this.route.queryParams.pipe(map(r => r["adress"])).subscribe(r => { - this.userEmail = r; - }); - this.subscriptions$.push(emailSub$); - - const timerSub$ = this.timer$.subscribe(noop); - this.subscriptions$.push(timerSub$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - subscriptions$: Subscription[] = []; - userEmail?: string; - counter = 0; - timer$: Observable = interval(1000).pipe( - filter(() => this.counter > 0), - map(() => this.counter--) - ); - - onResend(): void { - if (!this.userEmail) return; - this.authService.resendEmail(this.userEmail).subscribe(() => { - this.counter = 60; - }); - } -} diff --git a/projects/social_platform/src/app/auth/login/login.component.ts b/projects/social_platform/src/app/auth/login/login.component.ts deleted file mode 100644 index 0440029f8..000000000 --- a/projects/social_platform/src/app/auth/login/login.component.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** @format */ - -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - OnInit, - signal, -} from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { AuthService } from "../services"; -import { ErrorMessage } from "@error/models/error-message"; -import { ControlErrorPipe, TokenService, ValidationService } from "projects/core"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { ButtonComponent, IconComponent, InputComponent } from "@ui/components"; -import { CommonModule } from "@angular/common"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { ClickOutsideModule } from "ng-click-outside"; - -/** - * Компонент входа в систему - * - * Назначение: Реализует форму входа пользователя в систему - * Принимает: Email и пароль пользователя через форму, параметр redirect из URL - * Возвращает: Перенаправление в офис при успехе или отображение ошибок - * - * Функциональность: - * - Форма входа с полями email и пароля - * - Валидация email и обязательных полей - * - Показ/скрытие пароля - * - Отправка данных на сервер для аутентификации - * - Сохранение токенов при успешном входе - * - Обработка ошибок аутентификации (неверные данные) - * - Поддержка различных типов перенаправления после входа - * - Очистка токенов при инициализации - */ -@Component({ - selector: "app-login", - templateUrl: "./login.component.html", - styleUrl: "./login.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - RouterLink, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - TooltipComponent, - ClickOutsideModule, - ], -}) -export class LoginComponent implements OnInit { - constructor( - private readonly fb: FormBuilder, - private readonly authService: AuthService, - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly tokenService: TokenService, - private readonly validationService: ValidationService, - private readonly cdref: ChangeDetectorRef - ) { - this.loginForm = this.fb.group({ - email: ["", [Validators.required, Validators.email]], - password: ["", [Validators.required]], - }); - } - - loginForm: FormGroup; - loginIsSubmitting = false; - - errorWrongAuth = false; - - errorMessage = ErrorMessage; - - showPassword = false; - readonly isHintLoginVisible = signal(false); - - ngOnInit(): void { - this.tokenService.clearTokens(); - } - - toggleTooltip(): void { - this.isHintLoginVisible.set(!this.isHintLoginVisible()); - } - - toggleShowPassword() { - this.showPassword = !this.showPassword; - } - - onSubmit() { - const redirectType = this.route.snapshot.queryParams["redirect"]; - - if (!this.validationService.getFormValidation(this.loginForm) || this.loginIsSubmitting) { - return; - } - - this.loginIsSubmitting = true; - - this.authService.login(this.loginForm.value).subscribe({ - next: res => { - this.tokenService.memTokens(res); - this.loginIsSubmitting = false; - - this.cdref.detectChanges(); - - if (!redirectType) - this.router - .navigateByUrl("/office") - .then(() => console.debug("Route changed from LoginComponent")); - else if (redirectType === "program") - this.router - .navigateByUrl("/office/program") - .then(() => console.debug("Route changed from LoginComponent")); - }, - error: error => { - if (error.status === 401) { - this.errorWrongAuth = true; - } - - this.loginIsSubmitting = false; - this.cdref.detectChanges(); - }, - }); - } -} diff --git a/projects/social_platform/src/app/auth/models/http.model.ts b/projects/social_platform/src/app/auth/models/http.model.ts deleted file mode 100644 index d269913d9..000000000 --- a/projects/social_platform/src/app/auth/models/http.model.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -/** - * Модели HTTP запросов и ответов для аутентификации - * - * Назначение: Определяет структуру данных для API запросов аутентификации - * Принимает: Не принимает параметров (классы моделей) - * Возвращает: Типизированные объекты для HTTP взаимодействия - * - * Функциональность: - * - LoginRequest: данные для входа (email, пароль) - * - LoginResponse: ответ сервера при входе (токены) - * - RefreshResponse: ответ при обновлении токенов - * - RegisterRequest: данные для регистрации (имя, фамилия, email, пароль) - * - RegisterResponse: ответ сервера при регистрации (наследует LoginResponse) - */ - -export class LoginRequest { - email!: string; - password!: string; -} - -export class LoginResponse { - access!: string; - refresh!: string; -} - -export class RefreshResponse { - access!: string; - refresh!: string; -} - -export class RegisterRequest { - firstName!: string; - lastName!: string; - email!: string; - password!: string; -} - -export class RegisterResponse extends LoginResponse {} diff --git a/projects/social_platform/src/app/auth/register/register.component.ts b/projects/social_platform/src/app/auth/register/register.component.ts deleted file mode 100644 index 060f7c13c..000000000 --- a/projects/social_platform/src/app/auth/register/register.component.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, OnInit } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ControlErrorPipe, TokenService, ValidationService } from "projects/core"; -import { ErrorMessage } from "@error/models/error-message"; -import { Router, RouterLink } from "@angular/router"; -import * as dayjs from "dayjs"; -import * as cpf from "dayjs/plugin/customParseFormat"; -import { ButtonComponent, CheckboxComponent, InputComponent } from "@ui/components"; -import { AuthService } from "@auth/services"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { IconComponent } from "@uilib"; -import { CommonModule } from "@angular/common"; - -dayjs.extend(cpf); - -/** - * Компонент регистрации нового пользователя - * - * Назначение: Реализует двухэтапную форму регистрации с валидацией - * Принимает: Данные пользователя через форму (email, пароль, личные данные) - * Возвращает: Перенаправление на страницу подтверждения email или отображение ошибок - * - * Функциональность: - * - Двухэтапная регистрация (учетные данные → личная информация) - * - Валидация email, пароля, имени, фамилии, даты рождения - * - Проверка совпадения паролей - * - Обработка согласий пользователя - * - Отправка данных на сервер и обработка ошибок - * - Показ/скрытие паролей - * - Модальное окно при ошибке создания аккаунта - */ -@Component({ - selector: "app-login", - templateUrl: "./register.component.html", - styleUrl: "./register.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - CheckboxComponent, - ButtonComponent, - ModalComponent, - RouterLink, - IconComponent, - ControlErrorPipe, - ], -}) -export class RegisterComponent implements OnInit { - constructor( - private fb: FormBuilder, - private tokenService: TokenService, - private authService: AuthService, - private router: Router, - private validationService: ValidationService, - private cdref: ChangeDetectorRef - ) { - this.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]], - phoneNumber: ["", [Validators.maxLength(15)]], - }, - { validators: [validationService.useMatchValidator("password", "repeatedPassword")] } - ); - } - - ngOnInit(): void { - this.tokenService.clearTokens(); - } - - registerForm: FormGroup; - registerAgreement = false; - ageAgreement = false; - registerIsSubmitting = false; - - showPassword = false; - showPasswordRepeat = false; - - isUserCreationModalError = false; - - serverErrors: string[] = []; - - errorMessage = ErrorMessage; - - toggleShowPassword(type: "repeat" | "first") { - if (type === "repeat") { - this.showPasswordRepeat = !this.showPasswordRepeat; - } else { - this.showPassword = !this.showPassword; - } - } - - onSendForm(): void { - if (!this.validationService.getFormValidation(this.registerForm)) { - return; - } - - const payload = { - ...this.registerForm.value, - birthday: this.registerForm.value.birthday - ? dayjs(this.registerForm.value.birthday, "DD.MM.YYYY").format("YYYY-MM-DD") - : undefined, - phoneNumber: - typeof this.registerForm.value.phoneNumber === "string" - ? this.registerForm.value.phoneNumber.replace(/^([87])/, "+7") - : this.registerForm.value.phoneNumber, - }; - - delete payload.repeatedPassword; - - this.registerIsSubmitting = true; - - this.authService.register(payload).subscribe({ - next: () => { - this.registerIsSubmitting = false; - - this.cdref.detectChanges(); - - this.router - .navigateByUrl("/auth/verification/email?adress=" + payload.email) - .then(() => console.debug("Route changed from RegisterComponent")); - }, - error: error => { - if ( - error.status === 400 && - error.error.email.some((msg: string) => msg.includes("email")) - ) { - this.serverErrors = Object.values(error.error).flat() as string[]; - console.log(this.serverErrors); - } else if (error.status === 500) { - this.isUserCreationModalError = true; - } - - this.registerIsSubmitting = false; - this.cdref.detectChanges(); - }, - }); - } -} diff --git a/projects/social_platform/src/app/auth/reset-password/reset-password.component.ts b/projects/social_platform/src/app/auth/reset-password/reset-password.component.ts deleted file mode 100644 index 2d8dce1d3..000000000 --- a/projects/social_platform/src/app/auth/reset-password/reset-password.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** @format */ - -import { Component, OnInit } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { AuthService } from "@auth/services"; -import { ControlErrorPipe, ValidationService } from "projects/core"; -import { Router } from "@angular/router"; -import { ButtonComponent, InputComponent } from "@ui/components"; - -/** - * Компонент запроса сброса пароля - * - * Назначение: Позволяет пользователю запросить сброс пароля по email - * Принимает: Email адрес пользователя через форму - * Возвращает: Перенаправление на страницу подтверждения или отображение ошибки - * - * Функциональность: - * - Форма с полем email для запроса сброса пароля - * - Валидация email адреса - * - Отправка запроса на сервер - * - Обработка ошибок (неверный email) - * - Перенаправление на страницу подтверждения при успехе - */ -@Component({ - selector: "app-reset-password", - templateUrl: "./reset-password.component.html", - styleUrl: "./reset-password.component.scss", - standalone: true, - imports: [ReactiveFormsModule, InputComponent, ButtonComponent, ControlErrorPipe], -}) -export class ResetPasswordComponent implements OnInit { - constructor( - private readonly fb: FormBuilder, - private readonly authService: AuthService, - private readonly validationService: ValidationService, - private readonly router: Router - ) { - this.resetForm = this.fb.group({ - email: ["", [Validators.required, Validators.email]], - }); - } - - ngOnInit(): void {} - - resetForm: FormGroup; - isSubmitting = false; - - errorMessage = ErrorMessage; - errorServer = false; - - onSubmit(): void { - if (!this.validationService.getFormValidation(this.resetForm)) return; - - this.errorServer = false; - this.isSubmitting = true; - - this.authService.resetPassword(this.resetForm.value.email).subscribe({ - next: () => { - this.router - .navigate(["/auth/reset_password/confirm"], { - queryParams: { email: this.resetForm.value.email }, - }) - .then(() => console.debug("ResetPasswordComponent")); - }, - error: () => { - this.errorServer = true; - this.isSubmitting = false; - - this.resetForm.reset(); - }, - complete: () => { - this.isSubmitting = false; - }, - }); - } -} diff --git a/projects/social_platform/src/app/auth/services/auth.service.spec.ts b/projects/social_platform/src/app/auth/services/auth.service.spec.ts deleted file mode 100644 index eb5704ae6..000000000 --- a/projects/social_platform/src/app/auth/services/auth.service.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { AuthService } from "./auth.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("AuthService", () => { - let service: AuthService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [AuthService], - }); - service = TestBed.inject(AuthService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/auth/services/auth.service.ts b/projects/social_platform/src/app/auth/services/auth.service.ts deleted file mode 100644 index e93a13b9b..000000000 --- a/projects/social_platform/src/app/auth/services/auth.service.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService, TokenService } from "@corelib"; -import { plainToInstance } from "class-transformer"; -import { concatMap, map, Observable, ReplaySubject, take, tap } from "rxjs"; -import { - LoginRequest, - 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"; - -/** - * Сервис аутентификации и управления пользователями - * - * Назначение: Основной сервис для всех операций аутентификации и работы с профилем пользователя - * Принимает: Данные для входа, регистрации, сброса пароля, обновления профиля - * Возвращает: Observable с результатами операций, данными пользователя, токенами - * - * Функциональность: - * - Вход и выход из системы - * - Регистрация новых пользователей - * - Сброс и установка нового пароля - * - Управление профилем пользователя (получение, обновление) - * - Работа с ролями пользователей - * - Управление аватаром пользователя - * - Управление этапами онбординга - * - Работа с подписками пользователя - * - Скачивание и отправка резюме - * - Повторная отправка письма подтверждения - * - Использует RxJS для реактивного программирования - * - Кэширует данные профиля в ReplaySubject - */ -@Injectable({ - providedIn: "root", -}) -export class AuthService { - private readonly API_TOKEN_URL = "/api/token"; - private readonly AUTH_URL = "/auth"; - private readonly AUTH_USERS_URL = "/auth/users"; - - constructor(private apiService: ApiService, private tokenService: TokenService) {} - - /** - * Вход пользователя в систему - * @param credentials Данные для входа (email и пароль) - * @returns Observable с ответом сервера, содержащим токены - */ - login({ email, password }: LoginRequest): Observable { - return this.apiService - .post(`${this.API_TOKEN_URL}/`, { email, password }) - .pipe(map(json => plainToInstance(LoginResponse, json))); - } - - /** - * Выход пользователя из системы - * Отправляет refresh токен на сервер для инвалидации - * @returns Observable завершения операции - */ - logout(): Observable { - return this.apiService - .post(`${this.AUTH_URL}/logout/`, { refreshToken: this.tokenService.getTokens()?.refresh }) - .pipe(map(() => this.tokenService.clearTokens())); - } - - /** - * Регистрация нового пользователя - * @param data Данные для регистрации (email, пароль, имя и т.д.) - * @returns Observable с данными зарегистрированного пользователя - */ - register(data: RegisterRequest): Observable { - return this.apiService - .post(`${this.AUTH_USERS_URL}/`, data) - .pipe(map(json => plainToInstance(RegisterResponse, json))); - } - - downloadCV() { - return this.apiService.getFile(`${this.AUTH_USERS_URL}/download_cv/`); - } - - /** Поток данных профиля пользователя */ - private profile$ = new ReplaySubject(1); - profile = this.profile$.asObservable(); - - /** Поток доступных ролей пользователей */ - private roles$ = new ReplaySubject(1); - roles = this.roles$.asObservable(); - - /** Поток ролей, которые может изменить текущий пользователь */ - private changeableRoles$ = new ReplaySubject(1); - changeableRoles = this.changeableRoles$.asObservable(); - - /** - * Получить профиль текущего пользователя - * @returns Observable с данными профиля - */ - getProfile(): Observable { - return this.apiService.get(`${this.AUTH_USERS_URL}/current/`).pipe( - map(user => plainToInstance(User, user)), - tap(profile => this.profile$.next(profile)) - ); - } - - /** - * Проверить, есть ли у пользователя активная подписка - * @returns Observable с булевым значением статуса подписки - */ - isSubscribed(): Observable { - return this.profile.pipe(map(profile => profile.isSubscribed)); - } - - /** - * Получить список всех типов пользователей - * @returns Observable с массивом ролей пользователей - */ - getUserRoles(): Observable { - return this.apiService.get<[[number, string]]>(`${this.AUTH_USERS_URL}/types/`).pipe( - map(roles => roles.map(role => ({ id: role[0], name: role[1] }))), - map(roles => plainToInstance(UserRole, roles)), - tap(roles => this.roles$.next(roles)) - ); - } - - /** - * Получить проекты где пользователь leader - * @returns Observable проектов внутри профиля - */ - getLeaderProjects(): Observable> { - return this.apiService.get(`${this.AUTH_USERS_URL}/projects/leader/`); - } - - /** - * Получить роли, которые может изменить текущий пользователь - * @returns Observable с массивом изменяемых ролей - */ - getChangeableRoles(): Observable { - return this.apiService.get<[[number, string]]>(`${this.AUTH_USERS_URL}/roles/`).pipe( - map(roles => roles.map(role => ({ id: role[0], name: role[1] }))), - map(roles => plainToInstance(UserRole, roles)), - tap(roles => this.changeableRoles$.next(roles)) - ); - } - - /** - * Получить данные пользователя по ID - * @param id Идентификатор пользователя - * @returns Observable с данными пользователя - */ - getUser(id: number): Observable { - return this.apiService - .get(`${this.AUTH_USERS_URL}/${id}/`) - .pipe(map(user => plainToInstance(User, user))); - } - - /** - * Сохранить аватар пользователя - * @param url URL загруженного аватара - * @returns Observable с обновленными данными пользователя - */ - saveAvatar(url: string): Observable { - return this.profile.pipe( - take(1), - concatMap(profile => - this.apiService.patch(`${this.AUTH_USERS_URL}/${profile.id}`, { avatar: url }) - ) - ); - } - - /** - * Сохранить изменения в профиле пользователя - * @param newProfile Частичные данные профиля для обновления - * @returns Observable с обновленными данными профиля - */ - saveProfile(newProfile: Partial): Observable { - return this.profile.pipe( - take(1), - concatMap(profile => - this.apiService.patch(`${this.AUTH_USERS_URL}/${profile.id}/`, newProfile) - ), - tap(profile => { - this.profile$.next(profile); - }) - ); - } - - /** - * Установить этап онбординга для пользователя - * @param stage Номер этапа онбординга (null для завершения) - * @returns Observable с обновленными данными пользователя - */ - setOnboardingStage(stage: number | null): Observable { - return this.profile.pipe( - take(1), - concatMap(profile => - this.apiService.put(`${this.AUTH_USERS_URL}/${profile.id}/set_onboarding_stage/`, { - onboardingStage: stage, - }) - ), - concatMap(() => this.profile.pipe(take(1))), - tap(profile => { - this.profile$.next({ ...profile, onboardingStage: stage } as User); - }) - ); - } - - /** - * Запросить сброс пароля - * @param email Email для отправки ссылки сброса - * @returns Observable завершения операции - */ - resetPassword(email: string): Observable { - return this.apiService.post(`${this.AUTH_URL}/reset_password/`, { email }); - } - - /** - * Установить новый пароль после сброса - * @param password Новый пароль - * @param token Токен подтверждения сброса пароля - * @returns Observable завершения операции - */ - setPassword(password: string, token: string): Observable { - return this.apiService.post(`${this.AUTH_URL}/reset_password/confirm/`, { password, token }); - } - - /** - * Повторно отправить письмо подтверждения email - * @param email Email для повторной отправки - * @returns Observable с данными пользователя - */ - resendEmail(email: string): Observable { - return this.apiService - .post(`${this.AUTH_URL}/resend_email/`, { email }) - .pipe(map(user => plainToInstance(User, user))); - } -} diff --git a/projects/social_platform/src/app/auth/services/index.ts b/projects/social_platform/src/app/auth/services/index.ts deleted file mode 100644 index 0f5e6ddd9..000000000 --- a/projects/social_platform/src/app/auth/services/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** @format */ - -/** - * Индексный файл для экспорта сервисов модуля аутентификации - * - * Назначение: Централизованный экспорт всех сервисов модуля аутентификации - * Принимает: Не принимает параметров - * Возвращает: Экспортирует AuthService для использования в других модулях - * - * Функциональность: - * - Упрощает импорт сервисов в других частях приложения - * - Обеспечивает единую точку входа для сервисов аутентификации - */ - -export * from "./auth.service"; diff --git a/projects/social_platform/src/app/auth/set-password/set-password.component.ts b/projects/social_platform/src/app/auth/set-password/set-password.component.ts deleted file mode 100644 index 315f86eb1..000000000 --- a/projects/social_platform/src/app/auth/set-password/set-password.component.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** @format */ - -import { Component, OnInit } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ControlErrorPipe, ValidationService } from "projects/core"; -import { ErrorMessage } from "@error/models/error-message"; -import { ActivatedRoute, Router } from "@angular/router"; -import { AuthService } from "@auth/services"; -import { ButtonComponent, InputComponent } from "@ui/components"; - -/** - * Компонент установки нового пароля - * - * Назначение: Позволяет пользователю установить новый пароль после сброса - * Принимает: Новый пароль, подтверждение пароля, токен сброса из URL - * Возвращает: Перенаправление на страницу входа при успехе или отображение ошибок - * - * Функциональность: - * - Форма с полями нового пароля и его подтверждения - * - Валидация длины пароля (минимум 8 символов) - * - Проверка совпадения паролей - * - Показ/скрытие пароля - * - Получение токена сброса из query параметров - * - Отправка нового пароля на сервер - * - Перенаправление на страницу входа при успехе - */ -@Component({ - selector: "app-set-password", - templateUrl: "./set-password.component.html", - styleUrl: "./set-password.component.scss", - standalone: true, - imports: [ReactiveFormsModule, InputComponent, ButtonComponent, ControlErrorPipe], -}) -export class SetPasswordComponent implements OnInit { - constructor( - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly route: ActivatedRoute, - private readonly authService: AuthService, - private readonly router: Router - ) { - this.passwordForm = this.fb.group( - { - password: ["", [Validators.required, this.validationService.usePasswordValidator(8)]], - passwordRepeated: ["", [Validators.required]], - }, - { validators: [validationService.useMatchValidator("password", "passwordRepeated")] } - ); - } - - passwordForm: FormGroup; - isSubmitting = false; - errorMessage = ErrorMessage; - errorRequest = false; - credsSubmitInitiated = false; - - showPassword = false; - - ngOnInit(): void { - const token = this.route.snapshot.queryParamMap.get("token"); - if (!token) { - // Handle the case where token is not present - console.error("Token is missing"); - } - } - - toggleShowPassword() { - this.showPassword = !this.showPassword; - } - - onSubmit() { - this.credsSubmitInitiated = true; - const token = this.route.snapshot.queryParamMap.get("token"); - - if (!token || !this.validationService.getFormValidation(this.passwordForm)) return; - - this.authService.setPassword(this.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 = true; - }, - }); - } -} 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/core/pipes/user-role.pipe.spec.ts b/projects/social_platform/src/app/core/pipes/user-role.pipe.spec.ts deleted file mode 100644 index 1bda7006a..000000000 --- a/projects/social_platform/src/app/core/pipes/user-role.pipe.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** @format */ - -import { UserRolePipe } from "./user-role.pipe"; -import { AuthService } from "@auth/services"; -import { of } from "rxjs"; - -describe("UserRolePipe", () => { - it("create an instance", () => { - const authSpy = { - roles: of([]), - }; - const pipe = new UserRolePipe(authSpy as unknown as AuthService); - expect(pipe).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/core/pipes/user-role.pipe.ts b/projects/social_platform/src/app/core/pipes/user-role.pipe.ts deleted file mode 100644 index 6e0111b84..000000000 --- a/projects/social_platform/src/app/core/pipes/user-role.pipe.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -import { Pipe, PipeTransform } from "@angular/core"; -import { AuthService } from "@auth/services"; -import { map, Observable } from "rxjs"; - -/** - * Пайп для преобразования ID роли пользователя в название роли - * Используется в шаблонах Angular для отображения названия роли вместо её ID - * - * Пример использования в шаблоне: {{ userId | userRole | async }} - */ -@Pipe({ - name: "userRole", - standalone: true, -}) -export class UserRolePipe implements PipeTransform { - constructor(private readonly authService: AuthService) {} - - /** - * Преобразует числовой ID роли в название роли - * - * @param value - ID роли (число) - * @returns Observable - Observable с названием роли или undefined, если роль не найдена - */ - transform(value: number): Observable { - return this.authService.roles.pipe(map(roles => roles.find(role => role.id === value)?.name)); - } -} diff --git a/projects/social_platform/src/app/core/services/file.service.ts b/projects/social_platform/src/app/core/services/file.service.ts deleted file mode 100644 index d21f68697..000000000 --- a/projects/social_platform/src/app/core/services/file.service.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService, TokenService } from "@corelib"; -import { Observable } from "rxjs"; -import { HttpParams } from "@angular/common/http"; -import { environment } from "@environment"; - -/** - * Сервис для работы с файлами - * Предоставляет методы для загрузки и удаления файлов через API - * Использует авторизацию через Bearer токен - */ -@Injectable({ - providedIn: "root", -}) -export class FileService { - private readonly FILES_URL = "/files"; - - constructor(private readonly tokenService: TokenService, private apiService: ApiService) {} - - /** - * Загружает файл на сервер - * - * @param file - объект File для загрузки - * @returns Observable<{ url: string }> - Observable с URL загруженного файла - * - * Использует нативный fetch API вместо HttpClient для поддержки FormData - * Автоматически добавляет Authorization header с Bearer токеном - */ - uploadFile(file: File): Observable<{ url: string }> { - const formData = new FormData(); - formData.append("file", file); - - return new Observable<{ url: string }>(observer => { - const doFetch = (token: string) => - fetch(`${environment.apiUrl}${this.FILES_URL}/`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: formData, - }); - - const token = this.tokenService.getTokens()?.access; - if (!token) { - observer.error(new Error("No access token")); - return; - } - - doFetch(token) - .then(res => { - if (res.status === 401) { - this.tokenService.refreshTokens().subscribe({ - next: newTokens => { - this.tokenService.memTokens(newTokens); - doFetch(newTokens.access) - .then(r => r.json()) - .then(data => { - observer.next(data); - observer.complete(); - }) - .catch(err => observer.error(err)); - }, - error: err => observer.error(err), - }); - return; - } - return res.json(); - }) - .then(res => { - if (res) { - observer.next(res); - observer.complete(); - } - }) - .catch(err => observer.error(err)); - }); - } - - /** - * Удаляет файл с сервера по URL - * - * @param fileUrl - URL файла для удаления - * @returns Observable<{ success: true }> - Observable с результатом операции - * - * Передает URL файла как query параметр 'link' - */ - deleteFile(fileUrl: string): Observable<{ success: true }> { - const params = new HttpParams({ fromObject: { link: fileUrl } }); - return this.apiService.delete(`${this.FILES_URL}/`, params); - } -} diff --git a/projects/social_platform/src/app/domain/auth/commands/login.command.ts b/projects/social_platform/src/app/domain/auth/commands/login.command.ts new file mode 100644 index 000000000..000d3f927 --- /dev/null +++ b/projects/social_platform/src/app/domain/auth/commands/login.command.ts @@ -0,0 +1,6 @@ +/** @format */ + +export interface LoginCommand { + email: string | null; + password: string | null; +} diff --git a/projects/social_platform/src/app/domain/auth/commands/register.command.ts b/projects/social_platform/src/app/domain/auth/commands/register.command.ts new file mode 100644 index 000000000..c9e500eef --- /dev/null +++ b/projects/social_platform/src/app/domain/auth/commands/register.command.ts @@ -0,0 +1,9 @@ +/** @format */ + +export interface RegisterCommand { + firstName: string; + lastName: string; + birthday: string; + email: string; + password: string; +} diff --git a/projects/social_platform/src/app/domain/auth/http.model.ts b/projects/social_platform/src/app/domain/auth/http.model.ts new file mode 100644 index 000000000..5d8c8e802 --- /dev/null +++ b/projects/social_platform/src/app/domain/auth/http.model.ts @@ -0,0 +1,26 @@ +/** @format */ + +/** + * Модели HTTP запросов и ответов для аутентификации + * + * Назначение: Определяет структуру данных для API запросов аутентификации + * Принимает: Не принимает параметров (классы моделей) + * Возвращает: Типизированные объекты для HTTP взаимодействия + * + * Функциональность: + * - LoginResponse: ответ сервера при входе (токены) + * - RefreshResponse: ответ при обновлении токенов + * - RegisterResponse: ответ сервера при регистрации (наследует LoginResponse) + */ + +export class LoginResponse { + access!: string; + refresh!: string; +} + +export class RefreshResponse { + access!: string; + refresh!: string; +} + +export class RegisterResponse extends LoginResponse {} 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/ports/auth.repository.port.ts b/projects/social_platform/src/app/domain/auth/ports/auth.repository.port.ts new file mode 100644 index 000000000..8b856d521 --- /dev/null +++ b/projects/social_platform/src/app/domain/auth/ports/auth.repository.port.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { User, UserRole } from "../user.model"; +import { LoginResponse, RegisterResponse } from "../http.model"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { Project } from "../../project/project.model"; +import { LoginCommand } from "../commands/login.command"; +import { RegisterCommand } from "../commands/register.command"; + +/** + * Порт репозитория аутентификации. + * Определяет контракт для работы с данными пользователя. + * Реализуется в infrastructure/repository/auth/auth.repository.ts + */ +export abstract class AuthRepositoryPort { + /** Поток данных текущего профиля */ + abstract readonly profile: Observable; + /** Поток доступных ролей */ + abstract readonly roles: Observable; + /** Поток изменяемых ролей */ + abstract readonly changeableRoles: Observable; + + abstract login(data: LoginCommand): Observable; + abstract logout(): Observable; + abstract register(data: RegisterCommand): Observable; + abstract resendEmail(email: string): Observable; + abstract fetchUser(id: number): Observable; + abstract fetchProfile(): Observable; + abstract updateProfile(data: Partial): Observable; + abstract updateOnboardingStage(stage: number | null): Observable; + abstract updateAvatar(url: string): Observable; + abstract fetchLeaderProjects(): Observable>; + abstract fetchUserRoles(): Observable; + abstract fetchChangeableRoles(): Observable; + abstract downloadCV(): Observable; + abstract resetPassword(email: string): Observable; + abstract setPassword(password: string, token: string): Observable; +} 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/domain/auth/results/login.result.ts b/projects/social_platform/src/app/domain/auth/results/login.result.ts new file mode 100644 index 000000000..3904b46c9 --- /dev/null +++ b/projects/social_platform/src/app/domain/auth/results/login.result.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { LoginResponse } from "../http.model"; + +export interface LoginResult { + tokens: LoginResponse; +} + +export type LoginError = { kind: "wrong_credentials" } | { kind: "unknown" }; diff --git a/projects/social_platform/src/app/domain/auth/results/password.result.ts b/projects/social_platform/src/app/domain/auth/results/password.result.ts new file mode 100644 index 000000000..dc5dfbb0c --- /dev/null +++ b/projects/social_platform/src/app/domain/auth/results/password.result.ts @@ -0,0 +1,6 @@ +/** @format */ + +export type PasswordError = + | { kind: "server_error" } + | { kind: "invalid_token" } + | { kind: "unknown"; cause?: unknown }; diff --git a/projects/social_platform/src/app/domain/auth/results/register.result.ts b/projects/social_platform/src/app/domain/auth/results/register.result.ts new file mode 100644 index 000000000..3202bd9e2 --- /dev/null +++ b/projects/social_platform/src/app/domain/auth/results/register.result.ts @@ -0,0 +1,8 @@ +/** @format */ + +export type RegisterFieldErrors = Record; + +export type RegisterError = + | { kind: "server_error" } + | { kind: "validation_error"; errors: RegisterFieldErrors } + | { kind: "unknown"; cause?: unknown }; 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 91% 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 dce1893ac..57e3103c5 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.model"; -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"; /** * Модели данных пользователя и связанных сущностей @@ -38,7 +38,7 @@ export class Education { educationLevel!: string; } -export class workExperience { +export class WorkExperience { organizationName!: string; entryYear!: number; completionYear!: number; @@ -46,7 +46,7 @@ export class workExperience { jobPosition!: string; } -export class userLanguages { +export class UserLanguages { language!: string; languageLevel!: string; } @@ -100,8 +100,8 @@ export class User { phoneNumber!: number; region!: string; education!: Education[]; - userLanguages!: userLanguages[]; - workExperience!: workExperience[]; + userLanguages!: UserLanguages[]; + workExperience!: WorkExperience[]; achievements!: Achievement[]; programs!: Program[]; projects!: Project[]; 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 88% 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..430ec2047 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 "@domain/auth/user.model"; +import { ChatMessage } from "@domain/chat/chat-message.model"; /** * Модели данных для элементов чата @@ -40,6 +40,8 @@ export interface ChatListItem { imageAddress: string; /** Собеседник (для прямых чатов) */ opponent?: User; + /** Флаг непрочитанного последнего сообщения (вычисляется в фасаде) */ + isUnread?: boolean; } /** 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 98% 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..dff8aca8d 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 "@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 98% 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..5a6dadf1c 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 "@domain/chat/chat-message.model"; /** * Класс для уведомления об изменении статуса пользователя diff --git a/projects/social_platform/src/app/domain/chat/ports/chat-realtime.port.ts b/projects/social_platform/src/app/domain/chat/ports/chat-realtime.port.ts new file mode 100644 index 000000000..094a6c29d --- /dev/null +++ b/projects/social_platform/src/app/domain/chat/ports/chat-realtime.port.ts @@ -0,0 +1,45 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { + DeleteChatMessageDto, + EditChatMessageDto, + OnChatMessageDto, + OnDeleteChatMessageDto, + OnEditChatMessageDto, + OnReadChatMessageDto, + OnChangeStatus, + ReadChatMessageDto, + SendChatMessageDto, + TypingInChatDto, + TypingInChatEventDto, +} from "../chat.model"; + +/** + * Порт для real-time операций чата (WebSocket). + * Отправка/получение сообщений, отслеживание статусов, набор текста. + * + * Этот порт будет ключевым для CQRS+event-driven (тема 4 плана обучения): + * - Commands: sendMessage, editMessage, deleteMessage, readMessage, startTyping + * - Events: onMessage, onEditMessage, onDeleteMessage, onReadMessage, onTyping, onSetOnline/Offline + */ +export abstract class ChatRealtimePort { + /** Установить WebSocket соединение */ + abstract connect(): Observable; + + // === Commands (отправка действий) === + abstract sendMessage(message: SendChatMessageDto): void; + abstract editMessage(message: EditChatMessageDto): void; + abstract deleteMessage(message: DeleteChatMessageDto): void; + abstract readMessage(message: ReadChatMessageDto): void; + abstract startTyping(typing: TypingInChatDto): void; + + // === Events (подписка на события) === + abstract onMessage(): Observable; + abstract onEditMessage(): Observable; + abstract onDeleteMessage(): Observable; + abstract onReadMessage(): Observable; + abstract onTyping(): Observable; + abstract onSetOnline(): Observable; + abstract onSetOffline(): Observable; +} diff --git a/projects/social_platform/src/app/domain/chat/ports/chat.repository.port.ts b/projects/social_platform/src/app/domain/chat/ports/chat.repository.port.ts new file mode 100644 index 000000000..6c0b3ad7e --- /dev/null +++ b/projects/social_platform/src/app/domain/chat/ports/chat.repository.port.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { ChatFile, ChatMessage } from "../chat-message.model"; + +/** + * Порт для HTTP-операций чата (REST API). + * Загрузка истории сообщений, файлов, проверка непрочитанных. + */ +export abstract class ChatRepositoryPort { + abstract loadMessages( + projectId: number, + offset?: number, + limit?: number + ): Observable>; + + abstract loadProjectFiles(projectId: number): Observable; + + abstract hasUnreads(): Observable; +} diff --git a/projects/social_platform/src/app/domain/courses/courses.model.ts b/projects/social_platform/src/app/domain/courses/courses.model.ts new file mode 100644 index 000000000..b69a60190 --- /dev/null +++ b/projects/social_platform/src/app/domain/courses/courses.model.ts @@ -0,0 +1,116 @@ +/** @format */ + +export interface CourseCard { + id: number; + title: string; + accessType: "all_users" | "program_members" | "subscription_stub"; + status: "draft" | "published" | "ended"; + avatarUrl: string; + cardCoverUrl: string; + startDate: Date; + endDate: Date; + dateLabel: string; + isAvailable: boolean; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + actionState: "start" | "continue" | "lock"; +} + +export interface CourseDetail { + id: number; + title: string; + description: string; + accessType: "all_users" | "program_members" | "subscription_stub"; + status: "draft" | "published" | "ended"; + avatarUrl: string; + headerCoverUrl: string; + startDate: Date; + endDate: Date; + dateLabel: string; + isAvailable: boolean; + partnerProgramId: number; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + analyticsStub: any; +} + +export interface CourseLessons { + id: number; + moduleId: number; + title: string; + order: number; + status: string; + isAvailable: boolean; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + currentTaskId: number; + taskCount: number; +} + +export interface CourseModule { + id: number; + courseId: number; + title: string; + order: number; + avatarUrl: string; + startDate: Date; + status: string; + isAvailable: boolean; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + lessons: CourseLessons[]; +} + +export interface CourseStructure { + courseId: number; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + modules: CourseModule[]; +} + +export interface Option { + id: number; + order: number; + text: string; +} + +export interface Task { + id: number; + order: number; + title: string; + answerTitle: string; + status: string; + taskKind: "question" | "informational"; + checkType: string | null; + informationalType: string | null; + questionType: string | null; + answerType: string | null; + bodyText: string; + videoUrl: string | null; + imageUrl: string | null; + attachmentUrl: string | null; + isAvailable: boolean; + isCompleted: boolean; + options: Option[]; +} + +export interface CourseLesson { + id: number; + moduleId: number; + courseId: number; + title: string; + progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; + percent: number; + currentTaskId: number; + moduleOrder: number; + tasks: Task[]; +} + +export interface TaskAnswerResponse { + answerId: number; + status: "submitted" | "pending_review"; + isCorrect: boolean; + canContinue: boolean; + nextTaskId: number | null; + submittedAt: Date; +} diff --git a/projects/social_platform/src/app/domain/courses/events/task-answer-submitted.event.ts b/projects/social_platform/src/app/domain/courses/events/task-answer-submitted.event.ts new file mode 100644 index 000000000..ffe740e6e --- /dev/null +++ b/projects/social_platform/src/app/domain/courses/events/task-answer-submitted.event.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; +import { TaskAnswerResponse } from "../courses.model"; + +export interface TaskAnswerSubmitted extends DomainEvent { + readonly type: "TaskAnswerSubmitted"; + readonly payload: { + readonly taskId: number; + readonly lessonId: number; + readonly response: TaskAnswerResponse; + }; +} + +export function taskAnswerSubmitted( + taskId: number, + lessonId: number, + response: TaskAnswerResponse +): TaskAnswerSubmitted { + return { + type: "TaskAnswerSubmitted", + payload: { taskId, lessonId, response }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/courses/ports/courses.repository.port.ts b/projects/social_platform/src/app/domain/courses/ports/courses.repository.port.ts new file mode 100644 index 000000000..95c723e99 --- /dev/null +++ b/projects/social_platform/src/app/domain/courses/ports/courses.repository.port.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { + CourseCard, + CourseDetail, + CourseLesson, + CourseStructure, + TaskAnswerResponse, +} from "../courses.model"; + +export abstract class CoursesRepositoryPort { + abstract getCourses(): Observable; + + abstract getCourseDetail(courseId: number): Observable; + + abstract getCourseStructure(courseId: number): Observable; + + abstract getCourseLesson(lessonId: number): Observable; + + abstract postAnswerQuestion( + taskId: number, + answerText?: any, + optionIds?: number[], + fileIds?: number[] + ): Observable; +} 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/domain/feed/ports/feed.repository.port.ts b/projects/social_platform/src/app/domain/feed/ports/feed.repository.port.ts new file mode 100644 index 000000000..a1fde2ce9 --- /dev/null +++ b/projects/social_platform/src/app/domain/feed/ports/feed.repository.port.ts @@ -0,0 +1,13 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { FeedItem } from "../feed-item.model"; + +export abstract class FeedRepositoryPort { + abstract fetchFeed( + offset: number, + limit: number, + type: string + ): Observable>; +} 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/domain/industry/ports/industry.repository.port.ts b/projects/social_platform/src/app/domain/industry/ports/industry.repository.port.ts new file mode 100644 index 000000000..69aba802c --- /dev/null +++ b/projects/social_platform/src/app/domain/industry/ports/industry.repository.port.ts @@ -0,0 +1,16 @@ +/** @format */ + +import { Signal } from "@angular/core"; +import { Observable } from "rxjs"; +import { Industry } from "../industry.model"; + +/** + * Порт репозитория отраслей. + * Реализуется в infrastructure/repository/industry/industry.repository.ts + */ +export abstract class IndustryRepositoryPort { + abstract readonly industries: Signal; + + abstract getAll(): Observable; + abstract getOne(industryId: number): Industry | undefined; +} diff --git a/projects/social_platform/src/app/domain/invite/commands/send-for-user.command.ts b/projects/social_platform/src/app/domain/invite/commands/send-for-user.command.ts new file mode 100644 index 000000000..3b4fbb16c --- /dev/null +++ b/projects/social_platform/src/app/domain/invite/commands/send-for-user.command.ts @@ -0,0 +1,8 @@ +/** @format */ + +export interface SendForUserCommand { + userId: number; + projectId: number; + role: string; + specialization?: string; +} diff --git a/projects/social_platform/src/app/domain/invite/commands/update-invite.command.ts b/projects/social_platform/src/app/domain/invite/commands/update-invite.command.ts new file mode 100644 index 000000000..139f17b01 --- /dev/null +++ b/projects/social_platform/src/app/domain/invite/commands/update-invite.command.ts @@ -0,0 +1,7 @@ +/** @format */ + +export interface UpdateInviteCommand { + inviteId: number; + role: string; + specialization?: string; +} diff --git a/projects/social_platform/src/app/domain/invite/events/accept-invite.event.ts b/projects/social_platform/src/app/domain/invite/events/accept-invite.event.ts new file mode 100644 index 000000000..84b0f9fed --- /dev/null +++ b/projects/social_platform/src/app/domain/invite/events/accept-invite.event.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +/** + * Событие принятия приглашения в проект + * Излучается когда пользователь принимает приглашение присоединиться к проекту + */ +export interface AcceptInvite extends DomainEvent { + readonly type: "AcceptInvite"; + readonly payload: { + readonly inviteId: number; + readonly projectId: number; + readonly userId: number; + readonly role: string; + }; +} + +export function acceptInvite( + inviteId: number, + projectId: number, + userId: number, + role: string +): AcceptInvite { + return { + type: "AcceptInvite", + payload: { + inviteId, + projectId, + userId, + role, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/invite/events/reject-invite.event.ts b/projects/social_platform/src/app/domain/invite/events/reject-invite.event.ts new file mode 100644 index 000000000..a185ad8d3 --- /dev/null +++ b/projects/social_platform/src/app/domain/invite/events/reject-invite.event.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +/** + * Событие отклонения приглашения в проект + * Излучается когда пользователь отклоняет приглашение присоединиться к проекту + */ +export interface RejectInvite extends DomainEvent { + readonly type: "RejectInvite"; + readonly payload: { + readonly inviteId: number; + readonly projectId: number; + readonly userId: number; + }; +} + +export function rejectInvite(inviteId: number, projectId: number, userId: number): RejectInvite { + return { + type: "RejectInvite", + payload: { + inviteId, + projectId, + userId, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/invite/events/revoke-invite.event.ts b/projects/social_platform/src/app/domain/invite/events/revoke-invite.event.ts new file mode 100644 index 000000000..11c7aa476 --- /dev/null +++ b/projects/social_platform/src/app/domain/invite/events/revoke-invite.event.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +/** + * Событие отзыва приглашения в проект + * Излучается когда инициатор отзывает отправленное приглашение + */ +export interface RevokeInvite extends DomainEvent { + readonly type: "RevokeInvite"; + readonly payload: { + readonly inviteId: number; + readonly projectId: number; + readonly userId: number; + }; +} + +export function revokeInvite(inviteId: number, projectId: number, userId: number): RevokeInvite { + return { + type: "RevokeInvite", + payload: { + inviteId, + projectId, + userId, + }, + occurredAt: new Date(), + }; +} 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 88% 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..0e60ed535 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 "@domain/auth/user.model"; +import { Project } from "../project/project.model"; /** * Модель приглашения в проект diff --git a/projects/social_platform/src/app/domain/invite/ports/invite.repository.port.ts b/projects/social_platform/src/app/domain/invite/ports/invite.repository.port.ts new file mode 100644 index 000000000..4644d18b5 --- /dev/null +++ b/projects/social_platform/src/app/domain/invite/ports/invite.repository.port.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { Invite } from "../invite.model"; + +/** + * Порт репозитория приглашений. + * Реализуется в infrastructure/repository/invite/invite.repository.ts + */ +export abstract class InviteRepositoryPort { + abstract sendForUser( + userId: number, + projectId: number, + role: string, + specialization?: string + ): Observable; + abstract revokeInvite(invitationId: number): Observable; + abstract acceptInvite(inviteId: number): Observable; + abstract rejectInvite(inviteId: number): Observable; + abstract updateInvite( + inviteId: number, + role: string, + specialization?: string + ): Observable; + abstract getMy(): Observable; + abstract getByProject(projectId: number): Observable; +} 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..fba48ae79 --- /dev/null +++ b/projects/social_platform/src/app/domain/kanban/comments.model.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { FileModel } from "@domain/file/file.model"; +import { TaskDetail } from "./task.model"; +import { User } from "@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/ports/kanban.repository.port.ts b/projects/social_platform/src/app/domain/kanban/ports/kanban.repository.port.ts new file mode 100644 index 000000000..517b309be --- /dev/null +++ b/projects/social_platform/src/app/domain/kanban/ports/kanban.repository.port.ts @@ -0,0 +1,16 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { Board } from "../board.model"; +import { Column } from "../column.model"; +import { TaskDetail } from "../task.model"; + +/** + * Порт репозитория канбан-доски. + * Определяет контракт для работы с досками, колонками и задачами. + */ +export abstract class KanbanRepositoryPort { + abstract getBoardByProjectId(projectId: number): Observable; + abstract getTasksByColumnId(columnId: number): Observable; + abstract getTaskById(taskId: number): Observable; +} 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..39c764463 --- /dev/null +++ b/projects/social_platform/src/app/domain/kanban/task.model.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { User } from "@domain/auth/user.model"; +import { FileModel } from "@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/domain/member/ports/member.repository.port.ts b/projects/social_platform/src/app/domain/member/ports/member.repository.port.ts new file mode 100644 index 000000000..112a0aee4 --- /dev/null +++ b/projects/social_platform/src/app/domain/member/ports/member.repository.port.ts @@ -0,0 +1,15 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { User } from "../../auth/user.model"; + +export abstract class MemberRepositoryPort { + abstract getMembers( + skip: number, + take: number, + otherParams?: Record + ): Observable>; + + abstract getMentors(skip: number, take: number): Observable>; +} 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 97% 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 index 0085ea9d8..fd0108347 100644 --- a/projects/social_platform/src/app/office/models/filter-fields.model.ts +++ b/projects/social_platform/src/app/domain/other/filter-fields.model.ts @@ -5,7 +5,7 @@ import { Observable } from "rxjs"; export interface UnifiedOption { id: string | number; label: string; - value?: any; + value?: string | number | boolean; } export interface FilterFieldConfig { diff --git a/projects/social_platform/src/app/domain/other/navigation.model.ts b/projects/social_platform/src/app/domain/other/navigation.model.ts new file mode 100644 index 000000000..147325c01 --- /dev/null +++ b/projects/social_platform/src/app/domain/other/navigation.model.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { EditStep } from "@api/project/project-step.service"; + +export interface Navigation { + step: EditStep; + src: string; + label: string; +} 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/domain/profile/ports/profile-news.repository.port.ts b/projects/social_platform/src/app/domain/profile/ports/profile-news.repository.port.ts new file mode 100644 index 000000000..58056a735 --- /dev/null +++ b/projects/social_platform/src/app/domain/profile/ports/profile-news.repository.port.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { ProfileNews } from "../profile-news.model"; + +export abstract class ProfileNewsRepositoryPort { + abstract fetchNews(id: number): Observable>; + abstract fetchNewsDetail(userId: string, newsId: string): Observable; + abstract addNews(userId: string, obj: { text: string; files: string[] }): Observable; + abstract readNews(userId: number, newsIds: number[]): Observable; + abstract delete(userId: string, newsId: number): Observable; + abstract toggleLike(userId: string, newsId: number, state: boolean): Observable; + abstract editNews( + userId: string, + newsId: number, + newsItem: Partial + ): Observable; +} 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 97% 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..2b7228409 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 "@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 95% 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 index 3c76f10c6..fd3798805 100644 --- 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 @@ -27,7 +27,7 @@ export class PartnerProgramFieldsValues { value!: string; } -export class projectNewAdditionalProgramVields { +export class ProjectNewAdditionalProgramFields { field_id!: number; value_text!: string | boolean; } diff --git a/projects/social_platform/src/app/domain/program/ports/program-news.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-news.repository.port.ts new file mode 100644 index 000000000..218b26259 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/ports/program-news.repository.port.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { FeedNews } from "../../project/project-news.model"; + +export abstract class ProgramNewsRepositoryPort { + abstract fetchNews( + limit: number, + offset: number, + programId: number + ): Observable>; + + abstract readNews(programId: string, newsIds: number[]): Observable; + + abstract toggleLike(programId: string, newsId: number, state: boolean): Observable; + + abstract addNews(programId: number, obj: { text: string; files: string[] }): Observable; + + abstract editNews( + programId: number, + newsId: number, + newsItem: Partial + ): Observable; + + abstract deleteNews(programId: number, newsId: number): Observable; +} diff --git a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts new file mode 100644 index 000000000..752ef2deb --- /dev/null +++ b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts @@ -0,0 +1,59 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { Observable } from "rxjs"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { Program, ProgramDataSchema } from "../program.model"; +import { ProgramCreate } from "../program-create.model"; +import { Project } from "../../project/project.model"; +import { User } from "../../auth/user.model"; +import { PartnerProgramFields } from "../partner-program-fields.model"; +import { ProjectAdditionalFields } from "../../project/project-additional-fields.model"; + +export abstract class ProgramRepositoryPort { + abstract getAll( + skip: number, + take: number, + params?: HttpParams + ): Observable>; + + abstract getActualPrograms(): Observable>; + + abstract getOne(programId: number): Observable; + + abstract create(program: ProgramCreate): Observable; + + abstract getDataSchema(programId: number): Observable; + + abstract register( + programId: number, + additionalData: Record + ): Observable; + + abstract getAllProjects( + programId: number, + params?: HttpParams + ): Observable>; + + abstract getAllMembers( + programId: number, + skip: number, + take: number + ): Observable>; + + abstract getProgramFilters(programId: number): Observable; + + abstract getProgramProjectAdditionalFields( + programId: number + ): Observable; + + abstract applyProjectToProgram(programId: number, body: any): Observable; + + abstract createProgramFilters( + programId: number, + filters: Record, + params?: HttpParams + ): Observable>; + + abstract submitCompettetiveProject(relationId: number): Observable; +} 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/domain/project/commands/update-form.command.ts b/projects/social_platform/src/app/domain/project/commands/update-form.command.ts new file mode 100644 index 000000000..157f3ac8f --- /dev/null +++ b/projects/social_platform/src/app/domain/project/commands/update-form.command.ts @@ -0,0 +1,8 @@ +/** @format */ + +import { ProjectDto } from "@infrastructure/adapters/project/dto/project.dto"; + +export interface UpdateFormCommand { + id: number; + data: Partial; +} diff --git a/projects/social_platform/src/app/domain/project/events/project-created.event.ts b/projects/social_platform/src/app/domain/project/events/project-created.event.ts new file mode 100644 index 000000000..e2037c5e5 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/events/project-created.event.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; +import { Project } from "../project.model"; + +export interface ProjectCreated extends DomainEvent { + readonly type: "ProjectCreated"; + readonly payload: { + readonly projectId: number; + readonly project: Project; + }; +} + +export function projectCreated(project: Project): ProjectCreated { + return { + type: "ProjectCreated", + payload: { projectId: project.id, project }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/project/events/project-deleted.event.ts b/projects/social_platform/src/app/domain/project/events/project-deleted.event.ts new file mode 100644 index 000000000..218947c0e --- /dev/null +++ b/projects/social_platform/src/app/domain/project/events/project-deleted.event.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +export interface ProjectDeleted extends DomainEvent { + readonly type: "ProjectDeleted"; + readonly payload: { + readonly projectId: number; + }; +} + +export function projectDeleted(projectId: number): ProjectDeleted { + return { + type: "ProjectDeleted", + payload: { projectId }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/project/events/project-subscribed.event.ts b/projects/social_platform/src/app/domain/project/events/project-subscribed.event.ts new file mode 100644 index 000000000..fd979d06a --- /dev/null +++ b/projects/social_platform/src/app/domain/project/events/project-subscribed.event.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +export interface ProjectSubscribed extends DomainEvent { + readonly type: "ProjectSubscribed"; + readonly payload: { + readonly projectId: number; + }; +} + +export function projectSubscribed(projectId: number): ProjectSubscribed { + return { + type: "ProjectSubscribed", + payload: { + projectId, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/project/events/project-unsubsribed.event.ts b/projects/social_platform/src/app/domain/project/events/project-unsubsribed.event.ts new file mode 100644 index 000000000..d142b12cd --- /dev/null +++ b/projects/social_platform/src/app/domain/project/events/project-unsubsribed.event.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +export interface ProjectUnSubscribed extends DomainEvent { + readonly type: "ProjectUnSubscribed"; + readonly payload: { + readonly projectId: number; + }; +} + +export function projectUnSubscribed(projectId: number): ProjectUnSubscribed { + return { + type: "ProjectUnSubscribed", + payload: { + projectId, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/project/events/remove-project-collaborator.event.ts b/projects/social_platform/src/app/domain/project/events/remove-project-collaborator.event.ts new file mode 100644 index 000000000..362b6371c --- /dev/null +++ b/projects/social_platform/src/app/domain/project/events/remove-project-collaborator.event.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +export interface RemoveProjectCollaborator extends DomainEvent { + readonly type: "RemoveProjectCollaborator"; + readonly payload: { + readonly projectId: number; + readonly userId: number; + }; +} + +export function removeProjectCollaborator( + projectId: number, + userId: number +): RemoveProjectCollaborator { + return { + type: "RemoveProjectCollaborator", + payload: { + projectId, + userId, + }, + occurredAt: new Date(), + }; +} 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 85% 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..de6e6249a 100644 --- a/projects/social_platform/src/app/office/models/goals.model.ts +++ b/projects/social_platform/src/app/domain/project/goals.model.ts @@ -17,14 +17,6 @@ class ResponsibleInfo { avatar!: string | null; } -export class GoalPostForm { - id?: number; - title!: string; - completionDate!: string; - responsible!: number; - isDone!: boolean; -} - export class Goal { id!: number; project!: number; diff --git a/projects/social_platform/src/app/domain/project/partner.model.ts b/projects/social_platform/src/app/domain/project/partner.model.ts new file mode 100644 index 000000000..b32344f8e --- /dev/null +++ b/projects/social_platform/src/app/domain/project/partner.model.ts @@ -0,0 +1,22 @@ +/** @format */ + +interface Company { + id: number; + name: string; + inn: string; +} + +export class Partner { + id!: number; + projecId!: number; + company!: Company; + contribution!: string; + decisionMaker!: number; +} + +export interface PartnerDto { + name: string; + inn: string; + contribution: string; + decisionMaker: number; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project-collaborators.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-collaborators.repository.port.ts new file mode 100644 index 000000000..4eb0dfefe --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project-collaborators.repository.port.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Observable } from "rxjs"; + +export abstract class ProjectCollaboratorsRepositoryPort { + abstract deleteCollaborator(projectId: number, userId: number): Observable; + abstract patchSwitchLeader(projectId: number, userId: number): Observable; + abstract deleteLeave(projectId: number): Observable; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project-goals.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-goals.repository.port.ts new file mode 100644 index 000000000..e7eea223c --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project-goals.repository.port.ts @@ -0,0 +1,16 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { Goal } from "../goals.model"; +import { GoalFormData } from "@infrastructure/adapters/project/dto/project-goal.dto"; + +/** + * Порт репозитория целей проекта. + * Реализуется в infrastructure/repository/project/project-goals.repository.ts + */ +export abstract class ProjectGoalsRepositoryPort { + abstract fetchAll(projectId: number): Observable; + abstract createGoal(projectId: number, params: GoalFormData[]): Observable; + abstract editGoal(projectId: number, goalId: number, params: GoalFormData): Observable; + abstract deleteGoal(projectId: number, goalId: number): Observable; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project-news.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-news.repository.port.ts new file mode 100644 index 000000000..2fee2009a --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project-news.repository.port.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { FeedNews } from "../project-news.model"; +import { ApiPagination } from "../../other/api-pagination.model"; + +export abstract class ProjectNewsRepositoryPort { + abstract fetchNews(projectId: string): Observable>; + + abstract fetchNewsDetail(projectId: string, newsId: string): Observable; + + abstract addNews(projectId: string, obj: { text: string; files: string[] }): Observable; + + abstract readNews(projectId: number, newsIds: number[]): Observable; + + abstract delete(projectId: string, newsId: number): Observable; + + abstract toggleLike(projectId: string, newsId: number, state: boolean): Observable; + + abstract editNews( + projectId: string, + newsId: number, + newsItem: Partial + ): Observable; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project-partner.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-partner.repository.port.ts new file mode 100644 index 000000000..b4f114384 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project-partner.repository.port.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { Partner, PartnerDto } from "../partner.model"; + +/** + * Порт репозитория партнёров проекта. + * Реализуется в infrastructure/repository/project/project-partner.repository.ts + */ +export abstract class ProjectPartnerRepositoryPort { + abstract fetchAll(projectId: number): Observable; + abstract createPartner(projectId: number, params: PartnerDto): Observable; + abstract updatePartner( + projectId: number, + companyId: number, + params: Pick + ): Observable; + abstract deletePartner(projectId: number, companyId: number): Observable; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project-program.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-program.repository.port.ts new file mode 100644 index 000000000..9dda63641 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project-program.repository.port.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { ProjectAssign } from "../project-assign.model"; +import { Project } from "../project.model"; +import { ProjectNewAdditionalProgramFields } from "../../program/partner-program-fields.model"; + +export abstract class ProjectProgramRepositoryPort { + abstract assignProjectToProgram( + projectId: number, + partnerProgramId: number + ): Observable; + + abstract sendNewProjectFieldsValues( + projectId: number, + newValues: ProjectNewAdditionalProgramFields[] + ): Observable; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project-rating.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-rating.repository.port.ts new file mode 100644 index 000000000..6b308cf0b --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project-rating.repository.port.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { ProjectRate } from "../project-rate"; +import { HttpParams } from "@angular/common/http"; +import { ProjectRatingCriterionOutput } from "../project-rating-criterion-output"; +import { ProjectRatingCriterion } from "../project-rating-criterion"; + +export abstract class ProjectRatingRepositoryPort { + abstract getAll(programId: number, params?: HttpParams): Observable>; + + abstract postFilters( + programId: number, + filters: Record, + params?: HttpParams + ): Observable>; + + abstract rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable; + + abstract formValuesToDTO( + criteria: ProjectRatingCriterion[], + outputVals: Record + ): ProjectRatingCriterionOutput[]; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project-resource.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-resource.repository.port.ts new file mode 100644 index 000000000..de87f33af --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project-resource.repository.port.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { Resource, ResourceDto } from "../resource.model"; + +/** + * Порт репозитория ресурсов проекта. + * Реализуется в infrastructure/repository/project/project-resource.repository.ts + */ +export abstract class ProjectResourceRepositoryPort { + abstract fetchAll(projectId: number): Observable; + abstract createResource( + projectId: number, + params: Omit + ): Observable; + abstract updateResource( + projectId: number, + resourceId: number, + params: Omit + ): Observable; + abstract deleteResource(projectId: number, resourceId: number): Observable; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project-subscription.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-subscription.repository.port.ts new file mode 100644 index 000000000..0d6eb894c --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project-subscription.repository.port.ts @@ -0,0 +1,17 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { Observable } from "rxjs"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { ProjectSubscriber } from "../project-subscriber.model"; +import { Project } from "../project.model"; + +export abstract class ProjectSubscriptionRepositoryPort { + abstract getSubscribers(projectId: number): Observable; + abstract addSubscription(projectId: number): Observable; + abstract getSubscriptions( + userId: number, + params?: HttpParams + ): Observable>; + abstract deleteSubscription(projectId: number): Observable; +} diff --git a/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts new file mode 100644 index 000000000..c0938d602 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { BehaviorSubject, Observable } from "rxjs"; +import { Project, ProjectCount } from "../project.model"; +import { ApiPagination } from "../../other/api-pagination.model"; +import { HttpParams } from "@angular/common/http"; +import { ProjectDto } from "@infrastructure/adapters/project/dto/project.dto"; + +/** + * Порт репозитория проектов. + * Определяет контракт для CRUD операций с проектами. + * Реализуется в infrastructure/repository/project/project.repository.ts + */ +export abstract class ProjectRepositoryPort { + abstract readonly count$: BehaviorSubject; + + abstract getAll(params?: HttpParams): Observable>; + abstract getOne(id: number): Observable; + abstract getMy(params?: HttpParams): Observable>; + abstract postOne(): Observable; + abstract update(id: number, data: Partial): Observable; + abstract deleteOne(id: number): Observable; + abstract refreshCount(): Observable; + abstract invalidate(id: number): void; +} diff --git a/projects/social_platform/src/app/domain/project/project-additional-fields.model.ts b/projects/social_platform/src/app/domain/project/project-additional-fields.model.ts new file mode 100644 index 000000000..016008b33 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/project-additional-fields.model.ts @@ -0,0 +1,10 @@ +/** @format */ + +import { PartnerProgramFields } from "../program/partner-program-fields.model"; + +export class ProjectAdditionalFields { + programId!: number; + canSubmit!: boolean; + submissionDeadline!: string; + programFields!: PartnerProgramFields[]; +} 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 97% 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..d3f98c98a 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 "@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/domain/project/resource.model.ts b/projects/social_platform/src/app/domain/project/resource.model.ts new file mode 100644 index 000000000..fe63a0d92 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/resource.model.ts @@ -0,0 +1,16 @@ +/** @format */ + +export class Resource { + id!: number; + projectId!: number; + type!: "infrastructure" | "staff" | "financial" | "information"; + description!: string; + partnerCompany!: number; +} + +export interface ResourceDto { + projectId: number; + type: string; + description: string; + partnerCompany: number; +} diff --git a/projects/social_platform/src/app/office/models/skill.model.ts b/projects/social_platform/src/app/domain/project/skill.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/skill.model.ts rename to projects/social_platform/src/app/domain/project/skill.model.ts diff --git a/projects/social_platform/src/app/office/models/skills-group.model.ts b/projects/social_platform/src/app/domain/project/skills-group.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/skills-group.model.ts rename to projects/social_platform/src/app/domain/project/skills-group.model.ts diff --git a/projects/social_platform/src/app/office/models/specialization.model.ts b/projects/social_platform/src/app/domain/project/specialization.model.ts similarity index 100% rename from projects/social_platform/src/app/office/models/specialization.model.ts rename to projects/social_platform/src/app/domain/project/specialization.model.ts diff --git a/projects/social_platform/src/app/office/models/specializations-group.model.ts b/projects/social_platform/src/app/domain/project/specializations-group.model.ts similarity index 88% rename from projects/social_platform/src/app/office/models/specializations-group.model.ts rename to projects/social_platform/src/app/domain/project/specializations-group.model.ts index 49717c780..1f56cd32e 100644 --- a/projects/social_platform/src/app/office/models/specializations-group.model.ts +++ b/projects/social_platform/src/app/domain/project/specializations-group.model.ts @@ -1,6 +1,6 @@ /** @format */ -import type { Specialization } from "./specialization.model"; +import { Specialization } from "./specialization.model"; /** * Модель группы специализаций diff --git a/projects/skills/src/models/step.model.ts b/projects/social_platform/src/app/domain/project/step.model.ts similarity index 100% rename from projects/skills/src/models/step.model.ts rename to projects/social_platform/src/app/domain/project/step.model.ts diff --git a/projects/social_platform/src/app/domain/shared/async-state.ts b/projects/social_platform/src/app/domain/shared/async-state.ts new file mode 100644 index 000000000..73017d743 --- /dev/null +++ b/projects/social_platform/src/app/domain/shared/async-state.ts @@ -0,0 +1,49 @@ +/** + * Все возможные состояния асинхронной операции + * + * @format + */ + +export type AsyncState = + | { readonly status: "initial" } + | { readonly status: "loading"; readonly previous?: T } + | { readonly status: "success"; readonly data: T } + | { readonly status: "failure"; readonly error: E; readonly previous?: T }; + +// ── Фабрики (конструкторы состояний) ── + +export function initial(): AsyncState { + return { status: "initial" }; +} + +export function loading(previous?: T): AsyncState { + return { status: "loading", previous }; +} + +export function success(data: T): AsyncState { + return { status: "success", data }; +} + +export function failure(error: E, previous?: unknown): AsyncState { + return { status: "failure", error, previous } as any; +} + +// ── Type Guards (сужение типов) ── + +export function isInitial(state: AsyncState): state is { status: "initial" } { + return state.status === "initial"; +} + +export function isLoading( + state: AsyncState +): state is { status: "loading"; previous?: T } { + return state.status === "loading"; +} + +export function isSuccess(state: AsyncState): state is { status: "success"; data: T } { + return state.status === "success"; +} + +export function isFailure(state: AsyncState): state is { status: "failure"; error: E } { + return state.status === "failure"; +} diff --git a/projects/social_platform/src/app/domain/shared/domain-event.ts b/projects/social_platform/src/app/domain/shared/domain-event.ts new file mode 100644 index 000000000..1f2c32824 --- /dev/null +++ b/projects/social_platform/src/app/domain/shared/domain-event.ts @@ -0,0 +1,20 @@ +/** @format */ + +/** + * Базовый интерфейс доменного события. + * Доменные события описывают что произошло в системе (прошедшее время). + * + * @example + * export interface ProjectPublished extends DomainEvent { + * readonly type: 'ProjectPublished'; + * readonly payload: { projectId: number }; + * }w + */ +export interface DomainEvent { + /** Уникальный тип события (используется для маршрутизации) */ + readonly type: string; + /** Данные события */ + readonly payload: unknown; + /** Время создания события */ + readonly occurredAt: Date; +} diff --git a/projects/social_platform/src/app/domain/shared/entity-cache.ts b/projects/social_platform/src/app/domain/shared/entity-cache.ts new file mode 100644 index 000000000..a99b01f36 --- /dev/null +++ b/projects/social_platform/src/app/domain/shared/entity-cache.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { Observable, shareReplay } from "rxjs"; + +export class EntityCache { + private readonly store = new Map>(); + + getOrFetch(id: number, factory: () => Observable): Observable { + if (!this.store.has(id)) { + const entity$ = factory().pipe(shareReplay(1)); + + this.store.set(id, entity$); + } + + return this.store.get(id)!; + } + + invalidate(id: number): void { + this.store.delete(id); + } + + clear(): void { + this.store.clear(); + } +} diff --git a/projects/social_platform/src/app/domain/shared/event-bus.ts b/projects/social_platform/src/app/domain/shared/event-bus.ts new file mode 100644 index 000000000..386a60068 --- /dev/null +++ b/projects/social_platform/src/app/domain/shared/event-bus.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { filter, map, Observable, Subject } from "rxjs"; +import { DomainEvent } from "./domain-event"; +import { LoggerService } from "@corelib"; + +@Injectable({ providedIn: "root" }) +export class EventBus { + private readonly events$ = new Subject(); + private readonly logger = inject(LoggerService); + + constructor() { + this.events$.subscribe(event => { + this.logger.debug(`[EventBus] ${event.type}`, event.payload); + }); + } + + emit(event: T): void { + this.events$.next(event); + } + + on(type: T["type"]): Observable { + return this.events$.pipe( + filter(e => e.type === type), + map(e => e as T) + ); + } +} diff --git a/projects/social_platform/src/app/domain/shared/result.type.ts b/projects/social_platform/src/app/domain/shared/result.type.ts new file mode 100644 index 000000000..a7e0deb43 --- /dev/null +++ b/projects/social_platform/src/app/domain/shared/result.type.ts @@ -0,0 +1,35 @@ +/** @format */ + +/** + * Тип Result для обработки успешных и ошибочных результатов в use cases. + * Позволяет избежать throw/catch и явно типизировать ошибки. + * + * @example + * // В use case: + * execute(cmd): Observable> { + * return repo.login(cmd).pipe( + * map(user => ok(user)), + * catchError(err => of(fail({ kind: 'wrong_credentials' }))) + * ); + * } + * + * // В presenter: + * result.match({ + * ok: user => this.router.navigateByUrl('/office'), + * fail: err => this.error.set(err.kind), + * }); + */ + +export type Result = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: E }; + +/** Создаёт успешный Result */ +export function ok(value: T): Result { + return { ok: true, value }; +} + +/** Создаёт ошибочный Result */ +export function fail(error: E): Result { + return { ok: false, error }; +} diff --git a/projects/social_platform/src/app/domain/shared/to-async-state.ts b/projects/social_platform/src/app/domain/shared/to-async-state.ts new file mode 100644 index 000000000..c8678b9a9 --- /dev/null +++ b/projects/social_platform/src/app/domain/shared/to-async-state.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { catchError, map, Observable, of, startWith } from "rxjs"; +import { Result } from "./result.type"; +import { AsyncState, failure, loading, success } from "./async-state"; + +/** + * Превращает Observable> в Observable> + * + * Автоматически: + * - Начинает с loading + * - ok → success + * - fail → failure + * - HTTP-ошибка → failure + */ +export function toAsyncState( + previous?: T +): (source: Observable>) => Observable> { + return source => + source.pipe( + map(result => + result.ok + ? (success(result.value) as AsyncState) + : (failure(result.error, previous) as AsyncState) + ), + startWith(loading(previous) as AsyncState), + catchError(err => of(failure(err?.message ?? "Unknown error", previous) as AsyncState)) + ); +} diff --git a/projects/social_platform/src/app/domain/skills/ports/skills.repository.port.ts b/projects/social_platform/src/app/domain/skills/ports/skills.repository.port.ts new file mode 100644 index 000000000..340bea36e --- /dev/null +++ b/projects/social_platform/src/app/domain/skills/ports/skills.repository.port.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { Skill } from "../skill"; +import { SkillsGroup } from "../skills-group"; +import { ApiPagination } from "../../other/api-pagination.model"; + +/** + * Порт репозитория навыков. + * Реализуется в infrastructure/repository/skills/skills.repository.ts + */ +export abstract class SkillsRepositoryPort { + abstract getSkillsNested(): Observable; + abstract getSkillsInline( + search: string, + limit: number, + offset: number + ): Observable>; +} diff --git a/projects/social_platform/src/app/domain/skills/skill.ts b/projects/social_platform/src/app/domain/skills/skill.ts new file mode 100644 index 000000000..5ba79f980 --- /dev/null +++ b/projects/social_platform/src/app/domain/skills/skill.ts @@ -0,0 +1,49 @@ +/** + * Интерфейс для подтверждения навыка + * Содержит информацию о пользователе, который подтвердил навык + * + * @format + */ + +export interface Approve { + /** Информация о пользователе, подтвердившем навык */ + confirmedBy: { + /** Идентификатор подтверждающего пользователя */ + id: number; + /** Имя подтверждающего пользователя */ + firstName: string; + /** Фамилия подтверждающего пользователя */ + lastName: string; + /** URL аватара подтверждающего пользователя */ + avatar: string; + /** Специальность подтверждающего пользователя */ + speciality: string; + /** Информация о специальности версии 2 */ + v2Speciality: { + /** Идентификатор специальности */ + id: number; + /** Название специальности */ + name: string; + }; + }; +} + +/** + * Интерфейс навыка + * Представляет конкретный навык пользователя с категорией и подтверждениями + */ +export interface Skill { + /** Уникальный идентификатор навыка */ + id: number; + /** Название навыка */ + name: string; + /** Категория, к которой относится навык */ + category: { + /** Идентификатор категории */ + id: number; + /** Название категории */ + name: string; + }; + /** Массив подтверждений данного навыка от других пользователей */ + approves: Approve[]; +} diff --git a/projects/social_platform/src/app/domain/skills/skills-group.ts b/projects/social_platform/src/app/domain/skills/skills-group.ts new file mode 100644 index 000000000..015532d2f --- /dev/null +++ b/projects/social_platform/src/app/domain/skills/skills-group.ts @@ -0,0 +1,16 @@ +/** @format */ + +import type { Skill } from "./skill"; + +/** + * Интерфейс для группы навыков + * Представляет категорию навыков с вложенным списком конкретных навыков + */ +export interface SkillsGroup { + /** Уникальный идентификатор группы навыков */ + id: number; + /** Название группы навыков (например, "Программирование", "Дизайн") */ + name: string; + /** Массив навыков, входящих в данную группу */ + skills: Skill[]; +} diff --git a/projects/social_platform/src/app/domain/specializations/ports/specializations.repository.port.ts b/projects/social_platform/src/app/domain/specializations/ports/specializations.repository.port.ts new file mode 100644 index 000000000..f30d9ecb2 --- /dev/null +++ b/projects/social_platform/src/app/domain/specializations/ports/specializations.repository.port.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { Specialization } from "../specialization"; +import { SpecializationsGroup } from "../specializations-group"; +import { ApiPagination } from "../../other/api-pagination.model"; + +/** + * Порт репозитория специализаций. + * Реализуется в infrastructure/repository/specializations/specializations.repository.ts + */ +export abstract class SpecializationsRepositoryPort { + abstract getSpecializationsNested(): Observable; + abstract getSpecializationsInline( + search: string, + limit: number, + offset: number + ): Observable>; +} diff --git a/projects/social_platform/src/app/domain/specializations/specialization.ts b/projects/social_platform/src/app/domain/specializations/specialization.ts new file mode 100644 index 000000000..1fa2b0ffc --- /dev/null +++ b/projects/social_platform/src/app/domain/specializations/specialization.ts @@ -0,0 +1,14 @@ +/** @format */ + +/** + * Модель специализации пользователя + * Представляет профессиональную специализацию + * + * Содержит: + * - Уникальный идентификатор + * - Название специализации + */ +export interface Specialization { + id: number; + name: string; +} diff --git a/projects/social_platform/src/app/domain/specializations/specializations-group.ts b/projects/social_platform/src/app/domain/specializations/specializations-group.ts new file mode 100644 index 000000000..55149beb9 --- /dev/null +++ b/projects/social_platform/src/app/domain/specializations/specializations-group.ts @@ -0,0 +1,17 @@ +/** @format */ + +import type { Specialization } from "./specialization"; + +/** + * Модель группы специализаций + * Представляет категорию специализаций с вложенным списком + * + * Содержит: + * - Название группы специализаций + * - Массив специализаций в данной группе + */ +export interface SpecializationsGroup { + id: number; + name: string; + specializations: Specialization[]; +} diff --git a/projects/social_platform/src/app/domain/vacancy/events/accept-vacancy-response.event.ts b/projects/social_platform/src/app/domain/vacancy/events/accept-vacancy-response.event.ts new file mode 100644 index 000000000..60cfe45b3 --- /dev/null +++ b/projects/social_platform/src/app/domain/vacancy/events/accept-vacancy-response.event.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +/** + * Событие принятия отклика на вакансию + * Излучается когда рекрутер одобряет отклик кандидата на вакансию + */ +export interface AcceptVacancyResponse extends DomainEvent { + readonly type: "AcceptVacancyResponse"; + readonly payload: { + readonly vacancyResponseId: number; + readonly vacancyId: number; + readonly projectId: number; + readonly userId: number; + }; +} + +export function acceptVacancyResponse( + vacancyResponseId: number, + vacancyId: number, + projectId: number, + userId: number +): AcceptVacancyResponse { + return { + type: "AcceptVacancyResponse", + payload: { + vacancyResponseId, + vacancyId, + projectId, + userId, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/vacancy/events/reject-vacancy-response.event.ts b/projects/social_platform/src/app/domain/vacancy/events/reject-vacancy-response.event.ts new file mode 100644 index 000000000..aa5a0b3b3 --- /dev/null +++ b/projects/social_platform/src/app/domain/vacancy/events/reject-vacancy-response.event.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +/** + * Событие отклонения отклика на вакансию + * Излучается когда рекрутер отклоняет отклик кандидата на вакансию + */ +export interface RejectVacancyResponse extends DomainEvent { + readonly type: "RejectVacancyResponse"; + readonly payload: { + readonly vacancyResponseId: number; + readonly vacancyId: number; + readonly projectId: number; + readonly userId: number; + }; +} + +export function rejectVacancyResponse( + vacancyResponseId: number, + vacancyId: number, + projectId: number, + userId: number +): RejectVacancyResponse { + return { + type: "RejectVacancyResponse", + payload: { + vacancyResponseId, + vacancyId, + projectId, + userId, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/vacancy/events/send-vacancy-response.event.ts b/projects/social_platform/src/app/domain/vacancy/events/send-vacancy-response.event.ts new file mode 100644 index 000000000..0e1b3be60 --- /dev/null +++ b/projects/social_platform/src/app/domain/vacancy/events/send-vacancy-response.event.ts @@ -0,0 +1,34 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +export interface SendVacancyResponse extends DomainEvent { + readonly type: "SendVacancyResponse"; + readonly payload: { + readonly vacancyResponseId: number; + readonly vacancyId: number; + readonly projectId: number; + readonly userId: number; + readonly isApproved: boolean; + }; +} + +export function sendVacancyResponse( + vacancyResponseId: number, + vacancyId: number, + projectId: number, + userId: number, + isApproved: boolean +): SendVacancyResponse { + return { + type: "SendVacancyResponse", + payload: { + vacancyResponseId, + vacancyId, + projectId, + userId, + isApproved, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/vacancy/events/vacancy-created.event.ts b/projects/social_platform/src/app/domain/vacancy/events/vacancy-created.event.ts new file mode 100644 index 000000000..d2dc340f2 --- /dev/null +++ b/projects/social_platform/src/app/domain/vacancy/events/vacancy-created.event.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { CreateVacancyDto } from "@api/project/dto/create-vacancy.model"; +import { DomainEvent } from "../../shared/domain-event"; + +export interface VacancyCreated extends DomainEvent { + readonly type: "VacancyCreated"; + readonly payload: { + readonly projectId: number; + readonly vacancy: CreateVacancyDto; + }; +} + +export function vacancyCreated(projectId: number, vacancy: CreateVacancyDto): VacancyCreated { + return { + type: "VacancyCreated", + payload: { + projectId, + vacancy, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/vacancy/events/vacancy-deleted.event.ts b/projects/social_platform/src/app/domain/vacancy/events/vacancy-deleted.event.ts new file mode 100644 index 000000000..64337ccfc --- /dev/null +++ b/projects/social_platform/src/app/domain/vacancy/events/vacancy-deleted.event.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { DomainEvent } from "../../shared/domain-event"; + +export interface VacancyDelete extends DomainEvent { + readonly type: "VacancyDelete"; + readonly payload: { + readonly vacancyId: number; + }; +} + +export function vacancyDelete(vacancyId: number): VacancyDelete { + return { + type: "VacancyDelete", + payload: { + vacancyId, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/vacancy/events/vacancy-updated.event.ts b/projects/social_platform/src/app/domain/vacancy/events/vacancy-updated.event.ts new file mode 100644 index 000000000..943177411 --- /dev/null +++ b/projects/social_platform/src/app/domain/vacancy/events/vacancy-updated.event.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { CreateVacancyDto } from "@api/project/dto/create-vacancy.model"; +import { DomainEvent } from "../../shared/domain-event"; +import { Vacancy } from "../vacancy.model"; + +export interface VacancyUpdated extends DomainEvent { + readonly type: "VacancyUpdated"; + readonly payload: { + readonly vacancyId: number; + readonly vacancy: Partial | CreateVacancyDto; + }; +} + +export function vacancyUpdated( + vacancyId: number, + vacancy: Partial | CreateVacancyDto +): VacancyUpdated { + return { + type: "VacancyUpdated", + payload: { + vacancyId, + vacancy, + }, + occurredAt: new Date(), + }; +} diff --git a/projects/social_platform/src/app/domain/vacancy/ports/vacancy.repository.port.ts b/projects/social_platform/src/app/domain/vacancy/ports/vacancy.repository.port.ts new file mode 100644 index 000000000..487602683 --- /dev/null +++ b/projects/social_platform/src/app/domain/vacancy/ports/vacancy.repository.port.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { Observable } from "rxjs"; +import { Vacancy } from "../vacancy.model"; +import { VacancyResponse } from "../vacancy-response.model"; +import { CreateVacancyDto } from "@api/project/dto/create-vacancy.model"; + +/** + * Порт репозитория вакансий. + * Реализуется в infrastructure/repository/vacancy/vacancy.repository.ts + */ +export abstract class VacancyRepositoryPort { + abstract getForProject( + limit: number, + offset: number, + projectId?: number, + requiredExperience?: string, + workFormat?: string, + workSchedule?: string, + salary?: string, + searchValue?: string + ): Observable; + abstract getMyVacancies(limit: number, offset: number): Observable; + abstract getOne(vacancyId: number): Observable; + abstract postVacancy(projectId: number, vacancy: CreateVacancyDto): Observable; + abstract updateVacancy( + vacancyId: number, + vacancy: Partial | CreateVacancyDto + ): Observable; + abstract deleteVacancy(vacancyId: number): Observable; + abstract sendResponse(vacancyId: number, body: { whyMe: string }): Observable; + abstract responsesByProject(projectId: number): Observable; + abstract acceptResponse(responseId: number): Observable; + abstract rejectResponse(responseId: number): Observable; +} 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 86% 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..863b783fb 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 "@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 0030391d9..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.model"; +import { Project } from "../project/project.model"; +import { Skill } from "../skills/skill"; /** * Модель вакансии в проекте diff --git a/projects/social_platform/src/app/error/error.component.html b/projects/social_platform/src/app/error/error.component.html deleted file mode 100644 index c84e5e31f..000000000 --- a/projects/social_platform/src/app/error/error.component.html +++ /dev/null @@ -1,8 +0,0 @@ - - -
- - -
diff --git a/projects/social_platform/src/app/error/not-found/error-not-found.component.html b/projects/social_platform/src/app/error/not-found/error-not-found.component.html deleted file mode 100644 index 47a46f842..000000000 --- a/projects/social_platform/src/app/error/not-found/error-not-found.component.html +++ /dev/null @@ -1,8 +0,0 @@ - - -
-
- Страница не найдена -

Страница не найдена

-
-
diff --git a/projects/social_platform/src/app/error/services/global-error-handler.service.ts b/projects/social_platform/src/app/error/services/global-error-handler.service.ts deleted file mode 100644 index 400f09050..000000000 --- a/projects/social_platform/src/app/error/services/global-error-handler.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** @format */ - -import { ErrorHandler, Injectable, NgZone } from "@angular/core"; -import { ErrorService } from "./error.service"; - -/** - * Глобальный обработчик ошибок приложения - * - * Назначение: - * - Перехватывает все необработанные ошибки в приложении - * - Реализует интерфейс ErrorHandler от Angular - * - Обеспечивает централизованную обработку ошибок - * - * Функциональность: - * - Обрабатывает как синхронные, так и асинхронные ошибки (Promise rejections) - * - Логирует ошибки в консоль для отладки - * - Может перенаправлять на страницы ошибок (закомментированный код) - * - * Принимает: - * - err: any - любая ошибка, возникшая в приложении - * - * Возвращает: void - * - * Зависимости: - * - ErrorService - для навигации на страницы ошибок - * - NgZone - для выполнения операций в Angular зоне - * - * Примечание: - * - Код для обработки HTTP ошибок закомментирован - * - Можно расширить для специфической обработки разных типов ошибок - */ -@Injectable() -export class GlobalErrorHandlerService implements ErrorHandler { - constructor(private readonly errorService: ErrorService, private readonly zone: NgZone) {} - - /** - * Обрабатывает глобальные ошибки приложения - * @param err - ошибка или Promise rejection - */ - handleError(err: any): void { - // Извлекаем фактическую ошибку из Promise rejection или используем как есть - const error = err.rejection ? err.rejection : err; - - // Закомментированный код для обработки HTTP ошибок: - // if(error instanceof HttpErrorResponse) { - // switch(error.status) { - // case 404: { - // this.zone.run(() => this.errorService.throwNotFount()) - // break; - // } - // } - // } - - // Логируем ошибки типа Error в консоль - if (error instanceof Error) { - console.error(error); - } - } -} diff --git a/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.ts new file mode 100644 index 000000000..99b60e8f0 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.ts @@ -0,0 +1,152 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService, TokenService } from "@corelib"; +import { Observable } from "rxjs"; +import { LoginResponse, RegisterResponse } from "@domain/auth/http.model"; +import { User } from "@domain/auth/user.model"; +import { ProjectDto } from "../project/dto/project.dto"; +import { LoginCommand } from "@domain/auth/commands/login.command"; +import { RegisterCommand } from "@domain/auth/commands/register.command"; +import { ApiPagination } from "@domain/other/api-pagination.model"; + +@Injectable({ providedIn: "root" }) +export class AuthHttpAdapter { + private readonly API_TOKEN_URL = "/api/token"; + private readonly AUTH_URL = "/auth"; + private readonly AUTH_USERS_URL = "/auth/users"; + + private readonly apiService = inject(ApiService); + private readonly tokenService = inject(TokenService); + + /** + * Вход пользователя в систему + * @param credentials Данные для входа (email и пароль) + * @returns Observable с ответом сервера, содержащим токены + */ + login({ email, password }: LoginCommand): Observable { + return this.apiService.post(`${this.API_TOKEN_URL}/`, { email, password }); + } + + /** + * Выход пользователя из системы + * Отправляет refresh токен на сервер для инвалидации + * @returns Observable завершения операции + */ + logout(): Observable { + return this.apiService.post(`${this.AUTH_URL}/logout/`, { + refreshToken: this.tokenService.getTokens()?.refresh, + }); + } + + /** + * Регистрация нового пользователя + * @param data Данные для регистрации (email, пароль, имя и т.д.) + * @returns Observable с данными зарегистрированного пользователя + */ + register(data: RegisterCommand): Observable { + return this.apiService.post(`${this.AUTH_USERS_URL}/`, data); + } + + downloadCV(): Observable { + return this.apiService.getFile(`${this.AUTH_USERS_URL}/download_cv/`); + } + + /** + * Получить профиль текущего пользователя + * @returns Observable с данными профиля + */ + getProfile(): Observable { + return this.apiService.get(`${this.AUTH_USERS_URL}/current/`); + } + + /** + * Получить список всех типов пользователей + * @returns Observable с массивом ролей пользователей + */ + getUserRoles(): Observable<[[number, string]]> { + return this.apiService.get(`${this.AUTH_USERS_URL}/types/`); + } + + /** + * Получить проекты где пользователь leader + * @returns Observable проектов внутри профиля + */ + getLeaderProjects(): Observable> { + return this.apiService.get(`${this.AUTH_USERS_URL}/projects/leader/`); + } + + /** + * Получить роли, которые может изменить текущий пользователь + * @returns Observable с массивом изменяемых ролей + */ + getChangeableRoles(): Observable<[[number, string]]> { + return this.apiService.get(`${this.AUTH_USERS_URL}/roles/`); + } + + /** + * Получить данные пользователя по ID + * @param id Идентификатор пользователя + * @returns Observable с данными пользователя + */ + getUser(id: number): Observable { + return this.apiService.get(`${this.AUTH_USERS_URL}/${id}/`); + } + + /** + * Сохранить аватар пользователя + * @param url URL загруженного аватара + * @returns Observable с обновленными данными пользователя + */ + saveAvatar(url: string, profileId: number): Observable { + return this.apiService.patch(`${this.AUTH_USERS_URL}/${profileId}`, { avatar: url }); + } + + /** + * Сохранить изменения в профиле пользователя + * @param newProfile Частичные данные профиля для обновления + * @returns Observable с обновленными данными профиля + */ + saveProfile(newProfile: Partial): Observable { + return this.apiService.patch(`${this.AUTH_USERS_URL}/${newProfile.id}/`, newProfile); + } + + /** + * Установить этап онбординга для пользователя + * @param stage Номер этапа онбординга (null для завершения) + * @returns Observable с обновленными данными пользователя + */ + setOnboardingStage(stage: number | null, profileId: number): Observable { + return this.apiService.put(`${this.AUTH_USERS_URL}/${profileId}/set_onboarding_stage/`, { + onboardingStage: stage, + }); + } + + /** + * Запросить сброс пароля + * @param email Email для отправки ссылки сброса + * @returns Observable завершения операции + */ + resetPassword(email: string): Observable { + return this.apiService.post(`${this.AUTH_URL}/reset_password/`, { email }); + } + + /** + * Установить новый пароль после сброса + * @param password Новый пароль + * @param token Токен подтверждения сброса пароля + * @returns Observable завершения операции + */ + setPassword(password: string, token: string): Observable { + return this.apiService.post(`${this.AUTH_URL}/reset_password/confirm/`, { password, token }); + } + + /** + * Повторно отправить письмо подтверждения email + * @param email Email для повторной отправки + * @returns Observable с данными пользователя + */ + resendEmail(email: string): Observable { + return this.apiService.post(`${this.AUTH_URL}/resend_email/`, { email }); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/courses/courses-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/courses/courses-http.adapter.ts new file mode 100644 index 000000000..7814b0f9f --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/courses/courses-http.adapter.ts @@ -0,0 +1,47 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { + CourseCard, + CourseDetail, + CourseLesson, + CourseStructure, + TaskAnswerResponse, +} from "@domain/courses/courses.model"; +import { Observable } from "rxjs"; + +@Injectable({ providedIn: "root" }) +export class CoursesHttpAdapter { + private readonly COURSE_URL = "/courses"; + private readonly apiService = inject(ApiService); + + getCourses(): Observable { + return this.apiService.get(`${this.COURSE_URL}/`); + } + + getCourseDetail(id: number): Observable { + return this.apiService.get(`${this.COURSE_URL}/${id}/`); + } + + getCourseStructure(id: number): Observable { + return this.apiService.get(`${this.COURSE_URL}/${id}/structure/`); + } + + getCourseLesson(id: number): Observable { + return this.apiService.get(`${this.COURSE_URL}/lessons/${id}/`); + } + + postAnswerQuestion( + taskId: number, + answerText?: any, + optionIds?: number[], + fileIds?: number[] + ): Observable { + return this.apiService.post(`${this.COURSE_URL}/tasks/${taskId}/answer/`, { + answerText, + optionIds, + fileIds, + }); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/feed/feed-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/feed/feed-http.adapter.ts new file mode 100644 index 000000000..2d2cde6fd --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/feed/feed-http.adapter.ts @@ -0,0 +1,30 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedItem } from "@domain/feed/feed-item.model"; + +@Injectable({ providedIn: "root" }) +export class FeedHttpAdapter { + private readonly FEED_URL = "/feed"; + + private readonly apiService = inject(ApiService); + + /** + * Получает элементы ленты с пагинацией и фильтрацией + * + * @param offset - смещение для пагинации + * @param limit - количество элементов + * @param type - строка фильтра типов, объединённых через "|" (например "vacancy|news|project") + * @returns Observable> + */ + fetchFeed(offset: number, limit: number, type: string): Observable> { + return this.apiService.get>( + `${this.FEED_URL}/`, + new HttpParams({ fromObject: { limit, offset, type } }) + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/industry/industry-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/industry/industry-http.adapter.ts new file mode 100644 index 000000000..d284865a1 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/industry/industry-http.adapter.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Industry } from "@domain/industry/industry.model"; +import { Observable } from "rxjs"; + +@Injectable({ providedIn: "root" }) +export class IndustryHttpAdapter { + private readonly INDUSTRIES_URL = "/industries"; + + private readonly apiService = inject(ApiService); + + /** + * Получает список всех доступных отраслей с сервера + * Преобразует данные в типизированные объекты и кеширует их + * Обрабатывает ошибки и обновляет локальный кеш при успешной загрузке + * + * @returns Observable - массив отраслей с названиями и идентификаторами + */ + fetchAll(): Observable { + return this.apiService.get(`${this.INDUSTRIES_URL}/`); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.ts new file mode 100644 index 000000000..a3e6e91a2 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.ts @@ -0,0 +1,96 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { Invite } from "@domain/invite/invite.model"; +import { HttpParams } from "@angular/common/http"; + +/** + * Адаптер HTTP-запросов для управления приглашениями в проекты. + * Выполняет только сетевые операции без доменного маппинга. + */ +@Injectable({ providedIn: "root" }) +export class InviteHttpAdapter { + private readonly INVITES_URL = "/invites"; + private readonly apiService = inject(ApiService); + + /** + * Отправляет приглашение пользователю для участия в проекте. + * + * @param userId идентификатор пользователя, которому отправляется приглашение + * @param projectId идентификатор проекта + * @param role роль пользователя в проекте + * @param specialization специализация пользователя (необязательно) + */ + sendForUser( + userId: number, + projectId: number, + role: string, + specialization?: string + ): Observable { + return this.apiService.post(`${this.INVITES_URL}/`, { + user: userId, + project: projectId, + role, + specialization, + }); + } + + /** + * Отзывает (удаляет) отправленное приглашение. + * + * @param invitationId идентификатор приглашения + */ + revokeInvite(invitationId: number): Observable { + return this.apiService.delete(`${this.INVITES_URL}/${invitationId}`); + } + + /** + * Принимает приглашение в проект. + * + * @param inviteId идентификатор приглашения + */ + acceptInvite(inviteId: number): Observable { + return this.apiService.post(`${this.INVITES_URL}/${inviteId}/accept/`, {}); + } + + /** + * Отклоняет приглашение в проект. + * + * @param inviteId идентификатор приглашения + */ + rejectInvite(inviteId: number): Observable { + return this.apiService.post(`${this.INVITES_URL}/${inviteId}/decline/`, {}); + } + + /** + * Обновляет информацию о приглашении. + * + * @param inviteId идентификатор приглашения + * @param role новая роль + * @param specialization новая специализация (необязательно) + */ + updateInvite(inviteId: number, role: string, specialization?: string): Observable { + return this.apiService.patch(`${this.INVITES_URL}/${inviteId}`, { role, specialization }); + } + + /** + * Получает приглашения текущего пользователя. + */ + getMy(): Observable { + return this.apiService.get(`${this.INVITES_URL}/`); + } + + /** + * Получает приглашения по проекту. + * + * @param projectId идентификатор проекта + */ + getByProject(projectId: number): Observable { + return this.apiService.get( + `${this.INVITES_URL}/`, + new HttpParams({ fromObject: { project: projectId, user: "any" } }) + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/member/member-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/member/member-http.adapter.ts new file mode 100644 index 000000000..851d66102 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/member/member-http.adapter.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { User } from "@domain/auth/user.model"; + +@Injectable({ providedIn: "root" }) +export class MemberHttpAdapter { + private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; + private readonly apiService = inject(ApiService); + + getMembers( + skip: number, + take: number, + otherParams?: Record + ): Observable> { + let allParams = new HttpParams({ fromObject: { user_type: 1, limit: take, offset: skip } }); + if (otherParams) { + allParams = allParams.appendAll(otherParams); + } + return this.apiService.get>(`${this.AUTH_PUBLIC_USERS_URL}/`, allParams); + } + + getMentors(skip: number, take: number): Observable> { + return this.apiService.get>( + `${this.AUTH_PUBLIC_USERS_URL}/`, + new HttpParams({ fromObject: { user_type: "2,3,4", limit: take, offset: skip } }) + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/profile/profile-news-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/profile/profile-news-http.adapter.ts new file mode 100644 index 000000000..2abcebfdf --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/profile/profile-news-http.adapter.ts @@ -0,0 +1,101 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProfileNews } from "@domain/profile/profile-news.model"; + +@Injectable({ providedIn: "root" }) +export class ProfileNewsHttpAdapter { + private readonly AUTH_USERS_URL = "/auth/users"; + private readonly apiService = inject(ApiService); + + /** + * Получение списка новостей пользователя. + * + * @param userId идентификатор пользователя + * @returns пагинированный список новостей + */ + fetchNews(userId: string): Observable> { + return this.apiService.get>( + `${this.AUTH_USERS_URL}/${userId}/news/`, + new HttpParams({ fromObject: { limit: 10 } }) + ); + } + + /** + * Получение детальной информации о конкретной новости. + * + * @param userId идентификатор пользователя-владельца новости + * @param newsId идентификатор новости + */ + fetchNewsDetail(userId: string, newsId: string): Observable { + return this.apiService.get(`${this.AUTH_USERS_URL}/${userId}/news/${newsId}`); + } + + /** + * Создание новой новости в профиле пользователя. + * + * @param userId идентификатор пользователя + * @param obj объект с текстом и файлами новости + */ + addNews(userId: string, obj: { text: string; files: string[] }): Observable { + return this.apiService.post(`${this.AUTH_USERS_URL}/${userId}/news/`, obj); + } + + /** + * Отметка новости как просмотренной. + * + * @param userId идентификатор пользователя + * @param newsId идентификатор новости + */ + setNewsViewed(userId: number, newsId: number): Observable { + return this.apiService.post( + `${this.AUTH_USERS_URL}/${userId}/news/${newsId}/set_viewed/`, + {} + ); + } + + /** + * Удаление новости из профиля. + * + * @param userId идентификатор пользователя + * @param newsId идентификатор удаляемой новости + */ + deleteNews(userId: string, newsId: number): Observable { + return this.apiService.delete(`${this.AUTH_USERS_URL}/${userId}/news/${newsId}/`); + } + + /** + * Переключение лайка новости. + * + * @param userId идентификатор пользователя-владельца новости + * @param newsId идентификатор новости + * @param state новое состояние лайка + */ + toggleLike(userId: string, newsId: number, state: boolean): Observable { + return this.apiService.post(`${this.AUTH_USERS_URL}/${userId}/news/${newsId}/set_liked/`, { + is_liked: state, + }); + } + + /** + * Редактирование существующей новости. + * + * @param userId идентификатор пользователя + * @param newsId идентификатор редактируемой новости + * @param newsItem частичные данные для обновления новости + */ + editNews( + userId: string, + newsId: number, + newsItem: Partial + ): Observable { + return this.apiService.patch( + `${this.AUTH_USERS_URL}/${userId}/news/${newsId}/`, + newsItem + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts new file mode 100644 index 000000000..0b724d00d --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts @@ -0,0 +1,171 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { User } from "@domain/auth/user.model"; +import { PartnerProgramFields } from "@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"; + +@Injectable({ providedIn: "root" }) +export class ProgramHttpAdapter { + private readonly PROGRAMS_URL = "/programs"; + private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; + private readonly apiService = inject(ApiService); + + /** + * Получает список программ с пагинацией и дополнительными фильтрами. + * + * @param skip смещение + * @param take лимит + * @param params дополнительные query-параметры + */ + getAll(skip: number, take: number, params?: HttpParams): Observable> { + let httpParams = new HttpParams().set("limit", take).set("offset", skip); + + if (params) { + params.keys().forEach(key => { + const value = params.get(key); + if (value !== null) { + httpParams = httpParams.set(key, value); + } + }); + } + + return this.apiService.get(`${this.PROGRAMS_URL}/`, httpParams); + } + + getActualPrograms(): Observable> { + return this.apiService.get(`${this.PROGRAMS_URL}/`); + } + + /** + * Получает детальную информацию о программе. + * + * @param programId идентификатор программы + */ + getOne(programId: number): Observable { + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/`); + } + + /** + * Создает новую программу. + * + * @param program данные программы + */ + create(program: ProgramCreate): Observable { + return this.apiService.post(`${this.PROGRAMS_URL}/`, program); + } + + /** + * Получает схему полей регистрации/анкеты программы. + * + * @param programId идентификатор программы + */ + getDataSchema(programId: number): Observable<{ dataSchema: ProgramDataSchema }> { + return this.apiService.get<{ dataSchema: ProgramDataSchema }>( + `${this.PROGRAMS_URL}/${programId}/schema/` + ); + } + + /** + * Регистрирует пользователя в программе. + * + * @param programId идентификатор программы + * @param additionalData заполненные дополнительные поля + */ + register( + programId: number, + additionalData: Record + ): Observable { + return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/register/`, additionalData); + } + + /** + * Получает проекты программы. + * + * @param programId идентификатор программы + * @param params query-параметры пагинации/фильтрации + */ + getAllProjects(programId: number, params?: HttpParams): Observable> { + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects`, params); + } + + /** + * Получает участников программы. + * + * @param programId идентификатор программы + * @param skip смещение + * @param take лимит + */ + getAllMembers(programId: number, skip: number, take: number): Observable> { + return this.apiService.get( + `${this.AUTH_PUBLIC_USERS_URL}/`, + new HttpParams({ fromObject: { partner_program: programId, limit: take, offset: skip } }) + ); + } + + /** + * Получает метаданные фильтров программы. + * + * @param programId идентификатор программы + */ + getProgramFilters(programId: number): Observable { + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/filters/`); + } + + /** + * Получает дополнительные поля для подачи проекта в программу. + * + * @param programId идентификатор программы + */ + getProgramProjectAdditionalFields(programId: number): Observable { + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects/apply/`); + } + + /** + * Отправляет заявку проекта в программу. + * + * @param programId идентификатор программы + * @param body тело заявки + */ + applyProjectToProgram(programId: number, body: any): Observable { + return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/projects/apply/`, body); + } + + /** + * Создает отфильтрованный список проектов программы по переданным фильтрам. + * + * @param programId идентификатор программы + * @param filters объект фильтров + * @param params query-параметры пагинации + */ + createProgramFilters( + programId: number, + filters: Record, + params?: HttpParams + ): Observable> { + let url = `${this.PROGRAMS_URL}/${programId}/projects/filter/`; + if (params) { + url += `?${params.toString()}`; + } + return this.apiService.post(url, { filters }); + } + + /** + * Подтверждает подачу конкурсного проекта. + * + * @param relationId идентификатор связи project-program + */ + submitCompettetiveProject(relationId: number): Observable { + return this.apiService.post( + `${this.PROGRAMS_URL}/partner-program-projects/${relationId}/submit/`, + {} + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-news-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-news-http.adapter.ts new file mode 100644 index 000000000..c847b147a --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-news-http.adapter.ts @@ -0,0 +1,88 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedNews } from "@domain/project/project-news.model"; + +@Injectable({ providedIn: "root" }) +export class ProgramNewsHttpAdapter { + private readonly PROGRAMS_URL = "/programs"; + private readonly apiService = inject(ApiService); + + /** + * Загружает новости программы с пагинацией. + * + * @param limit лимит записей + * @param offset смещение + * @param programId идентификатор программы + */ + fetchNews(limit: number, offset: number, programId: number): Observable> { + return this.apiService.get( + `${this.PROGRAMS_URL}/${programId}/news/`, + new HttpParams({ fromObject: { limit, offset } }) + ); + } + + /** + * Отмечает новость программы как прочитанную. + * + * @param programId идентификатор программы + * @param newsId идентификатор новости + */ + setNewsViewed(programId: string, newsId: number): Observable { + return this.apiService.post( + `${this.PROGRAMS_URL}/${programId}/news/${newsId}/set_viewed/`, + {} + ); + } + + /** + * Переключает лайк новости программы. + * + * @param programId идентификатор программы + * @param newsId идентификатор новости + * @param state новое состояние лайка + */ + toggleLike(programId: string, newsId: number, state: boolean): Observable { + return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/news/${newsId}/set_liked/`, { + is_liked: state, + }); + } + + /** + * Добавляет новость программы. + * + * @param programId идентификатор программы + * @param obj объект с текстом и файлами + */ + addNews(programId: number, obj: { text: string; files: string[] }): Observable { + return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/news/`, obj); + } + + /** + * Редактирует новость программы. + * + * @param programId идентификатор программы + * @param newsId идентификатор новости + * @param newsItem данные обновления + */ + editNews(programId: number, newsId: number, newsItem: Partial): Observable { + return this.apiService.patch( + `${this.PROGRAMS_URL}/${programId}/news/${newsId}`, + newsItem + ); + } + + /** + * Удаляет новость программы. + * + * @param programId идентификатор программы + * @param newsId идентификатор новости + */ + deleteNews(programId: number, newsId: number): Observable { + return this.apiService.delete(`${this.PROGRAMS_URL}/${programId}/news/${newsId}`); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-collaborators.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-collaborators.dto.ts new file mode 100644 index 000000000..ab39c2a6a --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-collaborators.dto.ts @@ -0,0 +1,10 @@ +/** @format */ + +export interface CollaboratorDto { + userId: number; + firstName: string; + lastName: string; + avatar: string; + role: string; + skills: { id: number; name: string; category: { id: number; name: string } }[]; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-goal.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-goal.dto.ts new file mode 100644 index 000000000..c71ae4edb --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-goal.dto.ts @@ -0,0 +1,26 @@ +/** @format */ + +class ResponsibleInfo { + id!: number; + firstName!: string; + lastName!: string; + avatar!: string | null; +} + +export interface GoalDto { + id: number; + project: number; + title: string; + completionDate: string; + responsible: number; + responsibleInfo: ResponsibleInfo; + isDone: boolean; +} + +export class GoalFormData { + id?: number; + title!: string; + completionDate!: string; + responsible!: number; + isDone!: boolean; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-partners.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-partners.dto.ts new file mode 100644 index 000000000..25cfbbeaa --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-partners.dto.ts @@ -0,0 +1,9 @@ +/** @format */ + +export interface PartnerDto { + id: number; + name: string; + inn: string; + contribution: string; + decisionMaker: string; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-program.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-program.dto.ts new file mode 100644 index 000000000..137444505 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-program.dto.ts @@ -0,0 +1,9 @@ +/** @format */ + +export interface PartnerProgramInfoDto { + id: number; + programLinkId: number; + programId: number; + isSubmitted: boolean; + canSubmit: boolean; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-resources.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-resources.dto.ts new file mode 100644 index 000000000..002b9a04a --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-resources.dto.ts @@ -0,0 +1,8 @@ +/** @format */ + +export interface ResourceDto { + id: number; + type: string; + description: string; + partnerCompany: number | null; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-vacancy.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-vacancy.dto.ts new file mode 100644 index 000000000..1a26e3e52 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-vacancy.dto.ts @@ -0,0 +1,8 @@ +/** @format */ + +export interface VacancyDto { + id: number; + role: string; + description: string; + requiredSkillsIds: number[]; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts new file mode 100644 index 000000000..b2d3d795a --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts @@ -0,0 +1,65 @@ +/** + * DTO — контракт с API для проектов. + * Поля в camelCase — camelcase interceptor уже преобразует snake_case ответы бэка. + * + * Отличия от domain-модели Project: + * - только то что реально приходит с API, без вычисляемых полей и методов + * - вложенные объекты описаны через отдельные DTO-интерфейсы + * + * @format + */ + +import { CollaboratorDto } from "./project-collaborators.dto"; +import { PartnerDto } from "./project-partners.dto"; +import { ResourceDto } from "./project-resources.dto"; +import { VacancyDto } from "./project-vacancy.dto"; +import { PartnerProgramInfoDto } from "./project-program.dto"; +import { GoalDto } from "./project-goal.dto"; + +export interface ProjectDto { + id: number; + name: string; + description: string; + shortDescription: string; + targetAudience: string; + problem: string; + actuality: string; + region: string; + trl: string; + implementationDeadline: string; + industry: number; + draft: boolean; + leader: number; + leaderInfo?: { firstName: string; lastName: string }; + imageAddress: string; + presentationAddress: string; + cover: string | null; + coverImageAddress: string | null; + links: string[]; + numberOfCollaborators: number; + viewsCount: number; + isCompany: boolean; + inviteId: number; + achievements: { id: number; title: string; status: string }[]; + collaborators: CollaboratorDto[]; + collaborator?: CollaboratorDto; + vacancies: VacancyDto[]; + partners: PartnerDto[]; + resources: ResourceDto[]; + goals: GoalDto[]; + partnerProgram: PartnerProgramInfoDto | null; + partnerProgramsTags?: string[]; +} + +export interface ProjectCountDto { + all: number; + my: number; + subs: number; +} + +export interface ProjectListDto { + count: number; + results: ProjectDto[]; + next: string; + previous: string; +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.ts new file mode 100644 index 000000000..34931e54f --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.ts @@ -0,0 +1,46 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; + +@Injectable({ providedIn: "root" }) +export class ProjectCollaboratorsHttpAdapter { + private readonly PROJECTS_URL = "/projects"; + private readonly apiService = inject(ApiService); + + /** + * Удаляет коллаборатора из проекта + * + * @param projectId - идентификатор проекта + * @param userId - идентификатор пользователя для удаления из коллабораторов + * @returns Observable - завершается при успешном удалении коллаборатора + */ + deleteCollaborator(projectId: number, userId: number): Observable { + return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators?id=${userId}`); + } + + /** + * Передает лидерство в проекте другому пользователю + * + * @param projectId - идентификатор проекта + * @param userId - идентификатор пользователя, которому передается лидерство + * @returns Observable - завершается при успешной передаче лидерства + */ + patchSwitchLeader(projectId: number, userId: number): Observable { + return this.apiService.patch( + `${this.PROJECTS_URL}/${projectId}/collaborators/${userId}/switch-leader/`, + {} + ); + } + + /** + * Покидает проект (удаляет текущего пользователя из коллабораторов) + * + * @param projectId - идентификатор проекта, который нужно покинуть + * @returns Observable - завершается при успешном выходе из проекта + */ + deleteLeave(projectId: number): Observable { + return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators/leave`); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-goals-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-goals-http.adapter.ts new file mode 100644 index 000000000..eba25ad73 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-goals-http.adapter.ts @@ -0,0 +1,43 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { Goal } from "@domain/project/goals.model"; +import { GoalFormData } from "./dto/project-goal.dto"; + +@Injectable({ providedIn: "root" }) +export class ProjectGoalsHttpAdapter { + private readonly PROJECTS_URL = "/projects"; + private readonly apiService = inject(ApiService); + + /** + * Получает список целей проекта + * + * @returns Observable - объект с массивом целей проекта + */ + getGoals(projectId: number): Observable { + return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/goals/`); + } + + /** + * Отправляем цель + */ + addGoals(projectId: number, params: GoalFormData[]) { + return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/goals/`, params); + } + + /** + * Редактирование цели + */ + editGoal(projectId: number, goalId: number, params: GoalFormData) { + return this.apiService.put(`${this.PROJECTS_URL}/${projectId}/goals/${goalId}`, params); + } + + /** + * Удаляем цель + */ + deleteGoals(projectId: number, goalId: number): Observable { + return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/goals/${goalId}`); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts new file mode 100644 index 000000000..e4b62e202 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts @@ -0,0 +1,84 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { ProjectCountDto, ProjectDto, ProjectListDto } from "./dto/project.dto"; + +@Injectable({ providedIn: "root" }) +export class ProjectHttpAdapter { + private readonly PROJECTS_URL = "/projects"; + private readonly AUTH_USERS_URL = "/auth/users"; + + private readonly apiService = inject(ApiService); + + /** + * Получает список всех проектов с пагинацией + * + * @param params - HttpParams с параметрами запроса (limit, offset, фильтры) + * @returns Observable - объект с массивом проектов и метаданными пагинации + */ + fetchAll(params?: HttpParams): Observable { + return this.apiService.get(`${this.PROJECTS_URL}/`, params); + } + + /** + * Получает один проект по его идентификатору + * + * @param id - уникальный идентификатор проекта + * @returns Observable - сырой DTO проекта со всеми полями + */ + fetchOne(id: number): Observable { + return this.apiService.get(`${this.PROJECTS_URL}/${id}/`); + } + + /** + * Получает статистику по количеству проектов + * + * @returns Observable - объект с полями my, all, subs (количество проектов) + */ + fetchCount(): Observable { + return this.apiService.get(`${this.PROJECTS_URL}/count/`); + } + + /** + * Создает новый пустой проект + * + * @returns Observable - созданный проект со всеми полями + */ + postCreate(): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/`, {}); + } + + /** + * Обновляет существующий проект + * + * @param id - идентификатор проекта для обновления + * @param data - объект с полями проекта для обновления (частичный) + * @returns Observable - обновленный проект со всеми полями + */ + putUpdate(id: number, data: Partial): Observable { + return this.apiService.put(`${this.PROJECTS_URL}/${id}/`, data); + } + + /** + * Удаляет проект по его идентификатору + * + * @param id - уникальный идентификатор проекта для удаления + * @returns Observable - завершается при успешном удалении + */ + deleteOne(id: number): Observable { + return this.apiService.delete(`${this.PROJECTS_URL}/${id}/`); + } + + /** + * Получает список проектов текущего пользователя с пагинацией + * + * @param params - HttpParams с параметрами запроса (limit, offset, фильтры) + * @returns Observable - объект с массивом проектов пользователя и метаданными пагинации + */ + fetchMy(params?: HttpParams): Observable { + return this.apiService.get(`${this.AUTH_USERS_URL}/projects/`, params); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-news-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-news-http.adapter.ts new file mode 100644 index 000000000..8133cac8f --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-news-http.adapter.ts @@ -0,0 +1,96 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedNews } from "@domain/project/project-news.model"; + +@Injectable({ providedIn: "root" }) +export class ProjectNewsHttpAdapter { + private readonly PROJECTS_URL = "/projects"; + private readonly apiService = inject(ApiService); + + /** + * Загружает список новостей проекта. + * + * @param projectId идентификатор проекта + */ + fetchNews(projectId: string): Observable> { + return this.apiService.get>( + `${this.PROJECTS_URL}/${projectId}/news/`, + new HttpParams({ fromObject: { limit: 100 } }) + ); + } + + /** + * Загружает детальную информацию о новости проекта. + * + * @param projectId идентификатор проекта + * @param newsId идентификатор новости + */ + fetchNewsDetail(projectId: string, newsId: string): Observable { + return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/news/${newsId}`); + } + + /** + * Создает новость проекта. + * + * @param projectId идентификатор проекта + * @param obj объект с текстом и файлами + */ + addNews(projectId: string, obj: { text: string; files: string[] }): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/news/`, obj); + } + + /** + * Отмечает новость проекта как просмотренную. + * + * @param projectId идентификатор проекта + * @param newsId идентификатор новости + */ + setNewsViewed(projectId: number, newsId: number): Observable { + return this.apiService.post( + `${this.PROJECTS_URL}/${projectId}/news/${newsId}/set_viewed/`, + {} + ); + } + + /** + * Удаляет новость проекта. + * + * @param projectId идентификатор проекта + * @param newsId идентификатор новости + */ + deleteNews(projectId: string, newsId: number): Observable { + return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/news/${newsId}/`); + } + + /** + * Переключает лайк новости проекта. + * + * @param projectId идентификатор проекта + * @param newsId идентификатор новости + * @param state новое состояние лайка + */ + toggleLike(projectId: string, newsId: number, state: boolean): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/news/${newsId}/set_liked/`, { + is_liked: state, + }); + } + + /** + * Редактирует новость проекта. + * + * @param projectId идентификатор проекта + * @param newsId идентификатор новости + * @param newsItem данные обновления + */ + editNews(projectId: string, newsId: number, newsItem: Partial): Observable { + return this.apiService.patch( + `${this.PROJECTS_URL}/${projectId}/news/${newsId}/`, + newsItem + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-partner-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-partner-http.adapter.ts new file mode 100644 index 000000000..2d36b17aa --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-partner-http.adapter.ts @@ -0,0 +1,68 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { Partner, PartnerDto } from "@domain/project/partner.model"; + +@Injectable({ providedIn: "root" }) +export class ProjectPartnerHttpAdapter { + private readonly PROJECTS_URL = "/projects"; + private readonly apiService = inject(ApiService); + + /** + * + * @param id + * @param params + * @returns Создать или привязать компанию к проекту. + * Если компания с таким ИНН уже существует — создаёт или обновляет связь ProjectCompany. + * Если компании нет — создаёт новую и тут же привязывает. + */ + addPartner(id: number, params: PartnerDto): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/${id}/companies/`, params); + } + + /** + * Получить список всех компаний-партнёров (связей ProjectCompany) конкретного проекта. + * + * @param id + * + * @returns данные компании + * @returns вклад + * @returns ответственного + */ + getPartners(id: number): Observable { + return this.apiService.get(`${this.PROJECTS_URL}/${id}/companies/list/`); + } + + /** + * @param projectId + * @param companyId + * + * @returns Обновить информацию о связи проекта с компанией. + * Можно изменить вклад (contribution) и/или ответственное лицо (decision_maker). + * Компания остаётся без изменений. + */ + editParter( + projectId: number, + companyId: number, + params: Pick + ): Observable { + return this.apiService.patch( + `${this.PROJECTS_URL}/${projectId}/companies/${companyId}/`, + params + ); + } + + /** + * @param projectId + * @param companyId + * + * @returns Удалить связь проекта с компанией. Компания в базе остаётся, удаляется только запись ProjectCompany. + */ + deletePartner(projectId: number, companyId: number): Observable { + return this.apiService.delete( + `${this.PROJECTS_URL}/${projectId}/companies/${companyId}/` + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.ts new file mode 100644 index 000000000..c23eb3381 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-program-http.adapter.ts @@ -0,0 +1,44 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { ProjectAssign } from "@domain/project/project-assign.model"; +import { ProjectDto } from "./dto/project.dto"; +import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; + +@Injectable({ providedIn: "root" }) +export class ProjectProgramHttpAdapter { + private readonly apiService = inject(ApiService); + private readonly PROJECTS_URL = "/projects"; + + /** + * Ссоздаёт привязывает проект к программе с указанным ID. + * После чего в БД появляется новый проект в черновиках + * + * @param projectId - идентификатор проекта + * @param partnerProgramId - идентификатор программы, к которой привязывается проект + * @returns Observable - ответ с названием программы и инфой краткой о проекте + */ + assignProjectToProgram(projectId: number, partnerProgramId: number): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/assign-to-program/`, { + projectId, + partnerProgramId, + }); + } + + /** + * Ссоздаёт привязывает проект к программе с указанным ID. + * После чего в БД появляется новый проект в черновиках + * + * @param projectId - id проекта + * @param newValues - массив новых полей с значениями + * @returns Observable - измененный проект + */ + sendNewProjectFieldsValues( + projectId: number, + newValues: ProjectNewAdditionalProgramFields[] + ): Observable { + return this.apiService.put(`${this.PROJECTS_URL}/${projectId}/program-fields/`, newValues); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts new file mode 100644 index 000000000..dfdb33cfb --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-rating-http.adapter.ts @@ -0,0 +1,54 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectRate } from "@domain/project/project-rate"; +import { ProjectRatingCriterionOutput } from "@domain/project/project-rating-criterion-output"; + +@Injectable({ providedIn: "root" }) +export class ProjectRatingHttpAdapter { + private readonly RATE_PROJECT_URL = "/rate-project"; + private readonly apiService = inject(ApiService); + + /** + * Получает список проектов программы для оценки. + * + * @param programId идентификатор программы + * @param params query-параметры пагинации/фильтрации + */ + getAll(programId: number, params?: HttpParams): Observable> { + return this.apiService.get(`${this.RATE_PROJECT_URL}/${programId}`, params); + } + + /** + * Получает список проектов программы с фильтрами. + * + * @param programId идентификатор программы + * @param filters фильтры + * @param params query-параметры пагинации/дополнительной фильтрации + */ + postFilters( + programId: number, + filters: Record, + params?: HttpParams + ): Observable> { + let url = `${this.RATE_PROJECT_URL}/${programId}`; + if (params) { + url += `?${params.toString()}`; + } + return this.apiService.post(url, { filters }); + } + + /** + * Отправляет оценки по критериям для проекта. + * + * @param projectId идентификатор проекта + * @param scores массив оценок критериев + */ + rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable { + return this.apiService.post(`${this.RATE_PROJECT_URL}/rate/${projectId}`, scores); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-resource-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-resource-http.adapter.ts new file mode 100644 index 000000000..58f9ce117 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-resource-http.adapter.ts @@ -0,0 +1,67 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { Resource, ResourceDto } from "@domain/project/resource.model"; + +@Injectable({ providedIn: "root" }) +export class ProjectResourceHttpAdapter { + private readonly PROJECTS_URL = "/projects"; + private readonly apiService = inject(ApiService); + + /** + * + * @param id + * @param params + * @returns Создать новый ресурс в проекте. + * Если partner_company указана, проверяется, что она действительно является партнёром данного проекта. + */ + addResource(projectId: number, params: Omit): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/resources/`, { + projectId, + ...params, + }); + } + + /** + * + * @param id + * @returns Получить список всех ресурсов проекта. + * Каждый ресурс содержит тип, описание и партнёра (если назначен) + */ + getResources(id: number): Observable { + return this.apiService.get(`${this.PROJECTS_URL}/${id}/resources/`); + } + + /** + * @param projectId + * @param resourceId + * + * @returns Полностью обновить данные ресурса. + * Используется, если нужно заменить все поля сразу. + */ + editResource( + projectId: number, + resourceId: number, + params: Omit + ): Observable { + return this.apiService.patch(`${this.PROJECTS_URL}/${projectId}/resources/${resourceId}/`, { + projectId, + ...params, + }); + } + + /** + * @param projectId + * @param resourceId + * + * @returns Удалить ресурс проекта. + * Удаляется только сам ресурс, проект и компании не затрагиваются. + */ + deleteResource(projectId: number, resourceId: number): Observable { + return this.apiService.delete( + `${this.PROJECTS_URL}/${projectId}/resources/${resourceId}/` + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/skills/skills-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/skills/skills-http.adapter.ts new file mode 100644 index 000000000..34bec409c --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/skills/skills-http.adapter.ts @@ -0,0 +1,36 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { SkillsGroup } from "@domain/skills/skills-group"; +import { Skill } from "@domain/skills/skill"; + +@Injectable({ providedIn: "root" }) +export class SkillsHttpAdapter { + private readonly CORE_SKILLS_URL = "/core/skills"; + private readonly apiService = inject(ApiService); + + /** + * Получает навыки в виде иерархической структуры (группы и подгруппы). + */ + getSkillsNested(): Observable { + return this.apiService.get(`${this.CORE_SKILLS_URL}/nested`); + } + + /** + * Получает навыки в виде плоского списка с поиском и пагинацией. + * + * @param search строка поиска + * @param limit максимальное количество результатов + * @param offset смещение + */ + getSkillsInline(search: string, limit: number, offset: number): Observable> { + return this.apiService.get( + `${this.CORE_SKILLS_URL}/inline`, + new HttpParams({ fromObject: { limit, offset, name__icontains: search } }) + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/specializations/specializations-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/specializations/specializations-http.adapter.ts new file mode 100644 index 000000000..5c85a5b4e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/specializations/specializations-http.adapter.ts @@ -0,0 +1,40 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Specialization } from "@domain/specializations/specialization"; +import { SpecializationsGroup } from "@domain/specializations/specializations-group"; + +@Injectable({ providedIn: "root" }) +export class SpecializationsHttpAdapter { + private readonly AUTH_USERS_SPECIALIZATIONS_URL = "/auth/users/specializations"; + private readonly apiService = inject(ApiService); + + /** + * Получает специализации в виде иерархической структуры (группы и подгруппы). + */ + getSpecializationsNested(): Observable { + return this.apiService.get(`${this.AUTH_USERS_SPECIALIZATIONS_URL}/nested`); + } + + /** + * Получает специализации в виде плоского списка с поддержкой поиска и пагинации. + * + * @param search строка поиска + * @param limit максимальное количество результатов + * @param offset смещение + */ + getSpecializationsInline( + search: string, + limit: number, + offset: number + ): Observable> { + return this.apiService.get( + `${this.AUTH_USERS_SPECIALIZATIONS_URL}/inline`, + new HttpParams({ fromObject: { limit, offset, name__icontains: search } }) + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/subscription/subscription-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/subscription/subscription-http.adapter.ts new file mode 100644 index 000000000..e3a8ffcf8 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/subscription/subscription-http.adapter.ts @@ -0,0 +1,34 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectSubscriber } from "@domain/project/project-subscriber.model"; +import { Project } from "@domain/project/project.model"; + +@Injectable({ providedIn: "root" }) +export class SubscriptionHttpAdapter { + private readonly PROJECTS_URL = "/projects"; + private readonly AUTH_USERS_URL = "/auth/users"; + private readonly apiService = inject(ApiService); + + getSubscribers(projectId: number): Observable { + return this.apiService.get( + `${this.PROJECTS_URL}/${projectId}/subscribers/` + ); + } + + addSubscription(projectId: number): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/subscribe/`, {}); + } + + getSubscriptions(userId: number, params?: HttpParams): Observable> { + return this.apiService.get(`${this.AUTH_USERS_URL}/${userId}/subscribed_projects/`, params); + } + + deleteSubscription(projectId: number): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/unsubscribe/`, {}); + } +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/vacancy/vacancy-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/vacancy/vacancy-http.adapter.ts new file mode 100644 index 000000000..ab2bacc17 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/vacancy/vacancy-http.adapter.ts @@ -0,0 +1,126 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { HttpParams } from "@angular/common/http"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { CreateVacancyDto } from "@api/project/dto/create-vacancy.model"; + +@Injectable({ providedIn: "root" }) +export class VacancyHttpAdapter { + private readonly VACANCIES_URL = "/vacancies"; + private readonly PROJECTS_URL = "/projects"; + private readonly apiService = inject(ApiService); + + /** + * Получает список вакансий с фильтрацией и поиском. + * Возвращает сырой HTTP-ответ без доменного маппинга. + */ + getForProject( + limit: number, + offset: number, + projectId?: number, + requiredExperience?: string, + workFormat?: string, + workSchedule?: string, + salary?: string, + searchValue?: string + ): Observable { + let params = new HttpParams().set("limit", limit.toString()).set("offset", offset.toString()); + + if (projectId !== undefined) params = params.set("project_id", projectId.toString()); + if (requiredExperience) params = params.set("required_experience", requiredExperience); + if (workFormat) params = params.set("work_format", workFormat); + if (workSchedule) params = params.set("work_schedule", workSchedule); + if (salary) params = params.set("salary", salary); + if (searchValue) params = params.set("role_contains", searchValue); + + return this.apiService.get(`${this.VACANCIES_URL}/`, params); + } + + /** + * Получает список откликов текущего пользователя. + * Возвращает сырой HTTP-ответ без доменного маппинга. + */ + getMyVacancies(limit: number, offset: number): Observable { + const params = new HttpParams().set("limit", limit.toString()).set("offset", offset.toString()); + return this.apiService.get(`${this.VACANCIES_URL}/responses/self`, params); + } + + /** + * Получает вакансию по идентификатору. + * Возвращает сырой HTTP-ответ без доменного маппинга. + */ + getOne(vacancyId: number): Observable { + return this.apiService.get(`${this.VACANCIES_URL}/${vacancyId}`); + } + + /** + * Создает вакансию для проекта. + * Возвращает сырой HTTP-ответ без доменного маппинга. + */ + postVacancy(projectId: number, vacancy: CreateVacancyDto): Observable { + return this.apiService.post(`${this.VACANCIES_URL}/`, { + ...vacancy, + project: projectId, + }); + } + + /** + * Обновляет вакансию. + * Возвращает сырой HTTP-ответ без доменного маппинга. + */ + updateVacancy( + vacancyId: number, + vacancy: Partial | CreateVacancyDto + ): Observable { + return this.apiService.patch(`${this.VACANCIES_URL}/${vacancyId}`, { ...vacancy }); + } + + /** + * Удаляет вакансию. + */ + deleteVacancy(vacancyId: number): Observable { + return this.apiService.delete(`${this.VACANCIES_URL}/${vacancyId}`); + } + + /** + * Отправляет отклик на вакансию и возвращает данные созданного отклика. + */ + sendResponse(vacancyId: number, body: { whyMe: string }): Observable { + return this.apiService.post( + `${this.VACANCIES_URL}/${vacancyId}/responses/`, + body + ); + } + + /** + * Получает отклики по проекту. + * Возвращает сырой HTTP-ответ без доменного маппинга. + */ + responsesByProject(projectId: number): Observable { + return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/responses/`); + } + + /** + * Принимает отклик и возвращает обновленные данные отклика. + */ + acceptResponse(responseId: number): Observable { + return this.apiService.post( + `${this.VACANCIES_URL}/responses/${responseId}/accept/`, + {} + ); + } + + /** + * Отклоняет отклик и возвращает обновленные данные отклика. + */ + rejectResponse(responseId: number): Observable { + return this.apiService.post( + `${this.VACANCIES_URL}/responses/${responseId}/decline/`, + {} + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/di/auth.providers.ts b/projects/social_platform/src/app/infrastructure/di/auth.providers.ts new file mode 100644 index 000000000..4d1e3e0e1 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/auth.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { AuthRepository } from "../repository/auth/auth.repository"; + +export const AUTH_PROVIDERS: Provider[] = [ + { provide: AuthRepositoryPort, useExisting: AuthRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/courses/courses.providers.ts b/projects/social_platform/src/app/infrastructure/di/courses/courses.providers.ts new file mode 100644 index 000000000..68c08109e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/courses/courses.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { CoursesRepository } from "../../repository/courses/courses.repository"; +import { CoursesRepositoryPort } from "@domain/courses/ports/courses.repository.port"; + +export const COURSES_PROVIDERS: Provider[] = [ + { provide: CoursesRepositoryPort, useExisting: CoursesRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/feed.providers.ts b/projects/social_platform/src/app/infrastructure/di/feed.providers.ts new file mode 100644 index 000000000..7489feac4 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/feed.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { FeedRepositoryPort } from "@domain/feed/ports/feed.repository.port"; +import { FeedRepository } from "../repository/feed/feed.repository"; + +export const FEED_PROVIDERS: Provider[] = [ + { provide: FeedRepositoryPort, useExisting: FeedRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/industry.providers.ts b/projects/social_platform/src/app/infrastructure/di/industry.providers.ts new file mode 100644 index 000000000..2f3583dca --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/industry.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { IndustryRepository } from "../repository/industry/industry.repository"; + +export const INDUSTRY_PROVIDERS: Provider[] = [ + { provide: IndustryRepositoryPort, useExisting: IndustryRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/invite.providers.ts b/projects/social_platform/src/app/infrastructure/di/invite.providers.ts new file mode 100644 index 000000000..1e83ffa9e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/invite.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { InviteRepository } from "../repository/invite/invite.repository"; + +export const INVITE_PROVIDERS: Provider[] = [ + { provide: InviteRepositoryPort, useExisting: InviteRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/member.providers.ts b/projects/social_platform/src/app/infrastructure/di/member.providers.ts new file mode 100644 index 000000000..da02208a3 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/member.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { MemberRepositoryPort } from "@domain/member/ports/member.repository.port"; +import { MemberRepository } from "../repository/member/member.repository"; + +export const MEMBER_PROVIDERS: Provider[] = [ + { provide: MemberRepositoryPort, useExisting: MemberRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/profile-news.providers.ts b/projects/social_platform/src/app/infrastructure/di/profile-news.providers.ts new file mode 100644 index 000000000..0d9d81fd8 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/profile-news.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { ProfileNewsRepository } from "../repository/profile/profile-news.repository"; + +export const PROFILE_NEWS_PROVIDERS: Provider[] = [ + { provide: ProfileNewsRepositoryPort, useExisting: ProfileNewsRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/program/program-news.providers.ts b/projects/social_platform/src/app/infrastructure/di/program/program-news.providers.ts new file mode 100644 index 000000000..71b47c02f --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/program/program-news.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProgramNewsRepositoryPort } from "@domain/program/ports/program-news.repository.port"; +import { ProgramNewsRepository } from "../../repository/program/program-news.repository"; + +export const PROGRAM_NEWS_PROVIDERS: Provider[] = [ + { provide: ProgramNewsRepositoryPort, useExisting: ProgramNewsRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/program/program.providers.ts b/projects/social_platform/src/app/infrastructure/di/program/program.providers.ts new file mode 100644 index 000000000..4ac2d3a78 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/program/program.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProgramRepository } from "../../repository/program/program.repository"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + +export const PROGRAM_PROVIDERS: Provider[] = [ + { provide: ProgramRepositoryPort, useExisting: ProgramRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project-collaborators.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project-collaborators.providers.ts new file mode 100644 index 000000000..b64edbd59 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project-collaborators.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectCollaboratorsRepositoryPort } from "@domain/project/ports/project-collaborators.repository.port"; +import { ProjectCollaboratorsRepository } from "../../repository/project/project-collaborators.repository"; + +export const PROJECT_COLLABORATORS_PROVIDERS: Provider[] = [ + { provide: ProjectCollaboratorsRepositoryPort, useExisting: ProjectCollaboratorsRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project-goals.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project-goals.providers.ts new file mode 100644 index 000000000..e6cfe91ee --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project-goals.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectGoalsRepositoryPort } from "@domain/project/ports/project-goals.repository.port"; +import { ProjectGoalsRepository } from "../../repository/project/project-goals.repository"; + +export const PROJECT_GOALS_PROVIDERS: Provider[] = [ + { provide: ProjectGoalsRepositoryPort, useExisting: ProjectGoalsRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project-news.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project-news.providers.ts new file mode 100644 index 000000000..c75626b79 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project-news.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; +import { ProjectNewsRepository } from "../../repository/project/project-news.repository"; + +export const PROJECT_NEWS_PROVIDERS: Provider[] = [ + { provide: ProjectNewsRepositoryPort, useExisting: ProjectNewsRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project-partner.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project-partner.providers.ts new file mode 100644 index 000000000..4279eee1e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project-partner.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectPartnerRepositoryPort } from "@domain/project/ports/project-partner.repository.port"; +import { ProjectPartnerRepository } from "../../repository/project/project-partner.repository"; + +export const PROJECT_PARTNER_PROVIDERS: Provider[] = [ + { provide: ProjectPartnerRepositoryPort, useExisting: ProjectPartnerRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project-program.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project-program.providers.ts new file mode 100644 index 000000000..5321e46c7 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project-program.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; +import { ProjectProgramRepository } from "../../repository/project/project-program.repository"; + +export const PROJECT_PROGRAM_PROVIDERS: Provider[] = [ + { provide: ProjectProgramRepositoryPort, useExisting: ProjectProgramRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project-rating.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project-rating.providers.ts new file mode 100644 index 000000000..a5c750630 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project-rating.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectRatingRepositoryPort } from "@domain/project/ports/project-rating.repository.port"; +import { ProjectRatingRepository } from "../../repository/project/project-rating.repository"; + +export const PROJECT_RATING_PROVIDERS: Provider[] = [ + { provide: ProjectRatingRepositoryPort, useExisting: ProjectRatingRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project-resources.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project-resources.providers.ts new file mode 100644 index 000000000..e5e5e2366 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project-resources.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectResourceRepositoryPort } from "@domain/project/ports/project-resource.repository.port"; +import { ProjectResourceRepository } from "../../repository/project/project-resource.repository"; + +export const PROJECT_RESOURCES_PROVIDERS: Provider[] = [ + { provide: ProjectResourceRepositoryPort, useExisting: ProjectResourceRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project-subscription.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project-subscription.providers.ts new file mode 100644 index 000000000..dc4e65547 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project-subscription.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { ProjectSubscriptionRepository } from "../../repository/project/project-subscription.repository"; + +export const PROJECT_SUBSCRIPTION_PROVIDERS: Provider[] = [ + { provide: ProjectSubscriptionRepositoryPort, useExisting: ProjectSubscriptionRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/project/project.providers.ts b/projects/social_platform/src/app/infrastructure/di/project/project.providers.ts new file mode 100644 index 000000000..b628c9075 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/project/project.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { ProjectRepository } from "../../repository/project/project.repository"; + +export const PROJECT_PROVIDERS: Provider[] = [ + { provide: ProjectRepositoryPort, useExisting: ProjectRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/skills.providers.ts b/projects/social_platform/src/app/infrastructure/di/skills.providers.ts new file mode 100644 index 000000000..b29dae2ff --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/skills.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { SkillsRepository } from "../repository/skills/skills.repository"; + +export const SKILLS_PROVIDERS: Provider[] = [ + { provide: SkillsRepositoryPort, useExisting: SkillsRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/specializations.providers.ts b/projects/social_platform/src/app/infrastructure/di/specializations.providers.ts new file mode 100644 index 000000000..b4a2f7e56 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/specializations.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { SpecializationsRepositoryPort } from "@domain/specializations/ports/specializations.repository.port"; +import { SpecializationsRepository } from "../repository/specializations/specializations.repository"; + +export const SPECIALIZATIONS_PROVIDERS: Provider[] = [ + { provide: SpecializationsRepositoryPort, useExisting: SpecializationsRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/di/vacancy.providers.ts b/projects/social_platform/src/app/infrastructure/di/vacancy.providers.ts new file mode 100644 index 000000000..f4ab8f670 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/vacancy.providers.ts @@ -0,0 +1,9 @@ +/** @format */ + +import { Provider } from "@angular/core"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { VacancyRepository } from "../repository/vacancy/vacancy.repository"; + +export const VACANCY_PROVIDERS: Provider[] = [ + { provide: VacancyRepositoryPort, useExisting: VacancyRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.ts b/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.ts new file mode 100644 index 000000000..037537b6a --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.ts @@ -0,0 +1,117 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { AuthHttpAdapter } from "../../adapters/auth/auth-http.adapter"; +import { User, UserRole } from "@domain/auth/user.model"; +import { concatMap, map, Observable, ReplaySubject, take, tap } from "rxjs"; +import { LoginResponse, RegisterResponse } from "@domain/auth/http.model"; +import { plainToInstance } from "class-transformer"; +import { TokenService } from "@corelib"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Project } from "@domain/project/project.model"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { LoginCommand } from "@domain/auth/commands/login.command"; +import { RegisterCommand } from "@domain/auth/commands/register.command"; + +@Injectable({ providedIn: "root" }) +export class AuthRepository implements AuthRepositoryPort { + private readonly authAdapter = inject(AuthHttpAdapter); + private readonly tokenService = inject(TokenService); + + /** Поток данных профиля пользователя */ + private profile$ = new ReplaySubject(1); + profile = this.profile$.asObservable(); + + /** Поток доступных ролей пользователей */ + private roles$ = new ReplaySubject(1); + roles = this.roles$.asObservable(); + + /** Поток ролей, которые может изменить текущий пользователь */ + private changeableRoles$ = new ReplaySubject(1); + changeableRoles = this.changeableRoles$.asObservable(); + + login({ email, password }: LoginCommand): Observable { + return this.authAdapter + .login({ email, password }) + .pipe(map(json => plainToInstance(LoginResponse, json))); + } + + logout(): Observable { + return this.authAdapter.logout().pipe(map(() => this.tokenService.clearTokens())); + } + + register(data: RegisterCommand): Observable { + return this.authAdapter + .register(data) + .pipe(map(json => plainToInstance(RegisterResponse, json))); + } + + resendEmail(email: string): Observable { + return this.authAdapter.resendEmail(email).pipe(map(user => plainToInstance(User, user))); + } + + fetchUser(id: number): Observable { + return this.authAdapter.getUser(id).pipe(map(user => plainToInstance(User, user))); + } + + fetchProfile(): Observable { + return this.authAdapter.getProfile().pipe( + map(user => plainToInstance(User, user)), + tap(profile => this.profile$.next(profile)) + ); + } + + updateProfile(data: Partial): Observable { + return this.authAdapter.saveProfile(data).pipe(tap(user => this.profile$.next(user))); + } + + updateOnboardingStage(stage: number | null): Observable { + return this.profile.pipe( + take(1), + concatMap(profile => this.authAdapter.setOnboardingStage(stage, profile.id)), + tap(user => this.profile$.next(user)) + ); + } + + updateAvatar(url: string): Observable { + return this.profile.pipe( + take(1), + concatMap(profile => this.authAdapter.saveAvatar(url, profile.id)), + tap(user => this.profile$.next(user)) + ); + } + + fetchLeaderProjects(): Observable> { + return this.authAdapter + .getLeaderProjects() + .pipe(map(page => ({ ...page, results: plainToInstance(Project, page.results) }))); + } + + downloadCV(): Observable { + return this.authAdapter.downloadCV(); + } + + fetchUserRoles(): Observable { + return this.authAdapter.getUserRoles().pipe( + map(roles => roles.map(role => ({ id: role[0], name: role[1] }))), + map(roles => plainToInstance(UserRole, roles)), + tap(roles => this.roles$.next(roles)) + ); + } + + fetchChangeableRoles(): Observable { + return this.authAdapter.getChangeableRoles().pipe( + map(roles => roles.map(role => ({ id: role[0], name: role[1] }))), + map(roles => plainToInstance(UserRole, roles)), + tap(roles => this.changeableRoles$.next(roles)) + ); + } + + resetPassword(email: string): Observable { + return this.authAdapter.resetPassword(email); + } + + setPassword(password: string, token: string): Observable { + return this.authAdapter.setPassword(password, token); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/courses/courses.repository.ts b/projects/social_platform/src/app/infrastructure/repository/courses/courses.repository.ts new file mode 100644 index 000000000..590d9b34a --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/courses/courses.repository.ts @@ -0,0 +1,59 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Observable, tap } from "rxjs"; +import { + CourseCard, + CourseDetail, + CourseLesson, + CourseStructure, + TaskAnswerResponse, +} from "@domain/courses/courses.model"; +import { CoursesRepositoryPort } from "@domain/courses/ports/courses.repository.port"; +import { CoursesHttpAdapter } from "../../adapters/courses/courses-http.adapter"; +import { EntityCache } from "@domain/shared/entity-cache"; +import { EventBus } from "@domain/shared/event-bus"; +import { taskAnswerSubmitted } from "@domain/courses/events/task-answer-submitted.event"; + +@Injectable({ providedIn: "root" }) +export class CoursesRepository implements CoursesRepositoryPort { + private readonly coursesAdapter = inject(CoursesHttpAdapter); + private readonly eventBus = inject(EventBus); + private readonly detailCache = new EntityCache(); + private readonly structureCache = new EntityCache(); + + getCourses(): Observable { + return this.coursesAdapter.getCourses(); + } + + getCourseDetail(courseId: number): Observable { + return this.detailCache.getOrFetch(courseId, () => + this.coursesAdapter.getCourseDetail(courseId) + ); + } + + getCourseStructure(courseId: number): Observable { + return this.structureCache.getOrFetch(courseId, () => + this.coursesAdapter.getCourseStructure(courseId) + ); + } + + getCourseLesson(lessonId: number): Observable { + return this.coursesAdapter.getCourseLesson(lessonId); + } + + postAnswerQuestion( + taskId: number, + answerText?: any, + optionIds?: number[], + fileIds?: number[] + ): Observable { + return this.coursesAdapter.postAnswerQuestion(taskId, answerText, optionIds, fileIds).pipe( + tap(response => { + this.eventBus.emit(taskAnswerSubmitted(taskId, 0, response)); + // Инвалидируем кеш структуры, т.к. прогресс изменился + this.structureCache.clear(); + }) + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/feed/feed.repository.ts b/projects/social_platform/src/app/infrastructure/repository/feed/feed.repository.ts new file mode 100644 index 000000000..c863fd4a5 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/feed/feed.repository.ts @@ -0,0 +1,17 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedItem } from "@domain/feed/feed-item.model"; +import { FeedRepositoryPort } from "@domain/feed/ports/feed.repository.port"; +import { FeedHttpAdapter } from "../../adapters/feed/feed-http.adapter"; + +@Injectable({ providedIn: "root" }) +export class FeedRepository implements FeedRepositoryPort { + private readonly feedAdapter = inject(FeedHttpAdapter); + + fetchFeed(offset: number, limit: number, type: string): Observable> { + return this.feedAdapter.fetchFeed(offset, limit, type); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/industry/industry.repository.ts b/projects/social_platform/src/app/infrastructure/repository/industry/industry.repository.ts new file mode 100644 index 000000000..9d3991b0c --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/industry/industry.repository.ts @@ -0,0 +1,38 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { IndustryHttpAdapter } from "../../adapters/industry/industry-http.adapter"; +import { map, Observable, tap } from "rxjs"; +import { Industry } from "@domain/industry/industry.model"; +import { plainToInstance } from "class-transformer"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { EntityCache } from "@domain/shared/entity-cache"; + +@Injectable({ providedIn: "root" }) +export class IndustryRepository implements IndustryRepositoryPort { + private readonly industryAdapter = inject(IndustryHttpAdapter); + private readonly entityCache = new EntityCache(); + + readonly industries = signal([]); + + getAll(): Observable { + return this.industryAdapter.fetchAll().pipe( + map(industries => plainToInstance(Industry, industries)), + tap(industries => { + this.industries.set(industries); + }) + ); + } + + /** + * Находит конкретную отрасль в переданном массиве по идентификатору + * Вспомогательный метод для поиска отрасли без дополнительных запросов к серверу + * + * @param industries - массив отраслей для поиска + * @param industryId - идентификатор искомой отрасли + * @returns Industry | undefined - найденная отрасль или undefined, если не найдена + */ + getOne(industryId: number): Industry | undefined { + return this.industries().find(industry => industry.id === industryId); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/invite/invite.repository.ts b/projects/social_platform/src/app/infrastructure/repository/invite/invite.repository.ts new file mode 100644 index 000000000..8f2e362c1 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/invite/invite.repository.ts @@ -0,0 +1,106 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { plainToInstance } from "class-transformer"; +import { BehaviorSubject, map, Observable } from "rxjs"; +import { Invite } from "@domain/invite/invite.model"; +import { InviteHttpAdapter } from "../../adapters/invite/invite-http.adapter"; +import { InviteRepositoryPort } from "@domain/invite/ports/invite.repository.port"; +import { EventBus } from "@domain/shared/event-bus"; +import { AcceptInvite } from "@domain/invite/events/accept-invite.event"; +import { RejectInvite } from "@domain/invite/events/reject-invite.event"; + +@Injectable({ providedIn: "root" }) +export class InviteRepository implements InviteRepositoryPort { + private readonly inviteAdapter = inject(InviteHttpAdapter); + private readonly eventBus = inject(EventBus); + + readonly myInvitesCount$ = new BehaviorSubject(0); + + constructor() { + this.initializeEventListeners(); + } + + private initializeEventListeners(): void { + // Слушание события принятия приглашения + this.eventBus.on("AcceptInvite").subscribe({ + next: () => { + // Уменьшить счетчик приглашений текущего пользователя + const currentCount = this.myInvitesCount$.getValue(); + this.myInvitesCount$.next(Math.max(0, currentCount - 1)); + }, + }); + + // Слушание события отклонения приглашения + this.eventBus.on("RejectInvite").subscribe({ + next: () => { + // Уменьшить счетчик приглашений текущего пользователя + const currentCount = this.myInvitesCount$.getValue(); + this.myInvitesCount$.next(Math.max(0, currentCount - 1)); + }, + }); + } + + /** + * Отправляет приглашение и маппит ответ в доменную модель `Invite`. + */ + sendForUser( + userId: number, + projectId: number, + role: string, + specialization?: string + ): Observable { + return this.inviteAdapter + .sendForUser(userId, projectId, role, specialization) + .pipe(map(invite => plainToInstance(Invite, invite))); + } + + revokeInvite(invitationId: number): Observable { + return this.inviteAdapter + .revokeInvite(invitationId) + .pipe(map(invite => plainToInstance(Invite, invite))); + } + + /** + * Принимает приглашение и маппит ответ в доменную модель `Invite`. + */ + acceptInvite(inviteId: number): Observable { + return this.inviteAdapter + .acceptInvite(inviteId) + .pipe(map(invite => plainToInstance(Invite, invite))); + } + + /** + * Отклоняет приглашение и маппит ответ в доменную модель `Invite`. + */ + rejectInvite(inviteId: number): Observable { + return this.inviteAdapter + .rejectInvite(inviteId) + .pipe(map(invite => plainToInstance(Invite, invite))); + } + + /** + * Обновляет приглашение и маппит ответ в доменную модель `Invite`. + */ + updateInvite(inviteId: number, role: string, specialization?: string): Observable { + return this.inviteAdapter + .updateInvite(inviteId, role, specialization) + .pipe(map(invite => plainToInstance(Invite, invite))); + } + + /** + * Получает приглашения текущего пользователя и маппит их в доменную модель `Invite`. + */ + getMy(): Observable { + return this.inviteAdapter.getMy().pipe(map(invites => plainToInstance(Invite, invites))); + } + + /** + * Получает приглашения по проекту и маппит их в доменную модель `Invite`. + */ + getByProject(projectId: number): Observable { + return this.inviteAdapter + .getByProject(projectId) + .pipe(map(invites => plainToInstance(Invite, invites))); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/member/member.repository.ts b/projects/social_platform/src/app/infrastructure/repository/member/member.repository.ts new file mode 100644 index 000000000..1fb810779 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/member/member.repository.ts @@ -0,0 +1,36 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { plainToInstance } from "class-transformer"; +import { map, Observable } from "rxjs"; +import { User } from "@domain/auth/user.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { MemberRepositoryPort } from "@domain/member/ports/member.repository.port"; +import { MemberHttpAdapter } from "../../adapters/member/member-http.adapter"; + +@Injectable({ providedIn: "root" }) +export class MemberRepository implements MemberRepositoryPort { + private readonly memberAdapter = inject(MemberHttpAdapter); + + getMembers( + skip: number, + take: number, + otherParams?: Record + ): Observable> { + return this.memberAdapter.getMembers(skip, take, otherParams).pipe( + map(result => ({ + ...result, + results: plainToInstance(User, result.results), + })) + ); + } + + getMentors(skip: number, take: number): Observable> { + return this.memberAdapter.getMentors(skip, take).pipe( + map(result => ({ + ...result, + results: plainToInstance(User, result.results), + })) + ); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/profile/profile-news.repository.ts b/projects/social_platform/src/app/infrastructure/repository/profile/profile-news.repository.ts new file mode 100644 index 000000000..681b0137f --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/profile/profile-news.repository.ts @@ -0,0 +1,73 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { plainToInstance } from "class-transformer"; +import { forkJoin, map, Observable, of, tap } from "rxjs"; +import { StorageService } from "@api/storage/storage.service"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProfileNews } from "@domain/profile/profile-news.model"; +import { ProfileNewsRepositoryPort } from "@domain/profile/ports/profile-news.repository.port"; +import { ProfileNewsHttpAdapter } from "../../adapters/profile/profile-news-http.adapter"; + +@Injectable({ providedIn: "root" }) +export class ProfileNewsRepository implements ProfileNewsRepositoryPort { + private readonly profileNewsAdapter = inject(ProfileNewsHttpAdapter); + private readonly storageService = inject(StorageService); + + fetchNews(id: number): Observable> { + return this.profileNewsAdapter + .fetchNews(String(id)) + .pipe(map(page => ({ ...page, results: plainToInstance(ProfileNews, page.results) }))); + } + + fetchNewsDetail(userId: string, newsId: string): Observable { + return this.profileNewsAdapter + .fetchNewsDetail(userId, newsId) + .pipe(map(news => plainToInstance(ProfileNews, news))); + } + + addNews(userId: string, obj: { text: string; files: string[] }): Observable { + return this.profileNewsAdapter + .addNews(userId, obj) + .pipe(map(news => plainToInstance(ProfileNews, news))); + } + + readNews(userId: number, newsIds: number[]): Observable { + const cachedReadNews = this.storageService.getItem("readNews", sessionStorage) ?? []; + const readNews = new Set(cachedReadNews); + const unreadIds = newsIds.filter(id => !readNews.has(id)); + + if (unreadIds.length === 0) { + return of([]); + } + + return forkJoin( + unreadIds.map(id => + this.profileNewsAdapter.setNewsViewed(userId, id).pipe( + tap(() => { + readNews.add(id); + this.storageService.setItem("readNews", [...readNews], sessionStorage); + }) + ) + ) + ); + } + + delete(userId: string, newsId: number): Observable { + return this.profileNewsAdapter.deleteNews(userId, newsId); + } + + toggleLike(userId: string, newsId: number, state: boolean): Observable { + return this.profileNewsAdapter.toggleLike(userId, newsId, state); + } + + editNews( + userId: string, + newsId: number, + newsItem: Partial + ): Observable { + return this.profileNewsAdapter + .editNews(userId, newsId, newsItem) + .pipe(map(news => plainToInstance(ProfileNews, news))); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program-news.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program-news.repository.ts new file mode 100644 index 000000000..fdda17c25 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-news.repository.ts @@ -0,0 +1,44 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { plainToInstance } from "class-transformer"; +import { forkJoin, map, Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedNews } from "@domain/project/project-news.model"; +import { ProgramNewsHttpAdapter } from "../../adapters/program/program-news-http.adapter"; +import { ProgramNewsRepositoryPort } from "@domain/program/ports/program-news.repository.port"; + +@Injectable({ providedIn: "root" }) +export class ProgramNewsRepository implements ProgramNewsRepositoryPort { + private readonly programNewsAdapter = inject(ProgramNewsHttpAdapter); + + fetchNews(limit: number, offset: number, programId: number): Observable> { + return this.programNewsAdapter + .fetchNews(limit, offset, programId) + .pipe(map(page => ({ ...page, results: plainToInstance(FeedNews, page.results) }))); + } + + readNews(programId: string, newsIds: number[]): Observable { + return forkJoin(newsIds.map(id => this.programNewsAdapter.setNewsViewed(programId, id))); + } + + toggleLike(programId: string, newsId: number, state: boolean): Observable { + return this.programNewsAdapter.toggleLike(programId, newsId, state); + } + + addNews(programId: number, obj: { text: string; files: string[] }): Observable { + return this.programNewsAdapter + .addNews(programId, obj) + .pipe(map(news => plainToInstance(FeedNews, news))); + } + + editNews(programId: number, newsId: number, newsItem: Partial): Observable { + return this.programNewsAdapter + .editNews(programId, newsId, newsItem) + .pipe(map(news => plainToInstance(FeedNews, news))); + } + + deleteNews(programId: number, newsId: number): Observable { + return this.programNewsAdapter.deleteNews(programId, newsId); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts new file mode 100644 index 000000000..a777279e2 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -0,0 +1,84 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { map, Observable } from "rxjs"; +import { User } from "@domain/auth/user.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { ProgramCreate } from "@domain/program/program-create.model"; +import { Program, ProgramDataSchema } from "@domain/program/program.model"; +import { ProjectAdditionalFields } from "@domain/project/project-additional-fields.model"; +import { Project } from "@domain/project/project.model"; +import { ProgramHttpAdapter } from "../../adapters/program/program-http.adapter"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { EntityCache } from "@domain/shared/entity-cache"; + +@Injectable({ providedIn: "root" }) +export class ProgramRepository implements ProgramRepositoryPort { + private readonly programAdapter = inject(ProgramHttpAdapter); + private readonly entityCache = new EntityCache(); + + getAll(skip: number, take: number, params?: HttpParams): Observable> { + return this.programAdapter.getAll(skip, take, params); + } + + getActualPrograms(): Observable> { + return this.programAdapter.getActualPrograms(); + } + + getOne(programId: number): Observable { + return this.entityCache.getOrFetch(programId, () => this.programAdapter.getOne(programId)); + } + + create(program: ProgramCreate): Observable { + return this.programAdapter.create(program); + } + + /** + * Возвращает схему полей программы. + * Маппит вложенный ответ адаптера `{ dataSchema }` в доменный тип `ProgramDataSchema`. + */ + getDataSchema(programId: number): Observable { + return this.programAdapter.getDataSchema(programId).pipe(map(response => response.dataSchema)); + } + + register( + programId: number, + additionalData: Record + ): Observable { + return this.programAdapter.register(programId, additionalData); + } + + getAllProjects(programId: number, params?: HttpParams): Observable> { + return this.programAdapter.getAllProjects(programId, params); + } + + getAllMembers(programId: number, skip: number, take: number): Observable> { + return this.programAdapter.getAllMembers(programId, skip, take); + } + + getProgramFilters(programId: number): Observable { + return this.programAdapter.getProgramFilters(programId); + } + + getProgramProjectAdditionalFields(programId: number): Observable { + return this.programAdapter.getProgramProjectAdditionalFields(programId); + } + + applyProjectToProgram(programId: number, body: any): Observable { + return this.programAdapter.applyProjectToProgram(programId, body); + } + + createProgramFilters( + programId: number, + filters: Record, + params?: HttpParams + ): Observable> { + return this.programAdapter.createProgramFilters(programId, filters, params); + } + + submitCompettetiveProject(relationId: number): Observable { + return this.programAdapter.submitCompettetiveProject(relationId); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-collaborators.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-collaborators.repository.ts new file mode 100644 index 000000000..b35c90008 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-collaborators.repository.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { ProjectCollaboratorsRepositoryPort } from "@domain/project/ports/project-collaborators.repository.port"; +import { ProjectCollaboratorsHttpAdapter } from "../../adapters/project/project-collaborators-http.adapter"; + +@Injectable({ providedIn: "root" }) +export class ProjectCollaboratorsRepository implements ProjectCollaboratorsRepositoryPort { + private readonly projectCollaboratorsAdapter = inject(ProjectCollaboratorsHttpAdapter); + + deleteCollaborator(projectId: number, userId: number): Observable { + return this.projectCollaboratorsAdapter.deleteCollaborator(projectId, userId); + } + + patchSwitchLeader(projectId: number, userId: number): Observable { + return this.projectCollaboratorsAdapter.patchSwitchLeader(projectId, userId); + } + + deleteLeave(projectId: number): Observable { + return this.projectCollaboratorsAdapter.deleteLeave(projectId); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-goals.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-goals.repository.ts new file mode 100644 index 000000000..019cd0912 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-goals.repository.ts @@ -0,0 +1,36 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { map, Observable } from "rxjs"; +import { ProjectGoalsHttpAdapter } from "../../adapters/project/project-goals-http.adapter"; +import { plainToInstance } from "class-transformer"; +import { Goal } from "@domain/project/goals.model"; +import { GoalFormData } from "../../adapters/project/dto/project-goal.dto"; +import { ProjectGoalsRepositoryPort } from "@domain/project/ports/project-goals.repository.port"; + +@Injectable({ providedIn: "root" }) +export class ProjectGoalsRepository implements ProjectGoalsRepositoryPort { + private readonly projectGoalsHttpAdapter = inject(ProjectGoalsHttpAdapter); + + fetchAll(id: number): Observable { + return this.projectGoalsHttpAdapter + .getGoals(id) + .pipe(map(goals => plainToInstance(Goal, goals))); + } + + createGoal(id: number, params: GoalFormData[]): Observable { + return this.projectGoalsHttpAdapter + .addGoals(id, params) + .pipe(map(goals => plainToInstance(Goal, goals))); + } + + editGoal(projectId: number, goalId: number, params: GoalFormData): Observable { + return this.projectGoalsHttpAdapter + .editGoal(projectId, goalId, params) + .pipe(map(goal => plainToInstance(Goal, goal))); + } + + deleteGoal(projectId: number, goalId: number): Observable { + return this.projectGoalsHttpAdapter.deleteGoals(projectId, goalId); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-news.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-news.repository.ts new file mode 100644 index 000000000..fb0b3292e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-news.repository.ts @@ -0,0 +1,69 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { plainToInstance } from "class-transformer"; +import { forkJoin, map, Observable, of, tap } from "rxjs"; +import { StorageService } from "@api/storage/storage.service"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedNews } from "@domain/project/project-news.model"; +import { ProjectNewsHttpAdapter } from "../../adapters/project/project-news-http.adapter"; +import { ProjectNewsRepositoryPort } from "@domain/project/ports/project-news.repository.port"; + +@Injectable({ providedIn: "root" }) +export class ProjectNewsRepository implements ProjectNewsRepositoryPort { + private readonly projectNewsAdapter = inject(ProjectNewsHttpAdapter); + private readonly storageService = inject(StorageService); + + fetchNews(projectId: string): Observable> { + return this.projectNewsAdapter + .fetchNews(projectId) + .pipe(map(page => ({ ...page, results: plainToInstance(FeedNews, page.results) }))); + } + + fetchNewsDetail(projectId: string, newsId: string): Observable { + return this.projectNewsAdapter + .fetchNewsDetail(projectId, newsId) + .pipe(map(news => plainToInstance(FeedNews, news))); + } + + addNews(projectId: string, obj: { text: string; files: string[] }): Observable { + return this.projectNewsAdapter + .addNews(projectId, obj) + .pipe(map(news => plainToInstance(FeedNews, news))); + } + + readNews(projectId: number, newsIds: number[]): Observable { + const cachedReadNews = this.storageService.getItem("readNews", sessionStorage) ?? []; + const readNews = new Set(cachedReadNews); + const unreadIds = newsIds.filter(id => !readNews.has(id)); + + if (unreadIds.length === 0) { + return of([]); + } + + return forkJoin( + unreadIds.map(id => + this.projectNewsAdapter.setNewsViewed(projectId, id).pipe( + tap(() => { + readNews.add(id); + this.storageService.setItem("readNews", [...readNews], sessionStorage); + }) + ) + ) + ); + } + + delete(projectId: string, newsId: number): Observable { + return this.projectNewsAdapter.deleteNews(projectId, newsId); + } + + toggleLike(projectId: string, newsId: number, state: boolean): Observable { + return this.projectNewsAdapter.toggleLike(projectId, newsId, state); + } + + editNews(projectId: string, newsId: number, newsItem: Partial): Observable { + return this.projectNewsAdapter + .editNews(projectId, newsId, newsItem) + .pipe(map(news => plainToInstance(FeedNews, news))); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-partner.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-partner.repository.ts new file mode 100644 index 000000000..773aced7e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-partner.repository.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectPartnerHttpAdapter } from "../../adapters/project/project-partner-http.adapter"; +import { map, Observable } from "rxjs"; +import { Partner, PartnerDto } from "@domain/project/partner.model"; +import { plainToInstance } from "class-transformer"; +import { ProjectPartnerRepositoryPort } from "@domain/project/ports/project-partner.repository.port"; + +@Injectable({ providedIn: "root" }) +export class ProjectPartnerRepository implements ProjectPartnerRepositoryPort { + private readonly projectPartnerAdapter = inject(ProjectPartnerHttpAdapter); + + createPartner(id: number, params: PartnerDto): Observable { + return this.projectPartnerAdapter + .addPartner(id, params) + .pipe(map(partner => plainToInstance(Partner, partner))); + } + + fetchAll(id: number): Observable { + return this.projectPartnerAdapter + .getPartners(id) + .pipe(map(partners => plainToInstance(Partner, partners))); + } + + updatePartner( + projectId: number, + companyId: number, + params: Pick + ): Observable { + return this.projectPartnerAdapter + .editParter(projectId, companyId, params) + .pipe(map(partners => plainToInstance(Partner, partners))); + } + + deletePartner(projectId: number, companyId: number): Observable { + return this.projectPartnerAdapter.deletePartner(projectId, companyId); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.ts new file mode 100644 index 000000000..076de4970 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-program.repository.ts @@ -0,0 +1,30 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectProgramHttpAdapter } from "../../adapters/project/project-program-http.adapter"; +import { map, Observable } from "rxjs"; +import { ProjectAssign } from "@domain/project/project-assign.model"; +import { plainToInstance } from "class-transformer"; +import { Project } from "@domain/project/project.model"; +import { ProjectNewAdditionalProgramFields } from "@domain/program/partner-program-fields.model"; +import { ProjectProgramRepositoryPort } from "@domain/project/ports/project-program.repository.port"; + +@Injectable({ providedIn: "root" }) +export class ProjectProgramRepository implements ProjectProgramRepositoryPort { + private readonly projectProgramAdapter = inject(ProjectProgramHttpAdapter); + + assignProjectToProgram(projectId: number, partnerProgramId: number): Observable { + return this.projectProgramAdapter + .assignProjectToProgram(projectId, partnerProgramId) + .pipe(map(assign => plainToInstance(ProjectAssign, assign))); + } + + sendNewProjectFieldsValues( + projectId: number, + newValues: ProjectNewAdditionalProgramFields[] + ): Observable { + return this.projectProgramAdapter + .sendNewProjectFieldsValues(projectId, newValues) + .pipe(map(fields => plainToInstance(Project, fields))); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts new file mode 100644 index 000000000..21433890c --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-rating.repository.ts @@ -0,0 +1,62 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectRate } from "@domain/project/project-rate"; +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; +import { ProjectRatingCriterionOutput } from "@domain/project/project-rating-criterion-output"; +import { ProjectRatingHttpAdapter } from "../../adapters/project/project-rating-http.adapter"; +import { ProjectRatingRepositoryPort } from "@domain/project/ports/project-rating.repository.port"; + +@Injectable({ providedIn: "root" }) +export class ProjectRatingRepository implements ProjectRatingRepositoryPort { + private readonly projectRatingAdapter = inject(ProjectRatingHttpAdapter); + + getAll(programId: number, params?: HttpParams): Observable> { + return this.projectRatingAdapter.getAll(programId, params); + } + + postFilters( + programId: number, + filters: Record, + params?: HttpParams + ): Observable> { + return this.projectRatingAdapter.postFilters(programId, filters, params); + } + + rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable { + return this.projectRatingAdapter.rate(projectId, scores); + } + + /* + функция преобразует данные из формы вида { 1: 'value', 2: '5', 3: true }, + где ключом (key) является id критерия оценки, а значение является непосредственно значением оценки, + к виду [{ criterionId: 1, value: 'value' }, { criterionId: 2, value: 5 }, { criterionId: 3, value: 'true' }], + */ + formValuesToDTO( + criteria: ProjectRatingCriterion[], + outputVals: Record + ): ProjectRatingCriterionOutput[] { + const output: ProjectRatingCriterionOutput[] = []; + const normalizedOutputVals = { ...outputVals }; + + for (const key in normalizedOutputVals) { + // оценки с boolean значением переводятся в "string-boolean" (true => "True") + if (typeof normalizedOutputVals[key] === "boolean") { + const boolString = String(normalizedOutputVals[key]); + normalizedOutputVals[key] = boolString.charAt(0).toUpperCase() + boolString.slice(1); + } + + // оценки с числовым значением из инпута приходят строкой, их нужно привести к number + if (criteria.find(c => c.id === Number(key))?.type === "int") { + normalizedOutputVals[key] = Number(normalizedOutputVals[key]); + } + + output.push({ criterionId: Number(key), value: normalizedOutputVals[key] }); + } + + return output; + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-resource.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-resource.repository.ts new file mode 100644 index 000000000..bf153010e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-resource.repository.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ProjectResourceHttpAdapter } from "../../adapters/project/project-resource-http.adapter"; +import { map, Observable } from "rxjs"; +import { Resource, ResourceDto } from "@domain/project/resource.model"; +import { plainToInstance } from "class-transformer"; +import { ProjectResourceRepositoryPort } from "@domain/project/ports/project-resource.repository.port"; + +@Injectable({ providedIn: "root" }) +export class ProjectResourceRepository implements ProjectResourceRepositoryPort { + private readonly projectResourceAdapter = inject(ProjectResourceHttpAdapter); + + createResource(id: number, params: Omit): Observable { + return this.projectResourceAdapter + .addResource(id, params) + .pipe(map(resource => plainToInstance(Resource, resource))); + } + + fetchAll(id: number): Observable { + return this.projectResourceAdapter + .getResources(id) + .pipe(map(resources => plainToInstance(Resource, resources))); + } + + updateResource( + projectId: number, + resourceId: number, + params: Omit + ): Observable { + return this.projectResourceAdapter + .editResource(projectId, resourceId, params) + .pipe(map(resource => plainToInstance(Resource, resource))); + } + + deleteResource(projectId: number, resourceId: number): Observable { + return this.projectResourceAdapter.deleteResource(projectId, resourceId); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-subscription.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-subscription.repository.ts new file mode 100644 index 000000000..197290877 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-subscription.repository.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { ProjectSubscriber } from "@domain/project/project-subscriber.model"; +import { Project } from "@domain/project/project.model"; +import { SubscriptionHttpAdapter } from "../../adapters/subscription/subscription-http.adapter"; + +@Injectable({ providedIn: "root" }) +export class ProjectSubscriptionRepository implements ProjectSubscriptionRepositoryPort { + private readonly subscriptionAdapter = inject(SubscriptionHttpAdapter); + + getSubscribers(projectId: number): Observable { + return this.subscriptionAdapter.getSubscribers(projectId); + } + + addSubscription(projectId: number): Observable { + return this.subscriptionAdapter.addSubscription(projectId); + } + + getSubscriptions(userId: number, params?: HttpParams): Observable> { + return this.subscriptionAdapter.getSubscriptions(userId, params); + } + + deleteSubscription(projectId: number): Observable { + return this.subscriptionAdapter.deleteSubscription(projectId); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts new file mode 100644 index 000000000..d48c8945f --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts @@ -0,0 +1,139 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { BehaviorSubject, map, Observable, tap } from "rxjs"; +import { Project, ProjectCount } from "@domain/project/project.model"; +import { ProjectHttpAdapter } from "../../adapters/project/project-http.adapter"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { HttpParams } from "@angular/common/http"; +import { plainToInstance } from "class-transformer"; +import { ProjectDto } from "../../adapters/project/dto/project.dto"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { EventBus } from "@domain/shared/event-bus"; +import { ProjectCreated } from "@domain/project/events/project-created.event"; +import { ProjectDeleted } from "@domain/project/events/project-deleted.event"; +import { ProjectSubscribed } from "@domain/project/events/project-subscribed.event"; +import { ProjectUnSubscribed } from "@domain/project/events/project-unsubsribed.event"; +import { RemoveProjectCollaborator } from "@domain/project/events/remove-project-collaborator.event"; +import { SendVacancyResponse } from "@domain/vacancy/events/send-vacancy-response.event"; +import { AcceptVacancyResponse } from "@domain/vacancy/events/accept-vacancy-response.event"; +import { RejectVacancyResponse } from "@domain/vacancy/events/reject-vacancy-response.event"; +import { EntityCache } from "@domain/shared/entity-cache"; + +@Injectable({ providedIn: "root" }) +export class ProjectRepository implements ProjectRepositoryPort { + private readonly entityCache = new EntityCache(); + readonly count$ = new BehaviorSubject({ my: 0, all: 0, subs: 0 }); + + private readonly projectAdapter = inject(ProjectHttpAdapter); + private readonly eventBus = inject(EventBus); + + constructor() { + this.initializeEventListeners(); + } + + private initializeEventListeners(): void { + this.eventBus.on("ProjectCreated").subscribe(() => { + this.count$.next({ + ...this.count$.getValue(), + my: this.count$.getValue().my + 1, + }); + }); + + this.eventBus.on("ProjectDeleted").subscribe(event => { + this.invalidate(event.payload.projectId); + this.count$.next({ + ...this.count$.getValue(), + my: Math.max(0, this.count$.getValue().my - 1), + }); + }); + + this.eventBus.on("ProjectSubscribed").subscribe({ + next: () => { + this.count$.next({ + ...this.count$.getValue(), + subs: this.count$.getValue().subs + 1, + }); + }, + }); + + this.eventBus.on("ProjectUnSubscribed").subscribe({ + next: event => { + this.invalidate(event.payload.projectId); + this.count$.next({ + ...this.count$.getValue(), + subs: Math.max(0, this.count$.getValue().subs - 1), + }); + }, + }); + + this.eventBus.on("RemoveProjectCollaborator").subscribe({ + next: event => { + this.invalidate(event.payload.projectId); + }, + }); + + // Слушание событий вакансий - инвалидация кэша проекта + this.eventBus.on("SendVacancyResponse").subscribe({ + next: event => { + this.invalidate(event.payload.projectId); + }, + }); + + this.eventBus.on("AcceptVacancyResponse").subscribe({ + next: event => { + this.invalidate(event.payload.projectId); + }, + }); + + this.eventBus.on("RejectVacancyResponse").subscribe({ + next: event => { + this.invalidate(event.payload.projectId); + }, + }); + } + + getAll(params?: HttpParams): Observable> { + return this.projectAdapter + .fetchAll(params) + .pipe(map(page => ({ ...page, results: plainToInstance(Project, page.results) }))); + } + + getOne(id: number): Observable { + return this.entityCache.getOrFetch(id, () => + this.projectAdapter.fetchOne(id).pipe(map(dto => plainToInstance(Project, dto))) + ); + } + + refreshCount(): Observable { + return this.projectAdapter.fetchCount().pipe( + map(dto => plainToInstance(ProjectCount, dto)), + tap(count => this.count$.next(count)) + ); + } + + update(id: number, data: Partial): Observable { + return this.projectAdapter.putUpdate(id, data).pipe( + map(project => plainToInstance(Project, project)), + tap(() => this.invalidate(id)) + ); + } + + getMy(params?: HttpParams): Observable> { + return this.projectAdapter + .fetchMy(params) + .pipe(map(page => ({ ...page, results: plainToInstance(Project, page.results) }))); + } + + postOne(): Observable { + return this.projectAdapter.postCreate().pipe(map(dto => plainToInstance(Project, dto))); + } + + deleteOne(id: number): Observable { + return this.projectAdapter.deleteOne(id); + } + + invalidate(id: number): void { + this.entityCache.invalidate(id); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/skills/skills.repository.ts b/projects/social_platform/src/app/infrastructure/repository/skills/skills.repository.ts new file mode 100644 index 000000000..bb7fd05a7 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/skills/skills.repository.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Skill } from "@domain/skills/skill"; +import { SkillsGroup } from "@domain/skills/skills-group"; +import { SkillsHttpAdapter } from "../../adapters/skills/skills-http.adapter"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; + +@Injectable({ providedIn: "root" }) +export class SkillsRepository implements SkillsRepositoryPort { + private readonly skillsAdapter = inject(SkillsHttpAdapter); + + getSkillsNested(): Observable { + return this.skillsAdapter.getSkillsNested(); + } + + getSkillsInline(search: string, limit: number, offset: number): Observable> { + return this.skillsAdapter.getSkillsInline(search, limit, offset); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/specializations/specializations.repository.ts b/projects/social_platform/src/app/infrastructure/repository/specializations/specializations.repository.ts new file mode 100644 index 000000000..1c6172ee3 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/specializations/specializations.repository.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Specialization } from "@domain/specializations/specialization"; +import { SpecializationsGroup } from "@domain/specializations/specializations-group"; +import { SpecializationsHttpAdapter } from "../../adapters/specializations/specializations-http.adapter"; +import { SpecializationsRepositoryPort } from "@domain/specializations/ports/specializations.repository.port"; + +@Injectable({ providedIn: "root" }) +export class SpecializationsRepository implements SpecializationsRepositoryPort { + private readonly specializationsAdapter = inject(SpecializationsHttpAdapter); + + getSpecializationsNested(): Observable { + return this.specializationsAdapter.getSpecializationsNested(); + } + + getSpecializationsInline( + search: string, + limit: number, + offset: number + ): Observable> { + return this.specializationsAdapter.getSpecializationsInline(search, limit, offset); + } +} diff --git a/projects/social_platform/src/app/infrastructure/repository/vacancy/vacancy.repository.ts b/projects/social_platform/src/app/infrastructure/repository/vacancy/vacancy.repository.ts new file mode 100644 index 000000000..77c2ec1d8 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/vacancy/vacancy.repository.ts @@ -0,0 +1,138 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { plainToInstance } from "class-transformer"; +import { map, Observable } from "rxjs"; +import { CreateVacancyDto } from "@api/project/dto/create-vacancy.model"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { VacancyHttpAdapter } from "../../adapters/vacancy/vacancy-http.adapter"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { EntityCache } from "@domain/shared/entity-cache"; +import { EventBus } from "@domain/shared/event-bus"; +import { VacancyCreated } from "@domain/vacancy/events/vacancy-created.event"; +import { VacancyUpdated } from "@domain/vacancy/events/vacancy-updated.event"; +import { VacancyDelete } from "@domain/vacancy/events/vacancy-deleted.event"; + +@Injectable({ providedIn: "root" }) +export class VacancyRepository implements VacancyRepositoryPort { + private readonly vacancyAdapter = inject(VacancyHttpAdapter); + private readonly eventBus = inject(EventBus); + private readonly entityCache = new EntityCache(); + + constructor() { + this.eventBus.on("VacancyCreated").subscribe(event => { + this.invalidate(event.payload.projectId); + }); + + this.eventBus.on("VacancyUpdated").subscribe(event => { + this.invalidate(event.payload.vacancyId); + }); + + this.eventBus.on("VacancyDelete").subscribe(event => { + this.invalidate(event.payload.vacancyId); + }); + } + + /** + * Получает вакансии и маппит сырой HTTP-ответ в доменную модель `Vacancy`. + */ + getForProject( + limit: number, + offset: number, + projectId?: number, + requiredExperience?: string, + workFormat?: string, + workSchedule?: string, + salary?: string, + searchValue?: string + ): Observable { + return this.vacancyAdapter + .getForProject( + limit, + offset, + projectId, + requiredExperience, + workFormat, + workSchedule, + salary, + searchValue + ) + .pipe(map(vacancies => plainToInstance(Vacancy, vacancies))); + } + + /** + * Получает мои отклики и маппит их в доменную модель `VacancyResponse`. + */ + getMyVacancies(limit: number, offset: number): Observable { + return this.vacancyAdapter + .getMyVacancies(limit, offset) + .pipe(map(responses => plainToInstance(VacancyResponse, responses))); + } + + /** + * Получает одну вакансию и маппит ее в доменную модель `Vacancy`. + */ + getOne(vacancyId: number): Observable { + return this.entityCache.getOrFetch(vacancyId, () => + this.vacancyAdapter.getOne(vacancyId).pipe(map(vacancy => plainToInstance(Vacancy, vacancy))) + ); + } + + /** + * Создает вакансию и маппит ответ в доменную модель `Vacancy`. + */ + postVacancy(projectId: number, vacancy: CreateVacancyDto): Observable { + return this.vacancyAdapter + .postVacancy(projectId, vacancy) + .pipe(map(createdVacancy => plainToInstance(Vacancy, createdVacancy))); + } + + /** + * Обновляет вакансию и маппит ответ в доменную модель `Vacancy`. + */ + updateVacancy( + vacancyId: number, + vacancy: Partial | CreateVacancyDto + ): Observable { + return this.vacancyAdapter + .updateVacancy(vacancyId, vacancy) + .pipe(map(updatedVacancy => plainToInstance(Vacancy, updatedVacancy))); + } + + deleteVacancy(vacancyId: number): Observable { + this.invalidate(vacancyId); + return this.vacancyAdapter.deleteVacancy(vacancyId); + } + + sendResponse(vacancyId: number, body: { whyMe: string }): Observable { + return this.vacancyAdapter + .sendResponse(vacancyId, body) + .pipe(map(response => plainToInstance(VacancyResponse, response))); + } + + /** + * Получает отклики проекта и маппит ответ в доменную модель `VacancyResponse`. + */ + responsesByProject(projectId: number): Observable { + return this.vacancyAdapter + .responsesByProject(projectId) + .pipe(map(responses => plainToInstance(VacancyResponse, responses))); + } + + acceptResponse(responseId: number): Observable { + return this.vacancyAdapter + .acceptResponse(responseId) + .pipe(map(response => plainToInstance(VacancyResponse, response))); + } + + rejectResponse(responseId: number): Observable { + return this.vacancyAdapter + .rejectResponse(responseId) + .pipe(map(response => plainToInstance(VacancyResponse, response))); + } + + invalidate(vacancyId: number): void { + this.entityCache.invalidate(vacancyId); + } +} diff --git a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.html b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.html deleted file mode 100644 index 1172d12a8..000000000 --- a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.html +++ /dev/null @@ -1,27 +0,0 @@ - - -
- - -
- @if (chat) { - - } -
- -
-
-
diff --git a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.ts b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.ts deleted file mode 100644 index c570f6086..000000000 --- a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { map, noop, Observable, Subscription, tap } from "rxjs"; -import { ChatItem } from "@office/chat/models/chat-item.model"; -import { ChatService } from "@services/chat.service"; -import { ChatMessage } from "@models/chat-message.model"; -import { ChatDirectService } from "@office/chat/services/chat-direct.service"; -import { ChatWindowComponent } from "@office/features/chat-window/chat-window.component"; -import { AuthService } from "@auth/services"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { ApiPagination } from "@models/api-pagination.model"; -import { BarComponent } from "@ui/components"; -import { BackComponent } from "@uilib"; - -/** - * Компонент для отображения конкретного прямого чата - * - * Функциональность: - * - Отображение сообщений чата с пагинацией - * - Отправка, редактирование и удаление сообщений - * - Обработка событий WebSocket (новые сообщения, печатание, редактирование, удаление, прочтение) - * - Индикация печатающих пользователей - * - Прочтение сообщений - * - * @selector app-chat-direct - * @templateUrl ./chat-direct.component.html - * @styleUrl ./chat-direct.component.scss - */ -@Component({ - selector: "app-chat-direct", - templateUrl: "./chat-direct.component.html", - styleUrl: "./chat-direct.component.scss", - standalone: true, - imports: [RouterLink, AvatarComponent, ChatWindowComponent, BarComponent, BackComponent], -}) -export class ChatDirectComponent implements OnInit, OnDestroy { - constructor( - private readonly route: ActivatedRoute, - private readonly authService: AuthService, - private readonly chatService: ChatService, - private readonly chatDirectService: ChatDirectService - ) {} - - /** - * Инициализация компонента - * - Загружает данные чата из резолвера - * - Загружает первую порцию сообщений - * - Инициализирует обработчики WebSocket событий - * - Получает ID текущего пользователя - */ - ngOnInit(): void { - // Загрузка данных чата из резолвера - const routeData$ = this.route.data.pipe(map(r => r["data"])).subscribe(chat => { - this.chat = chat; - }); - this.subscriptions$.push(routeData$); - - // Загрузка первой порции сообщений - this.fetchMessages().subscribe(noop); - - // Инициализация обработчиков WebSocket событий - this.initMessageEvent(); - this.initTypingEvent(); - this.initDeleteEvent(); - this.initEditEvent(); - this.initReadEvent(); - - // Получение ID текущего пользователя - this.authService.profile.subscribe(u => { - this.currentUserId = u.id; - }); - } - - /** - * Очистка подписок при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** ID текущего пользователя */ - currentUserId?: number; - - /** - * Количество сообщений, загружаемых за один запрос - * @private - */ - private readonly messagesPerFetch = 20; - - /** - * Общее количество сообщений в чате (приходит с сервера) - * @private - */ - private messagesTotalCount = 0; - - /** Список пользователей, которые сейчас печатают */ - typingPersons: ChatWindowComponent["typingPersons"] = []; - - /** Массив подписок для очистки */ - subscriptions$: Subscription[] = []; - - /** Данные текущего чата */ - chat?: ChatItem; - - /** Массив сообщений чата */ - messages: ChatMessage[] = []; - - /** - * Загружает сообщения чата с сервера с поддержкой пагинации - * - * @private - * @returns {Observable>} Observable с пагинированными сообщениями - */ - private fetchMessages(): Observable> { - return this.chatDirectService - .loadMessages( - this.chat?.id ?? "", - this.messages.length > 0 ? this.messages.length : 0, - this.messagesPerFetch - ) - .pipe( - tap(messages => { - // Добавляем новые сообщения в начало массива (реверсируем порядок с сервера) - this.messages = messages.results.reverse().concat(this.messages); - this.messagesTotalCount = messages.count; - }) - ); - } - - /** - * Инициализирует обработчик события получения нового сообщения - * @private - */ - private initMessageEvent(): void { - const messageEvent$ = this.chatService.onMessage().subscribe(result => { - this.messages = [...this.messages, result.message]; - }); - - messageEvent$ && this.subscriptions$.push(messageEvent$); - } - - /** - * Инициализирует обработчик события печатания - * Показывает индикатор печатания на 2 секунды - * @private - */ - private initTypingEvent(): void { - const typingEvent$ = this.chatService.onTyping().subscribe(() => { - if (!this.chat?.opponent) return; - - this.typingPersons.push({ - firstName: this.chat.opponent.firstName, - lastName: this.chat.opponent.lastName, - userId: this.chat.opponent.id, - }); - - // Убираем индикатор через 2 секунды - setTimeout(() => { - const personIdx = this.typingPersons.findIndex(p => p.userId === this.chat?.opponent.id); - this.typingPersons.splice(personIdx, 1); - }, 2000); - }); - - typingEvent$ && this.subscriptions$.push(typingEvent$); - } - - /** - * Инициализирует обработчик события редактирования сообщения - * @private - */ - private initEditEvent(): void { - const editEvent$ = this.chatService.onEditMessage().subscribe(result => { - const messageIdx = this.messages.findIndex(msg => msg.id === result.message.id); - - const messages = JSON.parse(JSON.stringify(this.messages)); - messages.splice(messageIdx, 1, result.message); - - this.messages = messages; - }); - - editEvent$ && this.subscriptions$.push(editEvent$); - } - - /** - * Инициализирует обработчик события удаления сообщения - * @private - */ - private initDeleteEvent(): void { - const deleteEvent$ = this.chatService.onDeleteMessage().subscribe(result => { - const messageIdx = this.messages.findIndex(msg => msg.id === result.messageId); - - const messages = JSON.parse(JSON.stringify(this.messages)); - messages.splice(messageIdx, 1); - - this.messages = messages; - }); - - deleteEvent$ && this.subscriptions$.push(deleteEvent$); - } - - /** - * Инициализирует обработчик события прочтения сообщения - * @private - */ - private initReadEvent(): void { - const readEvent$ = this.chatService.onReadMessage().subscribe(result => { - const messageIdx = this.messages.findIndex(msg => msg.id === result.messageId); - - const messages = JSON.parse(JSON.stringify(this.messages)); - messages.splice(messageIdx, 1, { ...messages[messageIdx], isRead: true }); - - this.messages = messages; - }); - - readEvent$ && this.subscriptions$.push(readEvent$); - } - - /** Флаг процесса загрузки сообщений */ - fetching = false; - - /** - * Обработчик запроса на загрузку дополнительных сообщений - * Загружает следующую порцию сообщений если есть еще сообщения на сервере - */ - onFetchMessages(): void { - if ( - (this.messages.length < this.messagesTotalCount || - // messagesTotalCount равен 0 в начале, поэтому тоже нужно загружать - this.messagesTotalCount === 0) && - !this.fetching - ) { - this.fetching = true; - this.fetchMessages().subscribe(() => { - this.fetching = false; - }); - } - } - - /** - * Обработчик отправки нового сообщения - * @param message - Объект сообщения с текстом, файлами и ответом - */ - onSubmitMessage(message: any): void { - this.chatService.sendMessage({ - replyTo: message.replyTo, - text: message.text, - fileUrls: message.fileUrls, - chatType: "direct", - chatId: this.chat?.id ?? "", - }); - } - - /** - * Обработчик редактирования сообщения - * @param message - Объект сообщения с новым текстом и ID - */ - onEditMessage(message: any): void { - this.chatService.editMessage({ - text: message.text, - messageId: message.id, - chatType: "direct", - chatId: this.chat?.id ?? "", - }); - } - - /** - * Обработчик удаления сообщения - * @param messageId - ID удаляемого сообщения - */ - onDeleteMessage(messageId: number): void { - this.chatService.deleteMessage({ - chatId: this.chat?.id ?? "", - chatType: "direct", - messageId, - }); - } - - /** - * Обработчик события печатания - * Отправляет уведомление о том, что пользователь печатает - */ - onType() { - this.chatService.startTyping({ chatType: "direct", chatId: this.chat?.id ?? "" }); - } - - /** - * Обработчик прочтения сообщения - * @param messageId - ID прочитанного сообщения - */ - onReadMessage(messageId: number) { - this.chatService.readMessage({ - chatType: "direct", - chatId: this.chat?.id ?? "", - messageId, - }); - } -} diff --git a/projects/social_platform/src/app/office/chat/chat.component.html b/projects/social_platform/src/app/office/chat/chat.component.html deleted file mode 100644 index 7fc7bacef..000000000 --- a/projects/social_platform/src/app/office/chat/chat.component.html +++ /dev/null @@ -1,30 +0,0 @@ - - -
-
- - - -
- -
- @for (c of chats | async; track c.id; let last = $last) { - - } -
-
diff --git a/projects/social_platform/src/app/office/chat/chat.component.spec.ts b/projects/social_platform/src/app/office/chat/chat.component.spec.ts deleted file mode 100644 index 82518b04e..000000000 --- a/projects/social_platform/src/app/office/chat/chat.component.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ChatComponent } from "./chat.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ChatComponent", () => { - let component: ChatComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ChatComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ChatComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/chat/chat.component.ts b/projects/social_platform/src/app/office/chat/chat.component.ts deleted file mode 100644 index 3f95c4fe4..000000000 --- a/projects/social_platform/src/app/office/chat/chat.component.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { NavService } from "@services/nav.service"; -import { ActivatedRoute, Router } from "@angular/router"; -import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs"; -import { ChatListItem } from "@office/chat/models/chat-item.model"; -import { AuthService } from "@auth/services"; -import { ChatService } from "@services/chat.service"; -import { ChatCardComponent } from "./shared/chat-card/chat-card.component"; -import { AsyncPipe } from "@angular/common"; -import { BarComponent } from "@ui/components"; -import { BarNewComponent } from "@ui/components/bar-new/bar.component"; -import { BackComponent } from "@uilib"; - -/** - * Компонент списка чатов - отображает все чаты пользователя - * Управляет отображением прямых и групповых чатов с сортировкой по непрочитанным - * - * Принимает: - * - Данные чатов через резолвер - * - События новых сообщений через WebSocket - * - * Возвращает: - * - Отсортированный список чатов с индикаторами непрочитанных сообщений - * - Навигацию к конкретным чатам - */ -@Component({ - selector: "app-chat", - templateUrl: "./chat.component.html", - styleUrl: "./chat.component.scss", - standalone: true, - imports: [ChatCardComponent, AsyncPipe, BarComponent, BarNewComponent, BackComponent], -}) -export class ChatComponent implements OnInit, OnDestroy { - constructor( - private readonly navService: NavService, - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly authService: AuthService, - private readonly chatService: ChatService - ) {} - - chatsData = new BehaviorSubject([]); - - chats: Observable = combineLatest([ - this.authService.profile, - this.chatsData, - ]).pipe( - map(([profile, chats]) => - chats.map(chat => ({ - ...chat, - unread: profile.id !== chat.lastMessage.author.id && !chat.lastMessage.isRead, - })) - ), - map(chats => - chats.sort((prev, next) => { - if (prev.unread && !next.unread) return -1; - else if (!prev.unread && next.unread) return 1; - else return 0; - }) - ), - map(chats => - chats.map(chat => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delete chat.unread; - - return chat; - }) - ) - ); - - ngOnInit(): void { - this.navService.setNavTitle("Чат"); - - setTimeout(() => { - this.chatService.unread$.next(false); - }); - - const messageSub$ = this.chatService.onMessage().subscribe(result => { - const newChatsData: ChatListItem[] = JSON.parse(JSON.stringify(this.chatsData.value)); - const chatIdx = newChatsData.findIndex(c => c.id === result.chatId); - - newChatsData.splice(chatIdx, 1, { ...newChatsData[chatIdx], lastMessage: result.message }); - - this.chatsData.next(newChatsData); - }); - this.subscriptions$.push(messageSub$); - - const routeData$ = this.route.data - .pipe(map(r => r["data"])) - .subscribe(chats => { - this.chatsData.next(chats); - }); - this.subscriptions$.push(routeData$); - } - - subscriptions$: Subscription[] = []; - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - 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/office/chat/chat.resolver.ts b/projects/social_platform/src/app/office/chat/chat.resolver.ts deleted file mode 100644 index eb20532e5..000000000 --- a/projects/social_platform/src/app/office/chat/chat.resolver.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ChatDirectService } from "@office/chat/services/chat-direct.service"; -import { ChatListItem } from "@office/chat/models/chat-item.model"; -import { ResolveFn } from "@angular/router"; - -/** - * Резолвер для загрузки прямых чатов пользователя - * Предзагружает список личных сообщений - * - * Принимает: - * - Контекст маршрута через Angular DI - * - * Возвращает: - * - Observable - список элементов прямых чатов - */ -export const ChatResolver: ResolveFn = () => { - const chatDirectService = inject(ChatDirectService); - - return chatDirectService.getDirects(); -}; diff --git a/projects/social_platform/src/app/office/chat/chat.routes.ts b/projects/social_platform/src/app/office/chat/chat.routes.ts deleted file mode 100644 index 103e278bd..000000000 --- a/projects/social_platform/src/app/office/chat/chat.routes.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ChatComponent } from "@office/chat/chat.component"; -import { ChatResolver } from "@office/chat/chat.resolver"; -import { ChatGroupsResolver } from "@office/chat/chat-groups.resolver"; - -/** - * Маршруты для модуля чатов - * Определяет пути для прямых чатов, групповых чатов и конкретных диалогов - * - * Принимает: - * - URL пути чатов - * - * Возвращает: - * - Конфигурацию маршрутов с соответствующими резолверами - */ -export const CHAT_ROUTES: Routes = [ - { - path: "", - pathMatch: "full", - redirectTo: "directs", - }, - { - path: "directs", - component: ChatComponent, - resolve: { - data: ChatResolver, - }, - }, - { - path: "groups", - component: ChatComponent, - resolve: { - data: ChatGroupsResolver, - }, - }, - { - path: ":chatId", - loadChildren: () => import("./chat-direct/chat-direct.routes").then(c => c.CHAT_DIRECT_ROUTES), - }, -]; diff --git a/projects/social_platform/src/app/office/chat/shared/chat-card/chat-card.component.ts b/projects/social_platform/src/app/office/chat/shared/chat-card/chat-card.component.ts deleted file mode 100644 index 8295a0ec4..000000000 --- a/projects/social_platform/src/app/office/chat/shared/chat-card/chat-card.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; -import { ChatListItem } from "@office/chat/models/chat-item.model"; -import { AuthService } from "@auth/services"; -import { map } from "rxjs"; -import { DayjsPipe } from "projects/core"; -import { AsyncPipe } from "@angular/common"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; - -/** - * Компонент карточки чата для отображения в списке чатов - * - * Отображает: - * - Аватар чата/собеседника - * - Название чата - * - Последнее сообщение с аватаром автора - * - Дату последнего сообщения - * - Индикатор непрочитанных сообщений - * - * @selector app-chat-card - * @templateUrl ./chat-card.component.html - * @styleUrl ./chat-card.component.scss - */ -@Component({ - selector: "app-chat-card", - templateUrl: "./chat-card.component.html", - styleUrl: "./chat-card.component.scss", - standalone: true, - imports: [AvatarComponent, AsyncPipe, DayjsPipe], -}) -export class ChatCardComponent implements OnInit { - constructor(private readonly authService: AuthService) {} - - /** Данные чата для отображения */ - @Input({ required: true }) chat!: ChatListItem; - - /** Флаг последнего элемента в списке (для стилизации) */ - @Input() isLast = false; - - /** - * Observable для определения непрочитанного сообщения - * Сообщение считается непрочитанным если: - * - Автор не текущий пользователь - * - Сообщение помечено как непрочитанное - */ - public unread = this.authService.profile.pipe( - map(p => p.id !== this.chat.lastMessage.author.id && !this.chat.lastMessage.isRead) - ); - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/office/courses/courses.resolver.ts b/projects/social_platform/src/app/office/courses/courses.resolver.ts deleted file mode 100644 index 8d269ba6f..000000000 --- a/projects/social_platform/src/app/office/courses/courses.resolver.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { CoursesService } from "./courses.service"; - -/** - * Резолвер для загрузки списка всех доступных траекторий - * Выполняется перед активацией маршрута для предзагрузки данных - * @returns Observable с массивом траекторий (20 элементов с offset 0) - */ - -/** - * Функция-резолвер для получения списка траекторий - * @returns Promise/Observable с данными траекторий - */ -export const CoursesResolver = () => { - const coursesService = inject(CoursesService); - - return coursesService.getCourses(); -}; diff --git a/projects/social_platform/src/app/office/courses/courses.routes.ts b/projects/social_platform/src/app/office/courses/courses.routes.ts deleted file mode 100644 index ac341aa9f..000000000 --- a/projects/social_platform/src/app/office/courses/courses.routes.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { CoursesListComponent } from "./list/list.component"; -import { CoursesComponent } from "./courses.component"; -import { CoursesResolver } from "./courses.resolver"; - -/** - * Конфигурация маршрутов для модуля карьерных траекторий - * Определяет структуру навигации: - * - "" - редирект на "all" - * - "all" - список всех доступных траекторий - * - ":courseId" - детальная информация о конкретном курсе - */ - -export const COURSES_ROUTES: Routes = [ - { - path: "", - component: CoursesComponent, - children: [ - { - path: "", - redirectTo: "all", - pathMatch: "full", - }, - { - path: "all", - component: CoursesListComponent, - resolve: { - data: CoursesResolver, - }, - }, - ], - }, - { - path: ":courseId", - loadChildren: () => import("./detail/course-detail.routes").then(c => c.COURSE_DETAIL_ROUTES), - }, -]; diff --git a/projects/social_platform/src/app/office/courses/courses.service.ts b/projects/social_platform/src/app/office/courses/courses.service.ts deleted file mode 100644 index 629fa14ea..000000000 --- a/projects/social_platform/src/app/office/courses/courses.service.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "@corelib"; -import { - CourseCard, - CourseDetail, - CourseLesson, - CourseStructure, - TaskAnswerResponse, -} from "@office/models/courses.model"; -import { Observable } from "rxjs"; - -/** - * Сервис Курсов - * - * Управляет всеми операциями, связанными с курсами, включая: - * - Отслеживание прогресса пользователя по курсу - */ -@Injectable({ - providedIn: "root", -}) -export class CoursesService { - private readonly COURSE_URL = "/courses"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Получает доступные курсов - * - * @returns Observable - Список доступных курсов - */ - getCourses(): Observable { - return this.apiService.get(`${this.COURSE_URL}/`); - } - - /** - * Получает подробную информацию о конкретном курсе - * - * @param id - Уникальный идентификатор курса - * @returns Observable - Полная информация о курсе - */ - getCourseDetail(id: number): Observable { - return this.apiService.get(`${this.COURSE_URL}/${id}/`); - } - - /** - * Получает подробную информацию о структуре курса - * - * @param id - Уникальный идентификатор курса - * @returns Observable - Полную структуру курса - */ - getCourseStructure(id: number): Observable { - return this.apiService.get(`${this.COURSE_URL}/${id}/structure/`); - } - - /** - * Получает полную информацию по отдельному уроку внутри курса - * - * @param id - Уникальный идентификатор урока - * @returns Observable - Полная информация для урока конкретного - */ - getCourseLesson(id: number): Observable { - return this.apiService.get(`${this.COURSE_URL}/lessons/${id}/`); - } - - /** - * - * @param id - Уникальный идентификатор задачи - * @param answerText - Текст ответа - * @param optionIds - id ответов выбранных - * @param fileIds - id файлов загруженных - * @returns Observable - Информация от прохождении урока - */ - postAnswerQuestion( - id: number, - answerText?: any, - optionIds?: number[], - fileIds?: number[] - ): Observable { - return this.apiService.post(`${this.COURSE_URL}/tasks/${id}/answer/`, { - answerText, - optionIds, - fileIds, - }); - } -} diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.component.ts b/projects/social_platform/src/app/office/courses/detail/course-detail.component.ts deleted file mode 100644 index 8bd12453a..000000000 --- a/projects/social_platform/src/app/office/courses/detail/course-detail.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, inject, signal, OnInit } from "@angular/core"; -import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { filter, map, tap } from "rxjs"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { ButtonComponent } from "@ui/components"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { CourseDetail, CourseStructure } from "@office/models/courses.model"; - -/** - * Компонент детального просмотра траектории - * Отображает навигационную панель и служит контейнером для дочерних компонентов - * Управляет состоянием выбранной траектории и ID траектории из URL - */ -@Component({ - selector: "app-course-detail", - standalone: true, - imports: [CommonModule, RouterOutlet, AvatarComponent, ButtonComponent], - templateUrl: "./course-detail.component.html", - styleUrl: "./course-detail.component.scss", -}) -export class CourseDetailComponent implements OnInit { - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly destroyRef = inject(DestroyRef); - - protected readonly isTaskDetail = signal(false); - protected readonly isDisabled = signal(false); - - protected readonly courseModules = signal([]); - protected readonly course = signal(undefined); - - /** - * Инициализация компонента - * Подписывается на параметры маршрута и данные траектории - */ - ngOnInit(): void { - this.route.data - .pipe( - map(data => data["data"]), - filter(course => !!course), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: ([course, _]: [CourseDetail, CourseStructure]) => { - this.course.set(course); - - if (!course.partnerProgramId) { - this.isDisabled.set(true); - } - }, - }); - - this.isTaskDetail.set(this.router.url.includes("lesson")); - - this.router.events - .pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(() => { - this.isTaskDetail.set(this.router.url.includes("lesson")); - }); - } - - /** - * Перенаправляет на страницу с информацией в завивисимости от listType - */ - redirectDetailInfo(courseId?: number): void { - if (courseId != null) { - this.router.navigateByUrl(`/office/courses/${courseId}`); - } else { - this.router.navigateByUrl("/office/courses/all"); - } - } - - redirectToProgram(): void { - this.router.navigate([`/office/program/${this.course()?.partnerProgramId}`], { - queryParams: { courseId: this.course()?.id }, - }); - } -} diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.resolver.ts b/projects/social_platform/src/app/office/courses/detail/course-detail.resolver.ts deleted file mode 100644 index dc4553380..000000000 --- a/projects/social_platform/src/app/office/courses/detail/course-detail.resolver.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import type { ActivatedRouteSnapshot } from "@angular/router"; -import { Router } from "@angular/router"; -import { CoursesService } from "../courses.service"; -import { forkJoin, tap } from "rxjs"; - -/** - * Резолвер для получения детальной информации о курсе - * Также проверяет isAvailable — если false, редиректит на список курсов - * @param route - снимок маршрута содержащий параметр courseId - * @returns Observable с данными о курсе - */ -export const CoursesDetailResolver = (route: ActivatedRouteSnapshot) => { - const coursesService = inject(CoursesService); - const router = inject(Router); - const courseId = route.parent?.params["courseId"]; - - return forkJoin([ - coursesService.getCourseDetail(courseId).pipe( - tap(course => { - if (!course.isAvailable) { - router.navigate(["/office/courses/all"]); - } - }) - ), - coursesService.getCourseStructure(courseId), - ]); -}; diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.routes.ts b/projects/social_platform/src/app/office/courses/detail/course-detail.routes.ts deleted file mode 100644 index 8b168483c..000000000 --- a/projects/social_platform/src/app/office/courses/detail/course-detail.routes.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import type { Routes } from "@angular/router"; -import { TrajectoryInfoComponent } from "./info/info.component"; -import { CourseDetailComponent } from "./course-detail.component"; -import { CoursesDetailResolver } from "./course-detail.resolver"; - -export const COURSE_DETAIL_ROUTES: Routes = [ - { - path: "", - component: CourseDetailComponent, - runGuardsAndResolvers: "always", - resolve: { - data: CoursesDetailResolver, - }, - children: [ - { - path: "", - component: TrajectoryInfoComponent, - }, - { - path: "lesson", - loadChildren: () => import("../lesson/lesson.routes").then(m => m.LESSON_ROUTES), - }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/courses/detail/info/info.component.spec.ts b/projects/social_platform/src/app/office/courses/detail/info/info.component.spec.ts deleted file mode 100644 index 6ced6bd14..000000000 --- a/projects/social_platform/src/app/office/courses/detail/info/info.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { InfoComponent } from "./info.component"; - -describe("InfoComponent", () => { - let component: InfoComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [InfoComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(InfoComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/courses/detail/info/info.component.ts b/projects/social_platform/src/app/office/courses/detail/info/info.component.ts deleted file mode 100644 index 5e15b937d..000000000 --- a/projects/social_platform/src/app/office/courses/detail/info/info.component.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - DestroyRef, - ElementRef, - inject, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, RouterModule } from "@angular/router"; -import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; -import { IconComponent } from "@uilib"; -import { expandElement } from "@utils/expand-element"; -import { map } from "rxjs"; -import { CommonModule } from "@angular/common"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ButtonComponent } from "@ui/components"; -import { CourseModuleCardComponent } from "@office/courses/shared/course-module-card/course-module-card.component"; -import { CourseDetail, CourseStructure } from "@office/models/courses.model"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; - -/** - * Компонент детальной информации о траектории - * Отображает полную информацию о выбранной траектории пользователя: - * - Основную информацию (название, изображение, описание) - * - Временную шкалу траектории - * - Информацию о наставнике - * - Навыки (персональные, текущие, будущие, пройденные) - * - * Поддерживает навигацию к отдельным навыкам и взаимодействие с наставником - */ -@Component({ - selector: "app-detail", - standalone: true, - imports: [ - IconComponent, - RouterModule, - ParseBreaksPipe, - ParseLinksPipe, - CommonModule, - SoonCardComponent, - ModalComponent, - ButtonComponent, - CourseModuleCardComponent, - ], - templateUrl: "./info.component.html", - styleUrl: "./info.component.scss", -}) -export class TrajectoryInfoComponent implements OnInit, AfterViewInit { - @ViewChild("descEl") descEl?: ElementRef; - - private readonly route = inject(ActivatedRoute); - private readonly destroyRef = inject(DestroyRef); - - private readonly cdRef = inject(ChangeDetectorRef); - - protected readonly courseStructure = signal(undefined); - protected readonly courseDetail = signal(undefined); - protected readonly isCompleteModule = signal(false); - protected readonly isCourseCompleted = signal(false); - - /** - * Инициализация компонента - * Загружает данные траектории, пользовательскую информацию и настраивает навыки - */ - ngOnInit(): void { - this.route.parent?.data - .pipe( - map(r => r["data"]), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(([courseDetail, courseStructure]: [CourseDetail, CourseStructure]) => { - this.courseStructure.set(courseStructure); - this.courseDetail.set(courseDetail); - - const completedModuleIds = courseStructure.modules - .filter(m => m.progressStatus === "completed") - .map(m => m.id); - - const unseenModule = completedModuleIds.find( - id => - !localStorage.getItem(`course_${courseStructure.courseId}_module_${id}_complete_seen`) - ); - - if (unseenModule) { - const allModulesCompleted = courseStructure.modules.every( - m => m.progressStatus === "completed" - ); - this.isCourseCompleted.set(allModulesCompleted); - this.isCompleteModule.set(true); - localStorage.setItem( - `course_${courseStructure.courseId}_module_${unseenModule}_complete_seen`, - "true" - ); - } - }); - } - - protected descriptionExpandable?: boolean; - protected readFullDescription!: boolean; - - /** - * Проверка возможности расширения описания после инициализации представления - */ - ngAfterViewInit(): void { - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - - this.cdRef.detectChanges(); - } - - /** - * Переключение развернутого/свернутого состояния описания - * @param elem - HTML элемент описания - * @param expandedClass - CSS класс для развернутого состояния - * @param isExpanded - текущее состояние (развернуто/свернуто) - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } -} diff --git a/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.spec.ts deleted file mode 100644 index 7060506ba..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { CompleteComponent } from "./complete.component"; - -describe("CompleteComponent", () => { - let component: CompleteComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CompleteComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(CompleteComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/courses/lesson/lesson.component.ts b/projects/social_platform/src/app/office/courses/lesson/lesson.component.ts deleted file mode 100644 index ba51d4543..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/lesson.component.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** @format */ - -import { Component, computed, DestroyRef, inject, OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { filter, map, tap } from "rxjs"; -import { CourseLesson, Task } from "@office/models/courses.model"; -import { CoursesService } from "../courses.service"; -import { ButtonComponent } from "@ui/components"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { InfoTaskComponent } from "./shared/video-task/info-task.component"; -import { WriteTaskComponent } from "./shared/write-task/write-task.component"; -import { ExcludeTaskComponent } from "./shared/exclude-task/exclude-task.component"; -import { RadioSelectTaskComponent } from "./shared/radio-select-task/radio-select-task.component"; -import { FileTaskComponent } from "./shared/file-task/file-task.component"; -import { LoaderComponent } from "@ui/components/loader/loader.component"; - -@Component({ - selector: "app-lesson", - standalone: true, - imports: [ - CommonModule, - RouterOutlet, - ButtonComponent, - InfoTaskComponent, - WriteTaskComponent, - ExcludeTaskComponent, - RadioSelectTaskComponent, - FileTaskComponent, - LoaderComponent, - ], - templateUrl: "./lesson.component.html", - styleUrl: "./lesson.component.scss", -}) -export class LessonComponent implements OnInit { - private readonly router = inject(Router); - private readonly route = inject(ActivatedRoute); - private readonly destroyRef = inject(DestroyRef); - private readonly coursesService = inject(CoursesService); - private readonly snackbarService = inject(SnackbarService); - - protected readonly lessonInfo = signal(undefined); - protected readonly isComplete = signal(false); - protected readonly currentTaskId = signal(null); - - protected readonly loader = signal(false); - protected readonly loading = signal(false); - protected readonly success = signal(false); - - protected readonly answerBody = signal(null); - protected readonly hasError = signal(false); - protected readonly completedTaskIds = signal>(new Set()); - - protected readonly tasks = computed(() => this.lessonInfo()?.tasks ?? []); - - protected readonly currentTask = computed(() => { - const id = this.currentTaskId(); - return this.tasks().find(t => t.id === id) ?? null; - }); - - protected readonly isLastTask = computed(() => { - const allTasksLength = this.tasks().length; - return allTasksLength === this.currentTask()?.order; - }); - - protected readonly isSubmitDisabled = computed(() => { - const task = this.currentTask(); - const body = this.answerBody(); - if (!task) return true; - - switch (task.answerType) { - case "text": - return !body || (typeof body === "string" && !body.trim()); - case "text_and_files": - return !body?.text?.trim() || !body?.fileUrls?.length; - case "single_choice": - case "multiple_choice": - return !body || (Array.isArray(body) && body.length === 0); - case "files": - return !body || (Array.isArray(body) && body.length === 0); - default: - return false; - } - }); - - ngOnInit() { - this.route.data - .pipe( - map(data => data["data"] as CourseLesson), - tap(() => { - this.loading.set(true); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: lessonInfo => { - this.lessonInfo.set(lessonInfo); - - // Если курс уже завершен, редирект на results - if (lessonInfo.progressStatus === "completed") { - setTimeout(() => { - this.loading.set(false); - this.router.navigate(["results"], { relativeTo: this.route }); - }, 500); - return; - } - - const nextTaskId = - lessonInfo.currentTaskId ?? - lessonInfo.tasks.find(t => t.isAvailable && !t.isCompleted)?.id ?? - null; - - const allCompleted = lessonInfo.tasks.every(t => t.isCompleted); - const onResultsPage = this.router.url.includes("results"); - - if (onResultsPage && !allCompleted) { - // Находимся на results, но не все задания выполнены — редирект обратно - this.currentTaskId.set(nextTaskId); - setTimeout(() => { - this.loading.set(false); - this.router.navigate(["./"], { relativeTo: this.route }); - }, 500); - } else if (nextTaskId === null && allCompleted) { - // Все задания выполнены, редирект на results - setTimeout(() => { - this.loading.set(false); - this.router.navigate(["results"], { relativeTo: this.route }); - }, 500); - } else { - this.currentTaskId.set(nextTaskId); - setTimeout(() => this.loading.set(false), 500); - } - }, - complete: () => { - setTimeout(() => this.loading.set(false), 500); - }, - }); - - this.router.events - .pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(() => { - this.isComplete.set(this.router.url.includes("results")); - }); - - this.isComplete.set(this.router.url.includes("results")); - } - - isCurrent(taskId: number): boolean { - return this.currentTaskId() === taskId; - } - - isDone(task: Task): boolean { - return task.isCompleted || this.completedTaskIds().has(task.id); - } - - onSubmitAnswer() { - const task = this.currentTask(); - if (!task) return; - - this.loader.set(true); - - const body = this.answerBody(); - const isTextFile = task.answerType === "text_and_files"; - const answerText = task.answerType === "text" || isTextFile ? body?.text : undefined; - const optionIds = - task.answerType === "single_choice" || task.answerType === "multiple_choice" - ? body - : undefined; - const fileIds = task.answerType === "files" ? body : isTextFile ? body?.fileUrls : undefined; - - this.coursesService - .postAnswerQuestion(task.id, answerText, optionIds, fileIds) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: res => { - this.loader.set(false); - - if (res.isCorrect) { - this.success.set(true); - this.hasError.set(false); - this.completedTaskIds.update(ids => new Set([...ids, task.id])); - this.snackbarService.success("правильный ответ, продолжайте дальше"); - } else { - this.hasError.set(true); - this.success.set(false); - this.snackbarService.error("неверный ответ, попробуйте еще раз!"); - setTimeout(() => this.hasError.set(false), 1000); - return; - } - - if (!res.canContinue) return; - - setTimeout(() => { - const nextId = res.nextTaskId ?? this.getNextTask()?.id ?? null; - - if (nextId) { - this.currentTaskId.set(nextId); - this.success.set(false); - this.answerBody.set(null); - } else { - this.router.navigate(["results"], { relativeTo: this.route }); - } - }, 1000); - }, - error: () => { - this.loader.set(false); - this.hasError.set(true); - this.snackbarService.error("неверный ответ, попробуйте еще раз!"); - }, - }); - } - - private getNextTask(): Task | null { - const currentId = this.currentTaskId(); - const allTasks = this.tasks(); - const currentIndex = allTasks.findIndex(t => t.id === currentId); - const next = allTasks.slice(currentIndex + 1).find(t => t.isAvailable && !t.isCompleted); - return next ?? null; - } - - onAnswerChange(value: any) { - this.answerBody.set(value); - } -} diff --git a/projects/social_platform/src/app/office/courses/lesson/lesson.resolver.ts b/projects/social_platform/src/app/office/courses/lesson/lesson.resolver.ts deleted file mode 100644 index 3c05ac9e7..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/lesson.resolver.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import type { ResolveFn } from "@angular/router"; -import { inject } from "@angular/core"; -import { CourseLesson } from "@office/models/courses.model"; -import { CoursesService } from "../courses.service"; - -/** - * Резолвер для получения данных задачи - * Используется для предварительной загрузки данных о шагах задачи перед отображением компонента - * - * @param route - объект маршрута, содержащий параметры URL (включая taskId) - * @param _state - состояние маршрутизатора (не используется) - * @returns Promise - промис с данными о шагах задачи - */ -export const lessonDetailResolver: ResolveFn = (route, _state) => { - const coursesService = inject(CoursesService); - const lessonId = route.params["lessonId"]; - - // Получаем ID задачи из параметров маршрута и загружаем шаги задачи - return coursesService.getCourseLesson(lessonId); -}; diff --git a/projects/social_platform/src/app/office/courses/lesson/lesson.routes.ts b/projects/social_platform/src/app/office/courses/lesson/lesson.routes.ts deleted file mode 100644 index 7983a6cd4..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/lesson.routes.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** @format */ - -import type { Routes } from "@angular/router"; -import { LessonComponent } from "./lesson.component"; -import { TaskCompleteComponent } from "./complete/complete.component"; -import { lessonDetailResolver } from "./lesson.resolver"; - -/** - * Конфигурация маршрутов для модуля уроков - * Определяет структуру навигации и связывает компоненты с URL-путями - * - * Структура маршрутов: - * - /:lessonId - основной компонент урока - * - /results - компонент результатов выполнения урока - */ -export const LESSON_ROUTES: Routes = [ - { - path: ":lessonId", - component: LessonComponent, - resolve: { - data: lessonDetailResolver, - }, - children: [ - { - path: "results", - component: TaskCompleteComponent, - }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.html deleted file mode 100644 index f812a3c8f..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.html +++ /dev/null @@ -1,57 +0,0 @@ - - -
-

{{ data.text }}

-
-
- - -
- @for (_ of Array(data.connectLeft.length); track $index) { -
- @if (data.connectLeft[$index].text) { - {{ data.connectLeft[$index].text }} - } @else if (data.connectLeft[$index].file) { - - } -
- } -
- -
- @for (_ of Array(data.connectRight.length); track $index) { -
- @if (data.connectRight[$index].text) { - {{ data.connectRight[$index].text }} - } @else if (data.connectRight[$index].file) { - - } -
- } -
-
- @if (hint.length) { -
- } -
diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.scss b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.scss deleted file mode 100644 index 16477a974..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.scss +++ /dev/null @@ -1,92 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.relations { - padding: 26px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: 15px; - - &__title { - margin-bottom: 20px; - color: var(--black); - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 26px; - - @include typography.heading-3; - } - } - - &__description { - margin-bottom: 20px; - white-space: pre-line; - - @include typography.body-14; - } - - &__wrapper { - display: flex; - flex-direction: column; - gap: 50px; - justify-content: space-between; - - @include responsive.apply-desktop { - flex-direction: row; - } - } - - &__column { - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 10px; - min-width: 220px; - - &--grid { - display: grid; - - @include responsive.apply-desktop { - grid-template-columns: repeat(2, 1fr); - } - } - } - - &__item { - max-height: 500px; - padding: 10px; - overflow: hidden; - cursor: pointer; - border: 1px solid var(--grey-button); - border-radius: 10px; - - @include typography.body-14; - - @include responsive.apply-desktop { - @include typography.body-12; - } - - &--active { - border-color: var(--accent); - } - - &--success { - color: var(--green); - background-color: var(--light-green); - border-color: var(--green); - } - - &--disabled { - pointer-events: none; - opacity: 0.3; - } - } - - :host ::ng-deep &__hint p { - margin-top: 20px; - - @include typography.body-14; - } -} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.spec.ts deleted file mode 100644 index e2cfaff43..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { RelationsTaskComponent } from "./relations-task.component"; - -describe("RelationsTaskComponent", () => { - let component: RelationsTaskComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RelationsTaskComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RelationsTaskComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.ts deleted file mode 100644 index de13975f9..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/relations-task/relations-task.component.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** @format */ - -import { - Component, - type OnInit, - type AfterViewInit, - type OnDestroy, - ElementRef, - ViewChild, - ViewChildren, - type QueryList, - Input, - Output, - EventEmitter, - signal, - computed, - inject, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { DomSanitizer } from "@angular/platform-browser"; -import { fromEvent, type Subscription } from "rxjs"; -import { debounceTime } from "rxjs/operators"; -import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; -import { - ConnectQuestion, - ConnectQuestionRequest, - ConnectQuestionResponse, -} from "projects/skills/src/models/step.model"; - -/** - * Компонент задачи на установление связей - * Позволяет пользователю соединять элементы из левого столбца с элементами правого столбца - * - * Входные параметры: - * @Input data - данные вопроса типа ConnectQuestion - * @Input hint - текст подсказки - * @Input success - флаг успешного выполнения - * @Input error - объект ошибки для сброса состояния - * - * Выходные события: - * @Output update - событие обновления с массивом связей - * - * Функциональность: - * - Отображает два столбца элементов для соединения - * - Рисует SVG линии между связанными элементами - * - Поддерживает текстовые и графические элементы - * - Автоматически перерисовывает линии при изменении размера окна - */ -@Component({ - selector: "app-relations-task", - standalone: true, - imports: [CommonModule, ParseBreaksPipe, ParseLinksPipe], - templateUrl: "./relations-task.component.html", - styleUrls: ["./relations-task.component.scss"], -}) -export class RelationsTaskComponent implements OnInit, AfterViewInit, OnDestroy { - @Input({ required: true }) data!: ConnectQuestion; // Данные вопроса - @Input() hint!: string; // Текст подсказки - @Input() success = false; // Флаг успешного выполнения - - // Сеттер для обработки ошибок и сброса состояния - @Input() - set error(error: ConnectQuestionResponse | null) { - this._error.set(error); - - if (error !== null) { - this.result.set([]); // Сбрасываем результат при ошибке - this.selectedLeftId.set(null); // Сбрасываем выбранный элемент - } - } - - get error() { - return this._error(); - } - - protected readonly Array = Array; - - _error = signal(null); - - @Output() update = new EventEmitter(); // Событие обновления связей - - // Ссылки на DOM элементы - @ViewChild("svgOverlay", { static: true }) svgOverlay!: ElementRef; - @ViewChildren("leftItem", { read: ElementRef }) leftItems!: QueryList>; - @ViewChildren("rightItem", { read: ElementRef }) rightItems!: QueryList>; - - private resizeSub!: Subscription; // Подписка на изменение размера окна - - // Состояние компонента - result = signal([]); // Массив установленных связей - resultLeft = computed(() => this.result().map(r => r.leftId)); // ID связанных левых элементов - resultRight = computed(() => this.result().map(r => r.rightId)); // ID связанных правых элементов - selectedLeftId = signal(null); // ID выбранного левого элемента - - description!: any; // Обработанное описание - sanitizer = inject(DomSanitizer); - - // Проверяет, является ли правый столбец сеткой изображений - get isImageGrid() { - return this.data.connectRight.every(itm => !!itm.file); - } - - ngOnInit() { - // Безопасно обрабатываем HTML в описании - this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); - } - - ngAfterViewInit() { - // Подписываемся на изменение размера окна для перерисовки линий - this.resizeSub = fromEvent(window, "resize") - .pipe(debounceTime(100)) - .subscribe(() => this.drawLines()); - - // Рисуем линии после инициализации представления - setTimeout(() => this.drawLines()); - } - - ngOnDestroy() { - this.resizeSub.unsubscribe(); - } - - /** - * Обработчик выбора элемента из левого столбца - * @param id - ID выбранного элемента - */ - onSelectLeft(id: number) { - const current = this.selectedLeftId(); - - // Если элемент уже выбран, снимаем выбор - if (current === id) { - this.selectedLeftId.set(null); - return; - } - - // Если элемент уже связан, удаляем связь - const existingIndex = this.result().findIndex(r => r.leftId === id); - if (existingIndex !== -1) { - this.result.update(r => r.filter((_, i) => i !== existingIndex)); - this.drawLines(); - this.update.emit(this.result()); - } - - this.selectedLeftId.set(id); - } - - /** - * Обработчик выбора элемента из правого столбца - * Создает связь между выбранным левым и правым элементами - * @param id - ID выбранного правого элемента - */ - onSelectRight(id: number) { - const leftId = this.selectedLeftId(); - if (leftId === null) return; - - // Удаляем существующие связи для этих элементов - let newResult = this.result().filter(r => r.leftId !== leftId && r.rightId !== id); - - // Добавляем новую связь - newResult = [...newResult, { leftId, rightId: id }]; - - this.result.set(newResult); - this.selectedLeftId.set(null); - - this.drawLines(); - this.update.emit(this.result()); - } - - /** - * Удаляет все SVG линии - */ - removeLines() { - const svgEl = this.svgOverlay.nativeElement; - while (svgEl.firstChild) { - svgEl.removeChild(svgEl.firstChild); - } - } - - /** - * Рисует SVG линии между связанными элементами - * Вычисляет позиции элементов и создает линии между ними - */ - private drawLines() { - this.removeLines(); - - const svgEl = this.svgOverlay.nativeElement; - const svgRect = svgEl.getBoundingClientRect(); - - // Получаем позиции левых элементов - const leftPositions = new Map(); - this.leftItems.forEach(el => { - const id = Number(el.nativeElement.dataset["id"]); - leftPositions.set(id, el.nativeElement.getBoundingClientRect()); - }); - - // Получаем позиции правых элементов - const rightPositions = new Map(); - this.rightItems.forEach(el => { - const id = Number(el.nativeElement.dataset["id"]); - rightPositions.set(id, el.nativeElement.getBoundingClientRect()); - }); - - // Рисуем линии для каждой связи - this.result().forEach(pair => { - const leftRect = leftPositions.get(pair.leftId); - const rightRect = rightPositions.get(pair.rightId); - - if (!leftRect || !rightRect) return; - - // Вычисляем координаты линии - const x1 = leftRect.right - svgRect.left; - const y1 = leftRect.top + leftRect.height / 2 - svgRect.top; - const x2 = rightRect.left - svgRect.left; - const y2 = rightRect.top + rightRect.height / 2 - svgRect.top; - - // Создаем SVG линию - const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); - line.setAttribute("x1", x1.toString()); - line.setAttribute("y1", y1.toString()); - line.setAttribute("x2", x2.toString()); - line.setAttribute("y2", y2.toString()); - line.setAttribute("stroke", "#6B46C1"); - line.setAttribute("stroke-width", "4"); - line.setAttribute("stroke-linecap", "round"); - line.setAttribute("stroke-linejoin", "round"); - line.setAttribute("class", "connection-line"); - - svgEl.appendChild(line); - }); - } -} diff --git a/projects/social_platform/src/app/office/courses/list/list.component.html b/projects/social_platform/src/app/office/courses/list/list.component.html deleted file mode 100644 index ae371b7ce..000000000 --- a/projects/social_platform/src/app/office/courses/list/list.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
- @if (coursesList().length) { -
- @for (course of coursesList(); track course.id) { - - - - } -
- } -
diff --git a/projects/social_platform/src/app/office/courses/list/list.component.spec.ts b/projects/social_platform/src/app/office/courses/list/list.component.spec.ts deleted file mode 100644 index 8b9a839e4..000000000 --- a/projects/social_platform/src/app/office/courses/list/list.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ListComponent } from "@office/program/detail/rate-projects/list/list.component"; - -describe("ListComponent", () => { - let component: ListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ListComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/courses/list/list.component.ts b/projects/social_platform/src/app/office/courses/list/list.component.ts deleted file mode 100644 index 32241bcfe..000000000 --- a/projects/social_platform/src/app/office/courses/list/list.component.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -import { Component, inject, type OnDestroy, type OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ActivatedRoute, RouterModule } from "@angular/router"; -import { map, Subscription } from "rxjs"; -import { CourseComponent } from "../shared/course/course.component"; -import { CourseCard } from "@office/models/courses.model"; - -/** - * Компонент списка траекторий - * Отображает список доступных траекторий с поддержкой пагинации - * Поддерживает два режима: "all" (все траектории) и "my" (пользовательские) - * Реализует бесконечную прокрутку для загрузки дополнительных элементов - */ -@Component({ - selector: "app-list", - standalone: true, - imports: [CommonModule, RouterModule, CourseComponent], - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", -}) -export class CoursesListComponent implements OnInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - - protected readonly coursesList = signal([]); - - private readonly subscriptions$: Subscription[] = []; - - /** - * Инициализация компонента - * Определяет тип списка (all/my) и загружает начальные данные - */ - ngOnInit(): void { - this.route.data.pipe(map(r => r["data"])).subscribe(courses => { - this.coursesList.set(courses); - }); - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach(s => s.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/courses/list/list.resolver.spec.ts b/projects/social_platform/src/app/office/courses/list/list.resolver.spec.ts deleted file mode 100644 index 358a69b0d..000000000 --- a/projects/social_platform/src/app/office/courses/list/list.resolver.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { ResolveFn } from "@angular/router"; - -import { listResolver } from "./records.resolver"; - -describe("listResolver", () => { - const executeResolver: ResolveFn = (...resolverParameters) => - TestBed.runInInjectionContext(() => listResolver(...resolverParameters)); - - beforeEach(() => { - TestBed.configureTestingModule({}); - }); - - it("should be created", () => { - expect(executeResolver).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.html b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.html deleted file mode 100644 index a2ed58313..000000000 --- a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.html +++ /dev/null @@ -1,31 +0,0 @@ - - -
- {{ skill.name }} -
- @if (skill.approves.length > 0) { - - } - - {{ isUserApproveSkill(skill, loggedUserId!) ? "убрать оценку" : "подтвердить" }} -
-
- - - - diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts deleted file mode 100644 index aad91d08e..000000000 --- a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { ChangeDetectorRef, Component, inject, Input, OnDestroy, OnInit } from "@angular/core"; -import { Skill } from "@office/models/skill.model"; -import { ButtonComponent } from "@ui/components"; -import { map, of, Subscription, switchMap } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ActivatedRoute } from "@angular/router"; -import { ProfileService as profileApproveSkillService } from "@auth/services/profile.service"; -import { SnackbarService } from "@ui/services/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"; - -/** - * @params skill - информация о навыке (обязательно) - * - * Компонент на основе полученных данных о навыке - * выполняет логику подтверждения навыка - * с помощью сервисов связанных с навыками пользователя - */ -@Component({ - selector: "app-approve-skill", - styleUrl: "./approve-skill.component.scss", - templateUrl: "./approve-skill.component.html", - standalone: true, - imports: [CommonModule, ButtonComponent, ModalComponent, ApproveSkillPeopleComponent], -}) -export class ApproveSkillComponent implements OnInit, OnDestroy { - private readonly authService = inject(AuthService); - private readonly route = inject(ActivatedRoute); - private readonly profileApproveSkillService = inject(profileApproveSkillService); - private readonly snackbarService = inject(SnackbarService); - private readonly cdRef = inject(ChangeDetectorRef); - - // Указатель на то что пользватель подтвердил навык - isUserApproveSkill(skill: Skill, profileId: number): boolean { - return skill.approves.some(approve => approve.confirmedBy.id === profileId); - } - - // id пользователя за которого мы зарегистрировались - loggedUserId?: number; - - // переменные для работы с модальным окном для вывода ошибки с подтверждением своего навыка - approveOwnSkillModal = false; - - subscriptions: Subscription[] = []; - - // Получение данных о конкретном навыке - @Input({ required: true }) skill!: Skill; - - ngOnInit(): void { - const profileIdDataSub$ = this.authService.profile.pipe().subscribe({ - next: profile => { - this.loggedUserId = profile.id; - }, - }); - - this.subscriptions.push(profileIdDataSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - /** - * Подтверждение или отмена подтверждения навыка пользователя - * @param skillId - идентификатор навыка - * @param event - событие клика для предотвращения всплытия - * @param skill - объект навыка для обновления - */ - onToggleApprove(skillId: number, event: Event, skill: Skill, profileId: number) { - event.stopPropagation(); - const userId = this.route.snapshot.params["id"]; - - const isApprovedByCurrentUser = skill.approves.some(approve => { - return approve.confirmedBy.id === profileId; - }); - - if (isApprovedByCurrentUser) { - this.profileApproveSkillService.unApproveSkill(userId, skillId).subscribe(() => { - skill.approves = skill.approves.filter(approve => approve.confirmedBy.id !== profileId); - this.cdRef.markForCheck(); - }); - } else { - this.profileApproveSkillService - .approveSkill(userId, skillId) - .pipe( - switchMap(newApprove => - newApprove.confirmedBy - ? of(newApprove) - : this.authService.profile.pipe( - map(profile => ({ - ...newApprove, - confirmedBy: profile, - })) - ) - ) - ) - .subscribe({ - next: updatedApprove => { - skill.approves = [...skill.approves, updatedApprove]; - this.snackbarService.success("вы подтвердили навык"); - this.cdRef.markForCheck(); - }, - error: err => { - if (err instanceof HttpErrorResponse) { - if (err.status === 400) { - this.approveOwnSkillModal = true; - this.cdRef.markForCheck(); - } - } - }, - }); - } - } -} diff --git a/projects/social_platform/src/app/office/features/detail/detail.component.ts b/projects/social_platform/src/app/office/features/detail/detail.component.ts deleted file mode 100644 index 357ad0e22..000000000 --- a/projects/social_platform/src/app/office/features/detail/detail.component.ts +++ /dev/null @@ -1,576 +0,0 @@ -/** @format */ - -import { CommonModule, Location } from "@angular/common"; -import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; -import { ButtonComponent, InputComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { AuthService } from "@auth/services"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { concatMap, filter, map, Subscription, tap } from "rxjs"; -import { User } from "@auth/models/user.model"; -import { Collaborator } from "@office/models/collaborator.model"; -import { ProjectService } from "@office/services/project.service"; -import { Project } from "@office/models/project.model"; -import { ProjectAdditionalService } from "@office/projects/edit/services/project-additional.service"; -import { ProjectDataService } from "@office/projects/detail/services/project-data.service"; -import { ProgramDataService } from "@office/program/services/program-data.service"; -import { ChatService } from "@office/services/chat.service"; -import { calculateProfileProgress } from "@utils/calculateProgress"; -import { ProfileDataService } from "@office/profile/detail/services/profile-date.service"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { ApproveSkillComponent } from "../approve-skill/approve-skill.component"; -import { ProgramService } from "@office/program/services/program.service"; -import { ProjectFormService } from "@office/projects/edit/services/project-form.service"; -import { - PartnerProgramFields, - projectNewAdditionalProgramVields, -} from "@office/models/partner-program-fields.model"; -import { saveFile } from "@utils/helpers/export-file"; -import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { ControlErrorPipe, ValidationService } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { InviteService } from "@office/services/invite.service"; -import { ApiPagination } from "@office/models/api-pagination.model"; - -@Component({ - selector: "app-detail", - templateUrl: "./detail.component.html", - styleUrl: "./detail.component.scss", - imports: [ - CommonModule, - RouterModule, - ReactiveFormsModule, - IconComponent, - ButtonComponent, - ModalComponent, - AvatarComponent, - TooltipComponent, - ApproveSkillComponent, - InputComponent, - TruncatePipe, - ControlErrorPipe, - ], - standalone: true, -}) -export class DeatilComponent implements OnInit, OnDestroy { - private readonly authService = inject(AuthService); - private readonly fb = inject(FormBuilder); - private readonly route = inject(ActivatedRoute); - private readonly projectService = inject(ProjectService); - private readonly programDataService = inject(ProgramDataService); - private readonly projectDataService = inject(ProjectDataService); - private readonly projectAdditionalService = inject(ProjectAdditionalService); - private readonly snackbarService = inject(SnackbarService); - private readonly router = inject(Router); - private readonly location = inject(Location); - private readonly profileDataService = inject(ProfileDataService); - public readonly chatService = inject(ChatService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly programService = inject(ProgramService); - private readonly inviteService = inject(InviteService); - private readonly validationService = inject(ValidationService); - private readonly projectFormService = inject(ProjectFormService); - - // Основные данные(типы данных, данные) - info = signal(undefined); - profile?: User; - profileProjects = signal([]); - listType: "project" | "program" | "profile" = "project"; - queryCourseId = signal(null); - - // Переменная для подсказок - isTooltipVisible = false; - - tooltipText = "Заполни до конца — и открой весь функционал платформы!"; - - // Переменные для отображения данных в зависимости от url - isProjectsPage = false; - isMembersPage = false; - isProjectsRatingPage = false; - - isTeamPage = false; - isVacanciesPage = false; - isProjectChatPage = false; - - // Сторонние переменные для работы с роутингом или доп проверок - backPath?: string; - registerDateExpired?: boolean; - submissionProjectDateExpired?: boolean; - isInProject?: boolean; - - isSended = false; - isSubscriptionActive = signal(false); - isProfileFill = false; - - // Переменные для работы с модалкой подачи проекта - selectedProjectId: number | null = null; - memberProjects: Project[] = []; - - userType = signal(undefined); - - // Сигналы для работы с модальными окнами с текстом - // assignProjectToProgramModalMessage = signal(null); - errorMessageModal = signal(""); - - additionalFields = signal([]); - - // Переменные для работы с модалками - isAssignProjectToProgramModalOpen = signal(false); - showSubmitProjectModal = signal(false); - isProgramEndedModalOpen = signal(false); - isProgramSubmissionProjectsEndedModalOpen = signal(false); - isLeaveProjectModalOpen = false; // Флаг модального окна выхода - isEditDisable = false; // Флаг недоступности редактирования - isEditDisableModal = false; // Флаг недоступности редактирования для модалки - openSupport = false; // Флаг модального окна поддержки - leaderLeaveModal = false; // Флаг модального окна предупреждения лидера - isDelayModalOpen = false; - - // Переменные для работы с подтверждением навыков - showApproveSkillModal = false; - showSendInviteModal = false; - showNoProjectsModal = false; - showActiveInviteModal = false; - showNoInProgramModal = false; - showSuccessInviteModal = false; - readAllModal = false; - - // Сигналы для работы с модальными окнами с текстом - assignProjectToProgramModalMessage = signal(null); - - subscriptions: Subscription[] = []; - - get projectForm() { - return this.projectFormService.formModel; - } - - readonly inviteForm = this.fb.group({ - role: ["", Validators.required], - }); - - protected readonly errorMessage = ErrorMessage; - - ngOnInit(): void { - const listTypeSub$ = this.route.data.subscribe(data => { - this.listType = data["listType"]; - }); - - const queryParamsSub$ = this.route.queryParams.subscribe(params => { - const courseId = params["courseId"]; - this.queryCourseId.set(courseId ? +courseId : null); - }); - this.subscriptions.push(queryParamsSub$); - - this.initializeBackPath(); - - this.updatePageStates(); - this.location.onUrlChange(url => { - this.updatePageStates(url); - }); - - this.initializeInfo(); - - this.subscriptions.push(listTypeSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - // Геттеры для работы с отображением данных разного типа доступа - get isUserManager() { - if (this.listType === "program") { - return this.info().isUserManager; - } - } - - get isUserMember() { - if (this.listType === "program") { - return this.info().isUserMember; - } - } - - get isUserExpert() { - const type = this.userType(); - return type !== undefined && type === 3; - } - - get isProjectAssigned() { - const programId = this.info()?.id; - - return this.memberProjects.some( - project => project.leader === this.profile?.id && project.partnerProgram?.id === programId - ); - } - - // Методы для управления состоянием ошибок через сервис - setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { - this.projectAdditionalService.setAssignProjectToProgramError(error); - } - - /** Показать подсказку */ - showTooltip(): void { - this.isTooltipVisible = true; - } - - /** Скрыть подсказку */ - hideTooltip(): void { - this.isTooltipVisible = false; - } - - /** - * Обработчик изменения радио-кнопки для выбора проекта - */ - onProjectRadioChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.selectedProjectId = +target.value; - - if (this.selectedProjectId) { - this.memberProjects.find(project => project.id === this.selectedProjectId); - } - } - - addNewProject(): void { - const newFieldsFormValues: projectNewAdditionalProgramVields[] = []; - - this.additionalFields().forEach((field: PartnerProgramFields) => { - newFieldsFormValues.push({ - field_id: field.id, - value_text: field.options.length ? field.options[0] : "'", - }); - }); - - const body = { project: this.projectForm.value, program_field_values: newFieldsFormValues }; - - this.programService.applyProjectToProgram(this.info().id, body).subscribe({ - next: r => { - this.router - .navigate([`/office/projects/${r.projectId}/edit`], { - queryParams: { editingStep: "main", fromProgram: true }, - }) - .then(() => console.debug("Route change from ProjectsComponent")); - }, - error: err => { - if (err) { - if (err.status === 400) { - this.isAssignProjectToProgramModalOpen.set(true); - this.assignProjectToProgramModalMessage.set(err.error.detail); - } - } - }, - }); - } - - /** - * Закрытие модального окна выхода из проекта - */ - onCloseLeaveProjectModal(): void { - this.isLeaveProjectModalOpen = false; - } - - /** - * Закрытие модального окна для невозможности редактировать проект - */ - onUnableEditingProject(): void { - if (this.isEditDisable) { - this.isEditDisableModal = true; - } else { - this.isEditDisableModal = false; - } - } - - /** - * Выход из проекта - */ - onLeave() { - this.route.data - .pipe(map(r => r["data"][0])) - .pipe(concatMap(p => this.projectService.leave(p.id))) - .subscribe( - () => { - this.router - .navigateByUrl("/office/projects/my") - .then(() => console.debug("Route changed from ProjectInfoComponent")); - }, - () => { - this.leaderLeaveModal = true; // Показываем предупреждение для лидера - } - ); - } - - /** - * Копирование ссылки на профиль в буфер обмена - */ - onCopyLink(profileId: number): void { - let fullUrl = ""; - - // Формирование URL в зависимости от типа ресурса - fullUrl = `${location.origin}/office/profile/${profileId}/`; - - // Копирование в буфер обмена - navigator.clipboard.writeText(fullUrl).then(() => { - this.snackbarService.success("скопирован URL"); - }); - } - - openSkills: any = {}; - - /** - * Открытие модального окна с информацией о подтверждениях навыка - * @param skillId - идентификатор навыка - */ - onOpenSkill(skillId: number) { - this.openSkills[skillId] = !this.openSkills[skillId]; - } - - onCloseModal(skillId: number) { - this.openSkills[skillId] = false; - } - - /** - * Отправка CV пользователя на email - * Проверяет ограничения по времени и отправляет CV на почту пользователя - */ - downloadCV() { - this.isSended = true; - this.authService.downloadCV().subscribe({ - next: blob => { - saveFile(blob, "cv", this.profile?.firstName + " " + this.profile?.lastName); - this.isSended = false; - }, - error: err => { - this.isSended = false; - if (err.status === 400) { - this.isDelayModalOpen = true; - } - }, - }); - } - - /** - * Открывает модалку для отправки приглашения пользователю - * Проверяет какие отрендерить проекты где profile.id === leader - */ - inviteUser(): void { - if (!this.profileProjects().length) { - this.showNoProjectsModal = true; - } else { - this.showSendInviteModal = true; - } - } - - sendInvite(): void { - const role = this.inviteForm.get("role")?.value; - const userId = this.route.snapshot.params["id"]; - - if ( - !this.validationService.getFormValidation(this.inviteForm) || - this.selectedProjectId === null - ) { - return; - } - - this.inviteService.sendForUser(userId, this.selectedProjectId, role!).subscribe({ - next: () => { - this.showSendInviteModal = false; - this.showSuccessInviteModal = true; - - this.inviteForm.reset(); - this.selectedProjectId = null; - }, - error: err => { - if (err.error.user[0].includes("проект относится к программе")) { - this.showNoInProgramModal = true; - } else if (err.error.user[0].includes("активное приглашение")) { - this.showActiveInviteModal = true; - } - }, - }); - } - - /** - * Перенаправляет на страницу с информацией в завивисимости от listType - */ - redirectDetailInfo(): void { - switch (this.listType) { - case "profile": - this.router.navigateByUrl(`/office/profile/${this.info().id}`); - break; - - case "project": - this.router.navigateByUrl(`/office/projects/${this.info().id}`); - break; - - case "program": - this.router.navigateByUrl(`/office/program/${this.info().id}`); - break; - } - } - - routingToMyProjects(): void { - this.router.navigateByUrl(`/office/projects/my`); - } - - /** - * Проверка завершения программы перед регистрацией - */ - checkPrograRegistrationEnded(event: Event): void { - const program = this.info(); - - if ( - program?.datetimeRegistrationEnds && - Date.now() > Date.parse(program.datetimeRegistrationEnds) - ) { - event.preventDefault(); - event.stopPropagation(); - this.isProgramEndedModalOpen.set(true); - } else if ( - program?.datetimeProjectSubmissionEnds && - Date.now() > Date.parse(program?.datetimeProjectSubmissionEnds) - ) { - event.preventDefault(); - event.stopPropagation(); - this.isProgramSubmissionProjectsEndedModalOpen.set(true); - } else { - this.router.navigateByUrl("/office/program/" + this.info().id + "/register"); - } - } - - /** - * Обновляет состояния страниц на основе URL - */ - private updatePageStates(url?: string): void { - const currentUrl = url || this.router.url; - - this.isProjectsPage = - currentUrl.includes("/projects") && !currentUrl.includes("/projects-rating"); - - this.isMembersPage = currentUrl.includes("/members"); - - this.isProjectsRatingPage = currentUrl.includes("/projects-rating"); - - this.isTeamPage = currentUrl.includes("/team"); - this.isVacanciesPage = currentUrl.includes("/vacancies"); - this.isProjectChatPage = currentUrl.includes("/chat"); - } - - private initializeInfo() { - if (this.listType === "project") { - const projectSub$ = this.projectDataService.project$ - .pipe(filter(project => !!project)) - .subscribe(project => { - this.info.set(project); - - if (project?.partnerProgram) { - this.isEditDisable = project.partnerProgram?.isSubmitted; - } - }); - - this.isInProfileInfo(); - - this.subscriptions.push(projectSub$); - } else if (this.listType === "program") { - const program$ = this.programDataService.program$ - .pipe( - filter(program => !!program), - tap(program => { - if (program) { - this.info.set(program); - this.loadAdditionalFields(program.id); - this.registerDateExpired = Date.now() > Date.parse(program.datetimeRegistrationEnds); - this.submissionProjectDateExpired = - Date.now() > Date.parse(program.datetimeProjectSubmissionEnds); - } - }) - ) - .subscribe(); - - const profileDataSub$ = this.authService.profile.pipe(filter(user => !!user)).subscribe({ - next: user => { - this.userType.set(user!.userType); - this.profile = user; - this.cdRef.detectChanges(); - }, - }); - - const memeberProjects$ = this.projectService.getMy().subscribe({ - next: projects => { - this.memberProjects = projects.results.filter(project => !project.draft); - }, - }); - - this.subscriptions.push(program$); - this.subscriptions.push(memeberProjects$); - this.subscriptions.push(profileDataSub$); - } else { - const profileDataSub$ = this.profileDataService - .getProfile() - .pipe( - map(user => ({ ...user, progress: calculateProfileProgress(user!) })), - filter(user => !!user) - ) - .subscribe({ - next: user => { - this.info.set(user); - this.isProfileFill = - user.progress! < 100 ? (this.isProfileFill = true) : (this.isProfileFill = false); - }, - }); - - this.isInProfileInfo(); - - const profileLeaderProjectsSub$ = this.authService.getLeaderProjects().subscribe({ - next: (projects: ApiPagination) => { - this.profileProjects.set(projects.results); - }, - }); - - this.subscriptions.push(profileDataSub$, profileLeaderProjectsSub$); - } - } - - private isInProfileInfo(): void { - const profileInfoSub$ = this.authService.profile.subscribe({ - next: profile => { - this.profile = profile; - - if (this.info() && this.listType === "project") { - this.isInProject = this.info() - ?.collaborators?.map((person: Collaborator) => person.userId) - .includes(profile.id); - } - }, - }); - - this.subscriptions.push(profileInfoSub$); - } - - /** - * Инициализация строки для back компонента в зависимости от типа данных - */ - private initializeBackPath(): void { - if (this.listType === "project") { - this.backPath = "/office/projects/all"; - } else if (this.listType === "program") { - this.backPath = "/office/program/all"; - } - } - - private loadAdditionalFields(programId: number): void { - const additionalFieldsSub$ = this.programService - .getProgramProjectAdditionalFields(programId) - .subscribe({ - next: ({ programFields }) => { - if (programFields) { - this.additionalFields.set(programFields); - } - }, - }); - - this.subscriptions.push(additionalFieldsSub$); - } -} diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.ts b/projects/social_platform/src/app/office/features/info-card/info-card.component.ts deleted file mode 100644 index e1fb4456c..000000000 --- a/projects/social_platform/src/app/office/features/info-card/info-card.component.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, inject, Input, OnInit, Output, signal } from "@angular/core"; -import { IndustryService } from "@services/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 { 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"; - -/** - * Компонент карточки информации с разным наполнением, в зависимости от контекста - */ -@Component({ - selector: "app-info-card", - templateUrl: "./info-card.component.html", - styleUrl: "./info-card.component.scss", - standalone: true, - imports: [ - CommonModule, - AvatarComponent, - IconComponent, - AsyncPipe, - ModalComponent, - ButtonComponent, - ClickOutsideModule, - TagComponent, - YearsFromBirthdayPipe, - TruncatePipe, - RouterLink, - ], -}) -export class InfoCardComponent { - private readonly inviteService = inject(InviteService); - private readonly subscriptionService = inject(SubscriptionService); - public readonly industryService = inject(IndustryService); - private readonly router = inject(Router); - - @Input() info?: any; - @Input() type: "invite" | "projects" | "members" = "projects"; - @Input() appereance: "my" | "subs" | "base" | "empty" = "base"; - @Input() section: "projects" | "subscriptions" | "other" = "projects"; - @Input() canDelete?: boolean | null = false; - @Input() isSubscribed?: boolean | null = false; - @Input() profileId?: number; - @Input() leaderId?: number; - @Input() loggedUserId?: number; - - @Output() onAcceptingInvite = new EventEmitter(); - @Output() onRejectingInvite = new EventEmitter(); - @Output() onCreate = new EventEmitter(); - @Output() onRemoveCollaborator = new EventEmitter(); - - // Состояние компонента - isUnsubscribeModalOpen = false; - inviteErrorModal = false; - haveBadge = this.calculateHaveBadge(); - - programProjectHovered = false; - iconHovered = false; - draftProjectHovered = false; - - removeCollaboratorFromProject(userId: number): void { - this.onRemoveCollaborator.emit(userId); - } - - /** - * Определяет, нужно ли показывать информацию о проекте - */ - shouldShowProjectInfo(): boolean { - return this.type === "projects" && this.appereance !== "subs" && this.appereance !== "empty"; - } - - /** - * Определяет, нужно ли показывать бейдж подписки - */ - shouldShowSubscriptionBadge(): boolean { - return ( - this.appereance !== "empty" && - this.haveBadge && - this.appereance === "base" && - this.type !== "invite" && - this.type !== "members" - ); - } - - /** - * Возвращает URL для аватара - */ - getAvatarUrl(): string { - const currentImageAddress = - this.appereance === "empty" && this.section === "projects" - ? "/assets/images/projects/shared/add-project.svg" - : this.appereance === "empty" && this.section === "subscriptions" - ? "/assets/images/projects/shared/empty-subscriptions.svg" - : ""; - return this.info?.imageAddress || this.info?.avatar || currentImageAddress; - } - - /** - * Переключение подписки (универсальный метод) - */ - toggleSubscription(event: Event): void { - if (this.isSubscribed) { - this.onSubscribe(event, this.profileId!); - } else { - this.onSubscribe(event, this.profileId!); - } - } - - /** - * Обработка отклонения приглашения - */ - onRejectInvite(event: Event, inviteId: number): void { - if (!this.info || !inviteId) { - console.warn("Cannot reject invite: missing project or inviteId"); - return; - } - - this.stopEventPropagation(event); - - this.inviteService.rejectInvite(inviteId).subscribe({ - next: () => { - this.onRejectingInvite.emit(inviteId || this.info!.inviteId); - }, - error: error => { - console.error("Error rejecting invite:", error); - this.inviteErrorModal = true; - }, - }); - } - - /** - * Обработка принятия приглашения - */ - onAcceptInvite(event: Event, inviteId: number): void { - if (!this.info || !inviteId) { - console.warn("Cannot accept invite: missing project or inviteId"); - return; - } - - this.stopEventPropagation(event); - - this.inviteService.acceptInvite(inviteId).subscribe({ - next: () => { - this.onAcceptingInvite.emit(inviteId || this.info!.inviteId); - }, - error: error => { - console.error("Error accepting invite:", error); - this.inviteErrorModal = true; - }, - }); - } - - /** - * Подписка на проект или открытие модального окна отписки - */ - onSubscribe(event: Event, projectId: number): void { - if (!projectId) { - console.warn("Cannot subscribe: missing projectId"); - return; - } - - this.stopEventPropagation(event); - - if (this.isSubscribed) { - this.isUnsubscribeModalOpen = true; - return; - } - - this.subscriptionService.addSubscription(projectId).subscribe({ - next: () => { - this.isSubscribed = true; - }, - error: error => { - console.error("Error subscribing to project:", error); - }, - }); - } - - /** - * Отписка от проекта - */ - onUnsubscribe(event: Event, projectId: number): void { - if (!projectId) { - console.warn("Cannot unsubscribe: missing projectId"); - return; - } - - this.stopEventPropagation(event); - - this.subscriptionService.deleteSubscription(projectId).subscribe({ - next: () => { - this.isSubscribed = false; - this.isUnsubscribeModalOpen = false; - }, - error: error => { - console.error("Error unsubscribing from project:", error); - }, - }); - } - - /** - * Закрытие модального окна отписки - */ - onCloseUnsubscribeModal(): void { - this.isUnsubscribeModalOpen = false; - } - - /** - * Обработка создания нового проекта - */ - onCreateProject(event: Event): void { - this.stopEventPropagation(event); - this.onCreate.emit(); - } - - /** - * Остановка всплытия события - */ - private stopEventPropagation(event: Event): void { - event.stopPropagation(); - event.preventDefault(); - } - - /** - * Редирект на проеты при случае что подписки пустые - */ - redirectToProjects(): void { - this.router - .navigateByUrl(`/office/projects/all`) - .then(() => console.debug("Route change from ProjectsComponent")); - } - - /** - * Вычисление флага haveBadge - */ - private calculateHaveBadge(): boolean { - return ( - location.href.includes("/subscriptions") || - location.href.includes("/all") || - location.href.includes("/projects") - ); - } -} diff --git a/projects/social_platform/src/app/office/features/nav/nav.component.ts b/projects/social_platform/src/app/office/features/nav/nav.component.ts deleted file mode 100644 index 47742dc95..000000000 --- a/projects/social_platform/src/app/office/features/nav/nav.component.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from "@angular/core"; -import { NavService } from "@services/nav.service"; -import { NavigationStart, Router, RouterLink, RouterLinkActive } from "@angular/router"; -import { noop, Subscription } from "rxjs"; -import { NotificationService } from "@services/notification.service"; -import { Invite } from "@models/invite.model"; -import { AuthService } from "@auth/services"; -import { InviteService } from "@services/invite.service"; -import { AsyncPipe } from "@angular/common"; -import { IconComponent } from "@ui/components"; -import { InviteManageCardComponent, ProfileInfoComponent } from "@uilib"; - -/** - * Компонент навигационного меню - * - * Функциональность: - * - Отображает основное навигационное меню приложения - * - Управляет мобильным меню (открытие/закрытие) - * - Показывает уведомления и приглашения - * - Обрабатывает принятие и отклонение приглашений - * - Отображает информацию о профиле пользователя - * - Автоматически закрывает мобильное меню при навигации - * - Интеграция с внешним сервисом навыков - * - Динамическое обновление заголовка страницы - * - * Входные параметры: - * @Input invites - массив приглашений пользователя - * - * Внутренние свойства: - * - mobileMenuOpen - флаг состояния мобильного меню - * - notificationsOpen - флаг состояния панели уведомлений - * - title - текущий заголовок страницы - * - subscriptions$ - массив подписок для управления памятью - * - hasInvites - вычисляемое свойство наличия непрочитанных приглашений - * - * Сервисы: - * - navService - управление навигацией и заголовками - * - notificationService - управление уведомлениями - * - inviteService - работа с приглашениями - * - authService - аутентификация и профиль пользователя - */ -@Component({ - selector: "app-nav", - templateUrl: "./nav.component.html", - styleUrl: "./nav.component.scss", - standalone: true, - imports: [ - IconComponent, - RouterLink, - RouterLinkActive, - InviteManageCardComponent, - ProfileInfoComponent, - AsyncPipe, - ], -}) -export class NavComponent implements OnInit, OnDestroy { - constructor( - public readonly navService: NavService, - private readonly router: Router, - public readonly notificationService: NotificationService, - private readonly inviteService: InviteService, - public readonly authService: AuthService, - private readonly cdref: ChangeDetectorRef - ) {} - - ngOnInit(): void { - // Подписка на события роутера для закрытия мобильного меню - const routerEvents$ = this.router.events.subscribe(event => { - if (event instanceof NavigationStart) { - this.mobileMenuOpen = false; - } - }); - routerEvents$ && this.subscriptions$.push(routerEvents$); - - // Подписка на изменения заголовка страницы - const title$ = this.navService.navTitle.subscribe(title => { - this.title = title; - this.cdref.detectChanges(); - }); - - title$ && this.subscriptions$.push(title$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - @Input() invites: Invite[] = []; - - subscriptions$: Subscription[] = []; - mobileMenuOpen = false; - notificationsOpen = false; - title = ""; - - /** - * Проверка наличия непринятых приглашений - * Возвращает true если есть приглашения со статусом null (не принято/не отклонено) - */ - get hasInvites(): boolean { - return !!this.invites.filter(invite => invite.isAccepted === null).length; - } - - /** - * Обработчик отклонения приглашения - * Отправляет запрос на отклонение и удаляет приглашение из списка - */ - onRejectInvite(inviteId: number): void { - this.inviteService.rejectInvite(inviteId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === inviteId); - this.invites.splice(index, 1); - - this.notificationsOpen = false; - this.mobileMenuOpen = false; - }); - } - - /** - * Обработчик принятия приглашения - * Отправляет запрос на принятие, удаляет приглашение из списка - * и перенаправляет пользователя на страницу проекта - */ - onAcceptInvite(inviteId: number): void { - this.inviteService.acceptInvite(inviteId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === inviteId); - const invite = JSON.parse(JSON.stringify(this.invites[index])); - this.invites.splice(index, 1); - - this.notificationsOpen = false; - this.mobileMenuOpen = false; - - this.router - .navigateByUrl(`/office/projects/${invite.project.id}`) - .then(() => console.debug("Route changed from HeaderComponent")); - }); - } - - /** - * Переход на внешний сервис навыков - * Открывает новую вкладку с сервисом skills.procollab.ru - */ - openSkills() { - location.href = "https://skills.procollab.ru"; - } - - protected readonly noop = noop; -} diff --git a/projects/social_platform/src/app/office/features/news-card/news-card.component.html b/projects/social_platform/src/app/office/features/news-card/news-card.component.html deleted file mode 100644 index 082c3ed35..000000000 --- a/projects/social_platform/src/app/office/features/news-card/news-card.component.html +++ /dev/null @@ -1,134 +0,0 @@ - -
-
- - -

- {{ feedItem.name | truncate: 30 }} -

-
- @if (isOwner) { -
-
- -
- @if (menuOpen) { -
    - @if (!editMode) { -
  • редактировать
  • - } -
  • удалить
  • -
- } -
- } -
- @if (feedItem.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/features/news-card/news-card.component.scss b/projects/social_platform/src/app/office/features/news-card/news-card.component.scss deleted file mode 100644 index 7c1c6d018..000000000 --- a/projects/social_platform/src/app/office/features/news-card/news-card.component.scss +++ /dev/null @@ -1,224 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.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(--dark-grey); - cursor: pointer; - } - - &__options { - position: absolute; - 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%; - object-fit: cover; - } - - &__title { - display: flex; - align-items: center; - } - - &__top { - display: flex; - gap: 10px; - align-items: center; - } - - &__name { - color: var(--black); - } - - &__date { - color: var(--dark-grey); - } - - /* stylelint-disable value-no-vendor-prefix */ - &__text { - color: var(--grey-for-text); - white-space: break-spaces; - - 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; - } -} - -.footer { - display: flex; - align-items: center; - justify-content: space-between; - - &__left { - display: flex; - gap: 10px; - align-items: center; - } - - &__item { - display: flex; - align-items: center; - color: var(--dark-grey); - } - - &__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; - gap: 10px; - align-items: center; - } - - &__attach { - color: var(--dark-grey); - cursor: pointer; - - input { - display: none; - } - } -} diff --git a/projects/social_platform/src/app/office/features/news-card/news-card.component.spec.ts b/projects/social_platform/src/app/office/features/news-card/news-card.component.spec.ts deleted file mode 100644 index 220bac250..000000000 --- a/projects/social_platform/src/app/office/features/news-card/news-card.component.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { NewsCardComponent } 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: NewsCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const projectNewsServiceSpy = jasmine.createSpyObj(["addNews"]); - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - NewsCardComponent, - DayjsPipe, - ], - providers: [ - { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, - { provide: AuthService, useValue: authSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NewsCardComponent); - component = fixture.componentInstance; - component.feedItem = FeedNews.default(); - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/news-card/news-card.component.ts b/projects/social_platform/src/app/office/features/news-card/news-card.component.ts deleted file mode 100644 index 29cc4b868..000000000 --- a/projects/social_platform/src/app/office/features/news-card/news-card.component.ts +++ /dev/null @@ -1,387 +0,0 @@ -/** @format */ - -import { - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - Input, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { FeedNews } from "@office/projects/models/project-news.model"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { - DayjsPipe, - FormControlPipe, - ParseBreaksPipe, - ParseLinksPipe, - ValidationService, -} from "projects/core"; -import { FileService } from "@core/services/file.service"; -import { nanoid } from "nanoid"; -import { expandElement } from "@utils/expand-element"; -import { FileModel } from "@models/file.model"; -import { forkJoin, noop, Observable, tap } from "rxjs"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { FileUploadItemComponent } from "@ui/components/file-upload-item/file-upload-item.component"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { ClickOutsideModule } from "ng-click-outside"; -import { CarouselComponent } from "@office/shared/carousel/carousel.component"; -import { ImgCardComponent } from "@office/shared/img-card/img-card.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -@Component({ - selector: "app-news-card", - templateUrl: "./news-card.component.html", - styleUrl: "./news-card.component.scss", - standalone: true, - imports: [ - ClickOutsideModule, - RouterLink, - IconComponent, - TextareaComponent, - ReactiveFormsModule, - FileUploadItemComponent, - FileItemComponent, - ButtonComponent, - DayjsPipe, - FormControlPipe, - TruncatePipe, - ParseLinksPipe, - ParseBreaksPipe, - CarouselComponent, - ImgCardComponent, - ], -}) -export class NewsCardComponent implements OnInit { - constructor( - private readonly snackbarService: SnackbarService, - private readonly route: ActivatedRoute, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly fileService: FileService, - private readonly cdRef: ChangeDetectorRef - ) { - this.editForm = this.fb.group({ - text: ["", [Validators.required]], - }); - } - - @Input({ required: true }) feedItem!: FeedNews; - @Input({ required: true }) resourceLink!: (string | number)[]; - @Input({ required: false }) contentId?: number; - @Input() isOwner?: boolean; - - @Output() delete = new EventEmitter(); - @Output() like = new EventEmitter(); - @Output() edited = new EventEmitter(); - - placeholderUrl = "https://hwchamber.co.uk/wp-content/uploads/2022/04/avatar-placeholder.gif"; - - newsTextExpandable!: boolean; - readMore = false; - editMode = false; - editForm: FormGroup; - - // Оригинальные списки (не изменяются во время редактирования) - 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.feedItem.text, - }); - - const processedFiles = this.feedItem.files.map(file => { - if (typeof file === "string") { - return { - link: file, - name: "Image", - mimeType: "image/jpeg", - size: 0, - datetimeUploaded: "", - extension: "", - user: 0, - } as FileModel; - } - return file; - }); - - this.showLikes = this.feedItem.files.map(() => false); - - this.imagesViewList = processedFiles.filter(f => { - const [type] = (f.mimeType || "").split("/"); - return type === "image" || f.mimeType === "x-empty"; - }); - - this.filesViewList = processedFiles.filter(f => { - const [type] = (f.mimeType || "").split("/"); - return type !== "image" && f.mimeType !== "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 isProject = this.resourceLink[0].toString().includes("projects"); - let fullUrl = ""; - - if (isProject) { - fullUrl = `${location.origin}/office/projects/${this.contentId}/news/${this.feedItem.id}`; - } else { - fullUrl = `${location.origin}/office/profile/${this.contentId}/news/${this.feedItem.id}`; - } - - navigator.clipboard.writeText(fullUrl).then(() => { - this.snackbarService.success("Ссылка скопирована"); - }); - } - - menuOpen = false; - - onCloseMenu() { - this.menuOpen = false; - } - - 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, - })); - - this.feedItem.text = this.editForm.value.text; - - this.feedItem.files = [...this.imagesViewList, ...this.filesViewList]; - - this.edited.emit({ - ...this.editForm.value, - files: uploadedImages, - }); - - this.cdRef.detectChanges(); - this.onCloseEditMode(); - } - - onCloseEditMode() { - this.editMode = false; - - this.initEditLists(); - - this.editForm.setValue({ - text: this.feedItem.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: NewsCardComponent["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: NewsCardComponent["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); - - (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.feedItem.id); - this.showLikes[imgIdx] = true; - - setTimeout(() => { - this.showLikes[imgIdx] = false; - }, 1000); - } - - this.lastTouch = Date.now(); - } - - handleLike(index: number): void { - console.log("Лайк на изображении с индексом: ", index); - } - - onExpandNewsText(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readMore = !isExpanded; - } -} 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 deleted file mode 100644 index 233151650..000000000 --- a/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -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"; - -@Component({ - selector: "app-program-sidebar-card", - templateUrl: "./program-sidebar-card.component.html", - styleUrl: "./program-sidebar-card.component.scss", - imports: [CommonModule, IconComponent, AvatarComponent, TruncatePipe], - standalone: true, -}) -export class ProgramSidebarCardComponent { - @Input() program!: Program; -} diff --git a/projects/social_platform/src/app/office/features/response-card/response-card.component.ts b/projects/social_platform/src/app/office/features/response-card/response-card.component.ts deleted file mode 100644 index 1f7acaca1..000000000 --- a/projects/social_platform/src/app/office/features/response-card/response-card.component.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { VacancyResponse } from "@models/vacancy-response.model"; -import { UserRolePipe } from "@core/pipes/user-role.pipe"; -import { ButtonComponent } from "@ui/components"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { RouterLink } from "@angular/router"; -import { AsyncPipe } from "@angular/common"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { AuthService } from "@auth/services"; -import { ProjectVacancyCardComponent } from "@office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component"; -import { IconComponent } from "@uilib"; - -/** - * Компонент карточки отклика на вакансию - * - * Функциональность: - * - Отображает информацию об отклике на вакансию (кандидат, роль, файлы) - * - Показывает аватар и основную информацию о кандидате - * - Отображает прикрепленные файлы (резюме, портфолио) - * - Предоставляет кнопки для принятия или отклонения отклика - * - Ссылка на профиль кандидата - * - Получает ID текущего пользователя для проверки прав доступа - * - * Входные параметры: - * @Input response - объект отклика на вакансию (обязательный) - * - * Выходные события: - * @Output reject - событие отклонения отклика, передает ID отклика - * @Output accept - событие принятия отклика, передает ID отклика - * - * Внутренние свойства: - * - profileId - ID текущего пользователя для проверки прав - */ -@Component({ - selector: "app-response-card", - templateUrl: "./response-card.component.html", - styleUrl: "./response-card.component.scss", - standalone: true, - imports: [IconComponent, FileItemComponent], -}) -export class ResponseCardComponent implements OnInit { - constructor(private readonly authService: AuthService) {} - - @Input({ required: true }) response!: VacancyResponse; - @Output() reject = new EventEmitter(); - @Output() accept = new EventEmitter(); - - profileId!: number; - - ngOnInit(): void { - this.authService.getProfile().subscribe({ - next: profile => { - this.profileId = profile.id; - }, - }); - } - - /** - * Обработчик принятия отклика - * Эмитит событие с ID отклика - */ - onAccept(responseId: number) { - this.accept.emit(responseId); - } - - /** - * Обработчик отклонения отклика - * Эмитит событие с ID отклика - */ - onReject(responseId: number) { - this.reject.emit(responseId); - } -} diff --git a/projects/social_platform/src/app/office/feed/feed.component.ts b/projects/social_platform/src/app/office/feed/feed.component.ts deleted file mode 100644 index b0cd630d4..000000000 --- a/projects/social_platform/src/app/office/feed/feed.component.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - ElementRef, - inject, - OnDestroy, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { NewProjectComponent } from "@office/feed/shared/new-project/new-project.component"; -import { ActivatedRoute } from "@angular/router"; -import { FeedItem, FeedItemType } from "@office/feed/models/feed-item.model"; -import { concatMap, fromEvent, map, noop, of, skip, Subscription, tap, throttleTime } from "rxjs"; -import { ApiPagination } from "@models/api-pagination.model"; -import { FeedService } from "@office/feed/services/feed.service"; -import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; -import { ProfileNewsService } from "@office/profile/detail/services/profile-news.service"; -import { FeedFilterComponent } from "@office/feed/filter/feed-filter.component"; -import { NewsCardComponent } from "@office/features/news-card/news-card.component"; -import { OpenVacancyComponent } from "./shared/open-vacancy/open-vacancy.component"; -import { IconComponent } from "@ui/components"; - -@Component({ - selector: "app-feed", - standalone: true, - imports: [ - CommonModule, - IconComponent, - NewProjectComponent, - FeedFilterComponent, - NewsCardComponent, - OpenVacancyComponent, - IconComponent, - ], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: "./feed.component.html", - styleUrl: "./feed.component.scss", -}) -export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { - route = inject(ActivatedRoute); - projectNewsService = inject(ProjectNewsService); - profileNewsService = inject(ProfileNewsService); - feedService = inject(FeedService); - - ngOnInit() { - const routeData$ = this.route.data - .pipe(map(r => r["data"])) - .subscribe((feed: ApiPagination) => { - this.feedItems.set(feed.results); - this.totalItemsCount.set(feed.count); - this.feedPage.set(feed.results.length); - - setTimeout(() => { - const observer = new IntersectionObserver(this.onFeedItemView.bind(this), { - root: document.querySelector(".office__body"), - rootMargin: "0px 0px 0px 0px", - threshold: 0, - }); - - document.querySelectorAll(".page__item").forEach(e => { - observer.observe(e); - }); - }); - }); - this.subscriptions$().push(routeData$); - - const queryParams$ = this.route.queryParams - .pipe( - map(params => params["includes"]), - tap(includes => { - this.includes.set(includes); - }), - skip(1), - concatMap(includes => { - this.totalItemsCount.set(0); - this.feedPage.set(0); - - return this.onFetch(0, this.perFetchTake(), includes ?? ["vacancy", "project", "news"]); - }) - ) - .subscribe(feed => { - this.feedItems.set(feed); - this.feedPage.set(feed.length); - - setTimeout(() => { - this.feedRoot?.nativeElement.children[0].scrollIntoView({ behavior: "smooth" }); - }); - }); - this.subscriptions$().push(queryParams$); - } - - ngAfterViewInit() { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(500) - ) - .subscribe(noop); - - this.subscriptions$().push(scrollEvents$); - } - } - - ngOnDestroy() { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - @ViewChild("feedRoot") feedRoot?: ElementRef; - - totalItemsCount = signal(0); - feedItems = signal([]); - feedPage = signal(0); - perFetchTake = signal(20); - includes = signal([]); - - subscriptions$ = signal([]); - - 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 - ) - .subscribe(() => { - item.content.likesCount = item.content.isUserLiked - ? item.content.likesCount - 1 - : item.content.likesCount + 1; - item.content.isUserLiked = !item.content.isUserLiked; - - this.feedItems.update(items => { - const newItems = [...items]; - newItems.splice(itemIdx, 1, item); - return newItems; - }); - }); - } else if ("leader" in item.content.contentObject) { - this.projectNewsService - .toggleLike( - item.content.contentObject.id as unknown as string, - newsId, - !item.content.isUserLiked - ) - .subscribe(() => { - item.content.likesCount = item.content.isUserLiked - ? item.content.likesCount - 1 - : item.content.likesCount + 1; - item.content.isUserLiked = !item.content.isUserLiked; - - this.feedItems.update(items => { - const newItems = [...items]; - newItems.splice(itemIdx, 1, item); - return newItems; - }); - }); - } - } - - 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]) - .subscribe(noop); - }); - - profileNews.forEach(news => { - if (news.typeModel !== "news") return; - this.profileNewsService - .readNews(news.content.contentObject.id, [news.content.id]) - .subscribe(noop); - }); - } - - onScroll() { - if (this.totalItemsCount() && this.feedItems().length >= this.totalItemsCount()) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.feedRoot) return of({}); - - const diff = - target.scrollTop - - this.feedRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - const currentOffset = this.feedItems().length; - - return this.onFetch(currentOffset, this.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.feedPage.update(page => page + uniqueNewItems.length); - this.feedItems.update(items => [...items, ...uniqueNewItems]); - } - }) - ); - } - - return of({}); - } - - onFetch( - offset: number, - limit: number, - includes: FeedItemType[] = ["project", "vacancy", "news"] - ) { - return this.feedService.getFeed(offset, limit, includes).pipe( - tap(res => { - this.totalItemsCount.set(res.count); - }), - map(res => res.results) - ); - } -} diff --git a/projects/social_platform/src/app/office/feed/feed.resolver.ts b/projects/social_platform/src/app/office/feed/feed.resolver.ts deleted file mode 100644 index 8f4529270..000000000 --- a/projects/social_platform/src/app/office/feed/feed.resolver.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ResolveFn } from "@angular/router"; -import { FeedItem } from "@office/feed/models/feed-item.model"; -import { FeedService } from "@office/feed/services/feed.service"; -import { ApiPagination } from "@models/api-pagination.model"; - -/** - * резолвер ленты новостей - * - * Этот резолвер предназначен для предварительной загрузки данных ленты новостей - * перед отображением компонента. Выполняется автоматически при навигации на маршрут. - * - * ЧТО ДЕЛАЕТ: - * - Загружает первую страницу ленты новостей (20 элементов) - * - Получает параметры фильтрации из URL (includes) - * - Возвращает пагинированный список элементов ленты - * - * @param route - объект маршрута с параметрами запроса - * - * @returns Observable> - пагинированный список элементов ленты - */ -export const FeedResolver: ResolveFn> = route => { - const feedService = inject(FeedService); - - // Загружаем первую страницу ленты (offset: 0, limit: 20) - // По умолчанию включаем вакансии, новости и проекты - return feedService.getFeed( - 0, - 20, - route.queryParams["includes"] ?? ["vacancy", "news", "project"] - ); -}; diff --git a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts b/projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts deleted file mode 100644 index fa2020263..000000000 --- a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** @format */ - -import { animate, style, transition, trigger } from "@angular/animations"; -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - inject, - OnDestroy, - OnInit, - signal, -} from "@angular/core"; -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 { Subscription } from "rxjs"; -import { feedFilter } from "projects/core/src/consts/filters/feed-filter.const"; - -/** - * КОМПОНЕНТ ФИЛЬТРАЦИИ ЛЕНТЫ - * - * Предоставляет интерфейс для фильтрации элементов ленты по типам контента. - * Позволяет пользователю выбирать, какие типы элементов отображать в ленте. - * Обновления URL происходят мгновенно при каждом изменении фильтра. - * - * ОСНОВНЫЕ ФУНКЦИИ: - * - Отображение выпадающего меню с опциями фильтрации - * - Управление состоянием активных фильтров - * - Мгновенная синхронизация фильтров с URL параметрами - * - Применение и сброс фильтров - * - * ДОСТУПНЫЕ ФИЛЬТРЫ: - * - Новости (news) - * - Вакансии (vacancy) - * - Новости проектов (project) - */ -@Component({ - selector: "app-feed-filter", - standalone: true, - imports: [ - CommonModule, - CheckboxComponent, - ButtonComponent, - ClickOutsideModule, - IconComponent, - RouterLink, - ], - templateUrl: "./feed-filter.component.html", - styleUrl: "./feed-filter.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [ - trigger("dropdownAnimation", [ - transition(":enter", [ - style({ opacity: 0, transform: "scaleY(0.8)" }), - animate(".12s cubic-bezier(0, 0, 0.2, 1)"), - ]), - transition(":leave", [animate(".1s linear", style({ opacity: 0 }))]), - ]), - ], -}) -export class FeedFilterComponent implements OnInit, OnDestroy { - router = inject(Router); - route = inject(ActivatedRoute); - authService = inject(AuthService); - feedService = inject(FeedService); - - profile = signal(null); - subscriptions: Subscription[] = []; - - /** - * ИНИЦИАЛИЗАЦИЯ КОМПОНЕНТА - * - * ЧТО ДЕЛАЕТ: - * - Подписывается на изменения профиля пользователя - * - Читает текущие фильтры из URL параметров - * - Инициализирует состояние фильтров - */ - ngOnInit() { - const profileSubscription = this.authService.profile.subscribe(profile => { - this.profile.set(profile); - }); - - // Читаем активные фильтры из URL - const routeSubscription = this.route.queryParams.subscribe(queries => { - if (queries["includes"]) { - this.includedFilters.set(queries["includes"]); - } else { - this.includedFilters.set(""); - } - }); - - this.subscriptions.push(profileSubscription, routeSubscription); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - // Состояние выпадающего меню фильтров - filterOpen = signal(false); - - /** - * ОПЦИИ ФИЛЬТРАЦИИ - * - * Массив доступных опций для фильтрации ленты: - * - label: отображаемое название на русском языке - * - value: значение для API запроса - */ - readonly feedFilterOptions = feedFilter; - - // Массив активных фильтров - includedFilters = signal(""); - - /** - * ОБНОВЛЕНИЕ URL С ТЕКУЩИМИ ФИЛЬТРАМИ - * - * Приватный метод для обновления URL параметров. - * Вызывается автоматически при любом изменении фильтров. - */ - private updateUrl(): void { - const includesParam = this.includedFilters().length > 0 ? this.includedFilters() : null; - - this.router - .navigate([], { - queryParams: { - includes: includesParam, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from FeedFilterComponent")); - } - - /** - * ПЕРЕКЛЮЧЕНИЕ ФИЛЬТРА С МГНОВЕННЫМ ОБНОВЛЕНИЕМ URL - * - * ЧТО ПРИНИМАЕТ: - * @param id - id для фильтра - * @param keyword - значение фильтра для переключения - * - * ЧТО ДЕЛАЕТ: - * - Добавляет фильтр, если он не активен - * - Удаляет фильтр, если он уже активен - * - Обрабатывает переключение между projects и projects/1 - * - Мгновенно обновляет URL параметры - */ - setFilter(keyword: string): void { - this.includedFilters.update(included => { - if (keyword.startsWith("project/")) { - // Если уже активен этот же вложенный фильтр - сбрасываем к "projects" - if (included === keyword) { - return "project"; - } - return keyword; - } - - // Если кликнули на "projects" - if (keyword === "project") { - if (included.startsWith("project/")) { - return "project"; - } - - if (included === "project") { - return ""; - } - - return "project"; - } - - if (included === keyword) { - return ""; - } - return keyword; - }); - - // Мгновенно обновляем URL - this.updateUrl(); - } - - /** - * СБРОС ВСЕХ ФИЛЬТРОВ - * - * ЧТО ДЕЛАЕТ: - * - Очищает все активные фильтры - * - Мгновенно обновляет URL - * - Возвращает ленту к состоянию по умолчанию - */ - resetFilter(): void { - this.includedFilters.set(""); - this.updateUrl(); - } - - /** - * ЗАКРЫТИЕ ВЫПАДАЮЩЕГО МЕНЮ - * - * ЧТО ДЕЛАЕТ: - * - Закрывает выпадающее меню при клике вне его области - * - Используется директивой ClickOutside - */ - onClickOutside(): void { - this.filterOpen.set(false); - } -} diff --git a/projects/social_platform/src/app/office/feed/services/feed.service.ts b/projects/social_platform/src/app/office/feed/services/feed.service.ts deleted file mode 100644 index 740d0cc1b..000000000 --- a/projects/social_platform/src/app/office/feed/services/feed.service.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** @format */ - -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 { HttpParams } from "@angular/common/http"; - -/** - * СЕРВИС ДЛЯ РАБОТЫ С ЛЕНТОЙ НОВОСТЕЙ - * - * Предоставляет методы для взаимодействия с API ленты новостей. - * Обрабатывает запросы на получение элементов ленты с поддержкой - * пагинации и фильтрации по типам контента. - * - * ОСНОВНЫЕ ФУНКЦИИ: - * - Загрузка элементов ленты с сервера - * - Поддержка пагинации (offset/limit) - * - Фильтрация по типам контента - * - Обработка параметров запроса - * - * ИСПОЛЬЗУЕТСЯ В: - * - FeedComponent для загрузки данных - * - FeedResolver для предварительной загрузки - * - FeedFilterComponent для работы с фильтрами - */ -@Injectable({ - providedIn: "root", -}) -export class FeedService { - private readonly FEED_URL = "/feed"; - - constructor(private readonly apiService: ApiService) {} - - /** - * СИМВОЛ РАЗДЕЛЕНИЯ ФИЛЬТРОВ - * - * Используется для объединения множественных фильтров в строку - * для передачи в URL параметрах и API запросах - */ - readonly FILTER_SPLIT_SYMBOL = "|"; - - /** - * ПОЛУЧЕНИЕ ЭЛЕМЕНТОВ ЛЕНТЫ - * - * Основной метод для загрузки элементов ленты с сервера. - * Поддерживает пагинацию и фильтрацию по типам контента. - * - * ЧТО ПРИНИМАЕТ: - * @param offset - смещение для пагинации (с какого элемента начинать) - * @param limit - максимальное количество элементов для загрузки - * @param type - тип(ы) элементов для фильтрации (строка или массив) - * - * ЧТО ВОЗВРАЩАЕТ: - * @returns Observable> - пагинированный ответ с элементами ленты - * - * ЛОГИКА ОБРАБОТКИ ТИПОВ: - * - Если массив пустой: загружаются все типы по умолчанию - * - Если массив: элементы объединяются через разделитель - * - Если строка: используется как есть - */ - getFeed( - offset: number, - limit: number, - type: FeedItemType[] | FeedItemType - ): Observable> { - let reqType: string; - - // Обработка различных форматов параметра type - if (type.length === 0) { - // Если фильтры не выбраны, загружаем все типы по умолчанию - reqType = ["vacancy", "news", "projects"].join(this.FILTER_SPLIT_SYMBOL); - } else if (Array.isArray(type)) { - // Если передан массив типов, объединяем их через разделитель - reqType = type.join(this.FILTER_SPLIT_SYMBOL); - } else { - // Если передана строка, используем как есть - reqType = type; - } - - // Выполняем GET запрос к API с параметрами пагинации и фильтрации - return this.apiService.get( - `${this.FEED_URL}/`, - new HttpParams({ - fromObject: { - limit, - offset, - type: reqType, - }, - }) - ); - } -} diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.html b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.html deleted file mode 100644 index cf60586d5..000000000 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.html +++ /dev/null @@ -1,22 +0,0 @@ - - -
-
- newsItem.name -
-
PROCOLLAB
-
- {{ "2024-19-08 12:09:17" | dayjs: "format":"DD MMMM, HH:mm" }} -
-
-
-

- Проект PROCOLLAB нашел себе - человека в команду -

-

Если хочешь также, то скорее выкладывай вакансию в проекте!

-
- Backend developer - Найден -
-
diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss deleted file mode 100644 index 1a2c93761..000000000 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss +++ /dev/null @@ -1,94 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.card { - padding: 20px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - - &__head { - margin-bottom: 10px; - } - - &__title { - margin-bottom: 10px; - text-align: center; - - @include typography.bold-body-16; - } - - &__text { - color: var(--dark-grey); - text-align: center; - - @include typography.body-14; - } - - &__action { - margin-top: 20px; - } -} - -.head { - display: flex; - align-items: center; - - &__avatar { - width: 40px; - height: 40px; - margin-right: 10px; - border-radius: 50%; - object-fit: cover; - } - - &__name { - max-width: 200px; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - white-space: nowrap; - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - @include typography.bold-body-16; - } - } - - &__date { - color: var(--dark-grey); - } -} - -.action { - @include responsive.apply-desktop { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 20px; - border: 1px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &__job { - display: block; - padding: 20px 0; - margin-bottom: 15px; - text-align: center; - border: 1px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - padding: 0; - margin-bottom: 0; - border: none; - } - } - - &__button { - width: 150px; - } -} diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.spec.ts b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.spec.ts deleted file mode 100644 index 868b8d06e..000000000 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ClosedVacancyComponent } from "./closed-vacancy.component"; -import { RouterTestingModule } from "@angular/router/testing"; - -describe("OpenVacancyComponent", () => { - let component: ClosedVacancyComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ClosedVacancyComponent, RouterTestingModule], - }).compileComponents(); - - fixture = TestBed.createComponent(ClosedVacancyComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.ts b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.ts deleted file mode 100644 index cdc5aafc6..000000000 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** @format */ - -import { Component } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; -import { DayjsPipe } from "projects/core"; -import { Router, RouterLink } from "@angular/router"; - -/** - * КОМПОНЕНТ ЗАКРЫТОЙ ВАКАНСИИ - * - * Отображает карточку закрытой (неактивной) вакансии в ленте новостей. - * Предоставляет ограниченную информацию о вакансии и указывает на её статус. - * - * ОСНОВНЫЕ ФУНКЦИИ: - * - Отображение основной информации о закрытой вакансии - * - Показ статуса "закрыто" или "неактивно" - * - Навигация к детальной странице вакансии (если доступна) - * - Форматирование дат с помощью DayjsPipe - * - * ИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ: - * - ButtonComponent: кнопки действий - * - TagComponent: теги и метки - * - DayjsPipe: форматирование дат - * - RouterLink: навигация между страницами - * - * ОТЛИЧИЯ ОТ ОТКРЫТОЙ ВАКАНСИИ: - * - Ограниченный функционал - * - Визуальные индикаторы закрытого статуса - * - Отсутствие кнопок подачи заявки - */ -@Component({ - selector: "app-closed-vacancy", - standalone: true, - imports: [CommonModule, ButtonComponent, DayjsPipe, RouterLink], - templateUrl: "./closed-vacancy.component.html", - styleUrl: "./closed-vacancy.component.scss", -}) -export class ClosedVacancyComponent { - /** - * КОНСТРУКТОР - * - * ЧТО ПРИНИМАЕТ: - * @param router - сервис маршрутизации Angular для программной навигации - * - * НАЗНАЧЕНИЕ: - * Инициализирует компонент с доступом к сервису маршрутизации - * для возможной навигации к детальной странице вакансии - */ - constructor(public readonly router: Router) {} -} diff --git a/projects/social_platform/src/app/office/members/members.component.ts b/projects/social_platform/src/app/office/members/members.component.ts deleted file mode 100644 index dff7d51e5..000000000 --- a/projects/social_platform/src/app/office/members/members.component.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - OnDestroy, - OnInit, - Renderer2, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { - BehaviorSubject, - concatMap, - debounceTime, - distinctUntilChanged, - filter, - fromEvent, - map, - noop, - of, - skip, - Subscription, - switchMap, - take, - tap, - throttleTime, -} from "rxjs"; -import { User } from "@auth/models/user.model"; -import { NavService } from "@services/nav.service"; -import { - AbstractControl, - FormBuilder, - FormGroup, - ReactiveFormsModule, - Validators, -} from "@angular/forms"; -import { containerSm } from "@utils/responsive"; -import { MemberService } from "@services/member.service"; -import { CommonModule } from "@angular/common"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { MembersFiltersComponent } from "./filters/members-filters.component"; -import { ApiPagination } from "@models/api-pagination.model"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; -import { BackComponent } from "@uilib"; -import { ButtonComponent } from "@ui/components"; -import { ProfileDataService } from "@office/profile/detail/services/profile-date.service"; -import { AuthService } from "@auth/services"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; - -/** - * Компонент для отображения списка участников с возможностью поиска и фильтрации - * - * Основные функции: - * - Отображение списка участников в виде карточек - * - Поиск участников по имени - * - Фильтрация по навыкам, специальности, возрасту и принадлежности к МосПолитеху - * - Бесконечная прокрутка для подгрузки дополнительных участников - * - Синхронизация фильтров с URL параметрами - * - * @component MembersComponent - */ -@Component({ - selector: "app-members", - templateUrl: "./members.component.html", - styleUrl: "./members.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [ - ReactiveFormsModule, - SearchComponent, - CommonModule, - RouterLink, - MembersFiltersComponent, - InfoCardComponent, - BackComponent, - ButtonComponent, - SoonCardComponent, - ], -}) -export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { - /** - * Конструктор компонента - * - * Инициализирует формы поиска и фильтрации: - * - searchForm: форма для поиска по имени участника - * - filterForm: форма для фильтрации по навыкам, специальности, возрасту и статусу студента - * - * @param route - Сервис для работы с активным маршрутом - * @param router - Сервис для навигации - * @param navService - Сервис для управления навигацией - * @param fb - FormBuilder для создания реактивных форм - * @param memberService - Сервис для работы с данными участников - * @param cdref - ChangeDetectorRef для ручного запуска обнаружения изменений - */ - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly navService: NavService, - private readonly fb: FormBuilder, - private readonly memberService: MemberService, - private readonly authService: AuthService, - private readonly cdref: ChangeDetectorRef, - private readonly renderer: Renderer2 - ) { - // Форма поиска с обязательным полем для ввода имени - this.searchForm = this.fb.group({ - search: ["", [Validators.required]], - }); - - // Форма фильтрации с полями для различных критериев - this.filterForm = this.fb.group({ - keySkill: ["", Validators.required], // Ключевой навык - speciality: ["", Validators.required], // Специальность - age: [[null, null]], // Диапазон возраста [от, до] - isMosPolytechStudent: [false], // Является ли студентом МосПолитеха - }); - } - - /** - * Инициализация компонента - * - * Выполняет: - * - Очистку URL параметров - * - Установку заголовка навигации - * - Загрузку начальных данных из резолвера - * - Настройку подписок на изменения форм и URL параметров - */ - ngOnInit(): void { - // Очищаем URL параметры при инициализации - this.router.navigate([], { queryParams: {} }); - - // Устанавливаем заголовок страницы - this.navService.setNavTitle("Участники"); - - const profileIdSub$ = this.authService.profile.pipe(filter(user => !!user)).subscribe({ - next: user => { - this.profileId = user.id; - }, - }); - - profileIdSub$ && this.subscriptions$.push(profileIdSub$); - - // Загружаем начальные данные участников из резолвера - this.route.data - .pipe( - take(1), - map(r => r["data"]) - ) - .subscribe((members: ApiPagination) => { - this.membersTotalCount = members.count; - this.members = members.results; - }); - - // Настраиваем синхронизацию значений форм с 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"); - - // Подписываемся на изменения URL параметров для обновления списка участников - this.route.queryParams - .pipe( - skip(1), // Пропускаем первое значение - distinctUntilChanged(), // Игнорируем одинаковые значения - debounceTime(100), // Задержка для предотвращения частых запросов - 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.searchParamsSubject$.next(fetchParams); - return this.onFetch(0, 20, fetchParams); - }) - ) - .subscribe(members => { - this.members = members; - this.membersPage = 1; - this.cdref.detectChanges(); - }); - } - - /** - * Инициализация после создания представления - * - * Настраивает обработчик события прокрутки для реализации бесконечной прокрутки - */ - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(500) // Ограничиваем частоту обработки прокрутки - ) - .subscribe(noop); - - this.subscriptions$.push(scrollEvents$); - } - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - // Константы и свойства компонента - containerSm = containerSm; // Брейкпоинт для мобильных устройств - appWidth = window.innerWidth; // Ширина окна браузера - - @ViewChild("membersRoot") membersRoot?: ElementRef; // Ссылка на корневой элемент списка - @ViewChild("filterBody") filterBody!: ElementRef; // Ссылка на элемент фильтра - - membersTotalCount?: number; // Общее количество участников - membersPage = 1; // Текущая страница для пагинации - membersTake = 20; // Количество участников на странице - - subscriptions$: Subscription[] = []; // Массив подписок для очистки - - members: User[] = []; // Массив участников для отображения - - profileId?: number; - - searchParamsSubject$ = new BehaviorSubject>({}); // Subject для параметров поиска - - searchForm: FormGroup; // Форма поиска - filterForm: FormGroup; // Форма фильтрации - - /** - * Обработчик события прокрутки для бесконечной прокрутки - * - * @returns Observable с дополнительными участниками или пустой объект - */ - onScroll() { - // Проверяем, есть ли еще участники для загрузки - if (this.membersTotalCount && this.members.length >= this.membersTotalCount) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.membersRoot) return of({}); - - // Вычисляем, достиг ли пользователь конца списка - const diff = - target.scrollTop - - this.membersRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - // Загружаем следующую порцию участников - return this.onFetch( - this.membersPage * this.membersTake, - this.membersTake, - this.searchParamsSubject$.value - ).pipe( - tap(membersChunk => { - this.membersPage++; - this.members = [...this.members, ...membersChunk]; - this.cdref.detectChanges(); - }) - ); - } - - return of({}); - } - - /** - * Сохраняет значение элемента формы в URL параметрах - * - * @param control - Элемент управления формы - * @param queryName - Имя параметра в URL - */ - saveControlValue(control: AbstractControl | null, queryName: string): void { - if (!control) return; - - const sub$ = control.valueChanges.subscribe(value => { - this.router - .navigate([], { - queryParams: { [queryName]: value.toString() }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("QueryParams changed from MembersComponent")); - }); - - this.subscriptions$.push(sub$); - } - - /** - * Выполняет запрос на получение участников с заданными параметрами - * - * @param skip - Количество записей для пропуска (для пагинации) - * @param take - Количество записей для получения - * @param params - Дополнительные параметры фильтрации - * @returns Observable - Массив участников - */ - onFetch(skip: number, take: number, params?: Record) { - return this.memberService.getMembers(skip, take, params).pipe( - map((members: ApiPagination) => { - this.membersTotalCount = members.count; - return members.results; - }) - ); - } - - redirectToProfile(): void { - this.router.navigateByUrl(`/office/profile/${this.profileId}`); - } -} diff --git a/projects/social_platform/src/app/office/members/members.resolver.ts b/projects/social_platform/src/app/office/members/members.resolver.ts deleted file mode 100644 index 02b9c81f5..000000000 --- a/projects/social_platform/src/app/office/members/members.resolver.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { MemberService } from "@services/member.service"; -import { ResolveFn } from "@angular/router"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; - -/** - * Резолвер для предварительной загрузки данных участников перед переходом на страницу - * - * Этот резолвер выполняется Angular Router'ом перед активацией маршрута /members - * и загружает первую страницу участников (20 записей) для отображения - * - * @returns Promise> - Возвращает промис с пагинированным списком пользователей - */ -/** - * Функция-резолвер для загрузки участников - * - * @param route - Не используется, но доступен объект ActivatedRouteSnapshot - * @param state - Не используется, но доступен объект RouterStateSnapshot - * @returns Observable> - Наблюдаемый объект с данными участников - */ -export const MembersResolver: ResolveFn> = () => { - const memberService = inject(MemberService); - - // Загружаем первые 20 участников (skip: 0, take: 20) - return memberService.getMembers(0, 20); -}; diff --git a/projects/social_platform/src/app/office/mentors/mentors.component.html b/projects/social_platform/src/app/office/mentors/mentors.component.html deleted file mode 100644 index 5b2da3ec2..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
-
    - @for (member of members; track member.id) { - -
  • - -
  • -
    - } -
-
diff --git a/projects/social_platform/src/app/office/mentors/mentors.component.scss b/projects/social_platform/src/app/office/mentors/mentors.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/projects/social_platform/src/app/office/mentors/mentors.component.spec.ts b/projects/social_platform/src/app/office/mentors/mentors.component.spec.ts deleted file mode 100644 index 572606bd3..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.component.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { MentorsComponent } from "./mentors.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ReactiveFormsModule } from "@angular/forms"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("MembersComponent", () => { - let component: MentorsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = jasmine.createSpyObj([{ profile: of({}) }]); - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - MentorsComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MentorsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/mentors/mentors.component.ts b/projects/social_platform/src/app/office/mentors/mentors.component.ts deleted file mode 100644 index 1a1345d68..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.component.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - OnDestroy, - OnInit, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { concatMap, fromEvent, map, noop, of, Subscription, tap, throttleTime } from "rxjs"; -import { AuthService } from "@auth/services"; -import { User } from "@auth/models/user.model"; -import { NavService } from "@services/nav.service"; -import { FormBuilder, FormGroup, Validators } from "@angular/forms"; -import { containerSm } from "@utils/responsive"; -import { MemberService } from "@services/member.service"; -import { MemberCardComponent } from "../shared/member-card/member-card.component"; -import { ApiPagination } from "@models/api-pagination.model"; - -/** - * КОМПОНЕНТ СТРАНИЦЫ МЕНТОРОВ - * - * Назначение: Отображение списка менторов с возможностью поиска и бесконечной прокрутки - * - * Что делает: - * - Отображает список менторов в виде карточек - * - Реализует бесконечную прокрутку для загрузки дополнительных менторов - * - Предоставляет форму поиска по менторам (подготовлена, но не реализована) - * - Управляет пагинацией и состоянием загрузки - * - Отслеживает события прокрутки для автоматической подгрузки - * - Устанавливает заголовок навигации - * - * Что принимает: - * - Начальные данные менторов через ActivatedRoute (из MentorsResolver) - * - События прокрутки от пользователя - * - Потенциально поисковые запросы (форма подготовлена) - * - * Что возвращает: - * - Интерфейс со списком карточек менторов - * - Форму поиска (визуально готова, функционал не подключен) - * - Автоматическую подгрузку контента при прокрутке - * - * Механизм бесконечной прокрутки: - * - Отслеживание позиции скролла в .office__body - * - Вычисление расстояния до конца списка - * - Автоматический запрос следующей страницы при приближении к концу - * - Throttling запросов (500мс) для предотвращения спама - * - * Состояние пагинации: - * - membersTotalCount: общее количество менторов - * - membersPage: текущая страница для загрузки - * - membersTake: количество записей на страницу (20) - * - members: накопительный массив всех загруженных менторов - * - * Особенности реализации: - * - ChangeDetectionStrategy.OnPush для оптимизации производительности - * - Ручное управление detectChanges() после загрузки данных - * - Отписка от подписок в ngOnDestroy для предотвращения утечек памяти - * - Responsive дизайн с учетом ширины экрана - */ -@Component({ - selector: "app-mentors", - templateUrl: "./mentors.component.html", - styleUrl: "./mentors.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [RouterLink, MemberCardComponent], -}) -export class MentorsComponent implements OnInit, OnDestroy, AfterViewInit { - constructor( - private readonly route: ActivatedRoute, - private readonly authService: AuthService, - private readonly navService: NavService, - private readonly fb: FormBuilder, - private readonly memberService: MemberService, - private readonly cdref: ChangeDetectorRef - ) { - this.searchForm = this.fb.group({ - search: ["", [Validators.required]], - }); - } - - ngOnInit(): void { - this.navService.setNavTitle("Участники"); - - this.route.data.pipe(map(r => r["data"])).subscribe((members: ApiPagination) => { - this.membersTotalCount = members.count; - - this.members = members.results; - }); - } - - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) - fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(500) - ) - .subscribe(noop); - } - - ngOnDestroy(): void { - [this.members$, this.searchFormSearch$].forEach($ => $?.unsubscribe()); - } - - containerSm = containerSm; - appWidth = window.innerWidth; - - @ViewChild("membersRoot") membersRoot?: ElementRef; - membersTotalCount?: number; - membersPage = 1; - membersTake = 20; - - members: User[] = []; - members$?: Subscription; - - searchForm: FormGroup; - searchFormSearch$?: Subscription; - - onScroll() { - if (this.membersTotalCount && this.members.length >= this.membersTotalCount) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.membersRoot) return of({}); - - const diff = - target.scrollTop - - this.membersRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - return this.onFetch(); - } - - return of({}); - } - - onFetch() { - return this.memberService - .getMentors(this.membersPage * this.membersTake, this.membersTake) - .pipe( - tap((members: ApiPagination) => { - this.membersTotalCount = members.count; - this.members = [...this.members, ...members.results]; - - this.membersPage++; - - this.cdref.detectChanges(); - }) - ); - } -} diff --git a/projects/social_platform/src/app/office/mentors/mentors.resolver.spec.ts b/projects/social_platform/src/app/office/mentors/mentors.resolver.spec.ts deleted file mode 100644 index ce614e5c5..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.resolver.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { MentorsResolver } from "./mentors.resolver"; -import { MemberService } from "@services/member.service"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; -import { of } from "rxjs"; - -describe("MentorsResolver", () => { - beforeEach(() => { - const memberSpy = jasmine.createSpyObj("memberSpy", { getMentors: of({}) }); - - TestBed.configureTestingModule({ - providers: [{ provide: MemberService, useValue: memberSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - MentorsResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/mentors/mentors.resolver.ts b/projects/social_platform/src/app/office/mentors/mentors.resolver.ts deleted file mode 100644 index a6e17aee3..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.resolver.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { MemberService } from "@services/member.service"; -import { ResolveFn } from "@angular/router"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; - -/** - * РЕЗОЛВЕР СТРАНИЦЫ МЕНТОРОВ - * - * Назначение: Предзагрузка списка менторов перед отображением страницы - * - * Что делает: - * - Выполняется автоматически перед активацией маршрута страницы менторов - * - Загружает первую страницу менторов из API (20 записей) - * - Обеспечивает немедленное отображение данных без состояния загрузки - * - * Что принимает: - * - Контекст маршрута (автоматически от Angular Router) - * - Доступ к MemberService через dependency injection - * - * Что возвращает: - * - Observable> - пагинированный список менторов - * - Структура содержит: - * * results: User[] - массив пользователей-менторов - * * count: number - общее количество менторов - * * next/previous: ссылки на следующую/предыдущую страницы - * - * Параметры загрузки: - * - offset: 0 (начинаем с первой записи) - * - limit: 20 (загружаем 20 менторов за раз) - * - * Использование данных: - * - Данные доступны в компоненте через route.data['data'] - * - Используются для инициализации списка и счетчика - * - Основа для последующей пагинации при скролле - */ -export const MentorsResolver: ResolveFn> = () => { - const memberService = inject(MemberService); - - return memberService.getMentors(0, 20); -}; diff --git a/projects/social_platform/src/app/office/models/courses.model.ts b/projects/social_platform/src/app/office/models/courses.model.ts deleted file mode 100644 index 83e435941..000000000 --- a/projects/social_platform/src/app/office/models/courses.model.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Как в базе - * - * id — ID курса (создается автоматически). - * title — название курса (до 45 символов). - * description — описание курса (до 600 символов, можно оставить пустым). - * access_type — тип доступа: для всех, для участников программы, по подписке. - * partner_program — связанная программа (может быть пустой, кроме сценария “для участников программы”). - * avatar_file — аватар курса (файл, необязательно). - * card_cover_file — обложка карточки курса в каталоге (файл, необязательно). - * header_cover_file — обложка шапки внутри страницы курса (файл, необязательно). - * start_date — дата старта курса (может быть пустой). - * end_date — дата окончания курса (может быть пустой). - * status — статус контента курса: черновик, опубликован, завершен. - * is_completed — логический флаг завершения курса. - * completed_at — дата/время завершения курса. - * datetime_created — дата/время создания. - * datetime_updated — дата/время обновления. - * - * @format - */ - -export interface CourseCard { - id: number; - title: string; - accessType: "all_users" | "program_members" | "subscription_stub"; - status: "draft" | "published" | "ended"; - avatarUrl: string; - cardCoverUrl: string; - startDate: Date; - endDate: Date; - dateLabel: string; - isAvailable: boolean; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - actionState: "start" | "continue" | "lock"; -} - -export interface CourseDetail { - id: number; - title: string; - description: string; - accessType: "all_users" | "program_members" | "subscription_stub"; - status: "draft" | "published" | "ended"; - avatarUrl: string; - headerCoverUrl: string; - startDate: Date; - endDate: Date; - dateLabel: string; - isAvailable: boolean; - partnerProgramId: number; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - analyticsStub: any; -} - -/** - * Как в базе - * - id — уникальный идентификатор модуля. - course — курс, к которому относится модуль (обязательная связь). - title — название модуля, максимум 40 символов. - avatar_file — аватар модуля (необязательный файл). - start_date — дата старта модуля (обязательная). - status — статус модуля: draft (черновик), published (опубликован) - order — порядковый номер модуля внутри курса (по нему сортируется вывод). - datetime_created — дата/время создания. - datetime_updated — дата/время последнего обновления. - * - */ - -export interface CourseLessons { - id: number; - moduleId: number; - title: string; - order: number; - status: string; - isAvailable: boolean; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - currentTaskId: number; - taskCount: number; -} - -export interface CourseModule { - id: number; - courseId: number; - title: string; - order: number; - avatarUrl: string; - startDate: Date; - status: string; - isAvailable: boolean; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - lessons: CourseLessons[]; -} - -export interface CourseStructure { - courseId: number; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - modules: CourseModule[]; -} - -/** - * Как в базе - * - id — уникальный идентификатор урока. - module — модуль, к которому относится урок (обязательная связь). - title — название урока, максимум 45 символов. - status — статус урока: draft (черновик), published (опубликован) - order — порядковый номер урока внутри модуля. - datetime_created — дата/время создания. - datetime_updated — дата/время последнего обновления. - */ - -export interface Option { - id: number; - order: number; - text: string; -} - -export interface Task { - id: number; - order: number; - title: string; - answerTitle: string; - status: string; - taskKind: "question" | "informational"; - checkType: string | null; - informationalType: string | null; - questionType: string | null; - answerType: string | null; - bodyText: string; - videoUrl: string | null; - imageUrl: string | null; - attachmentUrl: string | null; - isAvailable: boolean; - isCompleted: boolean; - options: Option[]; -} - -export interface CourseLesson { - id: number; - moduleId: number; - courseId: number; - title: string; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - currentTaskId: number; - moduleOrder: number; - tasks: Task[]; -} - -export interface TaskAnswerResponse { - answerId: number; - status: "submitted" | "pending_review"; - isCorrect: boolean; - canContinue: boolean; - nextTaskId: number | null; - submittedAt: Date; -} diff --git a/projects/social_platform/src/app/office/models/partner.model.ts b/projects/social_platform/src/app/office/models/partner.model.ts deleted file mode 100644 index c15980716..000000000 --- a/projects/social_platform/src/app/office/models/partner.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -interface Company { - id: number; - name: string; - inn: string; -} - -export interface Partner { - id: number; - projecId: number; - company: Company; - contribution: string; - decisionMaker: number; -} - -export interface PartnerPostForm { - name: string; - inn: string; - contribution: string; - decisionMaker: number; -} diff --git a/projects/social_platform/src/app/office/models/resource.model.ts b/projects/social_platform/src/app/office/models/resource.model.ts deleted file mode 100644 index ca4a5542c..000000000 --- a/projects/social_platform/src/app/office/models/resource.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** @format */ - -export interface Resource { - id: number; - projectId: number; - type: "infrastructure" | "staff" | "financial" | "information"; - description: string; - partnerCompany: number; -} - -export interface ResourcePostForm { - projectId: number; - type: string; - description: string; - partnerCompany: number; -} diff --git a/projects/social_platform/src/app/office/models/step.model.ts b/projects/social_platform/src/app/office/models/step.model.ts deleted file mode 100644 index 7df3b4b13..000000000 --- a/projects/social_platform/src/app/office/models/step.model.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** @format */ - -/** - * Base interface for all step types - * Contains common properties shared across different question types - */ -interface BaseStep { - id: number; // Unique identifier for the step -} - -/** - * Всплывающее окно с дополнительной информацией - * Отображается после завершения шага для предоставления дополнительного контекста - */ -export interface Popup { - title: string | null; // Заголовок всплывающего окна - text: string | null; // Текстовое содержимое - fileLink: string | null; // URL к связанному файлу или ресурсу - ordinalNumber: number; // Порядковый номер для сортировки -} - -/** - * Информационный слайд - * - * Отображает образовательный контент без требования взаимодействия пользователя. - * Используется для представления концепций, объяснений или инструкций. - */ -export interface InfoSlide extends BaseStep { - text: string; // Основной текстовый контент слайда - description: string; // Дополнительное описание или контекст - files: string[]; // Массив URL файлов для отображения (изображения, документы) - popups: Popup[]; // Всплывающие окна для отображения после просмотра - videoUrl?: string; // Ссылка для видео -} - -/** - * Вопрос на соединение/сопоставление - * - * Требует от пользователей сопоставления элементов из двух колонок или соединения связанных концепций. - * Проверяет понимание отношений между различными элементами. - */ -export interface ConnectQuestion extends BaseStep { - connectLeft: { id: number; text?: string; file?: string }[]; // Элементы левой колонки - connectRight: { id: number; text?: string; file?: string }[]; // Элементы правой колонки - description: string; // Инструкции по выполнению сопоставления - files: string[]; // Дополнительные файлы для контекста - isAnswered: boolean; // Был ли вопрос уже отвечен - text: string; // Основной текст вопроса - popups: Popup[]; // Всплывающие окна для отображения после ответа -} - -/** - * Структура запроса для вопросов на соединение - * Массив пар соединений, выбранных пользователем - */ -export type ConnectQuestionRequest = { leftId: number; rightId: number }[]; - -/** - * Структура ответа для вопросов на соединение - * Показывает правильность каждого соединения - */ -export type ConnectQuestionResponse = { - leftId: number; - rightId: number; - isCorrect: boolean; // Правильно ли это соединение -}[]; - -/** - * Вопрос с единственным правильным ответом - * - * Представляет вопрос с несколькими вариантами, где только один ответ правильный. - * Наиболее распространенный тип оценочного вопроса. - */ -export interface SingleQuestion extends BaseStep { - answers: { id: number; text: string }[]; // Доступные варианты ответов - description: string; // Дополнительное описание или контекст - files: string[]; // Связанные файлы (изображения, документы) - isAnswered: boolean; // Был ли вопрос уже отвечен - text: string; // Основной текст вопроса - popups: Popup[]; // Всплывающие окна для отображения после ответа - videoUrl: string; -} - -/** - * Ответ об ошибке для вопросов с единственным ответом - * Возвращается, когда пользователь выбирает неправильный ответ - */ -export interface SingleQuestionError { - correctAnswer: number; // ID правильного варианта - isCorrect: boolean; // Был ли ответ правильным (всегда false для ошибок) -} - -/** - * Вопрос на исключение - * - * Представляет несколько элементов, где пользователи должны определить, какой не принадлежит - * или какие элементы должны быть исключены из группы. - */ -export interface ExcludeQuestion extends BaseStep { - answers: { id: number; text: string }[]; // Элементы для рассмотрения - description: string; // Инструкции для задачи исключения - files: string[]; // Связанные файлы для контекста - isAnswered: boolean; // Был ли вопрос уже отвечен - text: string; // Основной текст вопроса - popups: Popup[]; // Всплывающие окна для отображения после ответа -} - -/** - * Структура ответа для вопросов на исключение - */ -export interface ExcludeQuestionResponse { - isCorrect: boolean; // Был ли ответ пользователя правильным - wrongAnswers: number[]; // ID неправильно выбранных элементов -} - -/** - * Вопрос с письменным ответом - * - * Требует от пользователей предоставления текстового ответа. - * Может использоваться для коротких ответов, эссе или отправки кода. - */ -export interface WriteQuestion extends BaseStep { - answer: string | null; // Текущий ответ пользователя (если есть) - description: string; // Инструкции или дополнительный контекст - files: string[]; // Связанные файлы для справки - text: string; // Основной текст вопроса или подсказка - popups: Popup[]; // Всплывающие окна для отображения после отправки -} - -/** - * Объединенный тип, представляющий все возможные типы шагов - * Используется для типобезопасной обработки различных вариаций шагов - */ -export type StepType = - | InfoSlide - | ConnectQuestion - | SingleQuestion - | ExcludeQuestion - | WriteQuestion; diff --git a/projects/social_platform/src/app/office/office.component.ts b/projects/social_platform/src/app/office/office.component.ts deleted file mode 100644 index e3dbb6063..000000000 --- a/projects/social_platform/src/app/office/office.component.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit, signal, Signal } from "@angular/core"; -import { IndustryService } from "@services/industry.service"; -import { forkJoin, map, noop, Subscription } from "rxjs"; -import { ActivatedRoute, Router, RouterOutlet, RouterLink } from "@angular/router"; -import { Invite } from "@models/invite.model"; -import { AuthService } from "@auth/services"; -import { User } from "@auth/models/user.model"; -import { ChatService } from "@services/chat.service"; -import { SnackbarComponent } from "@ui/components/snackbar/snackbar.component"; -import { DeleteConfirmComponent } from "@ui/components/delete-confirm/delete-confirm.component"; -import { ButtonComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { NavComponent } from "./features/nav/nav.component"; -import { ProfileControlPanelComponent, SidebarComponent } from "@uilib"; -import { AsyncPipe } from "@angular/common"; -import { InviteService } from "@services/invite.service"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { ProgramSidebarCardComponent } from "./features/program-sidebar-card/program-sidebar-card.component"; -import { ProgramService } from "./program/services/program.service"; -import { Program } from "./program/models/program.model"; - -/** - * Главный компонент офиса - корневой компонент рабочего пространства - * Управляет общим состоянием приложения, навигацией и модальными окнами - * - * Принимает: - * - Данные о приглашениях через резолвер - * - События от сервисов (auth, chat, invite) - * - * Возвращает: - * - Рендерит основной интерфейс офиса с сайдбаром, навигацией и роутер-аутлетом - * - Управляет модальными окнами для верификации и приглашений - */ -@Component({ - selector: "app-office", - templateUrl: "./office.component.html", - styleUrl: "./office.component.scss", - standalone: true, - imports: [ - SidebarComponent, - NavComponent, - RouterOutlet, - ModalComponent, - ButtonComponent, - DeleteConfirmComponent, - SnackbarComponent, - AsyncPipe, - RouterLink, - ProfileControlPanelComponent, - ProgramSidebarCardComponent, - ], -}) -export class OfficeComponent implements OnInit, OnDestroy { - constructor( - private readonly industryService: IndustryService, - private readonly route: ActivatedRoute, - public readonly authService: AuthService, - private readonly inviteService: InviteService, - private readonly router: Router, - public readonly chatService: ChatService, - private readonly programService: ProgramService - ) {} - - invites: Signal = toSignal( - this.route.data.pipe( - map(r => r["invites"]), - map(invites => invites.filter((invite: Invite) => invite.isAccepted === null)) - ) - ); - - profile?: User; - - waitVerificationModal = false; - waitVerificationAccepted = false; - - showRegisteredProgramModal = signal(false); - - registeredProgramToShow?: Program | null = null; - - inviteErrorModal = false; - - protected readonly programs = signal([]); - - navItems: { - name: string; - icon: string; - link: string; - isExternal?: boolean; - isActive?: boolean; - }[] = []; - - subscriptions$: Subscription[] = []; - - ngOnInit(): void { - const globalSubscription$ = forkJoin([this.industryService.getAll()]).subscribe(noop); - this.subscriptions$.push(globalSubscription$); - - const profileSub$ = this.authService.profile.subscribe(profile => { - this.profile = profile; - this.buildNavItems(profile); - - if (!this.profile.doesCompleted()) { - this.router - .navigateByUrl("/office/onboarding") - .then(() => console.debug("Route changed from OfficeComponent")); - } else if (this.profile.verificationDate === null) { - this.waitVerificationModal = true; - } - }); - this.subscriptions$.push(profileSub$); - - this.chatService.connect().subscribe(() => { - this.chatService.onSetOffline().subscribe(evt => { - this.chatService.setOnlineStatus(evt.userId, false); - }); - - this.chatService.onSetOnline().subscribe(evt => { - this.chatService.setOnlineStatus(evt.userId, true); - }); - }); - - if (!this.router.url.includes("chats")) { - this.chatService.hasUnreads().subscribe(unreads => { - this.chatService.unread$.next(unreads); - }); - } - - if (localStorage.getItem("waitVerificationAccepted") === "true") { - this.waitVerificationAccepted = true; - } - - const programsSub$ = this.programService.getActualPrograms().subscribe({ - next: ({ results: programs }) => { - const resultPrograms = programs.filter( - (program: Program) => Date.now() < Date.parse(program.datetimeRegistrationEnds) - ); - this.programs.set(resultPrograms.slice(0, 3)); - this.tryShowRegisteredProgramModal(); - }, - }); - - this.subscriptions$.push(programsSub$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - onAcceptWaitVerification() { - this.waitVerificationAccepted = true; - localStorage.setItem("waitVerificationAccepted", "true"); - } - - onRejectInvite(inviteId: number): void { - this.inviteService.rejectInvite(inviteId).subscribe({ - next: () => { - const index = this.invites().findIndex(invite => invite.id === inviteId); - this.invites().splice(index, 1); - }, - error: () => { - this.inviteErrorModal = true; - }, - }); - } - - onAcceptInvite(inviteId: number): void { - this.inviteService.acceptInvite(inviteId).subscribe({ - next: () => { - const index = this.invites().findIndex(invite => invite.id === inviteId); - const invite = JSON.parse(JSON.stringify(this.invites()[index])); - this.invites().splice(index, 1); - - this.router - .navigateByUrl(`/office/projects/${invite.project.id}`) - .then(() => console.debug("Route changed from SidebarComponent")); - }, - error: () => { - this.inviteErrorModal = true; - }, - }); - } - - onLogout() { - this.authService - .logout() - .subscribe(() => - this.router - .navigateByUrl("/auth") - .then(() => console.debug("Route changed from OfficeComponent")) - ); - } - - private tryShowRegisteredProgramModal(): void { - const programs = this.programs(); - if (!programs || programs.length === 0) return; - - const memberProgram = programs.find(p => p.isUserMember); - if (!memberProgram) return; - - if (this.hasSeenRegisteredProgramModal(memberProgram.id)) return; - - this.registeredProgramToShow = memberProgram; - this.showRegisteredProgramModal.set(true); - this.markSeenRegisteredProgramModal(memberProgram.id); - } - - private getRegisteredProgramSeenKey(programId: number): string { - return `program_${this.profile?.id}_registered_modal_seen_${programId}`; - } - - private hasSeenRegisteredProgramModal(programId: number): boolean { - try { - return !!localStorage.getItem(this.getRegisteredProgramSeenKey(programId)); - } catch (e) { - return false; - } - } - - private markSeenRegisteredProgramModal(programId: number): void { - try { - localStorage.setItem(this.getRegisteredProgramSeenKey(programId), "1"); - } catch (e) { - // ignore storage errors - } - } - - private buildNavItems(profile: User) { - this.navItems = [ - { name: "мой профиль", icon: "person", link: `profile/${profile.id}` }, - { name: "новости", icon: "feed", link: "feed" }, - { name: "проекты", icon: "projects", link: "projects" }, - { name: "участники", icon: "people-bold", link: "members" }, - { name: "программы", icon: "program", link: "program" }, - { name: "курсы", icon: "trajectories", link: "courses" }, - { name: "вакансии", icon: "search-sidebar", link: "vacancies" }, - { name: "чаты", icon: "message", link: "chats" }, - ]; - } -} diff --git a/projects/social_platform/src/app/office/office.resolver.ts b/projects/social_platform/src/app/office/office.resolver.ts deleted file mode 100644 index 2e8cd588f..000000000 --- a/projects/social_platform/src/app/office/office.resolver.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { InviteService } from "@services/invite.service"; -import { Invite } from "@models/invite.model"; -import { ResolveFn } from "@angular/router"; - -/** - * Резолвер для предзагрузки приглашений пользователя - * Загружает данные о приглашениях перед инициализацией компонента офиса - * - * Принимает: - * - Контекст маршрута (неявно через Angular DI) - * - * Возвращает: - * - Observable - массив приглашений пользователя - */ -export const OfficeResolver: ResolveFn = () => { - const inviteService = inject(InviteService); - - return inviteService.getMy(); -}; diff --git a/projects/social_platform/src/app/office/office.routes.ts b/projects/social_platform/src/app/office/office.routes.ts deleted file mode 100644 index 109482981..000000000 --- a/projects/social_platform/src/app/office/office.routes.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { OfficeComponent } from "./office.component"; -import { ProfileEditComponent } from "./profile/edit/edit.component"; -import { MembersComponent } from "./members/members.component"; -import { MembersResolver } from "./members/members.resolver"; -import { OfficeResolver } from "./office.resolver"; - -/** - * Конфигурация маршрутов для модуля офиса - * Определяет все доступные пути и их компоненты в рабочем пространстве - * - * Принимает: - * - URL пути от роутера Angular - * - * Возвращает: - * - Конфигурацию маршрутов с ленивой загрузкой модулей - * - Резолверы для предзагрузки данных - */ -export const OFFICE_ROUTES: Routes = [ - { - path: "onboarding", - loadChildren: () => import("./onboarding/onboarding.routes").then(c => c.ONBOARDING_ROUTES), - }, - { - path: "", - component: OfficeComponent, - resolve: { - invites: OfficeResolver, - }, - children: [ - { - path: "", - pathMatch: "full", - redirectTo: "program", - }, - { - path: "profile/edit", - component: ProfileEditComponent, - }, - { - path: "profile/:id", - loadChildren: () => - import("./profile/detail/profile-detail.routes").then(c => c.PROFILE_DETAIL_ROUTES), - }, - { - path: "feed", - loadChildren: () => import("./feed/feed.routes").then(c => c.FEED_ROUTES), - }, - { - path: "projects", - loadChildren: () => import("./projects/projects.routes").then(c => c.PROJECTS_ROUTES), - }, - { - path: "members", - component: MembersComponent, - resolve: { - data: MembersResolver, - }, - }, - { - path: "program", - loadChildren: () => import("./program/program.routes").then(c => c.PROGRAM_ROUTES), - }, - { - path: "courses", - loadChildren: () => import("./courses/courses.routes").then(c => c.COURSES_ROUTES), - }, - { - path: "vacancies", - loadChildren: () => import("./vacancies/vacancies.routes").then(c => c.VACANCIES_ROUTES), - }, - { - path: "chats", - loadChildren: () => import("./chat/chat.routes").then(c => c.CHAT_ROUTES), - }, - { - path: "**", - redirectTo: "/error/404", - }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts b/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts deleted file mode 100644 index cbe8596e6..000000000 --- a/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { OnboardingComponent } from "@office/onboarding/onboarding/onboarding.component"; -import { OnboardingStageZeroComponent } from "@office/onboarding/stage-zero/stage-zero.component"; -import { OnboardingStageOneComponent } from "@office/onboarding/stage-one/stage-one.component"; -import { OnboardingStageThreeComponent } from "@office/onboarding/stage-three/stage-three.component"; -import { StageOneResolver } from "./stage-one/stage-one.resolver"; -import { OnboardingStageTwoComponent } from "./stage-two/stage-two.component"; -import { StageTwoResolver } from "./stage-two/stage-two.resolver"; - -/** - * ФАЙЛ МАРШРУТИЗАЦИИ ОНБОРДИНГА - * - * Назначение: Определяет структуру маршрутов для процесса онбординга новых пользователей - * - * Что делает: - * - Настраивает иерархию маршрутов для 4 этапов онбординга (stage-0, stage-1, stage-2, stage-3) - * - Связывает каждый маршрут с соответствующим компонентом - * - Подключает резолверы для предзагрузки данных на этапах 1 и 2 - * - * Что принимает: Нет входных параметров (статическая конфигурация) - * - * Что возвращает: Массив Routes для Angular Router - * - * Структура этапов: - * - stage-0: Базовая информация профиля (фото, город, образование, опыт работы) - * - stage-1: Выбор специализации пользователя - * - stage-2: Выбор навыков пользователя - * - stage-3: Выбор типа пользователя (ментор/менти) - */ -export const ONBOARDING_ROUTES: Routes = [ - { - path: "", - component: OnboardingComponent, - children: [ - { - path: "stage-0", - component: OnboardingStageZeroComponent, - }, - { - path: "stage-1", - component: OnboardingStageOneComponent, - resolve: { - data: StageOneResolver, - }, - }, - { - path: "stage-2", - component: OnboardingStageTwoComponent, - resolve: { - data: StageTwoResolver, - }, - }, - { - path: "stage-3", - component: OnboardingStageThreeComponent, - }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.html b/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.html deleted file mode 100644 index 2f5931d2b..000000000 --- a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - -
- - -
diff --git a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.ts b/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.ts deleted file mode 100644 index 4166fe1c5..000000000 --- a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router, RouterOutlet } from "@angular/router"; -import { Subscription } from "rxjs"; -import { OnboardingService } from "../services/onboarding.service"; - -/** - * ОСНОВНОЙ КОМПОНЕНТ ОНБОРДИНГА - * - * Назначение: Контейнер и координатор для всех этапов процесса онбординга - * - * Что делает: - * - Управляет навигацией между этапами онбординга (stage-0 до stage-3) - * - Отслеживает текущий и активный этапы процесса - * - Обеспечивает правильную последовательность прохождения этапов - * - Предоставляет интерфейс для перехода к предыдущим этапам - * - Автоматически перенаправляет в основное приложение при завершении - * - Синхронизирует состояние с OnboardingService - * - * Что принимает: - * - Данные о текущем этапе из OnboardingService.currentStage$ - * - События навигации от Angular Router - * - Пользовательские действия (клики по этапам) - * - * Что возвращает: - * - Контейнер с индикатором прогресса этапов - * - RouterOutlet для отображения компонентов текущего этапа - * - Навигационные элементы для перехода между этапами - * - * Логика навигации: - * - stage: текущий этап из URL - * - activeStage: этап, отображаемый в UI - * - Запрет перехода на будущие этапы (stage < targetStage) - * - Автоматическое перенаправление при currentStage$ = null - * - * Состояния этапов: - * - 0: Базовая информация профиля - * - 1: Выбор специализации - * - 2: Выбор навыков - * - 3: Выбор роли пользователя - * - null: Онбординг завершен, переход в /office - */ -@Component({ - selector: "app-onboarding", - templateUrl: "./onboarding.component.html", - styleUrl: "./onboarding.component.scss", - standalone: true, - imports: [RouterOutlet], -}) -export class OnboardingComponent implements OnInit, OnDestroy { - constructor( - private readonly route: ActivatedRoute, - private readonly onboardingService: OnboardingService, - private readonly router: Router - ) {} - - ngOnInit(): void { - const stage$ = this.onboardingService.currentStage$.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 = Number.parseInt(this.router.url.split("-")[1]); - } else { - this.stage = s; - } - - this.router - .navigate([`stage-${this.stage}`], { relativeTo: this.route }) - .then(() => console.debug("Route changed from OnboardingComponent")); - }); - - this.updateStage(); - const events$ = this.router.events.subscribe(this.updateStage.bind(this)); - - this.subscriptions$.push(stage$, events$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - stage = 0; - activeStage = 0; - - subscriptions$: Subscription[] = []; - - updateStage(): void { - this.activeStage = Number.parseInt(this.router.url.split("-")[1]); - this.stage = 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/office/onboarding/stage-one/stage-one.component.ts b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts deleted file mode 100644 index 303045aa5..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, OnDestroy, OnInit, signal } from "@angular/core"; -import { NonNullableFormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { concatMap, map, Observable, Subscription, take } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ControlErrorPipe, ValidationService } from "@corelib"; -import { ActivatedRoute, Router } from "@angular/router"; -import { OnboardingService } from "../services/onboarding.service"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { CommonModule } from "@angular/common"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { SpecializationsGroup } from "@office/models/specializations-group.model"; -import { SpecializationsGroupComponent } from "@office/shared/specializations-group/specializations-group.component"; -import { Specialization } from "@office/models/specialization.model"; -import { SpecializationsService } from "@office/services/specializations.service"; -import { ErrorMessage } from "@error/models/error-message"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; - -/** - * КОМПОНЕНТ ПЕРВОГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Этап выбора специализации пользователя из предложенных вариантов - * - * Что делает: - * - Отображает форму для ввода/выбора специализации - * - Предоставляет автокомплит для поиска специализаций - * - Показывает группированные специализации из базы данных - * - Валидирует введенные данные - * - Сохраняет специализацию в профиле и переходит к следующему этапу - * - Предоставляет возможность пропустить этап - * - * Что принимает: - * - Данные специализаций через ActivatedRoute (из StageOneResolver) - * - Текущее состояние формы из OnboardingService - * - Пользовательский ввод в поле специализации - * - Поисковые запросы для автокомплита - * - * Что возвращает: - * - Интерфейс с полем ввода специализации - * - Список предложенных специализаций для выбора - * - Навигацию на следующий этап (stage-2) или финальный (stage-3) - * - * Особенности: - * - Использует сигналы Angular для реактивного состояния - * - Поддерживает поиск специализаций в реальном времени - * - Интегрирован с сервисом специализаций для получения данных - */ -@Component({ - selector: "app-stage-one", - templateUrl: "./stage-one.component.html", - styleUrl: "./stage-one.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - IconComponent, - ButtonComponent, - ControlErrorPipe, - AutoCompleteInputComponent, - SpecializationsGroupComponent, - CommonModule, - TooltipComponent, - ], -}) -export class OnboardingStageOneComponent implements OnInit, OnDestroy { - constructor( - private readonly nnFb: NonNullableFormBuilder, - private readonly authService: AuthService, - private readonly onboardingService: OnboardingService, - private readonly validationService: ValidationService, - private readonly specsService: SpecializationsService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly cdref: ChangeDetectorRef - ) {} - - stageForm = this.nnFb.group({ - speciality: [""], - }); - - nestedSpecializations$: Observable = this.route.data.pipe( - map(r => r["data"]) - ); - - isHintAuthVisible = false; - isHintLibVisible = false; - - inlineSpecializations = signal([]); - - stageSubmitting = signal(false); - skipSubmitting = signal(false); - - errorMessage = ErrorMessage; - - subscriptions$ = signal([]); - - ngOnInit(): void { - const formValueState$ = this.onboardingService.formValue$.pipe(take(1)).subscribe(fv => { - this.stageForm.patchValue({ - speciality: fv.speciality, - }); - }); - - const formValueChange$ = this.stageForm.valueChanges.subscribe(value => { - this.onboardingService.setFormValue(value); - }); - - this.subscriptions$().push(formValueState$, formValueChange$); - } - - ngAfterViewInit(): void { - const specialityProfile$ = this.onboardingService.formValue$.subscribe(fv => { - this.stageForm.patchValue({ speciality: fv.speciality }); - }); - - this.cdref.detectChanges(); - - specialityProfile$ && this.subscriptions$().push(specialityProfile$); - } - - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - showTooltip(type: "auth" | "lib"): void { - type === "auth" ? (this.isHintAuthVisible = true) : (this.isHintLibVisible = true); - } - - hideTooltip(type: "auth" | "lib"): void { - type === "auth" ? (this.isHintAuthVisible = false) : (this.isHintLibVisible = false); - } - - // Для управления открытыми группами специализаций - openSpecializationGroup: string | null = null; - - /** - * Проверяет, есть ли открытые группы специализаций - */ - hasOpenSpecializationsGroups(): boolean { - return this.openSpecializationGroup !== null; - } - - /** - * Обработчик переключения группы специализаций - * @param isOpen - флаг открытия/закрытия группы - * @param groupName - название группы - */ - onSpecializationsGroupToggled(isOpen: boolean, groupName: string): void { - this.openSpecializationGroup = isOpen ? groupName : null; - } - - /** - * Проверяет, должна ли группа специализаций быть отключена - * @param groupName - название группы для проверки - */ - isSpecializationGroupDisabled(groupName: string): boolean { - return this.openSpecializationGroup !== null && this.openSpecializationGroup !== groupName; - } - - 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))) - .subscribe({ - next: () => this.completeRegistration(2), - error: () => this.stageSubmitting.set(false), - }); - } - - onSelectSpec(speciality: Specialization): void { - this.stageForm.patchValue({ speciality: speciality.name }); - } - - onSearchSpec(query: string): void { - this.specsService - .getSpecializationsInline(query, 1000, 0) - .pipe(take(1)) - .subscribe(({ results }) => { - this.inlineSpecializations.set(results); - }); - } - - private completeRegistration(stage: number): void { - this.skipSubmitting.set(true); - this.onboardingService.setFormValue(this.stageForm.value); - this.router.navigateByUrl( - stage === 2 ? "/office/onboarding/stage-2" : "/office/onboarding/stage-3" - ); - this.skipSubmitting.set(false); - } -} diff --git a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.ts b/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.ts deleted file mode 100644 index 1d8421f08..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { AuthService } from "@auth/services"; -import { concatMap, Subscription, take } from "rxjs"; -import { Router } from "@angular/router"; -import { OnboardingService } from "@office/onboarding/services/onboarding.service"; -import { ButtonComponent } from "@ui/components"; -import { UserTypeCardComponent } from "@office/onboarding/user-type-card/user-type-card.component"; - -/** - * КОМПОНЕНТ ТРЕТЬЕГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Финальный этап онбординга - выбор роли пользователя (ментор или менти) - * - * Что делает: - * - Отображает интерфейс для выбора типа пользователя - * - Валидирует выбор роли перед отправкой - * - Сохраняет выбранную роль в профиле пользователя - * - Завершает процесс онбординга и перенаправляет в основное приложение - * - Управляет состоянием загрузки и ошибок - * - * Что принимает: - * - Данные из OnboardingService (текущее состояние формы) - * - Взаимодействие пользователя (выбор роли, отправка формы) - * - * Что возвращает: - * - Визуальный интерфейс с карточками выбора роли - * - Навигацию в основное приложение после успешного завершения - * - * Состояния компонента: - * - userRole: выбранная роль (-1 = не выбрана, другие значения = конкретная роль) - * - stageTouched: флаг попытки отправки без выбора роли - * - stageSubmitting: флаг процесса отправки данных - */ -@Component({ - selector: "app-stage-three", - templateUrl: "./stage-three.component.html", - styleUrl: "./stage-three.component.scss", - standalone: true, - imports: [UserTypeCardComponent, ButtonComponent], -}) -export class OnboardingStageThreeComponent implements OnInit, OnDestroy { - constructor( - private readonly authService: AuthService, - private readonly onboardingService: OnboardingService, - private readonly router: Router - ) {} - - ngOnInit(): void { - const formValue$ = this.onboardingService.formValue$.pipe(take(1)).subscribe(fv => { - this.userRole = fv.userType ? fv.userType : -1; - }); - - this.subscriptions$.push(formValue$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - userRole!: number; - stageTouched = false; - stageSubmitting = false; - subscriptions$: Subscription[] = []; - - onSetRole(role: number) { - this.userRole = role; - this.onboardingService.setFormValue({ userType: role }); - } - - onSubmit() { - if (this.userRole === -1) { - this.stageTouched = true; - return; - } - - this.stageSubmitting = true; - - this.authService - .saveProfile({ userType: this.userRole }) - .pipe(concatMap(() => this.authService.setOnboardingStage(null))) - .subscribe(() => { - this.router - .navigateByUrl("/office") - .then(() => console.debug("Route changed from OnboardingStageTwo")); - }); - } -} diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts deleted file mode 100644 index 363d5bf21..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, OnDestroy, OnInit, signal } from "@angular/core"; -import { NonNullableFormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { concatMap, map, Observable, Subscription, take } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ControlErrorPipe, ValidationService } from "@corelib"; -import { ActivatedRoute, Router } from "@angular/router"; -import { OnboardingService } from "../services/onboarding.service"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { CommonModule } from "@angular/common"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { Skill } from "@office/models/skill.model"; -import { SkillsService } from "@office/services/skills.service"; -import { SkillsGroup } from "@office/models/skills-group.model"; -import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; -import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; - -/** - * КОМПОНЕНТ ВТОРОГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Этап выбора навыков пользователя из каталога доступных навыков - * - * Что делает: - * - Отображает интерфейс для поиска и выбора навыков - * - Управляет корзиной выбранных навыков - * - Предоставляет группированный каталог навыков - * - Поддерживает поиск навыков в реальном времени - * - Валидирует выбранные навыки перед отправкой - * - Сохраняет навыки в профиле и переходит к следующему этапу - * - Обрабатывает ошибки валидации от сервера - * - * Что принимает: - * - Данные групп навыков через ActivatedRoute (из StageTwoResolver) - * - Текущее состояние формы из OnboardingService - * - Пользовательские действия (поиск, добавление/удаление навыков) - * - Результаты поиска от SkillsService - * - * Что возвращает: - * - Интерфейс с поиском навыков и автокомплитом - * - Группированный каталог навыков для выбора - * - Корзину выбранных навыков с возможностью удаления - * - Модальное окно с ошибками валидации - * - Навигацию на следующий этап (stage-3) - * - * Особенности работы с навыками: - * - Предотвращение дублирования навыков в корзине - * - Переключение состояния навыка (добавить/удалить) одним действием - * - Отправка только ID навыков на сервер (skillsIds) - * - Обработка серверных ошибок с отображением в модальном окне - * - * Состояния компонента: - * - searchedSkills: результаты поиска навыков - * - stageSubmitting: флаг процесса отправки - * - isChooseSkill: флаг отображения модального окна с ошибкой - */ -@Component({ - selector: "app-stage-two", - templateUrl: "./stage-two.component.html", - styleUrl: "./stage-two.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - IconComponent, - ButtonComponent, - ModalComponent, - ControlErrorPipe, - AutoCompleteInputComponent, - SkillsGroupComponent, - SkillsBasketComponent, - TooltipComponent, - ], -}) -export class OnboardingStageTwoComponent implements OnInit, OnDestroy { - constructor( - private readonly nnFb: NonNullableFormBuilder, - private readonly authService: AuthService, - private readonly onboardingService: OnboardingService, - private readonly validationService: ValidationService, - private readonly skillsService: SkillsService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly cdref: ChangeDetectorRef - ) {} - - stageForm = this.nnFb.group({ - skills: this.nnFb.control([]), - }); - - nestedSkills$: Observable = this.route.data.pipe(map(r => r["data"])); - - searchedSkills = signal([]); - - isHintAuthVisible = false; - isHintLibVisible = false; - - stageSubmitting = signal(false); - skipSubmitting = signal(false); - - isChooseSkill = signal(false); - isChooseSkillText = signal(""); - - subscriptions$ = signal([]); - - // Для управления открытыми группами навыков - openSkillGroup: string | null = null; - - /** - * Проверяет, есть ли открытые группы навыков - */ - hasOpenSkillsGroups(): boolean { - return this.openSkillGroup !== null; - } - - /** - * Обработчик переключения группы навыков - * @param skillName - название навыка - * @param isOpen - флаг открытия/закрытия группы - */ - onSkillGroupToggled(isOpen: boolean, skillName: string): void { - this.openSkillGroup = isOpen ? skillName : null; - } - - /** - * Проверяет, должна ли группа навыков быть отключена - * @param skillName - название навыка - */ - isSkillGroupDisabled(skillName: string): boolean { - return this.openSkillGroup !== null && this.openSkillGroup !== skillName; - } - - ngOnInit(): void { - const fv$ = this.onboardingService.formValue$ - .pipe(take(1)) - .subscribe(({ skills }) => this.stageForm.patchValue({ skills })); - - const formValueChange$ = this.stageForm.valueChanges.subscribe(value => { - this.onboardingService.setFormValue(value); - }); - - this.subscriptions$().push(fv$, formValueChange$); - } - - ngAfterViewInit(): void { - const skillsProfile$ = this.onboardingService.formValue$.subscribe(fv => { - this.stageForm.patchValue({ skills: fv.skills }); - }); - - this.cdref.detectChanges(); - - skillsProfile$ && this.subscriptions$().push(skillsProfile$); - } - - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - showTooltip(type: "auth" | "lib"): void { - type === "auth" ? (this.isHintAuthVisible = true) : (this.isHintLibVisible = true); - } - - hideTooltip(type: "auth" | "lib"): void { - type === "auth" ? (this.isHintAuthVisible = false) : (this.isHintLibVisible = false); - } - - 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.id) }) - .pipe(concatMap(() => this.authService.setOnboardingStage(2))) - .subscribe({ - next: () => this.completeRegistration(3), - error: err => { - this.stageSubmitting.set(false); - if (err.status === 400) { - this.isChooseSkill.set(true); - this.isChooseSkillText.set(err.error[0]); - } - }, - }); - } - - onAddSkill(newSkill: Skill): void { - const { skills } = this.stageForm.getRawValue(); - - const isPresent = skills.some(s => s.id === newSkill.id); - - if (isPresent) return; - - this.stageForm.patchValue({ skills: [newSkill, ...skills] }); - } - - onRemoveSkill(oddSkill: Skill): void { - const { skills } = this.stageForm.getRawValue(); - - this.stageForm.patchValue({ skills: skills.filter(skill => skill.id !== oddSkill.id) }); - } - - onOptionToggled(toggledSkill: Skill): void { - const { skills } = this.stageForm.getRawValue(); - - const isPresent = skills.some(skill => skill.id === toggledSkill.id); - - if (isPresent) { - this.onRemoveSkill(toggledSkill); - } else { - this.onAddSkill(toggledSkill); - } - } - - onSearchSkill(query: string): void { - this.skillsService - .getSkillsInline(query, 1000, 0) - .subscribe(({ results }) => this.searchedSkills.set(results)); - } - - private completeRegistration(stage: number): void { - this.skipSubmitting.set(true); - this.onboardingService.setFormValue(this.stageForm.value); - stage === 3 && this.router.navigateByUrl("/office/onboarding/stage-3"); - this.skipSubmitting.set(false); - } -} diff --git a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.ts b/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.ts deleted file mode 100644 index c640864bc..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.ts +++ /dev/null @@ -1,797 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, OnDestroy, OnInit, signal } from "@angular/core"; -import { AuthService } from "@auth/services"; -import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { ControlErrorPipe, ValidationService, YearsFromBirthdayPipe } from "projects/core"; -import { concatMap, Subscription } from "rxjs"; -import { Router } from "@angular/router"; -import { User } from "@auth/models/user.model"; -import { OnboardingService } from "../services/onboarding.service"; -import { ButtonComponent, InputComponent, SelectComponent } from "@ui/components"; -import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; -import { CommonModule } from "@angular/common"; -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 { IconComponent } from "@uilib"; -import { transformYearStringToNumber } from "@utils/transformYear"; -import { yearRangeValidators } from "@utils/yearRangeValidators"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { generateOptionsList } from "@utils/generate-options-list"; - -/** - * КОМПОНЕНТ НУЛЕВОГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Начальный этап сбора базовой информации профиля пользователя - * - * Что делает: - * - Собирает основную информацию: фото, город, образование, опыт работы, языки, достижения - * - Управляет сложными формами с динамическими массивами (FormArray) - * - Валидирует данные с учетом временных диапазонов (годы обучения/работы) - * - Предоставляет интерфейс для добавления/редактирования/удаления записей - * - Поддерживает загрузку аватара пользователя - * - Сохраняет данные в профиле и переходит к следующему этапу - * - * Что принимает: - * - Текущий профиль пользователя из AuthService - * - Состояние формы из OnboardingService - * - Пользовательский ввод во все поля формы - * - Файлы изображений для аватара - * - * Что возвращает: - * - Комплексный интерфейс с множественными секциями: - * * Загрузка аватара - * * Поле города - * * Управление образованием (добавление/редактирование записей) - * * Управление опытом работы - * * Управление языками - * * Управление достижениями - * - Модальные окна для ошибок валидации - * - Навигацию на следующий этап (stage-1) или финальный (stage-3) - * - * Сложные функции управления данными: - * - addEducation/editEducation/removeEducation: управление записями образования - * - addWork/editWork/removeWork: управление записями опыта работы - * - addLanguage/editLanguage/removeLanguage: управление языками - * - addAchievement/removeAchievement: управление достижениями - * - * Валидация: - * - Обязательные поля: аватар, город - * - Валидация временных диапазонов (год начала < года окончания) - * - Динамическая валидация для записей в массивах - * - * Состояние компонента: - * - Множественные сигналы для управления элементами UI - * - Отслеживание режимов редактирования для каждого типа записей - * - Управление видимостью подсказок и модальных окон - */ -@Component({ - selector: "app-stage-zero", - templateUrl: "./stage-zero.component.html", - styleUrl: "./stage-zero.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - AvatarControlComponent, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - SelectComponent, - ModalComponent, - CommonModule, - TooltipComponent, - ], -}) -export class OnboardingStageZeroComponent implements OnInit, OnDestroy { - constructor( - public readonly authService: AuthService, - private readonly onboardingService: OnboardingService, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly router: Router, - private readonly cdref: ChangeDetectorRef - ) { - this.stageForm = this.fb.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], - }); - } - - ngOnInit(): void { - const profile$ = this.authService.profile.subscribe(p => { - this.profile = p; - }); - - const formValueState$ = this.onboardingService.formValue$.subscribe(fv => { - this.stageForm.patchValue({ - avatar: fv.avatar, - city: fv.city, - education: fv.education, - workExperience: fv.workExperience, - }); - }); - - this.subscriptions$.push(profile$, formValueState$); - } - - ngAfterViewInit() { - const onboardingProfile$ = this.onboardingService.formValue$.subscribe(formValues => { - this.stageForm.patchValue({ - avatar: formValues.avatar ?? "", - city: formValues.city ?? "", - }); - - 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"), - } - ) - ); - }); - - 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"), - } - ) - ); - }); - - this.userLanguages.clear(); - formValues.userLanguages?.forEach(lang => { - this.userLanguages.push( - this.fb.group({ - language: lang.language, - languageLevel: lang.languageLevel, - }) - ); - }); - - this.cdref.detectChanges(); - - formValues.achievements?.length && - formValues.achievements?.forEach(achievement => - this.addAchievement(achievement.id, achievement.title, achievement.status) - ); - }); - onboardingProfile$ && this.subscriptions$.push(onboardingProfile$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - isHintPhotoVisible = false; - isHintCityVisible = false; - isHintEducationVisible = false; - isHintEducationDescriptionVisible = false; - isHintWorkVisible = false; - isHintWorkNameVisible = false; - isHintWorkDescriptionVisible = false; - isHintAchievementsVisible = false; - isHintLanguageVisible = false; - - readonly yearListEducation = generateOptionsList(55, "years"); - - readonly educationStatusList = educationUserType; - - readonly educationLevelList = educationUserLevel; - - readonly languageList = languageNamesList; - - readonly languageLevelList = languageLevelsList; - - stageForm: FormGroup; - errorMessage = ErrorMessage; - profile?: User; - stageSubmitting = signal(false); - skipSubmitting = signal(false); - - educationItems = signal([]); - - workItems = signal([]); - - languageItems = signal([]); - - isModalErrorYear = signal(false); - isModalErrorYearText = signal(""); - - editIndex = signal(null); - - editEducationClick = false; - editWorkClick = false; - editLanguageClick = false; - - selectedEntryYearEducationId = signal(undefined); - selectedComplitionYearEducationId = signal(undefined); - selectedEducationStatusId = signal(undefined); - selectedEducationLevelId = signal(undefined); - - selectedEntryYearWorkId = signal(undefined); - selectedComplitionYearWorkId = signal(undefined); - - selectedLanguageId = signal(undefined); - selectedLanguageLevelId = signal(undefined); - - subscriptions$: Subscription[] = []; - - get achievements(): FormArray { - return this.stageForm.get("achievements") as FormArray; - } - - get education(): FormArray { - return this.stageForm.get("education") as FormArray; - } - - get workExperience(): FormArray { - return this.stageForm.get("workExperience") as FormArray; - } - - get userLanguages(): FormArray { - return this.stageForm.get("userLanguages") as FormArray; - } - - get isEducationDirty(): boolean { - const f = this.stageForm; - return [ - "organizationName", - "entryYear", - "completionYear", - "description", - "educationStatus", - "educationLevel", - ].some(name => f.get(name)?.dirty); - } - - get isWorkDirty(): boolean { - const f = this.stageForm; - return [ - "organizationNameWork", - "entryYearWork", - "completionYearWork", - "descriptionWork", - "jobPosition", - ].some(name => f.get(name)?.dirty); - } - - get isLanguageDirty(): boolean { - const f = this.stageForm; - return ["language", "languageLevel"].some(name => f.get(name)?.dirty); - } - - showTooltip( - type: - | "photo" - | "city" - | "education" - | "educationDescription" - | "work" - | "workName" - | "workDescription" - | "achievements" - | "language" - ): void { - switch (type) { - case "photo": - this.isHintPhotoVisible = true; - break; - case "city": - this.isHintCityVisible = true; - break; - case "education": - this.isHintEducationVisible = true; - break; - case "educationDescription": - this.isHintEducationDescriptionVisible = true; - break; - case "work": - this.isHintWorkVisible = true; - break; - case "workName": - this.isHintWorkNameVisible = true; - break; - case "workDescription": - this.isHintWorkDescriptionVisible = true; - break; - case "achievements": - this.isHintAchievementsVisible = true; - break; - case "language": - this.isHintLanguageVisible = true; - break; - } - } - - hideTooltip( - type: - | "photo" - | "city" - | "education" - | "educationDescription" - | "work" - | "workName" - | "workDescription" - | "achievements" - | "language" - ): void { - switch (type) { - case "photo": - this.isHintPhotoVisible = false; - break; - case "city": - this.isHintCityVisible = false; - break; - case "education": - this.isHintEducationVisible = false; - break; - case "educationDescription": - this.isHintEducationDescriptionVisible = false; - break; - case "work": - this.isHintWorkVisible = false; - break; - case "workName": - this.isHintWorkNameVisible = false; - break; - case "workDescription": - this.isHintWorkDescriptionVisible = false; - break; - case "achievements": - this.isHintAchievementsVisible = false; - break; - case "language": - this.isHintLanguageVisible = false; - break; - } - } - - 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 entryYear = - typeof this.stageForm.get("entryYear")?.value === "string" - ? +this.stageForm.get("entryYear")?.value.slice(0, 5) - : this.stageForm.get("entryYear")?.value; - const completionYear = - typeof this.stageForm.get("completionYear")?.value === "string" - ? +this.stageForm.get("completionYear")?.value.slice(0, 5) - : this.stageForm.get("completionYear")?.value; - - if (entryYear !== null && completionYear !== null && entryYear > completionYear) { - this.isModalErrorYear.set(true); - this.isModalErrorYearText.set("Год начала обучения должен быть меньше года окончания"); - 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 = false; - } - - editEducation(index: number) { - this.editEducationClick = 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 entryYear = - typeof this.stageForm.get("entryYearWork")?.value === "string" - ? +this.stageForm.get("entryYearWork")?.value.slice(0, 5) - : this.stageForm.get("entryYearWork")?.value; - const completionYear = - typeof this.stageForm.get("completionYearWork")?.value === "string" - ? +this.stageForm.get("completionYearWork")?.value.slice(0, 5) - : this.stageForm.get("completionYearWork")?.value; - - 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 = false; - } - - editWork(index: number) { - this.editWorkClick = 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 = false; - } - } - - editLanguage(index: number) { - this.editLanguageClick = 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); - } - - 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).subscribe({ - next: () => this.completeRegistration(3), - error: error => { - this.skipSubmitting.set(false); - this.isModalErrorYear.set(true); - this.isModalErrorYearText.set(error.error?.message || "Ошибка сохранения"); - }, - }); - } - - 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) - .pipe(concatMap(() => this.authService.setOnboardingStage(1))) - .subscribe({ - next: () => this.completeRegistration(1), - error: error => { - this.stageSubmitting.set(false); - this.isModalErrorYear.set(true); - if (error.error.language) { - this.isModalErrorYearText.set(error.error.language); - } - }, - }); - } - - private completeRegistration(stage: number): void { - this.skipSubmitting.set(true); - this.onboardingService.setFormValue(this.stageForm.value); - 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/office/profile/detail/main/main.component.html b/projects/social_platform/src/app/office/profile/detail/main/main.component.html deleted file mode 100644 index a01c14966..000000000 --- a/projects/social_platform/src/app/office/profile/detail/main/main.component.html +++ /dev/null @@ -1,391 +0,0 @@ - - -@if (user) { -
-
-
-
-
-

метаданные

- -
- -
    -
  • - -

    {{ (user.birthday | yearsFromBirthday) ?? "не указан" }}

    -
  • - - @if (user.city) { -
  • - -

    {{ (user.city | truncate: 12) ?? "не указан" }}

    -
  • - } @if (user.speciality) { -
  • - -

    - {{ (user.speciality | truncate: 13) ?? "не указана" }} -

    -
  • - } -
-
- - @if (user.userLanguages.length > 0) { -
-
-

языки

- -
- -
    - @for (language of user.userLanguages; track $index) { -
  • -
    {{ language.languageLevel }}
    -

    {{ language.language }}

    -
  • - } -
-
- } @if (user.programs.length; as programsLength) { -
-
    - @for (p of user.programs.slice(0, 3); track p.id) { -
  • - -
  • - } -
-
- @if (user.programs) { -
    - @for (program of user.programs.slice(3); track program.id) { -
  • - -
  • - } -
- } -
- -
-
- - program logo - -
-
-
- @if (programsLength > 3) { -
- {{ readAllPrograms ? "скрыть" : "подробнее" }} -
- } -
- } -
- - @if (loggedUserId) { -
- @if (user.aboutMe; as about) { -
-
-

обо мне

- -
- -
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "скрыть" : "подробнее" }} -
- } -
-
- } @if (user.skills.length || user.achievements.length) { -
- @for (directionItem of directions; track $index) { - - } -
- } @if (isProfileEmpty) { @if (loggedUserId === user.id) { -
- -

- заполните профиль и начните пользоваться PROCOLLAB -

- заполнить -
- } } @else { -
- @if (loggedUserId === user.id) { - - } -
    - @for (n of news(); track n.id) { -
  • - -
  • - } -
-
- } -
- } - -
-
- @if (user.links.length; as linksLength) { -
-
-

контакты

- -
-
    - @for (link of user.links.slice(0, 3); track $index) { -
  • - -
  • - } -
-
-
    - @for (link of user.links.slice(3); track $index) { -
  • - - -
  • - } -
-
- - @if (link | userLinks; as l) { - - - {{ l.tag | truncate: 30 }} - - } - - @if (linksLength > 3) { -
- {{ readAllLinks ? "скрыть" : "подробнее" }} -
- } -
- } @if (user.education.length; as educationLength) { -
-
-

образование

- -
-
    - @for (p of user.education.slice(0, 3); track $index) { -
  • - -
  • - } -
-
- @if (user.education) { -
    - @for (educationItem of user.education.slice(3); track $index) { -
  • - -
  • - } -
- } -
- -
-

- {{ education.entryYear }} -

- -

- -

{{ education.completionYear }}

-
- -
-

- {{ education.organizationName | truncate: 30 }} -

- - {{ education.description | truncate: 30 }}
- {{ education.educationLevel }} • {{ education.educationStatus }}
-
-
- @if (educationLength > 3) { -
- {{ readAllEducation ? "скрыть" : "подробнее" }} -
- } -
- } @if (user.workExperience.length; as workExperienceLength) { -
-
-

работа

- -
-
    - @for (p of user.workExperience.slice(0, 3); track $index) { -
  • - -
  • - } -
-
- @if (user.workExperience) { -
    - @for (workExperienceItem of user.workExperience.slice(3); track $index) { -
  • - -
  • - } -
- } -
- -
-

{{ workExperience.entryYear }}

- -

- -

{{ workExperience.completionYear }}

-
- -
-

- {{ workExperience.organizationName | truncate: 30 }} -

- - {{ workExperience.jobPosition }} - - подробнее -
- - -
-
-

{{ workExperience.organizationName }}

- -
- -

{{ workExperience.description }}

- -

- {{ workExperience.jobPosition }} • {{ workExperience.entryYear }} - - {{ workExperience.completionYear }} -

-
-
-
- @if (workExperienceLength > 3) { -
- {{ readAllWorkExperience ? "скрыть" : "подробнее" }} -
- } -
- } @if (user.projects.length; as projectsLength) { -
-
-

проекты

- -
-
    - @for (p of user.projects.slice(0, 3); track p.id) { -
  • - -
  • - } -
-
- @if (user.projects) { -
    - @for (project of user.projects.slice(3); track project.id) { -
  • - -
  • - } -
- } -
- -
- -
-

- {{ project.name | truncate: 30 }} -

- {{ project.collaborator?.role }} -
-
-
- @if (projectsLength > 3) { -
- {{ readAllProjects ? "скрыть" : "подробнее" }} -
- } -
- } -
-
-
-
-} diff --git a/projects/social_platform/src/app/office/profile/detail/main/main.component.scss b/projects/social_platform/src/app/office/profile/detail/main/main.component.scss deleted file mode 100644 index bbe118117..000000000 --- a/projects/social_platform/src/app/office/profile/detail/main/main.component.scss +++ /dev/null @@ -1,479 +0,0 @@ -/** @format */ - -@use "styles/responsive"; -@use "styles/typography"; - -@mixin expandable-list { - &__remaining { - display: grid; - grid-template-rows: 0fr; - overflow: hidden; - transition: all 0.5s ease-in-out; - - &--show { - grid-template-rows: 1fr; - } - - ul { - min-height: 0; - } - - li { - &:first-child { - margin-top: 12px; - } - - &:not(:last-child) { - margin-bottom: 12px; - } - } - } -} - -.profile { - padding-bottom: 100px; - - @include responsive.apply-desktop { - padding-bottom: 0; - } - - &__main { - display: grid; - grid-template-columns: 1fr; - } - - &__details { - display: grid; - grid-template-columns: 2fr 5fr 3fr; - grid-gap: 20px; - } - - &__right { - display: flex; - flex-direction: column; - } - - &__left { - width: 157px; - } - - &__aside { - display: grid; - grid-row-start: 3; - gap: 20px; - - @include responsive.apply-desktop { - grid-row-start: unset; - } - } - - &__section { - padding: 24px; - margin-bottom: 20px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - } - - &__info { - @include responsive.apply-desktop { - grid-column: span 3; - } - } - - &__content { - grid-row-start: 2; - min-width: 0; - word-break: break-word; - - @include responsive.apply-desktop { - grid-row-start: unset; - } - } - - &__news { - grid-row-start: 4; - - @include responsive.apply-desktop { - grid-row-start: unset; - } - } - - &__directions { - display: grid; - grid-template-columns: 1fr 1fr 3fr; - grid-gap: 20px; - align-items: center; - margin-top: 14px; - } - - &__empty { - display: flex; - flex-direction: column; - gap: 24px; - align-items: center; - - &--text { - color: var(--grey-for-text); - } - - i { - color: var(--grey-for-text); - } - } -} - -.info { - $body-slide: 15px; - - position: relative; - padding: 0; - background-color: transparent; - border: none; - border-radius: $body-slide; - - &__cover { - position: relative; - height: 230px; - border-radius: 15px 15px 0 0; - - img { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - object-fit: cover; - } - } - - &__body { - position: relative; - z-index: 2; - display: flex; - flex-direction: column; - gap: 20px; - padding: 40px 24px 24px; - margin-top: -$body-slide; - border-radius: $body-slide; - - app-button ::ng-deep .button--inline { - min-height: 38px; - } - - @include responsive.apply-desktop { - flex-direction: row; - gap: 10px; - align-items: flex-end; - padding-top: 10px; - padding-left: 225px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - } - } - - &__avatar { - position: absolute; - bottom: $body-slide; - left: 50%; - z-index: 3; - display: block; - transform: translateX(-50%) translateY(30px); - - @include responsive.apply-desktop { - left: 35px; - transform: translateY(50%); - } - } - - &__row { - display: flex; - align-items: center; - justify-content: center; - margin-top: 2px; - - @include responsive.apply-desktop { - justify-content: unset; - margin-top: 0; - } - } - - &__title { - overflow: hidden; - color: var(--black); - text-align: center; - text-overflow: ellipsis; - } - - &__right { - display: flex; - flex-direction: column; - gap: 20px; - - @include responsive.apply-desktop { - flex-direction: row; - margin-left: auto; - } - } - - &__presentation { - display: block; - - i { - margin-left: 10px; - } - } - - &__edit { - display: block; - } - - &__exit { - display: flex; - align-items: center; - justify-content: center; - width: 43px; - height: 43px; - color: var(--accent); - cursor: pointer; - border: 1px solid var(--accent); - border-radius: 8px; - transition: all 0.2s; - - &:hover { - color: var(--accent-dark); - border-color: var(--accent-dark); - } - } -} - -.about { - padding: 24px; - background-color: var(--light-white); - border-radius: var(--rounded-lg); - - &__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); - - &--icon { - color: var(--accent); - } - } - - &__title { - margin-bottom: 8px; - color: var(--accent); - } - /* stylelint-disable value-no-vendor-prefix */ - &__text { - p { - display: -webkit-box; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - word-break: break-word; - transition: all 0.7s ease-in-out; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - - &.expanded { - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } - } - - /* stylelint-enable value-no-vendor-prefix */ - - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } -} - -.lists { - &__section { - display: flex; - justify-content: space-between; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); - } - - &__list { - display: flex; - flex-direction: column; - gap: 10px; - - &--line { - display: flex; - flex-flow: wrap; - gap: 10px; - } - } - - &__icon { - color: var(--accent); - } - - &__title { - margin-bottom: 8px; - color: var(--accent); - } - - &__index { - color: var(--accent); - } - - &__logo { - border-radius: var(--rounded-xxl); - } - - &__info { - display: flex; - flex-direction: column; - - &--text { - color: var(--black) !important; - } - - &--subtext { - color: var(--grey-for-text) !important; - } - - &--more { - color: var(--accent) !important; - } - - img { - border-radius: var(--rounded-xxl); - } - } - - &__date { - display: flex; - flex-direction: column; - align-items: center; - width: 45px; - height: 45px; - padding: 5px; - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &__item { - display: flex; - gap: 6px; - align-items: center; - - &--status { - padding: 8px; - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &--title { - color: var(--black); - } - - &--more { - margin-top: 8px; - color: var(--accent); - } - - i, - .lists__index { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - padding-top: 1px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - p { - color: var(--accent); - } - - span { - cursor: pointer; - } - } - - @include expandable-list; -} - -.news { - &__form { - display: block; - margin-top: 20px; - } - - &__item { - display: block; - margin-top: 20px; - } -} - -.read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } -} - -.cancel { - display: flex; - flex-direction: column; - width: 350px; - height: 175px; - max-height: calc(100vh - 40px); - overflow-y: auto; - - &__top { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 8px; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); - } - - &__title { - color: var(--accent); - text-align: center; - } - - &__icon { - color: var(--accent); - } - - &__text { - margin-bottom: 8px; - color: var(--black); - } -} diff --git a/projects/social_platform/src/app/office/profile/detail/main/main.component.ts b/projects/social_platform/src/app/office/profile/detail/main/main.component.ts deleted file mode 100644 index 8687dddd5..000000000 --- a/projects/social_platform/src/app/office/profile/detail/main/main.component.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - inject, - OnDestroy, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { User } from "@auth/models/user.model"; -import { AuthService } from "@auth/services"; -import { expandElement } from "@utils/expand-element"; -import { concatMap, filter, map, noop, Observable, of, Subscription, switchMap } from "rxjs"; -import { ProfileNewsService } from "../services/profile-news.service"; -import { ProfileNews } from "../models/profile-news.model"; -import { - ParseBreaksPipe, - ParseLinksPipe, - PluralizePipe, - YearsFromBirthdayPipe, -} from "projects/core"; -import { UserLinksPipe } from "@core/pipes/user-links.pipe"; -import { IconComponent, ButtonComponent } from "@ui/components"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { AsyncPipe, CommonModule, NgTemplateOutlet } from "@angular/common"; -import { ProfileService } from "@auth/services/profile.service"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { AvatarComponent } from "../../../../ui/components/avatar/avatar.component"; -import { Skill } from "@office/models/skill.model"; -import { HttpErrorResponse } from "@angular/common/http"; -import { NewsFormComponent } from "@office/features/news-form/news-form.component"; -import { NewsCardComponent } from "@office/features/news-card/news-card.component"; -import { ProfileDataService } from "../services/profile-date.service"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; -import { ProjectDirectionCard } from "@office/projects/detail/shared/project-direction-card/project-direction-card.component"; -import { DirectionItem, directionItemBuilder } from "@utils/helpers/directionItemBuilder"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * Главный компонент страницы профиля пользователя - * - * Отображает основную информацию профиля пользователя, включая: - * - Раздел "Обо мне" с описанием и навыками пользователя - * - Ленту новостей пользователя с возможностью добавления, редактирования и удаления - * - Боковую панель с информацией о проектах, образовании, работе, достижениях и контактах - * - Систему подтверждения навыков другими пользователями - * - Модальные окна для детального просмотра подтверждений навыков - * - * Функциональность: - * - Управление новостями (CRUD операции) - * - Система лайков для новостей - * - Отслеживание просмотров новостей через Intersection Observer - * - Подтверждение/отмена подтверждения навыков пользователя - * - Раскрывающиеся списки для длинных списков (проекты, достижения и т.д.) - * - Адаптивное отображение контента - * - * @implements OnInit - для инициализации и загрузки новостей - * @implements AfterViewInit - для работы с DOM элементами - * @implements OnDestroy - для очистки подписок и observers - */ -@Component({ - selector: "app-profile-main", - templateUrl: "./main.component.html", - styleUrl: "./main.component.scss", - standalone: true, - imports: [ - CommonModule, - IconComponent, - ModalComponent, - RouterLink, - NgTemplateOutlet, - UserLinksPipe, - ParseBreaksPipe, - ParseLinksPipe, - TruncatePipe, - YearsFromBirthdayPipe, - NewsCardComponent, - NewsFormComponent, - ProjectDirectionCard, - ButtonComponent, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - private readonly authService = inject(AuthService); - private readonly profileNewsService = inject(ProfileNewsService); - private readonly profileDataService = inject(ProfileDataService); - private readonly cdRef = inject(ChangeDetectorRef); - - user?: User; - loggedUserId?: number; - isProfileEmpty?: boolean; - - directions: DirectionItem[] = []; - - subscriptions$: Subscription[] = []; - /** - * Инициализация компонента - * Загружает новости пользователя и настраивает Intersection Observer для отслеживания просмотров - */ - ngOnInit(): void { - const profileDataSub$ = this.profileDataService - .getProfile() - .pipe(filter(user => !!user)) - .subscribe({ - next: user => { - if (user) { - this.directions = directionItemBuilder( - 2, - ["навыки", "достижения"], - ["squiz", "medal"], - [user.skills, user.achievements], - ["array", "array"] - )!; - } - this.user = user as User; - }, - }); - - const profileIdDataSub$ = this.authService.profile.subscribe({ - next: user => { - this.loggedUserId = user?.id; - }, - }); - - this.isProfileEmpty = !( - this.user?.firstName && - this.user?.lastName && - this.user?.email && - this.user?.avatar && - this.user?.birthday - ); - - const route$ = this.route.params - .pipe( - map(r => r["id"]), - concatMap(userId => this.profileNewsService.fetchNews(userId)) - ) - .subscribe(news => { - this.news.set(news.results); - - setTimeout(() => { - const observer = new IntersectionObserver(this.onNewsInView.bind(this), { - root: document.querySelector(".office__body"), - rootMargin: "0px 0px 0px 0px", - threshold: 0, - }); - document.querySelectorAll(".news__item").forEach(e => { - observer.observe(e); - }); - }); - - setTimeout(() => { - this.checkDescriptionExpandable(); - this.cdRef.detectChanges(); - }, 200); - }); - this.subscriptions$.push(profileDataSub$, profileIdDataSub$, route$); - } - - @ViewChild("descEl") descEl?: ElementRef; - /** - * Инициализация после создания представления - * Проверяет необходимость отображения кнопки "Читать полностью" для описания профиля - */ - ngAfterViewInit(): void { - setTimeout(() => { - this.checkDescriptionExpandable(); - this.cdRef.detectChanges(); - }, 150); - } - - /** - * Очистка ресурсов при уничтожении компонента - * Отписывается от всех активных подписок - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - descriptionExpandable = false; - readFullDescription = false; - - readAllProjects = false; - readAllPrograms = false; - readAllAchievements = false; - readAllLinks = false; - readAllEducation = false; - readAllLanguages = false; - readAllWorkExperience = false; - - isShowModal = false; - - @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; - @ViewChild(NewsCardComponent) newsCardComponent?: NewsCardComponent; - - news = signal([]); - - /** - * Добавление новой новости в профиль - * @param news - объект с текстом и файлами новости - */ - onAddNews(news: { text: string; files: string[] }): void { - this.profileNewsService.addNews(this.route.snapshot.params["id"], news).subscribe(newsRes => { - this.newsFormComponent?.onResetForm(); - this.news.update(news => [newsRes, ...news]); - }); - } - - /** - * Удаление новости из профиля - * @param newsId - идентификатор удаляемой новости - */ - onDeleteNews(newsId: number): void { - const newsIdx = this.news().findIndex(n => n.id === newsId); - this.news().splice(newsIdx, 1); - - this.profileNewsService.delete(this.route.snapshot.params["id"], newsId).subscribe(() => {}); - } - - /** - * Переключение лайка новости - * @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) - .subscribe(() => { - item.likesCount = item.isUserLiked ? item.likesCount - 1 : item.likesCount + 1; - item.isUserLiked = !item.isUserLiked; - }); - } - - /** - * Редактирование существующей новости - * @param news - обновленные данные новости - * @param newsItemId - идентификатор редактируемой новости - */ - onEditNews(news: ProfileNews, newsItemId: number) { - this.profileNewsService - .editNews(this.route.snapshot.params["id"], newsItemId, news) - .subscribe(resNews => { - const newsIdx = this.news().findIndex(n => n.id === resNews.id); - this.news()[newsIdx] = resNews; - this.newsCardComponent?.onCloseEditMode(); - }); - } - - /** - * Обработчик появления новостей в области видимости - * Отмечает новости как просмотренные при скролле - * @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).subscribe(noop); - } - - /** - * Раскрытие/сворачивание описания профиля - * @param elem - DOM элемент описания - * @param expandedClass - CSS класс для раскрытого состояния - * @param isExpanded - текущее состояние (раскрыто/свернуто) - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - openWorkInfoModal(): void { - this.isShowModal = true; - } - - private checkDescriptionExpandable(): void { - const descElement = this.descEl?.nativeElement; - - if (!descElement || !this.user?.aboutMe) { - this.descriptionExpandable = false; - return; - } - - this.descriptionExpandable = descElement.scrollHeight > descElement.clientHeight; - } -} diff --git a/projects/social_platform/src/app/office/profile/detail/main/main.resolver.ts b/projects/social_platform/src/app/office/profile/detail/main/main.resolver.ts deleted file mode 100644 index 89a00aabe..000000000 --- a/projects/social_platform/src/app/office/profile/detail/main/main.resolver.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { ProfileNewsService } from "../services/profile-news.service"; -import { inject } from "@angular/core"; - -/** - * Резолвер для загрузки детальной информации о новости профиля - * - * Этот резолвер используется для предварительной загрузки конкретной новости - * пользователя перед отображением компонента просмотра новости. - * - * Извлекает параметры: - * - userId из родительского маршрута (ID пользователя-владельца профиля) - * - newsId из текущего маршрута (ID конкретной новости) - * - * @param route - снимок активного маршрута с параметрами - * @returns Observable - детальная информация о новости - * @throws Error - если отсутствуют обязательные параметры userId или newsId - * - * Использует ProfileNewsService для выполнения HTTP запроса к API - */ -export const ProfileMainResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { - const profileNewsService = inject(ProfileNewsService); - - const userId = route.parent?.paramMap.get("id"); - const newsId = route.paramMap.get("newsId"); - - if (!userId || !newsId) { - throw new Error("Required parameters are missing"); - } - - return profileNewsService.fetchNewsDetail(userId, newsId); -}; diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.resolver.ts b/projects/social_platform/src/app/office/profile/detail/profile-detail.resolver.ts deleted file mode 100644 index f28d76dd4..000000000 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.resolver.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { AuthService } from "@auth/services"; -import { User } from "@auth/models/user.model"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { forkJoin, map, mergeMap, tap } from "rxjs"; -import { Project } from "@office/models/project.model"; -import { ProfileDataService } from "./services/profile-date.service"; -import { profile } from "console"; - -/** - * Резолвер для загрузки данных профиля пользователя - * - * Этот резолвер выполняется перед активацией маршрута детального просмотра профиля - * и предварительно загружает необходимые данные пользователя и его подписки на проекты. - * - * Загружаемые данные: - * - Полная информация о пользователе (User) - * - Список проектов, на которые подписан пользователь (Project[]) - * - * @param route - снимок активного маршрута, содержащий параметр 'id' пользователя - * @returns Observable<[User, Project[]]> - кортеж с данными пользователя и его подписками - * - * Использует: - * - AuthService для получения информации о пользователе - * - SubscriptionService для получения подписок пользователя - * - forkJoin для параллельного выполнения запросов - */ -export const ProfileDetailResolver: ResolveFn<[User, Project[]]> = ( - route: ActivatedRouteSnapshot -) => { - const authService = inject(AuthService); - const subscriptionService = inject(SubscriptionService); - const profileDataService = inject(ProfileDataService); - - return forkJoin([ - authService - .getUser(Number(route.paramMap.get("id"))) - .pipe(tap(profile => profileDataService.setProfile(profile))), - - subscriptionService.getSubscriptions(Number(route.paramMap.get("id"))).pipe( - map(subs => subs.results), - tap(subs => profileDataService.setProfileSubs(subs)) - ), - ]); -}; diff --git a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.html b/projects/social_platform/src/app/office/profile/detail/projects/projects.component.html deleted file mode 100644 index baf56a2af..000000000 --- a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.html +++ /dev/null @@ -1,56 +0,0 @@ - -@if (user) { -
- @if (loggedUserId) { -
- @if (user.projects.length) { -
-

- {{ - user.id === loggedUserId - ? "Проекты, в которых я состою" - : "Проекты, в которых состоит " + user.firstName - }} -

-
    - @for (project of user.projects; track project.id) { -
  • - - - -
  • - } -
-
- } @if (subs) { @if (subs.length) { -
-

- {{ - user.id === loggedUserId - ? "Проекты, на которые я подписан" - : "Проекты, на которые подписан " + user.firstName - }} -

-
    - @for (project of subs; track project.id) { -
  • - - - -
  • - } -
-
- } } @if (!user.projects.length) { @if (subs) { @if (!subs.length) { -

- Вы пока не состоите ни в одном проекте и не подписаны ни на один. -

- } } @else { -

- Вы пока не состоите ни в одном проекте и не подписаны ни на один. -

- } } -
- } -
-} diff --git a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.scss b/projects/social_platform/src/app/office/profile/detail/projects/projects.component.scss deleted file mode 100644 index 684402e62..000000000 --- a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.scss +++ /dev/null @@ -1,48 +0,0 @@ -@use "styles/responsive"; - -.projects { - display: flex; - flex-direction: column; - - @include responsive.apply-desktop { - flex-direction: row; - } - - &__aside { - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 20px; - } - - &__content { - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 15px; - padding: 0 24px; - } - - &__section { - h3 { - margin-bottom: 16px; - } - - ul { - display: grid; - flex-direction: column; - grid-template-columns: 1fr; - gap: 14px; - - @include responsive.apply-desktop { - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - } - } - } -} - -.about { - &__title { - color: var(--black); - } -} diff --git a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.spec.ts b/projects/social_platform/src/app/office/profile/detail/projects/projects.component.spec.ts deleted file mode 100644 index f50d21e4f..000000000 --- a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { of } from "rxjs"; -import { ProfileProjectsComponent } from "./projects.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { AuthService } from "@auth/services"; - -describe("ProjectsComponent", () => { - let component: ProfileProjectsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = jasmine.createSpyObj("AuthService", {}, { profile: of({}) }); - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProfileProjectsComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProfileProjectsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.ts b/projects/social_platform/src/app/office/profile/detail/projects/projects.component.ts deleted file mode 100644 index f1e87421b..000000000 --- a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** @format */ - -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { User } from "@auth/models/user.model"; -import { AuthService } from "@auth/services"; -import { filter, Subscription, take } from "rxjs"; -import { AsyncPipe } from "@angular/common"; -import { Project } from "@office/models/project.model"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; -import { ProfileDataService } from "../services/profile-date.service"; - -/** - * Компонент для отображения проектов пользователя - * - * Отображает два типа проектов: - * 1. Проекты, в которых пользователь является участником - * 2. Проекты, на которые пользователь подписан - * - * Функциональность: - * - Получение данных пользователя и его подписок из родительского резолвера - * - Отображение проектов в виде карточек с возможностью перехода к деталям - * - Адаптивная сетка для отображения проектов - * - Различное отображение для собственного профиля и профиля другого пользователя - * - * @implements OnInit - для инициализации компонента - */ -@Component({ - selector: "app-projects", - templateUrl: "./projects.component.html", - styleUrl: "./projects.component.scss", - standalone: true, - imports: [RouterLink, AsyncPipe, InfoCardComponent], -}) -export class ProfileProjectsComponent implements OnInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - private readonly profileDataService = inject(ProfileDataService); - public readonly authService = inject(AuthService); - - ngOnInit(): void { - const profileDataSub$ = this.profileDataService - .getProfile() - .pipe( - filter(user => !!user), - take(1) - ) - .subscribe({ - next: user => { - this.user = user; - }, - }); - - const profileIdDataSub$ = this.profileDataService - .getProfileId() - .pipe( - filter(profileId => !!profileId), - take(1) - ) - .subscribe({ - next: profileId => { - this.loggedUserId = profileId; - }, - }); - - const profileSubsDataSub$ = this.profileDataService - .getProfileSubs() - .pipe( - filter(subs => !!subs), - take(1) - ) - .subscribe({ - next: subs => { - this.subs = subs; - }, - }); - - profileDataSub$ && this.subscriptions.push(profileDataSub$); - profileIdDataSub$ && this.subscriptions.push(profileIdDataSub$); - profileSubsDataSub$ && this.subscriptions.push(profileSubsDataSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - user?: User; - loggedUserId?: number; - subs?: Project[]; - - subscriptions: Subscription[] = []; -} diff --git a/projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts b/projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts deleted file mode 100644 index 9c53ceec0..000000000 --- a/projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { User } from "@auth/models/user.model"; -import { Project } from "@office/models/project.model"; -import { BehaviorSubject, filter, map } from "rxjs"; - -@Injectable({ - providedIn: "root", -}) -export class ProfileDataService { - private profilesSubject = new BehaviorSubject(undefined); - private profileIdSubject = new BehaviorSubject(undefined); - private profileSubsSubject = new BehaviorSubject(undefined); - - profile$ = this.profilesSubject.asObservable(); - profileId$ = this.profileIdSubject.asObservable(); - profileSubs$ = this.profileSubsSubject.asObservable(); - - setProfile(profile: User) { - this.profilesSubject.next(profile); - this.setProfileId(profile.id); - } - - setProfileId(id: number) { - this.profileIdSubject.next(id); - } - - setProfileSubs(subs: Project[]) { - this.profileSubsSubject.next(subs); - } - - getProfile() { - return this.profile$.pipe( - map(profile => profile), - filter(profile => !!profile) - ); - } - - getProfileId() { - return this.profileId$.pipe( - map(profileId => profileId), - filter(profileId => !!profileId) - ); - } - - getProfileSubs() { - return this.profileSubs$.pipe( - map(subs => subs), - filter(subs => !!subs) - ); - } -} diff --git a/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.spec.ts b/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.spec.ts deleted file mode 100644 index a6632b1aa..000000000 --- a/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProfileNewsService } from "./profile-news.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProfileNewsService", () => { - let service: ProfileNewsService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(ProfileNewsService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.ts b/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.ts deleted file mode 100644 index e411da09b..000000000 --- a/projects/social_platform/src/app/office/profile/detail/services/profile-news.service.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** @format */ - -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 { HttpParams } from "@angular/common/http"; -import { plainToInstance } from "class-transformer"; -import { StorageService } from "@services/storage.service"; -import { ApiPagination } from "@models/api-pagination.model"; - -/** - * Сервис для работы с новостями профиля пользователя - * - * Предоставляет методы для выполнения CRUD операций с новостями профиля: - * - Получение списка новостей пользователя с пагинацией - * - Получение детальной информации о конкретной новости - * - Создание новых новостей с текстом и файлами - * - Редактирование существующих новостей - * - Удаление новостей - * - Управление лайками новостей - * - Отслеживание просмотров новостей с кешированием в sessionStorage - * - * Использует: - * - ApiService для HTTP запросов к backend API - * - StorageService для кеширования просмотренных новостей - * - class-transformer для преобразования ответов API в модели - * - RxJS операторы для обработки асинхронных операций - * - * @injectable - сервис доступен для внедрения зависимостей - * @providedIn 'root' - синглтон на уровне приложения - */ -@Injectable({ - providedIn: "root", -}) -export class ProfileNewsService { - private readonly AUTH_USERS_URL = "/auth/users"; - - storageService = inject(StorageService); - apiService = inject(ApiService); - - /** - * Получение списка новостей пользователя - * @param userId - идентификатор пользователя - * @returns Observable> - пагинированный список новостей - */ - fetchNews(userId: string): Observable> { - return this.apiService.get>( - `${this.AUTH_USERS_URL}/${userId}/news/`, - new HttpParams({ fromObject: { limit: 10 } }) - ); - } - - /** - * Получение детальной информации о конкретной новости - * @param userId - идентификатор пользователя-владельца новости - * @param newsId - идентификатор новости - * @returns Observable - детальная информация о новости - */ - fetchNewsDetail(userId: string, newsId: string): Observable { - return this.apiService - .get(`${this.AUTH_USERS_URL}/${userId}/news/${newsId}`) - .pipe(map(r => plainToInstance(ProfileNews, r))); - } - - /** - * Создание новой новости в профиле пользователя - * @param userId - идентификатор пользователя - * @param obj - объект с текстом и файлами новости - * @returns Observable - созданная новость - */ - addNews(userId: string, obj: { text: string; files: string[] }): Observable { - return this.apiService - .post(`${this.AUTH_USERS_URL}/${userId}/news/`, obj) - .pipe(map(r => plainToInstance(ProfileNews, r))); - } - - /** - * Отметка новостей как просмотренных - * Использует sessionStorage для кеширования просмотренных новостей - * @param userId - идентификатор пользователя - * @param newsIds - массив идентификаторов новостей для отметки - * @returns Observable - результаты операций отметки просмотра - */ - readNews(userId: number, newsIds: number[]): Observable { - const readNews = this.storageService.getItem("readNews", sessionStorage) ?? []; - - return forkJoin( - newsIds - .filter(id => !readNews.includes(id)) - .map(id => - this.apiService - .post(`${this.AUTH_USERS_URL}/${userId}/news/${id}/set_viewed/`, {}) - .pipe( - tap(() => { - this.storageService.setItem("readNews", [...readNews, id], sessionStorage); - }) - ) - ) - ); - } - - /** - * Удаление новости из профиля - * @param userId - идентификатор пользователя - * @param newsId - идентификатор удаляемой новости - * @returns Observable - результат операции удаления - */ - delete(userId: string, newsId: number): Observable { - return this.apiService.delete(`${this.AUTH_USERS_URL}/${userId}/news/${newsId}/`); - } - - /** - * Переключение лайка новости - * @param userId - идентификатор пользователя-владельца новости - * @param newsId - идентификатор новости - * @param state - новое состояние лайка (true - лайк, false - убрать лайк) - * @returns Observable - результат операции изменения лайка - */ - toggleLike(userId: string, newsId: number, state: boolean): Observable { - return this.apiService.post(`${this.AUTH_USERS_URL}/${userId}/news/${newsId}/set_liked/`, { - is_liked: state, - }); - } - - /** - * Редактирование существующей новости - * @param userId - идентификатор пользователя - * @param newsId - идентификатор редактируемой новости - * @param newsItem - частичные данные для обновления новости - * @returns Observable - обновленная новость - */ - editNews( - userId: string, - newsId: number, - newsItem: Partial - ): Observable { - return this.apiService - .patch(`${this.AUTH_USERS_URL}/${userId}/news/${newsId}/`, newsItem) - .pipe(map(r => plainToInstance(ProfileNews, r))); - } -} diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.html b/projects/social_platform/src/app/office/profile/edit/edit.component.html deleted file mode 100644 index 801afc42d..000000000 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.html +++ /dev/null @@ -1,1113 +0,0 @@ - - -@if (profileForm.get("userType"); as currentType) { -
-
- -

редактирование профиля

-
- -
- - cохранить -
-
- -
-
-
-
    - @for (item of navProfileItems; track $index) { -
  • - -

    - {{ item.label }} -

    -
  • - } -
-
- -
- @if(editingStep === 'main'){ -
-
- @if (profileForm.get("avatar"); as avatar) { -
- -
- - @if (avatar | controlError: "required") { -
- {{ errorMessage.EMPTY_AVATAR }} -
- } -
-
- } @if (profileForm.get("coverImageAddress"); as coverImageAddress) { -
- - - - -

- обложка формата -
- .JPG или .JPEG весом до 50МБ -

- @if (coverImageAddress | controlError: "required") { -

загрузите файл

- } -
-
-
- } -
- -
-
- @if (profileForm.get("firstName"); as firstName) { -
- - - @if (firstName | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (profileForm.get("lastName"); as lastName) { -
- - - @if (lastName | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("city"); as city) { -
- - - @if (city | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (profileForm.get("birthday"); as birthday) { -
- - - @if (birthday | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("userType"); as userType) { @if (userType.value !== 1) { -
- - @if (roles | async; as options) { - - } @if (userType | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } } @if (profileForm.get("speciality"); as speciality) { -
- -
- -
- @if (speciality | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
-
- -
- @if (profileForm.get("aboutMe"); as aboutMe) { -
- - - @if (aboutMe | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- - -
- } @if (editingStep === 'education') { -
-
- @if (showEducationFields) { -
- @if (profileForm.get("entryYear"); as entryYear) { -
- - - - - - @if (entryYear | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (profileForm.get("completionYear"); as completionYear) { -
- - - - - @if (completionYear | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("organizationName"); as organizationName) { -
- - - @if (organizationName | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("description"); as description) { -
- - - @if (description | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("educationLevel"); as educationLevel) { -
- - - - - @if (educationLevel | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("educationStatus"); as educationStatus) { -
- - - - - @if (educationStatus | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- } - - - {{ editEducationClick ? "сохранить изменения" : "добавить образование" }} - - -
- -
- @if(educationItems().length || education.length){ @for (educationItem of education.value; - track $index) { -
-

- {{ educationItem.organizationName }} -

- -

- @if(educationItem.entryYear && educationItem.completionYear) { - {{ educationItem.entryYear }} год • {{ educationItem.completionYear }} год } @else if - (educationItem.entryYear && !educationItem.completionYear) { - {{ educationItem.entryYear }} год } @else if (!educationItem.entryYear && - educationItem.completionYear){ {{ educationItem.completionYear }} год } -

- -
-
-

- {{ educationItem.description }} -

- -

- {{ educationItem.educationLevel }} -

- -

- {{ educationItem.educationStatus }} -

-
- -
-
- -
- -
- -
-
-
-
- } } -
-
- } @if (editingStep === 'experience') { -
-
- @if (showWorkFields){ -
-
- @if (profileForm.get("entryYearWork"); as entryYearWork) { -
- - - - - - @if (entryYearWork | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (profileForm.get("completionYearWork"); as completionYearWork) { -
- - - - - - @if (completionYearWork | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
-
- -
- @if (profileForm.get("organization"); as organization) { -
- - - @if (organization | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("jobPosition"); as jobPosition) { -
- - - @if (jobPosition | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("descriptionWork"); as descriptionWork) { -
- - - @if (descriptionWork | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- } - - - {{ editWorkClick ? "сохранить изменения" : "добавить работу" }} - - -
- -
- @if(workItems().length || workExperience.length){ @for (workItem of workExperience.value; - track $index) { -
-

- {{ workItem.organizationName }} -

- -

- @if(workItem.entryYear && workItem.completionYear) { - {{ workItem.entryYear }} год • {{ workItem.completionYear }} год } @else if - (workItem.entryYear && !workItem.completionYear) { {{ workItem.entryYear }} год } - @else if (!workItem.entryYear && workItem.completionYear){ - {{ workItem.completionYear }} год } -

- -
-
-

- {{ workItem.description }} -

- -

- {{ workItem.jobPosition }} -

-
- -
-
- -
- -
- -
-
-
-
- } } -
-
- } @if(editingStep === 'achievements'){ -
-
- @if (showAchievementsFields) { -
- @if (profileForm.get("title"); as title) { -
- - - @if (title | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("year"); as year) { -
- - - @if (year | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("status"); as status) { -
- - - @if (status | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
- @if (profileForm.get("files"); as files) { -
- - - -

- файл или изображение
- с сертификатом подтверждающим
- достижение весом до 50МБ -

- @if (files | controlError: "required") { -

загрузите файл

- } -
-
-
- } -
- } - - {{ editAchievementsClick ? "сохранить изменения" : "добавить достижение" }} - - -
- -
- @if(achievementItems().length || achievements.length){ @for (achievementItem of - achievements.value; track $index) { -
-
-
-

- {{ achievementItem.title }} -

- -

- {{ achievementItem.year }} -

- -

- {{ achievementItem.status }} -

- - @if (achievementItem.files?.length) { @if (isStringFiles(achievementItem.files)) { - - - } @else { @for (file of achievementItem.files; track $index) { - - - } } } -
- -
-
- -
- -
- -
-
-
-
- } } -
-
- } @if (editingStep === 'skills') { -
-
-
- -
- -
-
- -
- @if (profileForm.get("skills"); as skills) { -
- -
- } -
-
- -
- @if (showLanguageFields) { -
- @if (profileForm.get("language"); as language) { -
- - - - - - @if (language | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (profileForm.get("languageLevel"); as languageLevel) { -
- - - - - - @if (languageLevel | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- } - - количество добавляемых языков не более 4-х - - {{ editLanguageClick ? "сохранить изменения" : "добавить язык" }} - - - -
- @if(languageItems().length || userLanguages.length){ @for (languageItem of - userLanguages.value; track $index) { -
-
-

- {{ languageItem.language }} -

- -
-
- -
- -
- -
-
-
- -

- {{ languageItem.languageLevel }} -

-
- } } -
-
-
- } @else if (editingStep === 'settings') { -
- удалить профиль -
- } -
-
-
-} - - -
-
- -

произошла ошибка при редактировании!

-
- @if (isModalErrorSkillChooseText()) { -

{{ isModalErrorSkillChooseText() }}.

- } @else { -

- для публикации профиля, нужно заполнить все обязательные поля (они будут - подсвечены красным). -

- } -
-
- - -
-
-

подтвердите удаление аккаунта

- -
- - удалить аккаунт -
-
- - - - - - - - diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.spec.ts b/projects/social_platform/src/app/office/profile/edit/edit.component.spec.ts deleted file mode 100644 index d8e54370b..000000000 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProfileEditComponent } from "./edit.component"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ReactiveFormsModule } from "@angular/forms"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { NgxMaskModule } from "ngx-mask"; - -describe("ProfileEditComponent", () => { - let component: ProfileEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - changeableRoles: of([]), - }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - NgxMaskModule.forRoot(), - ProfileEditComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProfileEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.ts b/projects/social_platform/src/app/office/profile/edit/edit.component.ts deleted file mode 100644 index b0c575b52..000000000 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.ts +++ /dev/null @@ -1,1241 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - OnDestroy, - OnInit, - signal, -} from "@angular/core"; -import { AuthService } from "@auth/services"; -import { - FormArray, - FormBuilder, - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { ButtonComponent, IconComponent, InputComponent, SelectComponent } from "@ui/components"; -import { ControlErrorPipe, ValidationService } from "projects/core"; -import { concatMap, first, map, noop, Observable, skip, Subscription } from "rxjs"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import * as dayjs from "dayjs"; -import * as cpf from "dayjs/plugin/customParseFormat"; -import { NavService } from "@services/nav.service"; -import { EditorSubmitButtonDirective } from "@ui/directives/editor-submit-button.directive"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; -import { AsyncPipe, CommonModule } from "@angular/common"; -import { Specialization } from "@office/models/specialization.model"; -import { SpecializationsService } from "@office/services/specializations.service"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; -import { SpecializationsGroupComponent } from "@office/shared/specializations-group/specializations-group.component"; -import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { Skill } from "@office/models/skill.model"; -import { SkillsService } from "@office/services/skills.service"; -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 { transformYearStringToNumber } from "@utils/transformYear"; -import { yearRangeValidators } from "@utils/yearRangeValidators"; -import { Achievement, User } from "@auth/models/user.model"; -import { generateOptionsList } from "@utils/generate-options-list"; -import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; -import { navProfileItems } from "projects/core/src/consts/navigation/nav-profile-items.const"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; - -dayjs.extend(cpf); - -/** - * Компонент редактирования профиля пользователя - * - * Этот компонент предоставляет полнофункциональную форму для редактирования профиля пользователя - * с поддержкой множественных разделов (основная информация, образование, опыт работы, достижения, навыки). - * - * Основные возможности: - * - Редактирование основной информации (имя, фамилия, дата рождения, город, телефон) - * - Управление образованием (добавление, редактирование, удаление записей об образовании) - * - Управление опытом работы (добавление, редактирование, удаление записей о работе) - * - Управление языками (добавление, редактирование, удаление языковых навыков) - * - Управление достижениями (добавление, редактирование, удаление достижений) - * - Управление навыками через автокомплит и модальные окна с группировкой - * - Загрузка и обновление аватара пользователя - * - Пошаговая навигация между разделами формы - * - Валидация всех полей формы с отображением ошибок - * - * @implements OnInit - для инициализации компонента и подписок - * @implements OnDestroy - для очистки подписок - * @implements AfterViewInit - для работы с DOM после инициализации представления - */ -@Component({ - selector: "app-profile-edit", - templateUrl: "./edit.component.html", - styleUrl: "./edit.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - CommonModule, - InputComponent, - SelectComponent, - IconComponent, - ButtonComponent, - AvatarControlComponent, - TextareaComponent, - EditorSubmitButtonDirective, - AsyncPipe, - ControlErrorPipe, - AutoCompleteInputComponent, - SkillsBasketComponent, - SkillsGroupComponent, - SpecializationsGroupComponent, - ModalComponent, - SelectComponent, - RouterModule, - UploadFileComponent, - FileItemComponent, - ], -}) -export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { - constructor( - private readonly cdref: ChangeDetectorRef, - public readonly authService: AuthService, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly specsService: SpecializationsService, - private readonly skillsService: SkillsService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly navService: NavService - ) { - 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.max(100)], - entryYear: [null], - completionYear: [null], - description: [null, Validators.max(400)], - educationLevel: [null], - educationStatus: [""], - isMospolytechStudent: [false], - studyGroup: ["", Validators.max(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({}), - }); - } - - /** - * Инициализация компонента - * Настраивает форму, подписки на изменения, валидацию и заголовок навигации - */ - ngOnInit(): void { - this.navService.setNavTitle("Редактирование профиля"); - - const userType$ = this.profileForm - .get("userType") - ?.valueChanges.pipe(skip(1), concatMap(this.changeUserType.bind(this))) - .subscribe(noop); - - userType$ && this.subscription$.push(userType$); - - const userAvatar$ = this.profileForm - .get("avatar") - ?.valueChanges.pipe( - skip(1), - concatMap(url => this.authService.saveAvatar(url)) - ) - .subscribe(noop); - - userAvatar$ && this.subscription$.push(userAvatar$); - - // const isMospolytechStudentSub$ = this.profileForm - // .get("isMospolytechStudent") - // ?.valueChanges.subscribe(isStudent => { - // const studyGroup = this.profileForm.get("studyGroup"); - // if (isStudent) { - // studyGroup?.setValidators([Validators.required]); - // } else { - // studyGroup?.clearValidators(); - // } - - // studyGroup?.updateValueAndValidity(); - // }); - - // isMospolytechStudentSub$ && this.subscription$.push(isMospolytechStudentSub$); - - this.editingStep = this.route.snapshot.queryParams["editingStep"]; - } - - /** - * Инициализация после создания представления - * Загружает данные профиля пользователя и заполняет форму - */ - ngAfterViewInit() { - const profile$ = this.authService.profile.pipe(first()).subscribe((profile: User) => { - this.profileId = 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.cdref.detectChanges(); - - 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 ?? []], - }) - ); - }); - - this.cdref.detectChanges(); - - 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) - ); - - this.cdref.detectChanges(); - } - - 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); - this.cdref.detectChanges(); - } - - this.cdref.detectChanges(); - }); - profile$ && this.subscription$.push(profile$); - } - - /** - * Очистка ресурсов при уничтожении компонента - * Отписывается от всех активных подписок - */ - ngOnDestroy(): void { - this.subscription$.forEach($ => $.unsubscribe()); - } - - editingStep: "main" | "education" | "experience" | "achievements" | "skills" | "settings" = - "main"; - - profileId?: number; - - inlineSpecs = signal([]); - - nestedSpecs$ = this.specsService.getSpecializationsNested(); - - specsGroupsModalOpen = signal(false); - - inlineSkills = signal([]); - - nestedSkills$ = this.skillsService.getSkillsNested(); - - skillsGroupsModalOpen = signal(false); - - openGroupIndex: number | null = null; - - onGroupToggled(index: number, isOpen: boolean) { - this.openGroupIndex = isOpen ? index : null; - } - - isGroupDisabled(index: number): boolean { - return this.openGroupIndex !== null && this.openGroupIndex !== index; - } - - educationItems = signal([]); - - workItems = signal([]); - - languageItems = signal([]); - - achievementItems = signal([]); - - isModalErrorSkillsChoose = signal(false); - isModalErrorSkillChooseText = signal(""); - - isModalDeleteProfile = signal(false); - - editIndex = signal(null); - - editEducationClick = false; - editWorkClick = false; - editLanguageClick = false; - editAchievementsClick = false; - - showEducationFields = false; - showWorkFields = false; - showLanguageFields = false; - showAchievementsFields = false; - - selectedEntryYearEducationId = signal(undefined); - selectedComplitionYearEducationId = signal(undefined); - selectedEducationStatusId = signal(undefined); - selectedEducationLevelId = signal(undefined); - - selectedEntryYearWorkId = signal(undefined); - selectedComplitionYearWorkId = signal(undefined); - - selectedAchievementsYearId = signal(undefined); - - selectedLanguageId = signal(undefined); - selectedLanguageLevelId = signal(undefined); - - subscription$: Subscription[] = []; - - readonly navProfileItems = navProfileItems; - - /** - * Навигация между шагами редактирования профиля - * @param step - название шага ('main' | 'education' | 'experience' | 'achievements' | 'skills' | 'settings) - */ - navigateStep(step: string) { - this.router.navigate([], { queryParams: { editingStep: step } }); - this.editingStep = step as - | "main" - | "education" - | "experience" - | "achievements" - | "skills" - | "settings"; - } - - readonly yearListEducation = generateOptionsList(55, "years").reverse(); - - readonly educationStatusList = educationUserType; - - readonly educationLevelList = educationUserLevel; - - readonly languageList = languageNamesList; - - readonly languageLevelList = languageLevelsList; - - readonly achievementsYearList = generateOptionsList(25, "years"); - - 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; - } - - newPreferredIndustryTitle = ""; - - 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 = ""; - } - - removePreferredIndustry(i: number): void { - this.preferredIndustries.removeAt(i); - } - - 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 isEducationDirty(): boolean { - const f = this.profileForm; - return [ - "organizationName", - "entryYear", - "completionYear", - "description", - "educationStatus", - "educationLevel", - ].some(name => f.get(name)?.dirty); - } - - get isWorkDirty(): boolean { - const f = this.profileForm; - return [ - "organization", - "entryYearWork", - "completionYearWork", - "descriptionWork", - "jobPosition", - ].some(name => f.get(name)?.dirty); - } - - get isLanguageDirty(): boolean { - const f = this.profileForm; - return ["language", "languageLevel"].some(name => f.get(name)?.dirty); - } - - get isAchievementsDirty(): boolean { - const f = this.profileForm; - return ["title", "status", "year", "files"].some(name => f.get(name)?.dirty); - } - - errorMessage = ErrorMessage; - - roles: Observable = this.authService.changeableRoles.pipe( - map(roles => roles.map(role => ({ id: role.id, value: role.id, label: role.name }))) - ); - - profileFormSubmitting = false; - profileForm: FormGroup; - - // Для управления открытыми группами специализаций - openSpecializationGroup: string | null = null; - - /** - * Проверяет, есть ли открытые группы специализаций - */ - hasOpenSpecializationsGroups(): boolean { - return this.openSpecializationGroup !== null; - } - - /** - * Проверяет, должна ли группа специализаций быть отключена - * @param groupName - название группы для проверки - */ - isSpecializationGroupDisabled(groupName: string): boolean { - return this.openSpecializationGroup !== null && this.openSpecializationGroup !== groupName; - } - - /** - * Обработчик переключения группы специализаций - * @param isOpen - флаг открытия/закрытия группы - * @param groupName - название группы - */ - onSpecializationsGroupToggled(isOpen: boolean, groupName: string): void { - this.openSpecializationGroup = isOpen ? groupName : null; - } - - /** - * Добавление записи об достижении - * Валидирует форму и добавляет новую запись в массив достижений - */ - addAchievement(): void { - if (!this.showAchievementsFields) { - this.showAchievementsFields = 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 = false; - this.editAchievementsClick = false; - } - - /** - * Редактирование записи об достижений - * @param index - индекс записи в массиве достижений - */ - editAchievements(index: number) { - this.editAchievementsClick = true; - this.showAchievementsFields = 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); - } - - /** - * Добавление записи об образовании - * Валидирует форму и добавляет новую запись в массив образования - */ - addEducation() { - if (!this.showEducationFields) { - this.showEducationFields = 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 = false; - } - this.editEducationClick = false; - } - - /** - * Редактирование записи об образовании - * @param index - индекс записи в массиве образования - */ - editEducation(index: number) { - this.editEducationClick = true; - this.showEducationFields = 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); - } - - /** - * Добавление записи об опыте работы - * Валидирует форму и добавляет новую запись в массив опыта работы - */ - addWork() { - if (!this.showWorkFields) { - this.showWorkFields = 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 = false; - } - this.editWorkClick = false; - } - - editWork(index: number) { - this.editWorkClick = true; - this.showWorkFields = 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); - } - - addLanguage() { - if (!this.showLanguageFields) { - this.showLanguageFields = 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 = false; - } - this.editLanguageClick = false; - } - - editLanguage(index: number) { - this.editLanguageClick = true; - this.showLanguageFields = 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); - } - - get links(): FormArray { - return this.profileForm.get("links") as FormArray; - } - - newLink = ""; - - addLink(title?: string): void { - const fromState = title ?? this.newLink; - - const control = this.fb.control(fromState, [Validators.required]); - this.links.push(control); - - this.newLink = ""; - } - - removeLink(i: number): void { - this.links.removeAt(i); - } - - private userTypeMap: { [type: number]: string } = { - 1: "member", - 2: "mentor", - 3: "expert", - 4: "investor", - }; - - /** - * Сохранение профиля пользователя - * Валидирует всю форму и отправляет данные на сервер - */ - 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 = 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, - }; - - console.log(newProfile); - - this.authService - .saveProfile(newProfile) - .pipe(concatMap(() => this.authService.getProfile())) - .subscribe({ - next: () => { - this.profileFormSubmitting = false; - this.router - .navigateByUrl(`/office/profile/${this.profileId}`) - .then(() => console.debug("Router Changed form ProfileEditComponent")); - }, - error: error => { - this.profileFormSubmitting = 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]); - } - }, - }); - } - - /** - * Изменение типа пользователя - * @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())); - } - - /** - * Выбор специальности из автокомплита - * @param speciality - выбранная специальность - */ - onSelectSpec(speciality: Specialization): void { - this.profileForm.patchValue({ speciality: speciality.name }); - } - - /** - * Поиск специальностей для автокомплита - * @param query - поисковый запрос - */ - onSearchSpec(query: string): void { - this.specsService.getSpecializationsInline(query, 1000, 0).subscribe(({ results }) => { - this.inlineSpecs.set(results); - }); - } - - /** - * Переключение навыка (добавление/удаление) - * @param toggledSkill - навык для переключения - */ - onToggleSkill(toggledSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.profileForm.value; - - const isPresent = skills.some(skill => skill.id === toggledSkill.id); - - if (isPresent) { - this.onRemoveSkill(toggledSkill); - } else { - this.onAddSkill(toggledSkill); - } - } - - /** - * Добавление нового навыка - * @param newSkill - новый навык для добавления - */ - onAddSkill(newSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.profileForm.value; - - const isPresent = skills.some(skill => skill.id === newSkill.id); - - if (isPresent) return; - - this.profileForm.patchValue({ skills: [newSkill, ...skills] }); - } - - /** - * Удаление навыка - * @param oddSkill - навык для удаления - */ - onRemoveSkill(oddSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.profileForm.value; - - this.profileForm.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); - }); - } - - toggleSkillsGroupsModal(): void { - this.skillsGroupsModalOpen.update(open => !open); - } - - toggleSpecsGroupsModal(): void { - this.specsGroupsModalOpen.update(open => !open); - } - - isStringFiles(files: any[]): boolean { - return typeof files === "string"; - } -} diff --git a/projects/social_platform/src/app/office/program/detail/detail.resolver.ts b/projects/social_platform/src/app/office/program/detail/detail.resolver.ts deleted file mode 100644 index f0de67f2c..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail.resolver.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { ProgramService } from "@office/program/services/program.service"; -import { Program } from "@office/program/models/program.model"; -import { tap } from "rxjs"; -import { ProgramDataService } from "../services/program-data.service"; - -/** - * Резолвер для получения детальной информации о программе - * - * Предзагружает полную информацию о программе перед отображением - * детальной страницы. Это обеспечивает мгновенное отображение - * данных программы во всех дочерних компонентах. - * - * Принимает: - * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами - * - * Использует: - * @param {ProgramService} programService - Инжектируемый сервис программ - * - * Логика: - * - Извлекает programId из параметров маршрута - * - Загружает детальную информацию через programService.getOne() - * - * Возвращает: - * @returns {Observable} Полная информация о программе - * - * Загружаемые данные включают: - * - Основную информацию (название, описание, даты) - * - Изображения и медиа файлы - * - Права текущего пользователя (участник, менеджер) - * - Статистику (просмотры, лайки) - * - Дополнительные материалы и ссылки - * - * Используется в: - * Родительском маршруте детальной страницы программы - */ -export const ProgramDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { - const programService = inject(ProgramService); - const programDataService = inject(ProgramDataService); - - return programService - .getOne(route.params["programId"]) - .pipe(tap(program => programDataService.setProgram(program))); -}; diff --git a/projects/social_platform/src/app/office/program/detail/detail.routes.ts b/projects/social_platform/src/app/office/program/detail/detail.routes.ts deleted file mode 100644 index 1a485d175..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail.routes.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProgramDetailMainComponent } from "@office/program/detail/main/main.component"; -import { ProgramRegisterComponent } from "@office/program/detail/register/register.component"; -import { ProgramRegisterResolver } from "@office/program/detail/register/register.resolver"; -import { ProgramProjectsResolver } from "@office/program/detail/list/projects.resolver"; -import { ProgramMembersResolver } from "@office/program/detail/list/members.resolver"; -import { ProgramListComponent } from "./list/list.component"; -import { ProgramDetailResolver } from "./detail.resolver"; -import { DeatilComponent } from "@office/features/detail/detail.component"; - -/** - * Маршруты для детальной страницы программы - * - * Определяет структуру навигации внутри детальной страницы программы: - * - Основная информация (по умолчанию) - * - Список проектов программы - * - Список участников программы - * - Страница регистрации в программе - * - * Все маршруты используют резолверы для предзагрузки данных. - * - * @returns {Routes} Конфигурация маршрутов для детальной страницы программы - */ -export const PROGRAM_DETAIL_ROUTES: Routes = [ - { - path: "", - component: DeatilComponent, - resolve: { - data: ProgramDetailResolver, - }, - data: { listType: "program" }, - children: [ - { - path: "", - component: ProgramDetailMainComponent, - }, - { - path: "projects", - component: ProgramListComponent, - resolve: { - data: ProgramProjectsResolver, - }, - data: { listType: "projects" }, - }, - { - path: "members", - component: ProgramListComponent, - resolve: { - data: ProgramMembersResolver, - }, - data: { listType: "members" }, - }, - { - path: "projects-rating", - component: ProgramListComponent, - data: { listType: "rating" }, - }, - ], - }, - { - path: "register", - component: ProgramRegisterComponent, - resolve: { - data: ProgramRegisterResolver, - }, - }, -]; diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.html b/projects/social_platform/src/app/office/program/detail/list/list.component.html deleted file mode 100644 index 630fdea79..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.html +++ /dev/null @@ -1,166 +0,0 @@ - - -
-
- - -
- Фильтры - -
- -
    - @for (listItem of searchedList; track listItem.id) { -
  • - @if (listType === 'projects' || listType === 'members') { - - - - } @else { - - } -
  • - } -
-
- - @if (listType !== 'members') { -
-
- @if (listType === 'projects' || listType === 'rating') { -
-
-
-
- - - @if (listType === 'projects') { -
- - выгрузка проектов - - - - - сданные решения - - -
- } @else { -
- - выгрузка оценок - - - - - итоговые расчеты - - -
- } -
-
- } -
-
- } @if (listType !== 'members' && listType !== 'projects') { -
-
- - - @if (isHintExpertsVisible()) { -
-

- Нажмите, чтобы открыть подсказку и узнать больше о процессе оценивания проектов -

-

подробнее

-
- } -
-
- - -
-
-

Как выставить оценки проекту

-
- -
-

- Перед стартом оценки,
- настройте фильтрацию справа – выберите регион или конкретный кейс, а также работы, которые - ранее не были оценены другими экспертами -

- -

- После изучения материалов участников (описания и презентации проекта), проставьте оценки - по критериям. При необходимости оставьте небольшой комментарий -

- -

- Для завершения оценивания, нажмите «оценить проект» – ваша оценка сохранилась в системе -

- -

- Вы можете исправить свою оценку или комментарий – для этого нажмите на иконку карандаша - справа от кнопки «проект оценен». Этот функционал появится после сохранения оценки -

- -

Благодарим за вашу работу!

-
- - спасибо, понятно -
-
- } -
diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts b/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts deleted file mode 100644 index 53995cf41..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ProgramListComponent } from "./list.component"; - -describe("ProgramListComponent", () => { - let component: ProgramListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProgramListComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.ts b/projects/social_platform/src/app/office/program/detail/list/list.component.ts deleted file mode 100644 index c6a617bbd..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.ts +++ /dev/null @@ -1,636 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - inject, - OnDestroy, - OnInit, - Renderer2, - ViewChild, - signal, -} from "@angular/core"; -import { - catchError, - concatMap, - debounceTime, - distinctUntilChanged, - fromEvent, - map, - noop, - of, - Subscription, - 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 { 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 { ExportFileService } from "@office/services/export-file.service"; -import { saveFile } from "@utils/helpers/export-file"; -import { ProgramDataService } from "@office/program/services/program-data.service"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; - -@Component({ - selector: "app-list", - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", - imports: [ - CommonModule, - ReactiveFormsModule, - RouterModule, - ProjectsFilterComponent, - SearchComponent, - RatingCardComponent, - InfoCardComponent, - ButtonComponent, - IconComponent, - TooltipComponent, - ModalComponent, - ], - standalone: true, -}) -export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { - constructor() { - const searchValue = - this.route.snapshot.queryParams["search"] || - this.route.snapshot.queryParams["name__contains"]; - const decodedSearchValue = searchValue ? decodeURIComponent(searchValue) : ""; - - this.searchForm = this.fb.group({ - search: [decodedSearchValue], - }); - } - - @ViewChild("listRoot") listRoot?: ElementRef; - @ViewChild("filterBody") filterBody!: ElementRef; - - private readonly renderer = inject(Renderer2); - private readonly fb = inject(FormBuilder); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly cdref = inject(ChangeDetectorRef); - private readonly programService = inject(ProgramService); - private readonly programDataService = inject(ProgramDataService); - private readonly projectRatingService = inject(ProjectRatingService); - private readonly authService = inject(AuthService); - private readonly subscriptionService = inject(SubscriptionService); - private readonly exportFileService = inject(ExportFileService); - - protected availableFilters: PartnerProgramFields[] = []; - - searchForm: FormGroup; - - listTotalCount?: number; - listPage = 0; - listTake = 20; - perPage = 21; - - list: any[] = []; - searchedList: any[] = []; - profile?: User; - profileProjSubsIds?: number[]; - - isRatedByExpert = signal(undefined); - searchValue = signal(""); - - listType: "projects" | "members" | "rating" = "projects"; - - readonly ratingOptionsList = tagsFilter; - isFilterOpen = false; - - readonly isHintExpertsVisible = signal(false); - readonly isHintExpertsModal = signal(false); - - protected readonly loadingExportProjects = signal(false); - protected readonly loadingExportSubmittedProjects = signal(false); - protected readonly loadingExportRates = signal(false); - protected readonly loadingExportCalculations = signal(false); - - subscriptions$: Subscription[] = []; - - routerLink(linkId: number): string { - switch (this.listType) { - case "projects": - return `/office/projects/${linkId}`; - - case "members": - return `/office/profile/${linkId}`; - - default: - return ""; - } - } - - ngOnInit(): void { - this.route.data.subscribe(data => { - this.listType = data["listType"]; - }); - - const routeData$ = this.route.data.pipe(map(r => r["data"])).subscribe(data => { - this.listTotalCount = data.count; - this.list = data.results; - this.searchedList = data.results; - }); - - this.subscriptions$.push(routeData$); - - this.setupSearch(); - - if (this.listType === "projects") { - this.setupProfile(); - } - - this.setupFilters(); - } - - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvent$ = fromEvent(target, "scroll") - .pipe( - debounceTime(this.listType === "rating" ? 200 : 500), - switchMap(() => this.onScroll()), - catchError(err => { - console.error("Scroll error:", err); - return of({}); - }) - ) - .subscribe(noop); - - this.subscriptions$.push(scrollEvent$); - } else { - console.error(".office__body element not found"); - } - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - private setupSearch(): void { - const searchFormSearch$ = this.searchForm - .get("search") - ?.valueChanges.pipe(debounceTime(300)) - .subscribe(search => { - this.router - .navigate([], { - queryParams: { [this.searchParamName]: search || null }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("QueryParams changed from ProgramListComponent")); - }); - - searchFormSearch$ && this.subscriptions$.push(searchFormSearch$); - - const querySearch$ = this.route.queryParams.pipe(map(q => q["search"])).subscribe(search => { - const searchKeys = - this.listType === "projects" || this.listType === "rating" - ? ["name"] - : ["firstName", "lastName"]; - - const fuse = new Fuse(this.list, { - keys: searchKeys, - }); - - this.searchedList = search ? fuse.search(search).map(el => el.item) : this.list; - this.cdref.detectChanges(); - }); - - querySearch$ && this.subscriptions$.push(querySearch$); - } - - private setupProfile(): void { - const profile$ = this.authService.profile - .pipe( - switchMap(p => { - this.profile = p; - return this.subscriptionService.getSubscriptions(p.id).pipe( - map(resp => { - this.profileProjSubsIds = resp.results.map(sub => sub.id); - }) - ); - }) - ) - .subscribe(); - - profile$ && this.subscriptions$.push(profile$); - } - - private setupFilters(): void { - if (this.listType === "members") return; - - const filtersObservable$ = 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 = 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: [] }); - }) - ) - .subscribe(result => { - if (!result) return; - - this.list = result.results || []; - this.searchedList = result.results || []; - this.listTotalCount = result.count; - this.listPage = 0; - this.cdref.detectChanges(); - }); - - this.subscriptions$.push(filtersObservable$); - } - - // Универсальный метод скролла - private onScroll() { - if (this.listTotalCount && this.list.length >= this.listTotalCount) { - console.log("All items loaded"); - return of({}); - } - - const target = document.querySelector(".office__body"); - if (!target) { - console.log("Target not found"); - return of({}); - } - - let shouldFetch = false; - - if (this.listType === "rating") { - const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - shouldFetch = scrollBottom <= 200; - console.log("Rating scroll check:", { scrollBottom, shouldFetch }); - } else { - if (!this.listRoot) return of({}); - const diff = - target.scrollTop - - this.listRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - const threshold = this.listType === "projects" ? -200 : 0; - shouldFetch = diff > threshold; - console.log("Projects/Members scroll check:", { diff, threshold, shouldFetch }); - } - - if (shouldFetch) { - console.log("Fetching next page:", this.listPage + 1); - this.listPage++; - return this.onFetch(); - } - - return of({}); - } - - // Универсальный метод загрузки данных - // Универсальный метод загрузки данных - private onFetch() { - const programId = this.route.parent?.snapshot.params["programId"]; - const offset = this.listPage * this.itemsPerPage; - - console.log("onFetch called:", { - listType: this.listType, - programId, - offset, - itemsPerPage: this.itemsPerPage, - currentPage: this.listPage, - currentListLength: this.list.length, - }); - - // Получаем текущие 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, - }, - }); - - console.log("Request params:", { filters, extraParams, paramsKeys: params.keys() }); - - 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(({ count, results }) => { - console.log("Rating response:", { - count, - resultsLength: results.length, - currentListLength: this.list.length, - offset, - expectedNewLength: this.list.length + results.length, - }); - - this.listTotalCount = count; - - if (this.listPage === 0) { - this.list = results; - } else { - const newResults = results.filter( - newItem => !this.list.some(existingItem => existingItem.id === newItem.id) - ); - console.log("New unique items to add:", newResults.length); - this.list = [...this.list, ...newResults]; - } - - this.searchedList = this.list; - this.cdref.detectChanges(); - }), - catchError(err => { - console.error("Error fetching ratings:", err); - this.listPage--; - return of({ count: this.listTotalCount || 0, results: [] }); - }) - ); - } - - 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) => { - console.log("Projects response:", { - count: projects.count, - resultsLength: projects.results.length, - currentListLength: this.list.length, - offset, - }); - - this.listTotalCount = projects.count; - - if (this.listPage === 0) { - this.list = projects.results; - } else { - const newResults = projects.results.filter( - newItem => !this.list.some(existingItem => existingItem.id === newItem.id) - ); - console.log("New unique projects to add:", newResults.length); - this.list = [...this.list, ...newResults]; - } - - this.searchedList = this.list; - this.cdref.detectChanges(); - }), - catchError(err => { - console.error("Error fetching projects:", err); - this.listPage--; - return of({ count: this.listTotalCount || 0, results: [] }); - }) - ); - } - - case "members": { - return this.programService.getAllMembers(programId, offset, this.itemsPerPage).pipe( - tap((members: ApiPagination) => { - console.log("Members response:", { - count: members.count, - resultsLength: members.results.length, - currentListLength: this.list.length, - offset, - }); - - this.listTotalCount = members.count; - - if (this.listPage === 0) { - this.list = members.results; - } else { - const newResults = members.results.filter( - newItem => !this.list.some(existingItem => existingItem.id === newItem.id) - ); - console.log("New unique members to add:", newResults.length); - this.list = [...this.list, ...newResults]; - } - - this.searchedList = this.list; - this.cdref.detectChanges(); - }), - catchError(err => { - console.error("Error fetching members:", err); - this.listPage--; - return of({ count: this.listTotalCount || 0, results: [] }); - }) - ); - } - - 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 = {}; - - console.log("buildFilterQuery input:", q); - - 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 }; - } - - onFiltersLoaded(filters: PartnerProgramFields[]): void { - this.availableFilters = filters; - } - - downloadProjects(): void { - const programId = this.route.parent?.snapshot.params["programId"]; - this.loadingExportProjects.set(true); - - this.exportFileService.exportAllProjects(programId).subscribe({ - next: blob => { - saveFile(blob, "all", this.programDataService.getProgramName()); - this.loadingExportProjects.set(false); - }, - error: err => { - console.error(err); - this.loadingExportProjects.set(false); - }, - }); - } - - downloadSubmittedProjects(): void { - const programId = this.route.parent?.snapshot.params["programId"]; - this.loadingExportSubmittedProjects.set(true); - - this.exportFileService.exportSubmittedProjects(programId).subscribe({ - next: blob => { - saveFile(blob, "submitted", this.programDataService.getProgramName()); - this.loadingExportSubmittedProjects.set(false); - }, - error: () => { - this.loadingExportSubmittedProjects.set(false); - }, - }); - } - - downloadRates(): void { - const programId = this.route.parent?.snapshot.params["programId"]; - this.loadingExportRates.set(true); - - this.exportFileService.exportProgramRates(programId).subscribe({ - next: blob => { - saveFile(blob, "rates", this.programDataService.getProgramName()); - this.loadingExportRates.set(false); - }, - error: () => { - this.loadingExportRates.set(false); - }, - }); - } - - downloadCalculations(): void {} - - // Swipe логика для мобильных устройств - private swipeStartY = 0; - private swipeThreshold = 50; - private isSwiping = false; - - onSwipeStart(event: TouchEvent): void { - this.swipeStartY = event.touches[0].clientY; - this.isSwiping = true; - } - - onSwipeMove(event: TouchEvent): 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( - this.filterBody.nativeElement, - "transform", - `translateY(${progress * 100}px)` - ); - } - - onSwipeEnd(event: TouchEvent): void { - if (!this.isSwiping) return; - - const endY = event.changedTouches[0].clientY; - const deltaY = endY - this.swipeStartY; - - if (deltaY > this.swipeThreshold) { - this.closeFilter(); - } - - this.isSwiping = false; - - this.renderer.setStyle(this.filterBody.nativeElement, "transform", "translateY(0)"); - } - - closeFilter(): void { - this.isFilterOpen = false; - } - - /** - * Сброс всех активных фильтров - * Очищает все query параметры и возвращает к состоянию по умолчанию - */ - onClearFilters(): void { - this.searchForm.reset(); - - this.router - .navigate([], { - queryParams: { - search: undefined, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.log("Query change from ProjectsComponent")); - } - - openHintModal(event: Event): void { - event.preventDefault(); - this.isHintExpertsVisible.set(false); - this.isHintExpertsModal.set(true); - } - - private get itemsPerPage(): number { - return this.listType === "rating" - ? 10 - : this.listType === "projects" - ? this.perPage - : this.listTake; - } - - private get searchParamName(): string { - return this.listType === "rating" ? "name__contains" : "search"; - } -} diff --git a/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts b/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts deleted file mode 100644 index 4314a4d88..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** @format */ - -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"; - -/** - * Резолвер для предзагрузки участников программы - * - * Загружает первую страницу участников программы перед отображением - * компонента. Обеспечивает мгновенное отображение списка участников. - * - * Принимает: - * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами - * - * Использует: - * @param {ProgramService} programService - Инжектируемый сервис программ - * - * Логика: - * - Извлекает programId из родительского маршрута (route.parent.params) - * - Загружает первые 20 участников (skip: 0, take: 20) - * - * Возвращает: - * @returns {Observable>} Пагинированный список участников - * - * Данные включают: - * - Массив пользователей (results) - * - Общее количество участников (count) - * - Информацию о пагинации - * - * Каждый участник содержит: - * - Профильную информацию - * - Аватар и контактные данные - * - Роль в программе - * - * Используется в: - * Маршруте members для предзагрузки списка участников - */ -export const ProgramMembersResolver: ResolveFn> = ( - route: ActivatedRouteSnapshot -) => { - const programService = inject(ProgramService); - - return programService.getAllMembers(route.parent?.params["programId"], 0, 20); -}; diff --git a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html deleted file mode 100644 index c05895953..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html +++ /dev/null @@ -1,63 +0,0 @@ - -
-

фильтры

- cбросить -
- -@if (filters()?.length) { -
-
- @if (filters()?.length && filterForm.controls) { @for (field of filters(); track field.id) { @if - (filterForm.get(field.name)) { -
- @switch (field.fieldType) { @case ("checkbox") { - -
- - {{ field.label }} -
- } @case ("radio") { - -
- - Нет - - - - Да - -
- } @case ("select") { - -
- -
- } } -
- } } } @if (listType === 'rating') { - - } -
-
-} diff --git a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.ts b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.ts deleted file mode 100644 index beafb5ac6..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, OnInit, Output, signal } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { debounceTime, distinctUntilChanged, filter, map, Subscription } from "rxjs"; -import { SwitchComponent } from "@ui/components/switch/switch.component"; -import { CheckboxComponent, SelectComponent } from "@ui/components"; -import { ProgramService } from "@office/program/services/program.service"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { ToSelectOptionsPipe } from "projects/core/src/lib/pipes/options-transform.pipe"; -import { CommonModule } from "@angular/common"; -import { - FormBuilder, - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from "@angular/forms"; - -/** - * Компонент фильтрации проектов - * - * Функциональность: - * - Предоставляет интерфейс для фильтрации списка проектов - * - Управляет фильтрами по различным критериям: - * - Этап проекта (идея, разработка, тестирование и т.д.) - * - Отрасль/направление проекта - * - Количество участников в команде - * - Наличие открытых вакансий - * - Принадлежность к программе МосПолитех - * - Тип проекта (оценен экспертами или нет) - * - * Принимает: - * - Query параметры из URL для восстановления состояния фильтров - * - Данные об отраслях и этапах проектов из сервисов - * - * Возвращает: - * - Обновляет query параметры URL при изменении фильтров - * - Эмитит события для закрытия панели фильтров - * - * Особенности: - * - Синхронизирует состояние фильтров с URL - * - Поддерживает сброс всех фильтров - * - Адаптивный интерфейс для мобильных устройств - */ -@Component({ - selector: "app-projects-filter", - templateUrl: "./projects-filter.component.html", - styleUrl: "./projects-filter.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - CheckboxComponent, - SwitchComponent, - SelectComponent, - ToSelectOptionsPipe, - ], -}) -export class ProjectsFilterComponent implements OnInit { - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly fb: FormBuilder, - private readonly programService: ProgramService - ) { - this.filterForm = this.fb.group({}); - } - - @Input() listType?: "projects" | "members" | "rating"; - @Output() clear = new EventEmitter(); - @Output() filtersLoaded = new EventEmitter(); - - // Константы для фильтрации по типу проекта - private programId = 0; - - ngOnInit(): void { - this.programId = this.route.parent?.snapshot.params["programId"]; - - if (this.listType === "projects" || this.listType === "rating") { - this.programService.getProgramFilters(this.programId).subscribe({ - next: filter => { - this.filters.set(filter); - this.initializeFilterForm(); - this.restoreFiltersFromUrl(); - this.subscribeToFormChanges(); - this.filtersLoaded.emit(filter); - }, - error(err) { - console.log(err); - }, - }); - } - } - - ngOnDestroy(): void { - this.queries$?.unsubscribe(); - } - - // Инициализация формы для фильтра - filterForm: FormGroup; - - // Подписки для управления жизненным циклом - queries$?: Subscription; - - // Массив фильтров по дополнительным полям привязанным к конкретной программе - filters = signal(null); - - /** - * Переключение значения для checkbox и radio полей - * @param fieldType - тип поля - * @param fieldName - имя поля - */ - toggleAdditionalFormValues( - fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", - fieldName: string - ): void { - if (fieldType === "checkbox" || fieldType === "radio") { - const control = this.filterForm.get(fieldName); - if (control) { - control.setValue(!control.value); - } - } - } - - // Методы фильтрации - setValue(event: Event): void { - event.stopPropagation(); - this.filterForm - .get("is_rated_by_expert") - ?.setValue(!this.filterForm.get("is_rated_by_expert")?.value); - } - - /** - * Сброс всех активных фильтров - * Очищает все query параметры и возвращает к состоянию по умолчанию - */ - clearFilters(): void { - this.filterForm.reset(); - - this.router - .navigate([], { - queryParams: { - is_rated_by_expert: undefined, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.log("Query change from ProjectsComponent")); - - this.clear.emit(); - } - - private initializeFilterForm(): void { - const formControls: { [key: string]: FormControl } = {}; - - this.filters()?.forEach(field => { - const validators = field.isRequired ? [Validators.required] : []; - const initialValue = - field.fieldType === "checkbox" || field.fieldType === "radio" ? false : ""; - formControls[field.name] = new FormControl(initialValue, validators); - }); - - if (this.listType === "rating") { - const isRatedByExpert = - this.route.snapshot.queryParams["is_rated_by_expert"] === "true" - ? true - : this.route.snapshot.queryParams["is_rated_by_expert"] === "false" - ? false - : null; - - formControls["is_rated_by_expert"] = new FormControl(isRatedByExpert); - } - - this.filterForm = this.fb.group(formControls); - } - - private restoreFiltersFromUrl(): void { - this.queries$ = this.route.queryParams.subscribe(queries => { - Object.keys(queries).forEach(key => { - const control = this.filterForm.get(key); - if (control && queries[key] !== undefined) { - const field = this.filters()?.find(f => f.name === key); - if (field && (field.fieldType === "checkbox" || field.fieldType === "radio")) { - control.setValue(queries[key] === "true", { emitEvent: false }); - } else { - control.setValue(queries[key], { emitEvent: false }); - } - } - }); - }); - } - - private subscribeToFormChanges(): void { - this.filterForm.valueChanges - .pipe(debounceTime(300), distinctUntilChanged()) - .subscribe(formValue => { - this.updateQueryParams(formValue); - }); - } - - private updateQueryParams(formValue: any): void { - const currentParams = { ...this.route.snapshot.queryParams }; - - Object.keys(formValue).forEach(fieldName => { - const value = formValue[fieldName]; - - const field = this.filters()?.find(f => f.name === fieldName); - if (this.shouldAddToQueryParams(value, field?.fieldType)) { - currentParams[fieldName] = value; - } else { - delete currentParams[fieldName]; - } - }); - - this.router - .navigate([], { - queryParams: currentParams, - relativeTo: this.route, - }) - .then(() => { - console.log("Query params updated:", currentParams); - }); - } - - private shouldAddToQueryParams( - value: any, - fieldType?: "text" | "textarea" | "checkbox" | "select" | "radio" | "file" - ): boolean { - if (fieldType === "checkbox" || fieldType === "radio") { - return value === true; - } - - if (fieldType === "select" || fieldType === "text" || fieldType === "textarea") { - return value !== null && value !== undefined && value !== ""; - } - - return !!value; - } -} diff --git a/projects/social_platform/src/app/office/program/detail/list/projects.resolver.ts b/projects/social_platform/src/app/office/program/detail/list/projects.resolver.ts deleted file mode 100644 index ec081377d..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/projects.resolver.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** @format */ - -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 { HttpParams } from "@angular/common/http"; -import { catchError, EMPTY } from "rxjs"; - -/** - * Резолвер для предзагрузки проектов программы - * - * Загружает первую страницу проектов программы перед отображением компонента. - * Это обеспечивает мгновенное отображение данных без состояния загрузки. - * - * Принимает: - * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами - * - * Использует: - * @param {ProgramService} programService - Инжектируемый сервис программ - * - * Логика: - * - Извлекает programId из родительского маршрута - * - Загружает первые 21 проект программы (offset: 0, limit: 21) - * - * Возвращает: - * @returns {Observable>} Поток с пагинированными проектами - * - * Используется в: - * Конфигурации маршрута projects для предзагрузки данных - */ -export const ProgramProjectsResolver: ResolveFn> = ( - route: ActivatedRouteSnapshot -) => { - const programService = inject(ProgramService); - const programId = route.parent?.params["programId"]; - const router = inject(Router); - - return programService - .getAllProjects( - programId, - new HttpParams({ - fromObject: { offset: 0, limit: 21 }, - }) - ) - .pipe( - catchError(error => { - if (error.status === 403) { - router.navigate([], { - queryParams: { access: "accessDenied" }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - - return EMPTY; - }) - ); -}; diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.html b/projects/social_platform/src/app/office/program/detail/main/main.component.html deleted file mode 100644 index 84e3dfa63..000000000 --- a/projects/social_platform/src/app/office/program/detail/main/main.component.html +++ /dev/null @@ -1,209 +0,0 @@ - - -@if (program) { -
-
- - @if (!program.isUserMember && !program.isUserManager) { -
- -
- } @else { -
-
- -
- -
-
-
-

о программе

- -
- @if (program.description) { -
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "cкрыть" : "подробнее" }} -
- } -
- } -
-
- @if (program.isUserManager) { - - } @for (n of news(); track n.id) { - - } -
-
- -
- - - -
-
- } -
-
- - -
-
- -

- вы не являетесь экспертом или организатором программы! -

-
- - @if (showProgramModalErrorMessage()) { -

- {{ showProgramModalErrorMessage() }} -

- } - - хорошо -
-
- - -
-
-

ошибка привязки проекта к программе!

-
- -

- {{ (errorAssignProjectToProgramModalMessage()?.non_field_errors)![0] }} -

- - понятно -
-
- - -
-
-

поздравляем с регистрацией на программу! 🎉

-
- -
-

- Это закрытая группа программы – доступ к ней есть только у зарегистрированных участников -

- -

Здесь вы найдете:

- -
    -
  • - самые актуальные и важные файлы программы (например, Положение) -
  • -
  • контакты организаторов для связи
  • -
  • новости программы
  • -
- -

- Важно: именно через закрытую группу и кнопку «подать проект» вы отправляете результаты - работы своей команды, когда будете готовы -

- -

- Будьте внимательны: по истечению дедлайна определенного организаторами, кнопка становится - некликабельной 👻 -

-
- - спасибо, понятно -
-
-} diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.ts b/projects/social_platform/src/app/office/program/detail/main/main.component.ts deleted file mode 100644 index b7f301fc7..000000000 --- a/projects/social_platform/src/app/office/program/detail/main/main.component.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** @format */ -import { - ChangeDetectorRef, - Component, - ElementRef, - OnDestroy, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { ProgramService } from "@office/program/services/program.service"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { - concatMap, - fromEvent, - map, - noop, - Observable, - of, - Subscription, - 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 { TagComponent } from "@ui/components/tag/tag.component"; -import { ProjectService } from "@office/services/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 { AvatarComponent } from "@uilib"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { NewsCardComponent } from "@office/features/news-card/news-card.component"; -import { AuthService } from "@auth/services"; - -@Component({ - selector: "app-main", - templateUrl: "./main.component.html", - styleUrl: "./main.component.scss", - standalone: true, - imports: [ - IconComponent, - ButtonComponent, - ProgramNewsCardComponent, - UserLinksPipe, - AsyncPipe, - ParseBreaksPipe, - ParseLinksPipe, - ModalComponent, - MatProgressBarModule, - SoonCardComponent, - NewsFormComponent, - ModalComponent, - MatProgressBarModule, - TruncatePipe, - RouterModule, - ], -}) -export class ProgramDetailMainComponent implements OnInit, OnDestroy { - constructor( - private readonly programNewsService: ProgramNewsService, - private readonly projectAdditionalService: ProjectAdditionalService, - private readonly authService: AuthService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly cdRef: ChangeDetectorRef, - private readonly loadingService: LoadingService - ) {} - - get isAssignProjectToProgramError() { - return this.projectAdditionalService.getIsAssignProjectToProgramError()(); - } - - get errorAssignProjectToProgramModalMessage() { - return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); - } - - news = signal([]); - totalNewsCount = signal(0); - fetchLimit = signal(10); - fetchPage = signal(0); - - // Сигналы для работы с модальными окнами с текстом - showProgramModal = signal(false); - showProgramModalErrorMessage = signal(null); - - registeredProgramModal = signal(false); - - programId?: number; - profileId = signal(undefined); - - subscriptions$ = signal([]); - - ngOnInit(): void { - const programIdSubscription$ = this.route.params - .pipe( - map(params => params["programId"]), - tap(programId => { - this.programId = programId; - this.fetchNews(0, this.fetchLimit()); - }) - ) - .subscribe(); - - const routeModalSub$ = this.route.queryParams.subscribe(param => { - if (param["access"] === "accessDenied") { - this.loadingService.hide(); - - this.showProgramModal.set(true); - this.showProgramModalErrorMessage.set("У вас не доступа к этой вкладке!"); - - this.router.navigate([], { - relativeTo: this.route, - queryParams: { access: null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - }); - - const profileIdSub$ = this.authService.profile.subscribe({ - next: profile => { - this.profileId.set(profile.id); - }, - }); - - this.subscriptions$().push(profileIdSub$); - - const program$ = this.route.data - .pipe( - map(r => r["data"]), - tap(program => { - this.program = program; - this.registerDateExpired = Date.now() > Date.parse(program.datetimeRegistrationEnds); - if (program.isUserMember) { - const seen = this.hasSeenRegisteredProgramModal(program.id); - if (!seen) { - this.registeredProgramModal.set(true); - this.markSeenRegisteredProgramModal(program.id); - } - } - }), - concatMap(program => { - if (program.isUserMember) { - return this.fetchNews(0, this.fetchLimit()); - } else { - return of({} as ApiPagination); - } - }) - ) - .subscribe({ - next: news => { - if (news.results?.length) { - this.news.set(news.results); - this.totalNewsCount.set(news.count); - - setTimeout(() => { - this.setupNewsObserver(); - }, 100); - } - - this.loadingService.hide(); - }, - error: () => { - this.loadingService.hide(); - - this.showProgramModal.set(true); - this.showProgramModalErrorMessage.set("Произошла ошибка при загрузке программы"); - }, - }); - - setTimeout(() => { - this.checkDescriptionExpandable(); - this.cdRef.detectChanges(); - }, 100); - - this.loadEvent = fromEvent(window, "load"); - - this.subscriptions$().push(program$); - this.subscriptions$().push(programIdSubscription$); - this.subscriptions$().push(routeModalSub$); - } - - ngAfterViewInit() { - setTimeout(() => { - this.checkDescriptionExpandable(); - this.cdRef.detectChanges(); - }, 150); - - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(2000) - ) - .subscribe(); - this.subscriptions$().push(scrollEvents$); - } - } - - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - onScroll() { - if (this.news().length < this.totalNewsCount()) { - return this.fetchNews(this.fetchPage() * this.fetchLimit(), this.fetchLimit()).pipe( - tap(({ results }) => { - this.news.update(news => [...news, ...results]); - if (results.length < this.fetchLimit()) { - // console.log('No more to fetch') - } else { - this.fetchPage.update(p => p + 1); - } - - setTimeout(() => { - this.setupNewsObserver(); - }, 100); - }) - ); - } - - const target = document.querySelector(".office__body"); - if (!target) return of({}); - const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - if (scrollBottom > 0) return of({}); - this.fetchPage.update(p => p + 1); - return this.fetchNews(this.fetchPage() * this.fetchLimit(), this.fetchLimit()); - } - - fetchNews(offset: number, limit: number) { - const programId = this.route.snapshot.params["programId"]; - return this.programNewsService.fetchNews(limit, offset, programId).pipe( - tap(({ count, results }) => { - this.totalNewsCount.set(count); - this.news.update(news => [...news, ...results]); - }) - ); - } - - @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; - @ViewChild(ProgramNewsCardComponent) ProgramNewsCardComponent?: ProgramNewsCardComponent; - @ViewChild("descEl") descEl?: ElementRef; - - 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).subscribe(noop); - } - - onAddNews(news: { text: string; files: string[] }): void { - this.programNewsService - .addNews(this.route.snapshot.params["programId"], news) - .subscribe(newsRes => { - this.newsFormComponent?.onResetForm(); - this.news.update(news => [newsRes, ...news]); - }); - } - - 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).subscribe({ - next: () => { - const index = this.news().findIndex(news => news.id === newsId); - this.news().splice(index, 1); - }, - }); - } - - 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) - .subscribe(() => { - item.likesCount = item.isUserLiked ? item.likesCount - 1 : item.likesCount + 1; - item.isUserLiked = !item.isUserLiked; - }); - } - - onEdit(news: FeedNews, newsId: number) { - this.programNewsService - .editNews(this.route.snapshot.params["programId"], newsId, news) - .subscribe({ - next: (resNews: any) => { - const newsIdx = this.news().findIndex(n => n.id === resNews.id); - this.news()[newsIdx] = resNews; - this.ProgramNewsCardComponent?.onCloseEditMode(); - }, - }); - } - - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - closeModal(): void { - this.showProgramModal.set(false); - this.loadingService.hide(); - } - - clearAssignProjectToProgramError(): void { - this.projectAdditionalService.clearAssignProjectToProgramError(); - } - - private loadEvent?: Observable; - - private checkDescriptionExpandable(): void { - const descElement = this.descEl?.nativeElement; - - if (!descElement || !this.program?.description) { - this.descriptionExpandable = false; - return; - } - - this.descriptionExpandable = descElement.scrollHeight > descElement.clientHeight; - } - - private getRegisteredProgramSeenKey(programId: number): string { - return `program_${this.profileId()}_modal_seen_${programId}`; - } - - private hasSeenRegisteredProgramModal(programId: number): boolean { - try { - return !!localStorage.getItem(this.getRegisteredProgramSeenKey(programId)); - } catch (e) { - return false; - } - } - - private markSeenRegisteredProgramModal(programId: number): void { - try { - localStorage.setItem(this.getRegisteredProgramSeenKey(programId), "1"); - } catch (e) {} - } - - private setupNewsObserver(): void { - 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(element => { - observer.observe(element); - }); - } - - program?: Program; - registerDateExpired!: boolean; - descriptionExpandable!: boolean; - readFullDescription = false; -} diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.ts b/projects/social_platform/src/app/office/program/detail/register/register.component.ts deleted file mode 100644 index cbf3bd75e..000000000 --- a/projects/social_platform/src/app/office/program/detail/register/register.component.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** @format */ - -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"; - -/** - * Компонент регистрации в программе - * - * Предоставляет форму для регистрации пользователя в программе. - * Динамически генерирует поля формы на основе схемы данных программы. - * - * Принимает: - * @param {Router} router - Для навигации после успешной регистрации - * @param {ActivatedRoute} route - Для получения данных из резолвера - * @param {FormBuilder} fb - Для создания реактивных форм - * @param {ValidationService} validationService - Для валидации форм - * @param {ProgramService} programService - Для отправки данных регистрации - * - * Данные из резолвера: - * @property {ProgramDataSchema} schema - Схема дополнительных полей программы - * - * Форма: - * @property {FormGroup} registerForm - Динамически генерируемая форма регистрации - * - * Жизненный цикл: - * - OnInit: Получает схему из резолвера и создает форму с валидаторами - * - OnDestroy: Отписывается от всех подписок - * - * Методы: - * @method onSubmit() - Обработчик отправки формы - * - Валидирует форму - * - Отправляет данные через ProgramService - * - Перенаправляет на страницу программы при успехе - * - * Возвращает: - * HTML шаблон с динамической формой регистрации - */ -@Component({ - selector: "app-register", - templateUrl: "./register.component.html", - styleUrl: "./register.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - InputComponent, - ButtonComponent, - KeyValuePipe, - ControlErrorPipe, - BarComponent, - ], -}) -export class ProgramRegisterComponent implements OnInit, OnDestroy { - constructor( - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly programService: ProgramService - ) {} - - ngOnInit(): void { - const route$ = this.route.data.pipe(map(r => r["data"])).subscribe(schema => { - this.schema = schema; - - const group: Record = {}; - for (const cKey in schema) { - group[cKey] = ["", [Validators.required]]; - } - - this.registerForm = this.fb.group(group); - }); - this.subscriptions$.push(route$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - subscriptions$: Subscription[] = []; - - registerForm?: FormGroup; - - schema?: ProgramDataSchema; - - onSubmit(): void { - if (this.registerForm && !this.validationService.getFormValidation(this.registerForm)) { - return; - } - - this.programService - .register(this.route.snapshot.params["programId"], this.registerForm?.value) - .subscribe(() => { - this.router - .navigateByUrl(`/office/program/${this.route.snapshot.params["programId"]}`) - .then(() => console.debug("Route changed from ProgramRegisterComponent")); - }); - } -} 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/main/main.component.html b/projects/social_platform/src/app/office/program/main/main.component.html deleted file mode 100644 index dec79837a..000000000 --- a/projects/social_platform/src/app/office/program/main/main.component.html +++ /dev/null @@ -1,29 +0,0 @@ - - -
- @if (programs) { -
- @for (p of searchedPrograms; track p.id) { - - - - } -
- } - -
-

фильтры

- - - - -
-
diff --git a/projects/social_platform/src/app/office/program/main/main.component.ts b/projects/social_platform/src/app/office/program/main/main.component.ts deleted file mode 100644 index 7d14e73d3..000000000 --- a/projects/social_platform/src/app/office/program/main/main.component.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; -import { ActivatedRoute, Params, Router, RouterLink } from "@angular/router"; -import { - combineLatest, - concatMap, - distinctUntilChanged, - map, - of, - Subscription, - switchMap, -} from "rxjs"; -import { Program } from "@office/program/models/program.model"; -import { NavService } from "@office/services/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 { HttpParams } from "@angular/common/http"; -import { ProgramService } from "../services/program.service"; - -/** - * Главный компонент списка программ - * - * Отображает список всех доступных программ с функциональностью поиска. - * Поддерживает фильтрацию программ по названию в реальном времени. - * - * Принимает: - * @param {ActivatedRoute} route - Для получения данных из резолвера и query параметров - * @param {NavService} navService - Для установки заголовка навигации - * - * Данные: - * @property {Program[]} programs - Полный массив программ - * @property {Program[]} searchedPrograms - Отфильтрованный массив программ - * @property {number} programCount - Общее количество программ - * - * Поиск: - * - Использует библиотеку Fuse.js для нечеткого поиска - * - Поиск происходит по полю "name" программы - * - Реагирует на изменения query параметра "search" - * - Обновляет searchedPrograms при изменении поискового запроса - * - * Жизненный цикл: - * - OnInit: - * - Устанавливает заголовок "Программы" - * - Подписывается на изменения query параметров для поиска - * - Загружает данные из резолвера - * - OnDestroy: Отписывается от всех подписок - * - * Подписки: - * @property {Subscription[]} subscriptions$ - Массив подписок для очистки - * - * Возвращает: - * HTML шаблон со списком карточек программ и результатами поиска - */ -@Component({ - selector: "app-main", - templateUrl: "./main.component.html", - styleUrl: "./main.component.scss", - standalone: true, - imports: [ - RouterLink, - ProgramCardComponent, - CheckboxComponent, - SelectComponent, - ClickOutsideModule, - ], -}) -export class ProgramMainComponent implements OnInit, OnDestroy { - private readonly navService = inject(NavService); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly programService = inject(ProgramService); - private readonly cdref = inject(ChangeDetectorRef); - - programCount = 0; - - programs: Program[] = []; - searchedPrograms: Program[] = []; - subscriptions$: Subscription[] = []; - isPparticipating = signal(false); - - readonly programOptionsFilter = generateOptionsList(4, "strings", [ - "все", - "актуальные", - "архив", - "учавстсвовал", - ]); - - ngOnInit(): void { - this.navService.setNavTitle("Программы"); - - const combined$ = 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 }))); - }) - ) - .subscribe(({ response, search }) => { - this.programCount = response.count; - this.programs = response.results ?? []; - - if (search) { - const fuse = new Fuse(this.programs, { - keys: ["name"], - threshold: 0.3, - }); - this.searchedPrograms = fuse.search(search).map(el => el.item); - } else { - this.searchedPrograms = this.programs; - } - - this.cdref.detectChanges(); - }); - - this.subscriptions$.push(combined$); - } - - private buildFilterQuery(q: Params): Record { - const reqQuery: Record = {}; - - if (q["participating"]) { - reqQuery["participating"] = q["participating"]; - } - - return reqQuery; - } - - /** - * Переключает состояние чекбокса "участвую" - */ - onTogglePparticipating(): void { - const newValue = !this.isPparticipating(); - this.isPparticipating.set(newValue); - - this.router.navigate([], { - queryParams: { - participating: newValue ? "true" : null, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/program/main/main.resolver.ts b/projects/social_platform/src/app/office/program/main/main.resolver.ts deleted file mode 100644 index bf91ddbe9..000000000 --- a/projects/social_platform/src/app/office/program/main/main.resolver.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** @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"; - -/** - * Резолвер для предзагрузки списка программ - * - * Загружает первую страницу программ перед отображением главного - * компонента списка программ. Обеспечивает мгновенное отображение - * данных без состояния загрузки. - * - * Использует: - * @param {ProgramService} programService - Инжектируемый сервис программ - * - * Логика: - * - Загружает первые 20 программ (skip: 0, take: 20) - * - Не требует параметров маршрута - * - * Возвращает: - * @returns {Observable>} Пагинированный список программ - * - * Данные включают: - * - Массив программ (results) - * - Общее количество программ (count) - * - Информацию о пагинации - * - * Каждая программа содержит: - * - Основную информацию (название, описание, даты) - * - Изображения и медиа - * - Статистику просмотров и лайков - * - Информацию о участии пользователя - * - * Используется в: - * Главном маршруте списка программ (path: "all") - */ -export const ProgramMainResolver: ResolveFn> = () => { - const programService = inject(ProgramService); - - return programService.getAll(0, 20); -}; diff --git a/projects/social_platform/src/app/office/program/program.component.ts b/projects/social_platform/src/app/office/program/program.component.ts deleted file mode 100644 index 6c8b51465..000000000 --- a/projects/social_platform/src/app/office/program/program.component.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { NavService } from "@services/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"; - -/** - * Основной компонент модуля "Программы" - * - * Функциональность: - * - Отображает заголовок навигации "Программы" - * - Предоставляет форму поиска программ - * - Управляет состоянием активных вкладок (My/All) - * - Обрабатывает изменения поисковых параметров в URL - * - Содержит router-outlet для дочерних компонентов - * - * Принимает: - * - NavService - для установки заголовка навигации - * - ActivatedRoute - для работы с параметрами маршрута - * - ProgramService - сервис для работы с программами - * - Router - для навигации и изменения URL параметров - * - FormBuilder - для создания реактивных форм - * - * Возвращает: - * - HTML шаблон с формой поиска и router-outlet - * - Управляет состоянием флагов isMy и isAll - */ -@Component({ - selector: "app-program", - templateUrl: "./program.component.html", - styleUrl: "./program.component.scss", - standalone: true, - imports: [ReactiveFormsModule, SearchComponent, RouterOutlet, BarComponent, BackComponent], -}) -export class ProgramComponent implements OnInit, OnDestroy { - constructor( - private readonly navService: NavService, - private readonly route: ActivatedRoute, - public readonly programService: ProgramService, - private readonly router: Router, - private readonly fb: FormBuilder - ) { - this.searchForm = this.fb.group({ - search: [""], - }); - } - - ngOnInit(): void { - this.navService.setNavTitle("Программы"); - - const searchFormSearch$ = this.searchForm.get("search")?.valueChanges.subscribe(search => { - this.router - .navigate([], { - queryParams: { search }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("QueryParams changed from ProjectsComponent")); - }); - - searchFormSearch$ && this.subscriptions$.push(searchFormSearch$); - - const routeUrl$ = this.router.events.subscribe(event => { - if (event instanceof NavigationEnd) { - this.isAll = location.href.includes("/all"); - } - }); - routeUrl$ && this.subscriptions$.push(routeUrl$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - searchForm: FormGroup; - subscriptions$: Subscription[] = []; - - isAll = location.href.includes("/all"); -} diff --git a/projects/social_platform/src/app/office/program/program.routes.ts b/projects/social_platform/src/app/office/program/program.routes.ts deleted file mode 100644 index e1a17341e..000000000 --- a/projects/social_platform/src/app/office/program/program.routes.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProgramComponent } from "./program.component"; -import { ProgramMainComponent } from "./main/main.component"; -import { ProgramMainResolver } from "./main/main.resolver"; - -/** - * Конфигурация маршрутов для модуля "Программы" - * - * Описание маршрутов: - * - "" - корневой маршрут программ с дочерними маршрутами - * - "" - редирект на "/all" - * - "all" - список всех программ с резолвером данных - * - ":programId" - детальная страница программы (ленивая загрузка) - * - ":programId/projects-rating" - страница оценки проектов программы (ленивая загрузка) - * - * @returns {Routes} Массив конфигураций маршрутов для Angular Router - */ -export const PROGRAM_ROUTES: Routes = [ - { - path: "", - component: ProgramComponent, - children: [ - { - path: "", - pathMatch: "full", - redirectTo: "all", - }, - { - path: "all", - component: ProgramMainComponent, - resolve: { - data: ProgramMainResolver, - }, - }, - ], - }, - { - path: ":programId", - loadChildren: () => import("./detail/detail.routes").then(c => c.PROGRAM_DETAIL_ROUTES), - }, -]; 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 deleted file mode 100644 index c0c1ef698..000000000 --- a/projects/social_platform/src/app/office/program/services/program-data.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { BehaviorSubject } from "rxjs"; -import { Program } from "../models/program.model"; - -@Injectable({ - providedIn: "root", -}) -export class ProgramDataService { - private programSubject$ = new BehaviorSubject(undefined); - program$ = this.programSubject$.asObservable(); - - setProgram(program: Program): void { - return this.programSubject$.next(program); - } - - getProgramName(): string { - const program = this.programSubject$.value; - if (!program?.name) return ""; - return program.name; - } -} diff --git a/projects/social_platform/src/app/office/program/services/program-news.service.spec.ts b/projects/social_platform/src/app/office/program/services/program-news.service.spec.ts deleted file mode 100644 index b1b76beb9..000000000 --- a/projects/social_platform/src/app/office/program/services/program-news.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProgramNewsService } from "./program-news.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProgramNewsService", () => { - let service: ProgramNewsService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(ProgramNewsService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/services/program-news.service.ts b/projects/social_platform/src/app/office/program/services/program-news.service.ts deleted file mode 100644 index 9f7c51ec2..000000000 --- a/projects/social_platform/src/app/office/program/services/program-news.service.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** @format */ - -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 { HttpParams } from "@angular/common/http"; -import { plainToInstance } from "class-transformer"; - -/** - * Сервис для работы с новостями программ - * - * Обеспечивает функциональность новостной ленты программы: - * - Загрузка новостей с пагинацией - * - Отметка новостей как прочитанных - * - Лайки/дизлайки новостей - * - Добавление новых новостей - * - * Принимает: - * @param {ApiService} apiService - Сервис для HTTP запросов - * - * Методы: - * @method fetchNews(limit: number, offset: number, programId: number) - Загружает новости программы - * @method readNews(projectId: string, newsIds: number[]) - Отмечает новости как прочитанные - * @method toggleLike(projectId: string, newsId: number, state: boolean) - Переключает лайк новости - * @method addNews(programId: number, obj: {text: string; files: string[]}) - Добавляет новую новость - * @method deleteNews(programId: number, newsId: number) - Удаляет новость - * - * @returns Соответствующие Observable для каждого метода - */ -@Injectable({ - providedIn: "root", -}) -export class ProgramNewsService { - private readonly PROGRAMS_URL = "/programs"; - - constructor(private readonly apiService: ApiService) {} - - fetchNews(limit: number, offset: number, programId: number): Observable> { - return this.apiService.get( - `${this.PROGRAMS_URL}/${programId}/news/`, - new HttpParams({ fromObject: { limit, offset } }) - ); - } - - readNews(projectId: string, newsIds: number[]): Observable { - return forkJoin( - newsIds.map(id => - this.apiService.post(`${this.PROGRAMS_URL}/${projectId}/news/${id}/set_viewed/`, {}) - ) - ); - } - - toggleLike(projectId: string, newsId: number, state: boolean): Observable { - return this.apiService.post(`${this.PROGRAMS_URL}/${projectId}/news/${newsId}/set_liked/`, { - is_liked: state, - }); - } - - addNews(programId: number, obj: { text: string; files: string[] }) { - return this.apiService - .post(`${this.PROGRAMS_URL}/${programId}/news/`, obj) - .pipe(map(r => plainToInstance(FeedNews, r))); - } - - editNews(programId: number, newsId: number, newsItem: Partial) { - return this.apiService.patch(`${this.PROGRAMS_URL}/${programId}/news/${newsId}`, newsItem); - } - - deleteNews(programId: number, newsId: number) { - return this.apiService.delete(`${this.PROGRAMS_URL}/${programId}/news/${newsId}`); - } -} diff --git a/projects/social_platform/src/app/office/program/services/program.service.spec.ts b/projects/social_platform/src/app/office/program/services/program.service.spec.ts deleted file mode 100644 index 922a404c7..000000000 --- a/projects/social_platform/src/app/office/program/services/program.service.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProgramService } from "./program.service"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProgramService", () => { - let service: ProgramService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule], - }); - service = TestBed.inject(ProgramService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/services/program.service.ts b/projects/social_platform/src/app/office/program/services/program.service.ts deleted file mode 100644 index f9e261853..000000000 --- a/projects/social_platform/src/app/office/program/services/program.service.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** @format */ - -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"; - -/** - * Сервис для работы с программами - * - * Предоставляет методы для взаимодействия с API программ: - * - Получение списка программ с пагинацией - * - Получение детальной информации о программе - * - Создание новой программы - * - Регистрация в программе - * - Получение проектов и участников программы - * - Работа с тегами программ - * - * Принимает: - * @param {ApiService} apiService - Сервис для HTTP запросов - * - * Методы: - * @method getAll(skip: number, take: number) - Получает список программ с пагинацией - * @method getOne(programId: number) - Получает детальную информацию о программе - * @method create(program: ProgramCreate) - Создает новую программу - * @method getDataSchema(programId: number) - Получает схему дополнительных полей программы - * @method register(programId: number, additionalData: Record) - Регистрирует пользователя в программе - * @method getAllProjects(programId: number, offset: number, limit: number) - Получает проекты программы - * @method getAllMembers(programId: number, skip: number, take: number) - Получает участников программы - * @method submitCompettetiveProject(prelationId: number) - Cохранить и "подать проект" на сдачу в программу конкурсную - * @method getProgramFilters(programId: number) - Получение данных для фильтра проектов-участников по доп полям - * @method programTags() - Получает и кеширует теги программ пользователя - * - * Свойства: - * @property {BehaviorSubject} programTags$ - Реактивный поток тегов программ - */ -@Injectable({ - providedIn: "root", -}) -export class ProgramService { - private readonly PROGRAMS_URL = "/programs"; - private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; - - constructor(private readonly apiService: ApiService) {} - - getAll(skip: number, take: number, params?: HttpParams): Observable> { - let httpParams = new HttpParams(); - - httpParams.set("limit", take); - httpParams.set("offset", skip); - - if (params) { - params.keys().forEach(key => { - const value = params.get(key); - if (value !== null) { - httpParams = httpParams.set(key, value); - } - }); - } - - return this.apiService.get(`${this.PROGRAMS_URL}/`, httpParams); - } - - getActualPrograms(): Observable> { - return this.apiService.get(`${this.PROGRAMS_URL}/`); - } - - getOne(programId: number): Observable { - return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/`); - } - - create(program: ProgramCreate): Observable { - return this.apiService.post(`${this.PROGRAMS_URL}/`, program); - } - - getDataSchema(programId: number): Observable { - return this.apiService - .get<{ dataSchema: ProgramDataSchema }>(`${this.PROGRAMS_URL}/${programId}/schema/`) - .pipe(map(r => r["dataSchema"])); - } - - register( - programId: number, - additionalData: Record - ): Observable { - return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/register/`, additionalData); - } - - getAllProjects(programId: number, params?: HttpParams): Observable> { - return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects`, params); - } - - getAllMembers(programId: number, skip: number, take: number): Observable> { - return this.apiService.get( - `${this.AUTH_PUBLIC_USERS_URL}/`, - new HttpParams({ fromObject: { partner_program: programId, limit: take, offset: skip } }) - ); - } - - getProgramFilters(programId: number): Observable { - return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/filters/`); - } - - getProgramProjectAdditionalFields(programId: number): Observable { - return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects/apply/`); - } - - // body - это форма проекта который подается + programFieldValues - applyProjectToProgram(programId: number, body: any): Observable { - return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/projects/apply/`, body); - } - - createProgramFilters( - programId: number, - filters: Record, - params?: HttpParams - ): Observable> { - let url = `${this.PROGRAMS_URL}/${programId}/projects/filter/`; - - if (params) { - url += `?${params.toString()}`; - } - - return this.apiService.post(url, { filters: filters }); - } - - submitCompettetiveProject(relationId: number): Observable { - return this.apiService.post( - `${this.PROGRAMS_URL}/partner-program-projects/${relationId}/submit/`, - {} - ); - } -} diff --git a/projects/social_platform/src/app/office/program/services/project-rating.service.spec.ts b/projects/social_platform/src/app/office/program/services/project-rating.service.spec.ts deleted file mode 100644 index 382569831..000000000 --- a/projects/social_platform/src/app/office/program/services/project-rating.service.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProjectRatingService } from "./project-rating.service"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProjectRatingService", () => { - let service: ProjectRatingService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule], - }); - service = TestBed.inject(ProjectRatingService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/services/project-rating.service.ts b/projects/social_platform/src/app/office/program/services/project-rating.service.ts deleted file mode 100644 index e30cb490e..000000000 --- a/projects/social_platform/src/app/office/program/services/project-rating.service.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** @format */ - -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"; - -/** - * Сервис для оценки проектов в рамках программы - * - * Предоставляет функциональность для экспертной оценки проектов: - * - Получение списка проектов для оценки с фильтрацией - * - Отправка оценок проектов - * - Преобразование данных форм в формат API - * - * Принимает: - * @param {ApiService} apiService - Сервис для HTTP запросов - * - * Методы: - * @method getAll(id, skip, take, isRatedByExpert?, nameContains?) - Получает проекты для оценки - * @param {number} id - ID программы - * @param {number} skip - Количество пропускаемых записей - * @param {number} take - Количество загружаемых записей - * @param {boolean} isRatedByExpert - Фильтр по статусу оценки экспертом - * @param {string} nameContains - Фильтр по названию проекта - * - * @method rate(projectId: number, scores: ProjectRatingCriterionOutput[]) - Отправляет оценку проекта - * - * @method formValuesToDTO(criteria, outputVals) - Преобразует данные формы в DTO - * Конвертирует объект вида {1: 'value', 2: '5', 3: true} в массив - * [{criterionId: 1, value: 'value'}, {criterionId: 2, value: 5}, ...] - * Обрабатывает типы данных согласно типу критерия (bool -> string, int -> number) - */ -@Injectable({ - providedIn: "root", -}) -export class ProjectRatingService { - private readonly RATE_PROJECT_URL = "/rate-project"; - - constructor(private readonly apiService: ApiService) {} - - getAll(programId: number, params?: HttpParams): Observable> { - return this.apiService.get(`${this.RATE_PROJECT_URL}/${programId}`, params); - } - - postFilters( - programId: number, - filters: Record, - params?: HttpParams - ): Observable> { - let url = `${this.RATE_PROJECT_URL}/${programId}`; - - if (params) { - url += `?${params.toString()}`; - } - - return this.apiService.post(url, { filters: filters }); - } - - rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable { - return this.apiService.post(`${this.RATE_PROJECT_URL}/rate/${projectId}`, scores); - } - - /* - функция преобразует данные из формы вида { 1: 'value', 2: '5', 3: true }, - где ключом (key) является id критерия оценки, а значение является непосредственно значением оценки, - к виду [{ criterionId: 1, value: 'value' }, { criterionId: 2, value: 5 }, { criterionId: 3, value: 'true' }], - */ - formValuesToDTO( - criteria: ProjectRatingCriterion[], - outputVals: Record - ): ProjectRatingCriterionOutput[] { - const output: ProjectRatingCriterionOutput[] = []; - - outputVals = Object.assign({}, outputVals); - - for (const key in outputVals) { - // оценки с boolean значением переводятся в "string-boolean" (true => "true") - if (typeof outputVals[key] === "boolean") { - const boolString = String(outputVals[key]); - outputVals[key] = boolString.charAt(0).toUpperCase() + boolString.slice(1); - } - // оценки с числовым значением поступают в виде string (из инпута), и их требуется привести к типу number - // поскольку типом string могут обладать не только оценки с числовым значением, но и "комментарий", - // нужно явно убедиться, что критерий именно числовой, для чего осуществляется поиск критерия по id - // в списке критериев оценки проекта и проверка его принадлежности типу "int" - if (criteria.find(c => c.id === Number(key))?.type === "int") { - outputVals[key] = Number(outputVals[key]); - } - - output.push({ criterionId: Number(key), value: outputVals[key] }); - } - - return output; - } -} diff --git a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.html b/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.html deleted file mode 100644 index c26f9e315..000000000 --- a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
- @for (dashboardItem of dashboardItems; track $index) { - - } -
diff --git a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts b/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts deleted file mode 100644 index 374208b6a..000000000 --- a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** @format */ - -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 { DashboardItem, dashboardItemBuilder } from "@utils/helpers/dashboardItemBuilder"; - -@Component({ - selector: "app-dashboard", - templateUrl: "./dashboard.component.html", - styleUrl: "./dashboard.component.scss", - imports: [CommonModule, DashboardItemComponent], - standalone: true, -}) -export class DashboardProjectsComponent implements OnInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - - dashboardItems: DashboardItem[] = []; - profileProjSubsIds?: number[]; - - subscriptions$: Subscription[] = []; - - ngOnInit(): void { - this.route.data.subscribe({ - next: ({ data: { all, my, subs } }) => { - const allProjects = all.results.slice(0, 4); - const myProjects = my.results.slice(0, 4); - const mySubs = subs.results.slice(0, 4); - this.profileProjSubsIds = subs.results.map((project: Project) => project.id); - - this.dashboardItems = dashboardItemBuilder( - 3, - ["my", "subscriptions", "all"], - ["мои проекты", "мои подписки", "витрина проектов"], - ["main", "favourities", "folders"], - [myProjects, mySubs, allProjects] - ); - }, - }); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.ts b/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.ts deleted file mode 100644 index 074d15676..000000000 --- a/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input, OnInit } from "@angular/core"; -import { Project } from "@office/models/project.model"; -import { IconComponent } from "@uilib"; -import { ProjectsService } from "@office/projects/services/projects.service"; -import { RouterLink } from "@angular/router"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; - -@Component({ - selector: "app-dashboard-item", - templateUrl: "./dashboardItem.component.html", - styleUrl: "./dashboardItem.component.scss", - standalone: true, - imports: [CommonModule, IconComponent, RouterLink, InfoCardComponent], -}) -export class DashboardItemComponent implements OnInit { - @Input() title!: string; - @Input() arrayItems!: Project[]; - @Input() iconName!: string; - @Input() sectionName!: string; - @Input() profileProjSubsIds?: number[]; - - appereance: "base" | "subs" | "my" = "base"; - - private readonly projectsService = inject(ProjectsService); - - ngOnInit(): void { - switch (this.iconName) { - case "favourities": - this.appereance = "subs"; - - break; - - case "main": - this.appereance = "my"; - break; - - default: - break; - } - } - - addProject(): void { - this.projectsService.addProject(); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.html b/projects/social_platform/src/app/office/projects/detail/chat/chat.component.html deleted file mode 100644 index c73d6fe13..000000000 --- a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.html +++ /dev/null @@ -1,69 +0,0 @@ - -@if (project) { -
-
- @if (!messages.length) { -
- -

- начните обсуждать ваш план по заработку первого миллиона -

-
- } - - -
- - -
-} diff --git a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.spec.ts b/projects/social_platform/src/app/office/projects/detail/chat/chat.component.spec.ts deleted file mode 100644 index 73fa9a611..000000000 --- a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectChatComponent } from "./chat.component"; -import { ReactiveFormsModule } from "@angular/forms"; -import { MessageInputComponent } from "@office/features/message-input/message-input.component"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { AuthService } from "@auth/services"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; - -describe("ChatComponent", () => { - let component: ProjectChatComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - providers: [{ provide: AuthService, useValue: authSpy }], - imports: [ - ReactiveFormsModule, - HttpClientTestingModule, - RouterTestingModule, - ProjectChatComponent, - MessageInputComponent, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProjectChatComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.ts b/projects/social_platform/src/app/office/projects/detail/chat/chat.component.ts deleted file mode 100644 index 5aa7616d5..000000000 --- a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** @format */ - -import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; -import { ChatFile, ChatMessage } from "@models/chat-message.model"; -import { filter, map, noop, Observable, Subscription, tap } from "rxjs"; -import { Project } from "@models/project.model"; -import { NavService } from "@services/nav.service"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { AuthService } from "@auth/services"; -import { ModalService } from "@ui/models/modal.service"; -import { ChatService } from "@services/chat.service"; -import { MessageInputComponent } from "@office/features/message-input/message-input.component"; -import { ChatWindowComponent } from "@office/features/chat-window/chat-window.component"; -import { PluralizePipe } from "projects/core"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { IconComponent } from "@ui/components"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { ApiPagination } from "@models/api-pagination.model"; -import { ProjectDataService } from "../services/project-data.service"; - -/** - * Компонент чата проекта - * - * Функциональность: - * - Отображение чата проекта с сообщениями участников - * - Отправка, редактирование и удаление сообщений - * - Показ индикатора набора текста - * - Загрузка файлов чата - * - Пагинация сообщений при прокрутке - * - Мобильная версия с переключением между чатом и боковой панелью - * - * Принимает: - * - Данные проекта через ActivatedRoute - * - WebSocket события через ChatService - * - Профиль пользователя через AuthService - * - * Предоставляет: - * - Список сообщений чата - * - Список участников проекта - * - Файлы, загруженные в чат - * - Интерфейс для отправки сообщений - */ -@Component({ - selector: "app-chat", - templateUrl: "./chat.component.html", - styleUrl: "./chat.component.scss", - standalone: true, - imports: [ - AvatarComponent, - IconComponent, - ChatWindowComponent, - RouterLink, - FileItemComponent, - PluralizePipe, - ], -}) -export class ProjectChatComponent implements OnInit, OnDestroy { - constructor( - private readonly navService: NavService, - private readonly route: ActivatedRoute, - private readonly authService: AuthService, - private readonly projectDataService: ProjectDataService, - private readonly chatService: ChatService - ) {} - - ngOnInit(): void { - this.navService.setNavTitle("Чат проекта"); - - // Получение ID текущего пользователя - const profile$ = this.authService.profile.subscribe({ - next: profile => { - this.currentUserId = profile.id; - }, - }); - - profile$ && this.subscriptions$.push(profile$); - - // Загрузка данных проекта - const projectSub$ = this.projectDataService.project$ - .pipe(filter(project => !!project)) - .subscribe({ - next: project => { - this.project = project; - }, - }); - projectSub$ && this.subscriptions$.push(projectSub$); - - console.debug("Chat websocket connected from ProjectChatComponent"); - - // Инициализация WebSocket событий - this.initTypingEvent(); // Показ индикатора набора текста - this.initMessageEvent(); // Получение новых сообщений - this.initEditEvent(); // Обновление отредактированных сообщений - this.initDeleteEvent(); // Удаление сообщений - - // Загрузка истории сообщений - this.fetchMessages().subscribe(noop); - - // Загрузка файлов чата - this.chatService - .loadProjectFiles(Number(this.route.parent?.snapshot.paramMap.get("projectId"))) - .subscribe(files => { - this.chatFiles = files; - }); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** - * Количество сообщений, загружаемых за один запрос - * @private - */ - private readonly messagesPerFetch = 20; - - /** - * Общее количество сообщений в чате - * Устанавливается при первой загрузке - * @private - */ - private messagesTotalCount = 0; - - /** Массив всех подписок компонента */ - subscriptions$: Subscription[] = []; - - /** Данные проекта */ - project?: Project; - - /** Все файлы, загруженные в чат */ - chatFiles?: ChatFile[]; - - /** ID текущего пользователя */ - currentUserId?: number; - - /** Ссылка на компонент ввода сообщений */ - @ViewChild(MessageInputComponent, { read: ElementRef }) messageInputComponent?: ElementRef; - - /** Все сообщения чата */ - messages: ChatMessage[] = []; - - /** Количество пользователей онлайн (устарело) */ - membersOnlineCount = 3; - - /** Список пользователей, которые сейчас печатают */ - typingPersons: ChatWindowComponent["typingPersons"] = []; - - /** Флаг отображения боковой панели на мобильных устройствах */ - isAsideMobileShown = false; - - /** Переключение боковой панели на мобильных устройствах */ - onToggleMobileAside(): void { - this.isAsideMobileShown = !this.isAsideMobileShown; - } - - /** - * Инициализация обработки события набора текста - * Показывает индикатор, когда другие участники печатают - * @private - */ - private initTypingEvent(): void { - const typingEvent$ = this.chatService - .onTyping() - .pipe( - map(typingEvent => - this.project?.collaborators.find( - collaborator => collaborator.userId === typingEvent.userId - ) - ), - filter(Boolean) - ) - .subscribe(person => { - if ( - !this.typingPersons.map(p => p.userId).includes(person.userId) && - person.userId !== this.currentUserId - ) - this.typingPersons.push({ - firstName: person.firstName, - lastName: person.lastName, - userId: person.userId, - }); - - // Автоматическое скрытие индикатора через 2 секунды - setTimeout(() => { - const personIdx = this.typingPersons.findIndex(p => p.userId === person.userId); - this.typingPersons.splice(personIdx, 1); - }, 2000); - }); - - typingEvent$ && this.subscriptions$.push(typingEvent$); - } - - /** - * Инициализация обработки новых сообщений - * Добавляет новые сообщения в конец списка - * @private - */ - private initMessageEvent(): void { - const messageEvent$ = this.chatService.onMessage().subscribe(result => { - this.messages = [...this.messages, result.message]; - }); - - messageEvent$ && this.subscriptions$.push(messageEvent$); - } - - /** - * Инициализация обработки редактирования сообщений - * Обновляет отредактированные сообщения в списке - * @private - */ - private initEditEvent(): void { - const editEvent$ = this.chatService.onEditMessage().subscribe(result => { - const messageIdx = this.messages.findIndex(msg => msg.id === result.message.id); - - const messages = JSON.parse(JSON.stringify(this.messages)); - messages.splice(messageIdx, 1, result.message); - - this.messages = messages; - }); - - editEvent$ && this.subscriptions$.push(editEvent$); - } - - /** - * Инициализация обработки удаления сообщений - * Удаляет сообщения из списка - * @private - */ - private initDeleteEvent(): void { - const deleteEvent$ = this.chatService.onDeleteMessage().subscribe(result => { - const messageIdx = this.messages.findIndex(msg => msg.id === result.messageId); - - const messages = JSON.parse(JSON.stringify(this.messages)); - messages.splice(messageIdx, 1); - - this.messages = messages; - }); - - deleteEvent$ && this.subscriptions$.push(deleteEvent$); - } - - /** - * Загрузка сообщений чата с сервера - * @private - * @returns Observable с пагинированными сообщениями - */ - private fetchMessages(): Observable> { - return this.chatService - .loadMessages( - Number(this.route.parent?.snapshot.paramMap.get("projectId")), - this.messages.length > 0 ? this.messages.length : 0, - this.messagesPerFetch - ) - .pipe( - tap(messages => { - this.messages = messages.results.reverse().concat(this.messages); - this.messagesTotalCount = messages.count; - }) - ); - } - - /** Флаг процесса загрузки сообщений */ - fetching = false; - - /** Загрузка дополнительных сообщений при прокрутке */ - onFetchMessages(): void { - if ( - (this.messages.length < this.messagesTotalCount || this.messagesTotalCount === 0) && - !this.fetching - ) { - this.fetching = true; - this.fetchMessages().subscribe(() => { - this.fetching = false; - }); - } - } - - /** Отправка нового сообщения */ - onSubmitMessage(message: any): void { - this.chatService.sendMessage({ - replyTo: message.replyTo, - text: message.text, - fileUrls: message.fileUrls, - chatType: "project", - chatId: this.route.parent?.snapshot.paramMap.get("projectId") ?? "", - }); - } - - /** Редактирование существующего сообщения */ - onEditMessage(message: any): void { - this.chatService.editMessage({ - text: message.text, - messageId: message.id, - chatType: "project", - chatId: this.route.parent?.snapshot.paramMap.get("projectId") ?? "", - }); - } - - /** Удаление сообщения */ - onDeleteMessage(messageId: number): void { - this.chatService.deleteMessage({ - chatId: this.route.parent?.snapshot.paramMap.get("projectId") ?? "", - chatType: "project", - messageId, - }); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/chat/chat.resolver.ts b/projects/social_platform/src/app/office/projects/detail/chat/chat.resolver.ts deleted file mode 100644 index 20b2074fc..000000000 --- a/projects/social_platform/src/app/office/projects/detail/chat/chat.resolver.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { Project } from "@models/project.model"; -import { ProjectService } from "@services/project.service"; -import { tap } from "rxjs"; -import { ProjectDataService } from "../services/project-data.service"; - -/** - * Резолвер для загрузки данных проекта для чата - * - * Принимает: - * - ActivatedRouteSnapshot с родительским параметром projectId - * - * Возвращает: - * - Observable - данные проекта для отображения в чате - * - * Использует: - * - ProjectService для получения данных проекта по ID - */ -export const ProjectChatResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { - const projectService = inject(ProjectService); - const projectDataService = inject(ProjectDataService); - const id = Number(route.parent?.paramMap.get("projectId")); - - return projectService.getOne(id).pipe(tap(profile => projectDataService.setProject(profile))); -}; diff --git a/projects/social_platform/src/app/office/projects/detail/detail.resolver.ts b/projects/social_platform/src/app/office/projects/detail/detail.resolver.ts deleted file mode 100644 index aa1df6833..000000000 --- a/projects/social_platform/src/app/office/projects/detail/detail.resolver.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { forkJoin, of, switchMap, tap } from "rxjs"; -import { ProjectService } from "@services/project.service"; -import { Project } from "@models/project.model"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { ProjectSubscriber } from "@office/models/project-subscriber.model"; -import { ProjectDataService } from "./services/project-data.service"; - -/** - * Резолвер для загрузки данных проекта и его подписчиков - * - * Принимает: - * - ActivatedRouteSnapshot с параметром projectId - * - * Возвращает: - * - Observable<[Project, ProjectSubscriber[]]> - кортеж с данными проекта и списком подписчиков - * - * Использует: - * - ProjectService для получения данных проекта - * - SubscriptionService для получения списка подписчиков - */ -export const ProjectDetailResolver: ResolveFn<[Project, ProjectSubscriber[]]> = ( - route: ActivatedRouteSnapshot -) => { - const projectService = inject(ProjectService); - const subscriptionService = inject(SubscriptionService); - const projectDataService = inject(ProjectDataService); - - return projectService.getOne(Number(route.paramMap.get("projectId"))).pipe( - tap(project => projectDataService.setProject(project)), - switchMap(project => { - return forkJoin([of(project), subscriptionService.getSubscribers(project.id)]); - }) - ); -}; diff --git a/projects/social_platform/src/app/office/projects/detail/detail.routes.ts b/projects/social_platform/src/app/office/projects/detail/detail.routes.ts deleted file mode 100644 index 26c030e88..000000000 --- a/projects/social_platform/src/app/office/projects/detail/detail.routes.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProjectInfoComponent } from "./info/info.component"; -import { ProjectInfoResolver } from "./info/info.resolver"; -import { ProjectResponsesResolver } from "./work-section/responses.resolver"; -import { ProjectChatComponent } from "./chat/chat.component"; -import { ProjectChatResolver } from "@office/projects/detail/chat/chat.resolver"; -import { ProjectDetailResolver } from "@office/projects/detail/detail.resolver"; -import { NewsDetailComponent } from "@office/projects/detail/news-detail/news-detail.component"; -import { NewsDetailResolver } from "@office/projects/detail/news-detail/news-detail.resolver"; -import { ProjectTeamComponent } from "./team/team.component"; -import { ProjectVacanciesComponent } from "./vacancies/vacancies.component"; -import { DeatilComponent } from "@office/features/detail/detail.component"; -import { ProjectWorkSectionComponent } from "./work-section/work-section.component"; - -/** - * Конфигурация маршрутов для детального просмотра проекта - * - * Определяет: - * - Главный маршрут с резолвером для загрузки данных проекта - * - Дочерние маршруты для разных разделов проекта: - * - "" (пустой) - информация о проекте с возможностью просмотра новостей - * - "responses" - отклики на вакансии проекта - * - "chat" - чат проекта - * - * Каждый дочерний маршрут имеет свой резолвер для предзагрузки данных - */ -export const PROJECT_DETAIL_ROUTES: Routes = [ - { - path: "", - component: DeatilComponent, - resolve: { - data: ProjectDetailResolver, - }, - data: { listType: "project" }, - children: [ - { - path: "", - component: ProjectInfoComponent, - resolve: { - data: ProjectInfoResolver, - }, - children: [ - { - path: "news/:newsId", - component: NewsDetailComponent, - resolve: { - data: NewsDetailResolver, - }, - }, - ], - }, - { - path: "vacancies", - component: ProjectVacanciesComponent, - }, - { - path: "team", - component: ProjectTeamComponent, - }, - { - path: "work-section", - component: ProjectWorkSectionComponent, - resolve: { - data: ProjectResponsesResolver, - }, - }, - { - path: "chat", - component: ProjectChatComponent, - resolve: { - data: ProjectChatResolver, - }, - }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/projects/detail/info/info.component.html b/projects/social_platform/src/app/office/projects/detail/info/info.component.html deleted file mode 100644 index 54020669a..000000000 --- a/projects/social_platform/src/app/office/projects/detail/info/info.component.html +++ /dev/null @@ -1,220 +0,0 @@ - - -@if (project) { -
-
-
-
-
-
-

метаданные

- -
- -
    -
  • - - @if (industryService.industries | async; as industries) { -

    - @if (industryService.getIndustry(industries, project.industry); as industry) { - {{ industry?.name }} - } -

    - } -
  • - -
  • - -

    {{ project.region ?? "не указан" | truncate: 10 }}

    -
  • - -
  • - -

    {{ project.trl ?? "0" }}

    -
  • - -
  • - -

    {{ project.implementationDeadline ?? "не указана" }}

    -
  • - -
  • - -

    - {{ project.leaderInfo?.lastName | truncate: 10 }} - {{ project.leaderInfo?.firstName | truncate: 10 }} -

    -
  • -
-
-
- -
-
-
-

о проекте

- -
- @if (project.description) { -
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "скрыть" : "подробнее" }} -
- } -
- } -
- - @if (authService.profile | async; as profile) { -
- @if (project.leader === profile.id) { - - } - -
- @for (directionItem of directions; track $index) { - - } -
- - @for (n of news; track n.id) { - - } -
- } -
- -
- -
-
-
- - -
-} diff --git a/projects/social_platform/src/app/office/projects/detail/info/info.component.scss b/projects/social_platform/src/app/office/projects/detail/info/info.component.scss deleted file mode 100644 index 3db056ff2..000000000 --- a/projects/social_platform/src/app/office/projects/detail/info/info.component.scss +++ /dev/null @@ -1,403 +0,0 @@ -/** @format */ - -@use "styles/responsive"; -@use "styles/typography"; - -@mixin expandable-list { - &__remaining { - display: grid; - grid-template-rows: 0fr; - overflow: hidden; - transition: all 0.5s ease-in-out; - - &--show { - grid-template-rows: 1fr; - } - - ul { - min-height: 0; - } - - li { - &:first-child { - margin-top: 12px; - } - - &:not(:last-child) { - margin-bottom: 12px; - } - } - } -} - -.project { - padding-bottom: 100px; - - @include responsive.apply-desktop { - padding-bottom: 0; - } - - &__main { - display: grid; - grid-template-columns: 1fr; - } - - &__details { - display: grid; - grid-template-columns: 2fr 5fr 3fr; - grid-gap: 20px; - } - - &__right { - display: flex; - flex-direction: column; - } - - &__left { - width: 157px; - } - - &__aside { - display: grid; - grid-row-start: 3; - gap: 20px; - - @include responsive.apply-desktop { - grid-row-start: unset; - } - } - - &__section { - padding: 24px; - margin-bottom: 14px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - } - - &__info { - @include responsive.apply-desktop { - grid-column: span 3; - } - } - - &__content { - grid-row-start: 2; - min-width: 0; - - @include responsive.apply-desktop { - grid-row-start: unset; - } - } - - &__news { - grid-row-start: 4; - - @include responsive.apply-desktop { - grid-row-start: unset; - } - } - - &__directions { - display: grid; - grid-template-columns: repeat(5, 1fr); - grid-gap: 10px; - align-items: center; - margin-top: 24px; - } -} - -.info { - $body-slide: 15px; - - position: relative; - padding: 0; - background-color: transparent; - border: none; - border-radius: $body-slide; - - &__cover { - position: relative; - height: 230px; - border-radius: 15px 15px 0 0; - - img { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - object-fit: cover; - } - } - - &__body { - position: relative; - z-index: 2; - display: flex; - flex-direction: column; - gap: 20px; - padding: 40px 24px 24px; - margin-top: -$body-slide; - border-radius: $body-slide; - - app-button ::ng-deep .button--inline { - min-height: 38px; - } - - @include responsive.apply-desktop { - flex-direction: row; - gap: 10px; - align-items: flex-end; - padding-top: 10px; - padding-left: 225px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - } - } - - &__avatar { - position: absolute; - bottom: $body-slide; - left: 50%; - z-index: 3; - display: block; - transform: translateX(-50%) translateY(30px); - - @include responsive.apply-desktop { - left: 35px; - transform: translateY(50%); - } - } - - &__row { - display: flex; - align-items: center; - justify-content: center; - margin-top: 2px; - - @include responsive.apply-desktop { - justify-content: unset; - margin-top: 0; - } - } - - &__title { - overflow: hidden; - color: var(--black); - text-align: center; - text-overflow: ellipsis; - } - - &__right { - display: flex; - flex-direction: column; - gap: 20px; - - @include responsive.apply-desktop { - flex-direction: row; - margin-left: auto; - } - } - - &__presentation { - display: block; - - i { - margin-left: 10px; - } - } - - &__edit { - display: block; - } - - &__subscribe { - position: absolute; - top: 20px; - right: 20px; - cursor: pointer; - - // color: white; - // padding-bottom: 18px; - // background-color: var(--green); - // border-radius: 0 0 100px 100px; - // box-shadow: 0 6px 22px var(--green); - // transition: all 0.5s ease; - - // i { - // display: flex; - // align-items: flex-end; - // justify-content: center; - // height: 100%; - // } - - // &-active { - // height: 51px; - // background-color: var(--accent); - // box-shadow: 0 6px 18px var(--accent); - // } - } - - &__exit { - display: flex; - align-items: center; - justify-content: center; - width: 43px; - height: 43px; - color: var(--accent); - cursor: pointer; - border: 1px solid var(--accent); - border-radius: 8px; - transition: all 0.2s; - - &:hover { - color: var(--accent-dark); - border-color: var(--accent-dark); - } - } -} - -.about { - padding: 24px; - background-color: var(--light-white); - border-radius: var(--rounded-lg); - - &__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); - - &--icon { - color: var(--accent); - } - } - - &__title { - margin-bottom: 8px; - color: var(--accent); - } - /* stylelint-disable value-no-vendor-prefix */ - &__text { - p { - display: -webkit-box; - overflow: hidden; - line-height: 1.5; - color: var(--black); - text-overflow: ellipsis; - word-break: break-word; - transition: all 0.7s ease-in-out; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - - &.expanded { - display: block; - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } - } - - /* stylelint-enable value-no-vendor-prefix */ - - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } -} - -.lists { - &__section { - display: flex; - justify-content: space-between; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); - } - - &__list { - display: flex; - flex-direction: column; - gap: 8px; - } - - &__icon { - color: var(--accent); - } - - &__title { - margin-bottom: 8px; - color: var(--accent); - } - - &__item { - display: flex; - gap: 6px; - align-items: center; - - &--status { - padding: 8px; - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &--title { - color: var(--black); - } - - i { - padding: 6px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - p { - color: var(--accent); - } - - span { - cursor: pointer; - } - } - - @include expandable-list; -} - -.news { - &__form { - display: block; - margin-top: 20px; - } - - &__item { - display: block; - margin-top: 20px; - } -} - -.read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/info/info.component.spec.ts b/projects/social_platform/src/app/office/projects/detail/info/info.component.spec.ts deleted file mode 100644 index 71282cb4a..000000000 --- a/projects/social_platform/src/app/office/projects/detail/info/info.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectInfoComponent } from "./info.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; -import { ReactiveFormsModule } from "@angular/forms"; - -describe("ProjectInfoComponent", () => { - let component: ProjectInfoComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - const projectNewsServiceSpy = jasmine.createSpyObj({ fetchNews: of({}) }); - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - HttpClientTestingModule, - ReactiveFormsModule, - ProjectInfoComponent, - ], - providers: [ - { provide: AuthService, useValue: authSpy }, - { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProjectInfoComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/detail/info/info.component.ts b/projects/social_platform/src/app/office/projects/detail/info/info.component.ts deleted file mode 100644 index be093863b..000000000 --- a/projects/social_platform/src/app/office/projects/detail/info/info.component.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** @format */ - -import { AsyncPipe, CommonModule } from "@angular/common"; -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - OnDestroy, - OnInit, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, RouterOutlet, RouterLink } from "@angular/router"; -import { User } from "@auth/models/user.model"; -import { AuthService } from "@auth/services"; -import { UserLinksPipe } from "@core/pipes/user-links.pipe"; -import { Project } from "@models/project.model"; -import { Collaborator } from "@office/models/collaborator.model"; -import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; -import { FeedNews } from "@office/projects/models/project-news.model"; -import { ProjectService } from "@office/services/project.service"; -import { IndustryService } from "@services/industry.service"; -import { NavService } from "@services/nav.service"; -import { expandElement } from "@utils/expand-element"; -import { containerSm } from "@utils/responsive"; -import { ParseBreaksPipe, ParseLinksPipe } from "projects/core"; -import { Observable, Subscription, map, noop, switchMap } from "rxjs"; -import { DirectionItem, directionItemBuilder } from "@utils/helpers/directionItemBuilder"; -import { ProjectDirectionCard } from "../shared/project-direction-card/project-direction-card.component"; -import { IconComponent } from "@uilib"; -import { NewsFormComponent } from "@office/features/news-form/news-form.component"; -import { NewsCardComponent } from "@office/features/news-card/news-card.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * КОМПОНЕНТ ДЕТАЛЬНОЙ ИНФОРМАЦИИ О ПРОЕКТЕ - * - * Этот компонент отображает подробную информацию о проекте, включая: - * - Основную информацию (название, описание, обложка) - * - Команду проекта с возможностью управления - * - Новости проекта с возможностью добавления/редактирования - * - Вакансии, достижения и контакты - * - Функции подписки и поддержки проекта - * - * @param: - * - Получает данные проекта через резолвер из маршрута - * - Использует параметр projectId из URL - * - * - Отображение информации о проекте - * - Управление подпиской на проект - * - Добавление/редактирование/удаление новостей - * - Управление командой проекта (для лидера) - * - Выход из проекта - * - Передача лидерства другому участнику - * - * @returns: - * - Отображает HTML-шаблон с информацией о проекте - * - Обрабатывает пользовательские действия через методы компонента - */ -@Component({ - selector: "app-detail", - templateUrl: "./info.component.html", - styleUrl: "./info.component.scss", - standalone: true, - imports: [ - RouterOutlet, - IconComponent, - AsyncPipe, - RouterOutlet, - UserLinksPipe, - ParseBreaksPipe, - ParseLinksPipe, - TruncatePipe, - CommonModule, - ProjectDirectionCard, - NewsCardComponent, - NewsFormComponent, - RouterLink, - ], -}) -export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { - constructor( - private readonly route: ActivatedRoute, // Сервис для работы с активным маршрутом - public readonly industryService: IndustryService, // Сервис сфер проекта - public readonly authService: AuthService, // Сервис аутентификации - private readonly navService: NavService, // Сервис навигации - private readonly projectNewsService: ProjectNewsService, // Сервис новостей проекта - private readonly projectService: ProjectService, // Сервис проектов - private readonly cdRef: ChangeDetectorRef // Сервис для ручного запуска обнаружения изменений - ) {} - - // Observable с подписчиками проекта - projSubscribers$?: Observable = this.route.parent?.data.pipe(map(r => r["data"][1])); - - profileId!: number; // ID текущего пользователя - - subscriptions$: Subscription[] = []; // Массив подписок для очистки - - /** - * Инициализация компонента - * Устанавливает заголовок навигации, загружает новости, определяет статус подписки - */ - ngOnInit(): void { - this.navService.setNavTitle("Профиль проекта"); - - const projectSub$ = - this.route.parent?.data - .pipe( - map(r => r["data"][0]), - switchMap(project => { - return this.authService.getUser(project.leader).pipe( - map(user => { - return { - ...project, - leaderInfo: { - firstName: user.firstName, - lastName: user.lastName, - }, - }; - }) - ); - }) - ) - .subscribe({ - next: (project: Project) => { - this.project = project; - - if (project) { - this.directions = 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"] - )!; - } - setTimeout(() => { - this.checkDescriptionExpandable(); - this.cdRef.detectChanges(); - }, 100); - }, - }) ?? Subscription.EMPTY; - - // Загрузка новостей проекта - const news$ = this.projectNewsService - .fetchNews(this.route.snapshot.params["projectId"]) - .subscribe(news => { - this.news = news.results; - - // Настройка наблюдателя для отслеживания просмотра новостей - 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); - }); - }); - }); - - // Получение ID текущего пользователя - const profileId$ = this.authService.profile.subscribe(profile => { - this.profileId = profile.id; - }); - - this.subscriptions$.push(projectSub$, news$, profileId$); - } - - // Ссылки на элементы DOM - @ViewChild("newsEl") newsEl?: ElementRef; - @ViewChild("contentEl") contentEl?: ElementRef; - @ViewChild("descEl") descEl?: ElementRef; - - /** - * Хук после инициализации представления - * Перемещает новости в контентную область на десктопе, проверяет необходимость кнопки "Читать полностью" - */ - ngAfterViewInit(): void { - setTimeout(() => { - this.checkDescriptionExpandable(); - this.cdRef.detectChanges(); - }, 150); - } - - /** - * Очистка подписок при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** - * Обработчик появления новостей в области видимости - * Отмечает новости как просмотренные - * @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.route.snapshot.params["projectId"]), ids) - .subscribe(noop); - } - - // Ссылки на дочерние компоненты - @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; - @ViewChild(NewsCardComponent) newsCardComponent?: NewsCardComponent; - - // Состояние компонента - news: FeedNews[] = []; // Массив новостей - readFullDescription = false; // Флаг развернутого описания - descriptionExpandable!: boolean; // Флаг необходимости кнопки "Читать полностью" - readAllAchievements = false; // Флаг показа всех достижений - readAllVacancies = false; // Флаг показа всех вакансий - readAllMembers = false; // Флаг показа всех участников - isCompleted = false; // Флаг завершенности проекта - - // Данные о проекте - project?: Project; - - directions: DirectionItem[] = []; - - /** - * Добавление новой новости - * @param news - объект с текстом и файлами новости - */ - onAddNews(news: { text: string; files: string[] }): void { - this.projectNewsService - .addNews(this.route.snapshot.params["projectId"], news) - .subscribe(newsRes => { - this.newsFormComponent?.onResetForm(); - this.news.unshift(newsRes); - }); - } - - /** - * Удаление новости - * @param newsId - ID удаляемой новости - */ - onDeleteNews(newsId: number): void { - const newsIdx = this.news.findIndex(n => n.id === newsId); - this.news.splice(newsIdx, 1); - - this.projectNewsService - .delete(this.route.snapshot.params["projectId"], newsId) - .subscribe(() => {}); - } - - /** - * Переключение лайка новости - * @param newsId - ID новости для лайка - */ - onLike(newsId: number) { - const item = this.news.find(n => n.id === newsId); - if (!item) return; - - this.projectNewsService - .toggleLike(this.route.snapshot.params["projectId"], newsId, !item.isUserLiked) - .subscribe(() => { - item.likesCount = item.isUserLiked ? item.likesCount - 1 : item.likesCount + 1; - item.isUserLiked = !item.isUserLiked; - }); - } - - /** - * Редактирование новости - * @param news - обновленные данные новости - * @param newsItemId - ID редактируемой новости - */ - onEditNews(news: FeedNews, newsItemId: number) { - this.projectNewsService - .editNews(this.route.snapshot.params["projectId"], newsItemId, news) - .subscribe(resNews => { - const newsIdx = this.news.findIndex(n => n.id === resNews.id); - this.news[newsIdx] = resNews; - this.newsCardComponent?.onCloseEditMode(); - }); - } - - /** - * Удаление участника из проекта - * @param id - ID удаляемого участника - */ - onRemoveMember(id: Collaborator["userId"]) { - this.projectService - .removeColloborator(this.route.snapshot.params["projectId"], id) - .subscribe(() => { - location.reload(); - }); - } - - /** - * Передача лидерства другому участнику - * @param id - ID нового лидера - */ - onTransferOwnership(id: Collaborator["userId"]) { - this.projectService.switchLeader(this.route.snapshot.params["projectId"], id).subscribe(() => { - location.reload(); - }); - } - - /** - * Раскрытие/сворачивание описания профиля - * @param elem - DOM элемент описания - * @param expandedClass - CSS класс для раскрытого состояния - * @param isExpanded - текущее состояние (раскрыто/свернуто) - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - private checkDescriptionExpandable(): void { - const descElement = this.descEl?.nativeElement; - - if (!descElement || !this.project?.description) { - this.descriptionExpandable = false; - return; - } - - this.descriptionExpandable = descElement.scrollHeight > descElement.clientHeight; - } -} 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/services/project-news.service.ts b/projects/social_platform/src/app/office/projects/detail/services/project-news.service.ts deleted file mode 100644 index 03319cf99..000000000 --- a/projects/social_platform/src/app/office/projects/detail/services/project-news.service.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** @format */ - -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"; - -/** - * СЕРВИС ДЛЯ РАБОТЫ С НОВОСТЯМИ ПРОЕКТА - * - * Этот сервис предоставляет методы для работы с новостями проекта: - * - Загрузка списка новостей - * - Получение детальной информации о новости - * - Добавление новых новостей - * - Редактирование существующих новостей - * - Удаление новостей - * - Отметка новостей как просмотренных - * - Управление лайками новостей - * - * @params - * - projectId: string - ID проекта - * - newsId: string/number - ID новости - * - obj: объект с данными новости (текст, файлы) - * - state: boolean - состояние лайка - * - * @returns - * - Observable с результатами API-запросов - * - Трансформированные объекты новостей через class-transformer - * - * ОСОБЕННОСТИ: - * - Использует локальное хранение для отслеживания просмотренных новостей - * - Поддерживает пагинацию (лимит 100 новостей) - * - Автоматически трансформирует ответы API в типизированные объекты - */ -@Injectable({ - providedIn: "root", -}) -export class ProjectNewsService { - private readonly PROJECTS_URL = "/projects"; - - storageService = inject(StorageService); // Сервис для работы с локальным хранилищем - apiService = inject(ApiService); // Сервис для API-запросов - - /** - * Загрузка списка новостей проекта - * @param projectId - ID проекта - * @returns Observable с пагинированным списком новостей - */ - fetchNews(projectId: string): Observable> { - return this.apiService.get>( - `${this.PROJECTS_URL}/${projectId}/news/`, - new HttpParams({ fromObject: { limit: 100 } }) // Загружаем до 100 новостей - ); - } - - /** - * Получение детальной информации о конкретной новости - * @param projectId - ID проекта - * @param newsId - ID новости - * @returns Observable с объектом новости - */ - fetchNewsDetail(projectId: string, newsId: string): Observable { - return this.apiService - .get(`${this.PROJECTS_URL}/${projectId}/news/${newsId}`) - .pipe(map(r => plainToInstance(FeedNews, r))); // Трансформируем ответ в типизированный объект - } - - /** - * Добавление новой новости в проект - * @param projectId - ID проекта - * @param obj - объект с текстом и файлами новости - * @returns Observable с созданной новостью - */ - addNews(projectId: string, obj: { text: string; files: string[] }): Observable { - return this.apiService - .post(`${this.PROJECTS_URL}/${projectId}/news/`, obj) - .pipe(map(r => plainToInstance(FeedNews, r))); - } - - /** - * Отметка новостей как просмотренных - * Использует локальное хранилище для отслеживания уже просмотренных новостей - * @param projectId - ID проекта - * @param newsIds - массив ID новостей для отметки - * @returns Observable с массивом результатов запросов - */ - readNews(projectId: number, newsIds: number[]): Observable { - // Получаем список уже просмотренных новостей из сессионного хранилища - const readNews = this.storageService.getItem("readNews", sessionStorage) ?? []; - - return forkJoin( - newsIds - .filter(id => !readNews.includes(id)) // Фильтруем уже просмотренные - .map(id => - this.apiService - .post(`${this.PROJECTS_URL}/${projectId}/news/${id}/set_viewed/`, {}) - .pipe( - tap(() => { - // Сохраняем ID просмотренной новости в локальное хранилище - this.storageService.setItem("readNews", [...readNews, id], sessionStorage); - }) - ) - ) - ); - } - - /** - * Удаление новости - * @param projectId - ID проекта - * @param newsId - ID удаляемой новости - * @returns Observable с результатом удаления - */ - delete(projectId: string, newsId: number): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/news/${newsId}/`); - } - - /** - * Переключение лайка новости - * @param projectId - ID проекта - * @param newsId - ID новости - * @param state - новое состояние лайка (true/false) - * @returns Observable с результатом операции - */ - toggleLike(projectId: string, newsId: number, state: boolean): Observable { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/news/${newsId}/set_liked/`, { - is_liked: state, - }); - } - - /** - * Редактирование существующей новости - * @param projectId - ID проекта - * @param newsId - ID редактируемой новости - * @param newsItem - частичные данные для обновления - * @returns Observable с обновленной новостью - */ - editNews(projectId: string, newsId: number, newsItem: Partial): Observable { - return this.apiService - .patch(`${this.PROJECTS_URL}/${projectId}/news/${newsId}/`, newsItem) - .pipe(map(r => plainToInstance(FeedNews, r))); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.html b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.html deleted file mode 100644 index 391dcb1ca..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.html +++ /dev/null @@ -1,36 +0,0 @@ - - -
  • - - -
    -

    {{ member.firstName }} {{ member.lastName }}

    -

    {{ member.role }}

    -
    -
    -
    - @if (isLeader) { - - } @else if (manageRights) { - - } -
    - - - -
  • diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.scss b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.scss deleted file mode 100644 index 0a9aab80c..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.scss +++ /dev/null @@ -1,59 +0,0 @@ -.member { - position: relative; - display: flex; - align-items: center; - - &__inner { - display: flex; - align-items: center; - } - - &__avatar { - margin-right: 10px; - } - - &__name { - color: var(--black); - } - - &__speciality { - color: var(--dark-grey); - } - - &__right { - display: flex; - align-items: center; - margin-left: auto; - } - - &__star { - color: var(--accent); - } - - &__dots { - color: var(--black); - cursor: pointer; - } -} - -.menu { - padding: 20px 14px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: var(--rounded-xl); - transform: translateX(-50%); - - &__item { - color: var(--dark-grey); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--black); - } - - &:not(:last-child) { - margin-bottom: 8px; - } - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.spec.ts b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.spec.ts deleted file mode 100644 index d6322c05d..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectMemberCardComponent } from "./project-member-card.component"; - -describe("ProjectMemberCardComponent", () => { - let component: ProjectMemberCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ProjectMemberCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ProjectMemberCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.ts b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.ts deleted file mode 100644 index 72ddffdd6..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** @format */ - -import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from "@angular/cdk/menu"; -import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { RouterLink } from "@angular/router"; -import { Collaborator } from "@office/models/collaborator.model"; -import { AvatarComponent, IconComponent } from "@uilib"; - -/** - * КОМПОНЕНТ КАРТОЧКИ УЧАСТНИКА ПРОЕКТА - * - * Этот компонент отображает информацию об участнике проекта в виде карточки - * с возможностью управления (для лидера проекта). - * - * НАЗНАЧЕНИЕ: - * - Отображение информации об участнике (аватар, имя, роль) - * - Предоставление функций управления командой для лидера - * - Индикация статуса лидера проекта - * - * @params - * - member: Collaborator - объект с данными участника (обязательный) - * - isLeader: boolean - флаг, является ли участник лидером (по умолчанию false) - * - manageRights: boolean - флаг наличия прав управления у текущего пользователя (по умолчанию false) - * - * @returns - * - remove: EventEmitter - событие удаления участника из команды - * - transferOwnership: EventEmitter - событие передачи лидерства участнику - * - * ФУНКЦИОНАЛЬНОСТЬ: - * - Отображение аватара, имени и роли участника - * - Ссылка на профиль участника - * - Контекстное меню с действиями (для пользователей с правами управления) - * - Индикация лидера проекта звездочкой - * - * @returns - * - HTML-разметка карточки участника - * - События для управления командой - */ -@Component({ - selector: "app-project-member-card", - standalone: true, - imports: [ - CommonModule, - RouterLink, - IconComponent, - AvatarComponent, - CdkMenuTrigger, // Директива для триггера контекстного меню - CdkMenuItem, // Директива для элементов меню - CdkMenu, // Директива для контейнера меню - ], - templateUrl: "./project-member-card.component.html", - styleUrl: "./project-member-card.component.scss", -}) -export class ProjectMemberCardComponent { - @Input({ required: true }) member!: Collaborator; // Данные участника (обязательное поле) - @Input() isLeader = false; // Флаг лидера проекта - @Input() manageRights = false; // Флаг прав управления - - @Output() remove = new EventEmitter(); // Событие удаления участника - @Output() transferOwnership = new EventEmitter(); // Событие передачи лидерства -} diff --git a/projects/social_platform/src/app/office/projects/detail/team/team.component.html b/projects/social_platform/src/app/office/projects/detail/team/team.component.html deleted file mode 100644 index 63e2fc80e..000000000 --- a/projects/social_platform/src/app/office/projects/detail/team/team.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - -@if(team) { @if (team.length) { -
    - @for (collaborator of team; track $index) { - - } -
    -} } 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/projects/detail/vacancies/vacancies.component.html b/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.html deleted file mode 100644 index dcdecd2f6..000000000 --- a/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.html +++ /dev/null @@ -1,12 +0,0 @@ - -@if (vacancies) { @if (vacancies.length) { -
    -
      - @for (vacancy of vacancies; track vacancy.id) { -
    • - -
    • - } -
    -
    -} } diff --git a/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.ts b/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.ts deleted file mode 100644 index 548c0f678..000000000 --- a/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { IconComponent } from "@uilib"; -import { ProjectDataService } from "../services/project-data.service"; -import { Project } from "@office/models/project.model"; -import { Subscription } from "rxjs"; -import { ProjectVacancyCardComponent } from "../shared/project-vacancy-card/project-vacancy-card.component"; - -/** - * Компонент страницы вакансий в деательной информации о проекте - */ -@Component({ - selector: "app-vacancies", - templateUrl: "./vacancies.component.html", - styleUrl: "./vacancies.component.scss", - imports: [CommonModule, IconComponent, ProjectVacancyCardComponent], - standalone: true, -}) -export class ProjectVacanciesComponent implements OnInit, OnDestroy { - // сервис для работы с данными детальной информации проекта - private readonly projectDataService = inject(ProjectDataService); - - // массив пользователей в команде - vacancies?: Project["vacancies"]; - - // массив подписок - subscriptions: Subscription[] = []; - - ngOnInit(): void { - const vacanciesSub$ = this.projectDataService.getVacancies().subscribe({ - next: vacancies => { - this.vacancies = vacancies; - }, - }); - - vacanciesSub$ && this.subscriptions.push(vacanciesSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/work-section/responses.resolver.ts b/projects/social_platform/src/app/office/projects/detail/work-section/responses.resolver.ts deleted file mode 100644 index 2cfa12afe..000000000 --- a/projects/social_platform/src/app/office/projects/detail/work-section/responses.resolver.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { VacancyResponse } from "@models/vacancy-response.model"; -import { VacancyService } from "@services/vacancy.service"; - -/** - * Резолвер для загрузки откликов на вакансии проекта - * - * Принимает: - * - ActivatedRouteSnapshot с родительским параметром projectId - * - * Возвращает: - * - Observable - список откликов на вакансии проекта - * - * Использует: - * - VacancyService для получения откликов по ID проекта - */ -export const ProjectResponsesResolver: ResolveFn = ( - route: ActivatedRouteSnapshot -) => { - const vacancyService = inject(VacancyService); - - return vacancyService.responsesByProject(Number(route.parent?.paramMap.get("projectId"))); -}; diff --git a/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.ts b/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.ts deleted file mode 100644 index 8197bd5d7..000000000 --- a/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { ButtonComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; -import { map, Subscription } from "rxjs"; -import { ActivatedRoute } from "@angular/router"; -import { VacancyResponse } from "@office/models/vacancy-response.model"; -import { VacancyService } from "@office/services/vacancy.service"; - -@Component({ - selector: "app-work-section", - templateUrl: "./work-section.component.html", - styleUrl: "./work-section.component.scss", - imports: [CommonModule, IconComponent, ButtonComponent], - standalone: true, -}) -export class ProjectWorkSectionComponent implements OnInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - private readonly vacancyService = inject(VacancyService); - private subscriptions: Subscription[] = []; - - vacancies: VacancyResponse[] = []; - - ngOnInit(): void { - const vacanciesSub$ = this.route.data.pipe(map(r => r["data"])).subscribe({ - next: (responses: VacancyResponse[]) => { - this.vacancies = responses.filter( - (response: VacancyResponse) => response.isApproved === null - ); - }, - }); - - this.subscriptions.push(vacanciesSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - /** - * Принятие отклика на вакансию - * @param responseId - ID отклика для принятия - */ - acceptResponse(responseId: number) { - this.vacancyService.acceptResponse(responseId).subscribe(() => { - const index = this.vacancies.findIndex(el => el.id === responseId); - this.vacancies.splice(index, 1); - }); - } - - /** - * Отклонение отклика на вакансию - * @param responseId - ID отклика для отклонения - */ - rejectResponse(responseId: number) { - this.vacancyService.rejectResponse(responseId).subscribe(() => { - const index = this.vacancies.findIndex(el => el.id === responseId); - this.vacancies.splice(index, 1); - }); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.html b/projects/social_platform/src/app/office/projects/edit/edit.component.html deleted file mode 100644 index 2592260e4..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.html +++ /dev/null @@ -1,244 +0,0 @@ - -
    -
    -
    - -

    редактировать проект

    -
    -
    - - удалить проект - - - сохранить черновик - - - {{ isCompetitive ? "отправить заявку" : "сохранить" }} - -
    -
    - -
    -
    - - -
    - @if (editingStep === "main") { - - - } @else if (editingStep === "contacts") { - - } @else if (editingStep === "achievements") { - - } @else if (editingStep === "vacancies"){ - - } @else if (editingStep === "team") { - - } @else if (editingStep === "additional") { - - } -
    -
    - -
    -

    📢 внимание!

    -

    - для публикации проекта, нужно заполнить все обязательные поля (они будут - подсвечены красным). Если вы пока не знаете что написать, можно сохранить черновик проекта и заполнить поля - позже :) - - {{ - fromProgram || isProjectAssignToProgram - ? 'также проверь вкладку "данные для конкурсов"' - : "" - }} -

    - понятно -
    -
    -
    -
    - - -
    -
    - -

    Проект завершен!

    -
    - - end - -

    - Этот проект был успешно завершён в рамках программы {{ errorModalMessage()?.program_name }}. - Редактирование или удаление проекта больше недоступно. -

    -
    -
    - - -
    -
    - -

    Подача проектов завершена!

    -
    - -

    Срок подачи проектов в программу завершён

    -
    -
    - - -
    -
    -

    Начнем создавать историю!

    -
    - -
    -

    - Вы находитесь в проектной мастерской – здесь мы с нуля создаем и редактируем проектные идеи -

    - -

    - Есть несколько вкладок – заполнив каждую, вы полностью опишите свой проект.
    - Обязательные поля отмечены красным, обязательно не забудь про вкладку «данные для конкурсов» -

    - -

    - Будьте внимательны: проект единожды создается лидером, команда приглашается в уже созданный - проект -

    - -

    - Если вы понимаете, что заполнить каждую графу пока нет времени (или не хватает информации!), - нажмите «сохранить черновик» – так вы сохраните проект, но не опубликуете его для - пользователей всей платформы -

    - -

    Расскажите миру о вашем проекте!

    -
    - - спасибо, понятно -
    -
    - - - - - - -
    -
    - - end -

    Отправить заявку?

    -
    - -

    - После отправки заявку нельзя будет редактировать до окончания конкурса. -
    Вы уверены, что хотите отправить заявку сейчас? -

    - -
    - Отмена - Отправить -
    -
    -
    diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.spec.ts b/projects/social_platform/src/app/office/projects/edit/edit.component.spec.ts deleted file mode 100644 index d96b4dc21..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectEditComponent } from "./edit.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { NgxMaskModule } from "ngx-mask"; -import { AuthService } from "@auth/services"; - -describe("ProjectEditComponent", () => { - let component: ProjectEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = {}; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - NgxMaskModule.forRoot(), - ProjectEditComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProjectEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.ts b/projects/social_platform/src/app/office/projects/edit/edit.component.ts deleted file mode 100644 index 7806eeaf4..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.ts +++ /dev/null @@ -1,752 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - OnDestroy, - OnInit, - signal, -} from "@angular/core"; -import { FormArray, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { ErrorMessage } from "@error/models/error-message"; -import { Invite } from "@models/invite.model"; -import { Project } from "@models/project.model"; -import { Skill } from "@office/models/skill.model"; -import { ProgramService } from "@office/program/services/program.service"; -import { SkillsService } from "@office/services/skills.service"; -import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; -import { IndustryService } from "@services/industry.service"; -import { NavService } from "@services/nav.service"; -import { ProjectService } from "@services/project.service"; -import { ButtonComponent, IconComponent, SelectComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ValidationService } from "projects/core"; -import { - Observable, - Subscription, - distinctUntilChanged, - forkJoin, - map, - of, - switchMap, - tap, -} from "rxjs"; -import { CommonModule, AsyncPipe } from "@angular/common"; -import { ProjectNavigationComponent } from "./shared/project-navigation/project-navigation.component"; -import { EditStep, ProjectStepService } from "./services/project-step.service"; -import { ProjectMainStepComponent } from "./shared/project-main-step/project-main-step.component"; -import { ProjectFormService } from "./services/project-form.service"; -import { ProjectPartnerResourcesStepComponent } from "./shared/project-partner-resources-step/project-partner-resources-step.component"; -import { ProjectAchievementStepComponent } from "./shared/project-achievement-step/project-achievement-step.component"; -import { ProjectVacancyStepComponent } from "./shared/project-vacancy-step/project-vacancy-step.component"; -import { ProjectVacancyService } from "./services/project-vacancy.service"; -import { ProjectTeamStepComponent } from "./shared/project-team-step/project-team-step.component"; -import { ProjectTeamService } from "./services/project-team.service"; -import { ProjectAdditionalStepComponent } from "./shared/project-additional-step/project-additional-step.component"; -import { ProjectAdditionalService } from "./services/project-additional.service"; -import { ProjectAchievementsService } from "./services/project-achievements.service"; -import { Goal } from "@office/models/goals.model"; -import { ProjectGoalService } from "./services/project-goals.service"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { Resource } from "@office/models/resource.model"; -import { Partner } from "@office/models/partner.model"; -import { ProjectPartnerService } from "./services/project-partner.service"; -import { ProjectResourceService } from "./services/project-resources.service"; - -/** - * Компонент редактирования проекта - * - * Функциональность: - * - Многошаговое редактирование проекта (основная информация, контакты, достижения, вакансии, команда) - * - Управление формами для проекта, вакансий и приглашений - * - Загрузка файлов (презентация, обложка, аватар) - * - Создание и редактирование вакансий с навыками - * - Приглашение участников в команду - * - Управление достижениями, ссылками и целями проекта - * - Сохранение как черновик или публикация - */ -@Component({ - selector: "app-edit", - templateUrl: "./edit.component.html", - styleUrl: "./edit.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - CommonModule, - RouterModule, - IconComponent, - ButtonComponent, - ModalComponent, - AsyncPipe, - SkillsGroupComponent, - ProjectNavigationComponent, - ProjectMainStepComponent, - ProjectAchievementStepComponent, - ProjectVacancyStepComponent, - ProjectTeamStepComponent, - ProjectAdditionalStepComponent, - ProjectPartnerResourcesStepComponent, - ], - providers: [ - ProjectFormService, - ProjectVacancyService, - ProjectAdditionalService, - ProjectGoalService, - ProjectPartnerService, - ProjectResourceService, - ], -}) -export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly industryService: IndustryService, - protected readonly projectService: ProjectService, - private readonly navService: NavService, - private readonly validationService: ValidationService, - private readonly cdRef: ChangeDetectorRef, - private readonly projectStepService: ProjectStepService, - private readonly projectFormService: ProjectFormService, - private readonly projectVacancyService: ProjectVacancyService, - private readonly projectTeamService: ProjectTeamService, - private readonly projectAchievementsService: ProjectAchievementsService, - private readonly projectGoalsService: ProjectGoalService, - private readonly projectPartnerService: ProjectPartnerService, - private readonly projectResourceService: ProjectResourceService, - private readonly snackBarService: SnackbarService, - private readonly skillsService: SkillsService, - private readonly projectAdditionalService: ProjectAdditionalService, - private readonly programService: ProgramService, - private readonly projectGoalService: ProjectGoalService - ) {} - - // Получаем форму проекта из сервиса - get projectForm(): FormGroup { - return this.projectFormService.getForm(); - } - - // Получаем форму вакансии из сервиса - get vacancyForm(): FormGroup { - return this.projectVacancyService.getVacancyForm(); - } - - // Получаем форму дополнительных полей из сервиса - get additionalForm(): FormGroup { - return this.projectAdditionalService.getAdditionalForm(); - } - - // Получаем сигналы из сервиса - get achievements() { - return this.projectFormService.achievements; - } - - // Id редактируемой части проекта - get editIndex() { - return this.projectFormService.editIndex; - } - - // Id связи проекта и программы - get relationId() { - return this.projectFormService.relationId; - } - - // Геттеры для доступа к данным из сервиса дополнительных полей - get partnerProgramFields() { - return this.projectAdditionalService.getPartnerProgramFields(); - } - - get isAssignProjectToProgramError() { - return this.projectAdditionalService.getIsAssignProjectToProgramError()(); - } - - get errorAssignProjectToProgramModalMessage() { - return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); - } - - // Методы для управления состоянием ошибок через сервис - setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { - this.projectAdditionalService.setAssignProjectToProgramError(error); - } - - clearAssignProjectToProgramError(): void { - this.projectAdditionalService.clearAssignProjectToProgramError(); - } - - // Геттеры для работы с целями - get goals(): FormArray { - return this.projectGoalsService.goals; - } - - get partners(): FormArray { - return this.projectPartnerService.partners; - } - - get resources(): FormArray { - return this.projectResourceService.resources; - } - - ngOnInit(): void { - this.navService.setNavTitle("Создание проекта"); - - // Получение текущего шага редактирования из query параметров - this.setupEditingStep(); - - // Получение Id лидера проекта - this.setupLeaderIdSubscription(); - } - - ngAfterViewInit(): void { - // Загрузка данных программных тегов и проекта - this.loadProgramTagsAndProject(); - } - - ngOnDestroy(): void { - this.profile$?.unsubscribe(); - this.subscriptions.forEach($ => $?.unsubscribe()); - - // Сброс состояния ProjectGoalService при уничтожении компонента - this.projectGoalService.reset(); - } - - // Опции для программных тегов - programTagsOptions: SelectComponent["options"] = []; - - // Id Лидера проекта - leaderId = 0; - - fromProgram: string | null = ""; - fromProgramOpen = signal(false); - - // Маркер того является ли проект привязанный к конкурсной программе - isCompetitive = false; - isProjectAssignToProgram = false; - - // Маркер что проект привязан - isProjectBoundToProgram = false; - - // Текущий шаг редактирования - get editingStep(): EditStep { - return this.projectStepService.getCurrentStep()(); - } - - get hasOpenSkillsGroups(): boolean { - return this.openGroupIds.size > 0; - } - - // Состояние компонента - isCompleted = false; - isSendDescisionLate = false; - isSendDescisionToPartnerProgramProject = false; - - profile$?: Subscription; - errorMessage = ErrorMessage; - - // Сигналы для работы с модальными окнами с ошибкой - errorModalMessage = signal<{ - program_name: string; - whenCanEdit: string; - daysUntilResolution: string; - } | null>(null); - - onEditClicked = signal(false); - warningModalSeen = false; - - // Observables для данных - industries$ = this.industryService.industries.pipe( - map(industries => - industries.map(industry => ({ value: industry.id, id: industry.id, label: industry.name })) - ) - ); - - subscriptions: (Subscription | undefined)[] = []; - - profileId: number = +this.route.snapshot.params["projectId"]; - - // Сигналы для управления состоянием - inlineSkills = signal([]); - nestedSkills$ = this.skillsService.getSkillsNested(); - skillsGroupsModalOpen = signal(false); - isAssignProjectToProgramModalOpen = signal(false); - - // Состояние отправки форм - projSubmitInitiated = false; - projFormIsSubmittingAsPublished = false; - projFormIsSubmittingAsDraft = false; - openGroupIds = new Set(); - - /** - * Навигация между шагами редактирования - * @param step - название шага - */ - navigateStep(step: EditStep): void { - this.projectStepService.navigateToStep(step); - } - - // /** - // * Привязка проекта к программе выбранной - // * Перенаправление её на редактирование "нового" проекта - // */ - // assignProjectToProgram(): void { - // this.projectService - // .assignProjectToProgram( - // Number(this.route.snapshot.paramMap.get("projectId")), - // this.projectForm.get("partnerProgramId")?.value - // ) - // .subscribe({ - // next: r => { - // this.assignProjectToProgramModalMessage.set(r); - // this.isAssignProjectToProgramModalOpen.set(true); - // 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 = status; - } - - setIsSubmittingAsDraft(status: boolean): void { - this.projFormIsSubmittingAsDraft = status; - } - - setProjFormIsSubmitting!: (status: boolean) => void; - - /** - * Очистка всех ошибок валидации - */ - clearAllValidationErrors(): void { - // Очистка основной формы - this.projectFormService.clearAllValidationErrors(); - this.projectAchievementsService.clearAllAchievementsErrors(this.achievements); - - // Очистка ошибок целей теперь входит в clearAllValidationErrors() ProjectFormService - } - - onGroupToggled(isOpen: boolean, skillsGroupId: number): void { - this.openGroupIds.clear(); - if (isOpen) { - this.openGroupIds.add(skillsGroupId); - } - - this.cdRef.markForCheck(); - } - - /** - * Удаление проекта с проверкой удаления у пользователя - */ - deleteProject(): void { - if (!confirm("Вы точно хотите удалить проект?")) { - return; - } - - const programId = this.projectForm.get("partnerProgramId")?.value; - - this.projectService.remove(Number(this.route.snapshot.paramMap.get("projectId"))).subscribe({ - next: () => { - if (this.fromProgram) { - this.router.navigateByUrl(`/office/program/${programId}`); - } else { - this.router.navigateByUrl(`/office/projects/my`); - } - }, - }); - } - - /** - * Сохранение проекта как опубликованного с проверкой доп. полей - */ - saveProjectAsPublished(): void { - this.projectForm.get("draft")?.patchValue(false); - this.setProjFormIsSubmitting = this.setIsSubmittingAsPublished; - - if (!this.isCompetitive) { - 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 = true; - this.cdRef.markForCheck(); - return; - } - - if (this.validateAdditionalFields()) { - this.projSubmitInitiated = true; - this.cdRef.markForCheck(); - return; - } - - this.isSendDescisionToPartnerProgramProject = true; - this.cdRef.markForCheck(); - } - - /** - * Сохранение проекта как черновика - */ - saveProjectAsDraft(): void { - this.clearAllValidationErrors(); - this.projectForm.get("draft")?.patchValue(true); - this.setProjFormIsSubmitting = this.setIsSubmittingAsDraft; - const partnerProgramId = this.projectForm.get("partnerProgramId")?.value; - this.projectForm.patchValue({ partnerProgramId }); - this.closeSendingDescisionModal(); - 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.vacancyForm.dirty) { - this.projectVacancyService.submitVacancy(projectId); - } - - if (isDraft) { - if ( - !this.validationService.getFormValidation(this.projectForm) || - !this.validationService.getFormValidation(this.vacancyForm) - ) { - return; - } - } else { - if ( - !this.validationService.getFormValidation(this.projectForm) || - !this.validationService.getFormValidation(this.additionalForm) || - !this.validationService.getFormValidation(this.vacancyForm) - ) { - return; - } - } - - this.setProjFormIsSubmitting(true); - this.projectService - .updateProject(projectId, payload) - .pipe( - switchMap(() => this.saveOrEditGoals(projectId)), - switchMap(() => this.savePartners(projectId)), - switchMap(() => this.saveOrEditResources(projectId)) - ) - .subscribe({ - next: () => { - this.completeSubmitedProjectForm(projectId); - }, - error: err => { - this.setProjFormIsSubmitting(false); - this.snackBarService.error("ошибка при сохранении данных"); - if (err.error["error"].includes("Срок подачи проектов в программу завершён.")) { - this.isSendDescisionLate = true; - } - }, - }); - } - - // Методы для работы с модальными окнами - closeWarningModal(): void { - this.warningModalSeen = true; - } - - closeSendingDescisionModal(): void { - this.isSendDescisionToPartnerProgramProject = false; - - const projectId = Number(this.route.snapshot.params["projectId"]); - const relationId = this.relationId(); - - this.sendAdditionalFields(projectId, relationId); - } - - closeAssignProjectToProgramModal(): void { - this.isAssignProjectToProgramModalOpen.set(false); - } - - private getFromProgramSeenKey(type: "program" | "project"): string { - if (type === "program") { - return `project_fromProgram_modal_seen_${this.profileId}`; - } else return `project_modal_seen_${this.profileId}`; - } - - private hasSeenFromProgramModal(): boolean { - try { - if (this.fromProgram) { - return !!localStorage.getItem(this.getFromProgramSeenKey("program")); - } - return !!localStorage.getItem(this.getFromProgramSeenKey("project")); - } catch (e) { - return false; - } - } - - private markSeenFromProgramModal(): void { - try { - if (this.fromProgram) { - localStorage.setItem(this.getFromProgramSeenKey("program"), "1"); - } - localStorage.setItem(this.getFromProgramSeenKey("project"), "1"); - } catch (e) {} - } - - closeFromProgramModal(): void { - this.fromProgramOpen.set(false); - this.markSeenFromProgramModal(); - } - - 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.projectGoalService.saveGoals(projectId, newGoals)); - } - - if (existingGoals.length > 0) { - requests.push(this.projectGoalService.editGoals(projectId, existingGoals)); - } - - if (requests.length === 0) { - return of(null); - } - - return forkJoin(requests).pipe( - tap(() => { - this.projectGoalService.syncGoalItems(this.projectGoalService.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.setProjFormIsSubmitting(false); - this.router.navigateByUrl(`/office/projects/${projectId}`); - } - - /** - * Валидация дополнительных полей для публикации - * Делегирует валидацию сервису - * @returns true если есть ошибки валидации - */ - private validateAdditionalFields(): boolean { - const partnerProgramFields = this.projectAdditionalService.getPartnerProgramFields(); - - // Если нет дополнительных полей - пропускаем валидацию - if (!partnerProgramFields?.length) { - return false; - } - - // Проверяем только обязательные поля - const hasInvalid = this.projectAdditionalService.validateRequiredFields(); - - if (hasInvalid) { - this.cdRef.markForCheck(); - return true; - } - - // Подготавливаем поля для отправки (убираем валидаторы с заполненных полей) - this.projectAdditionalService.prepareFieldsForSubmit(); - return false; - } - - /** - * Отправка дополнительных полей через сервис - * @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).subscribe({ - next: () => { - if (!isDraft) { - this.projectAdditionalService.submitCompettetiveProject(relationId).subscribe(_ => { - this.submitProjectForm(); - }); - } - }, - error: error => { - console.error("Error sending additional fields:", error); - this.setProjFormIsSubmitting(false); - }, - }); - } - - /** - * Добавление навыка - * @param newSkill - новый навык - */ - onAddSkill(newSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.vacancyForm.value; - const isPresent = skills.some(skill => skill.id === newSkill.id); - - if (isPresent) return; - - this.vacancyForm.patchValue({ skills: [newSkill, ...skills] }); - } - - /** - * Удаление навыка - * @param oddSkill - навык для удаления - */ - onRemoveSkill(oddSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.vacancyForm.value; - - this.vacancyForm.patchValue({ - skills: skills.filter(skill => skill.id !== oddSkill.id), - }); - } - - /** - * Поиск навыков - * @param query - поисковый запрос - */ - onSearchSkill(query: string): void { - this.skillsService.getSkillsInline(query, 1000, 0).subscribe(({ results }) => { - this.inlineSkills.set(results); - }); - } - - /** - * Переключение навыка в списке выбранных - * @param toggledSkill - навык для переключения - */ - onToggleSkill(toggledSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.vacancyForm.value; - const isPresent = skills.some(skill => skill.id === toggledSkill.id); - - if (isPresent) { - this.onRemoveSkill(toggledSkill); - } else { - this.onAddSkill(toggledSkill); - } - } - - /** - * Переключение модального окна групп навыков - */ - toggleSkillsGroupsModal(): void { - this.skillsGroupsModalOpen.update(open => !open); - } - - private setupEditingStep(): void { - const stepFromUrl = this.route.snapshot.queryParams["editingStep"] as EditStep; - if (stepFromUrl) { - this.projectStepService.setStepFromRoute(stepFromUrl); - } - - const editingStepSub$ = this.route.queryParams.subscribe(params => { - const step = params["editingStep"] as EditStep; - this.fromProgram = params["fromProgram"]; - - const seen = this.hasSeenFromProgramModal(); - if (!seen) { - this.fromProgramOpen.set(true); - this.markSeenFromProgramModal(); - } else { - this.fromProgramOpen.set(false); - } - - if (step && step !== this.editingStep) { - this.projectStepService.setStepFromRoute(step); - } - }); - - this.subscriptions.push(editingStepSub$); - } - - private setupLeaderIdSubscription(): void { - this.route.data - .pipe( - distinctUntilChanged(), - map(d => d["data"]) - ) - .subscribe(([project]: [Project]) => { - this.leaderId = project.leader; - }); - } - - private loadProgramTagsAndProject(): void { - this.route.data - .pipe(map(d => d["data"])) - .subscribe( - ([project, goals, partners, resources, invites]: [ - Project, - Goal[], - Partner[], - Resource[], - Invite[] - ]) => { - // Используем сервис для инициализации данных проекта - this.projectFormService.initializeProjectData(project); - this.projectGoalService.initializeGoalsFromProject(goals); - this.projectPartnerService.initializePartnerFromProject(partners); - this.projectResourceService.initializeResourcesFromProject(resources); - this.projectTeamService.setInvites(invites); - this.projectTeamService.setCollaborators(project.collaborators); - - if (project.partnerProgram) { - this.isCompetitive = project.partnerProgram.canSubmit; - this.isProjectAssignToProgram = !!project.partnerProgram.programId; - - this.projectAdditionalService.initializeAdditionalForm( - project.partnerProgram?.programFields, - project.partnerProgram?.programFieldValues - ); - } - - this.projectVacancyService.setVacancies(project.vacancies); - this.projectTeamService.setInvites(invites); - - this.cdRef.detectChanges(); - } - ); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/edit.resolver.ts b/projects/social_platform/src/app/office/projects/edit/edit.resolver.ts deleted file mode 100644 index 3d48dcba9..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.resolver.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { forkJoin } from "rxjs"; -import { ProjectService } from "@services/project.service"; -import { Project } from "@models/project.model"; -import { InviteService } from "@services/invite.service"; -import { Invite } from "@models/invite.model"; -import { Goal } from "@office/models/goals.model"; -import { Partner } from "@office/models/partner.model"; -import { Resource } from "@office/models/resource.model"; - -/** - * Resolver для загрузки данных редактирования проекта - * - * Функциональность: - * - Загружает данные проекта по ID из параметров маршрута - * - Получает список приглашений для проекта - * - Объединяет данные в единый массив для компонента - * - * Принимает: - * - ActivatedRouteSnapshot с параметром projectId - * - * Возвращает: - * - Observable<[Project, Invite[]]> с данными: - * - Project: полная информация о проекте - * - Invite[]: массив приглашений в проект - * - * Используется перед загрузкой ProjectEditComponent для предварительной - * загрузки всех необходимых данных для редактирования. - * - * Применяет forkJoin для параллельной загрузки данных проекта и приглашений, - * что оптимизирует время загрузки страницы. - */ -export const ProjectEditResolver: ResolveFn<[Project, Goal[], Partner[], Resource[], Invite[]]> = ( - route: ActivatedRouteSnapshot -) => { - const projectService = inject(ProjectService); - const inviteService = inject(InviteService); - - const projectId = Number(route.paramMap.get("projectId")); - - return forkJoin<[Project, Goal[], Partner[], Resource[], Invite[]]>([ - projectService.getOne(projectId), - projectService.getGoals(projectId), - projectService.getPartners(projectId), - projectService.getResources(projectId), - inviteService.getByProject(projectId), - ]); -}; diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts deleted file mode 100644 index ebc3e1edf..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** @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 "@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 { concatMap, filter } from "rxjs"; -/** - * Сервис для управления основной формой проекта и формой дополнительных полей партнерской программы. - * Обеспечивает создание, инициализацию, валидацию, автосохранение, сброс и получение данных форм. - */ -@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); - - constructor() { - this.initializeForm(); - } - - formModel = (this.projectForm = this.fb.group({ - imageAddress: [""], - name: ["", [Validators.required, Validators.maxLength(256)]], - region: ["", [Validators.required, Validators.maxLength(256)]], - implementationDeadline: [null], - trl: [null], - links: this.fb.array([]), - link: ["", optionalUrlOrMentionValidator], - industryId: [undefined], - description: ["", [Validators.maxLength(800)]], - presentationAddress: [""], - coverImageAddress: [""], - actuality: ["", [Validators.maxLength(1000)]], - targetAudience: ["", [Validators.maxLength(500)]], - problem: ["", [Validators.maxLength(1000)]], - partnerProgramId: [null], - achievements: this.fb.array([]), - title: [""], - status: [""], - - draft: [null], - })); - - /** - * Создает и настраивает основную форму проекта с набором контролов и валидаторов. - * Подписывается на изменения полей 'presentationAddress' и 'coverImageAddress' для автосохранения при очищении. - */ - private initializeForm(): void { - this.projectForm = this.fb.group({ - imageAddress: [""], - name: ["", [Validators.required, Validators.maxLength(256)]], - region: ["", [Validators.required, Validators.maxLength(256)]], - implementationDeadline: [null], - trl: [null], - links: this.fb.array([]), - link: ["", optionalUrlOrMentionValidator], - industryId: [undefined], - description: ["", [Validators.maxLength(800)]], - presentationAddress: [""], - coverImageAddress: [""], - actuality: ["", [Validators.maxLength(1000)]], - targetAudience: ["", [Validators.maxLength(500)]], - problem: ["", [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, - }) - ) - ) - .subscribe(); - - // Автосохранение при очистке coverImageAddress - this.coverImageAddress?.valueChanges - .pipe( - filter(value => !value), - concatMap(() => - this.projectService.updateProject(Number(this.route.snapshot.params["projectId"]), { - coverImageAddress: "", - draft: true, - }) - ) - ) - .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, optionalUrlOrMentionValidator)); - }); - } - - /** - * Заполняет 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); - }); - } - - /** - * Возвращает основную форму проекта. - * @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 { - const value = stripNullish(this.projectForm.value); - - if (Array.isArray(value["links"])) { - value["links"] = value["links"].map((v: string) => v?.trim()).filter((v: string) => !!v); - } - - return 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(500)); - 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/office/projects/edit/services/project-goals.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts deleted file mode 100644 index 9aff1cc92..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** @format */ - -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"; - -/** - * Сервис для управления целями проекта - * Предоставляет полный набор методов для работы с целями: - * - инициализация, добавление, редактирование, удаление - * - валидация и очистка ошибок - * - управление состоянием модального окна выбора лидера - */ -@Injectable({ - providedIn: "root", -}) -export class ProjectGoalService { - private readonly fb = inject(FormBuilder); - private goalForm!: FormGroup; - private readonly projectFormService = inject(ProjectFormService); - private readonly projectService = inject(ProjectService); - public readonly goalItems = signal([]); - - /** Флаг инициализации сервиса */ - private initialized = false; - - public readonly goalLeaderShowModal = signal(false); - public readonly activeGoalIndex = signal(null); - public readonly selectedLeaderId = signal(""); - - 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([]); - } - } - - /** - * Возвращает форму целей. - * @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): void { - const goalFormArray = this.goals; - - this.goalItems.update(items => items.filter((_, i) => i !== index)); - goalFormArray.removeAt(index); - } - - /** - * Получает выбранного лидера для конкретной цели - * @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()); - } - - /** - * Обработчик изменения радио-кнопки для выбора лидера - * @param event - событие изменения - */ - public onLeaderRadioChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.selectedLeaderId.set(target.value); - } - - /** - * Добавляет лидера на определенную цель - */ - public addLeaderToGoal(): void { - const goalIndex = this.activeGoalIndex(); - const leaderId = this.selectedLeaderId(); - - if (goalIndex === null || !leaderId) { - return; - } - - const goalFormGroup = this.goals.at(goalIndex); - goalFormGroup?.get("responsible")?.setValue(leaderId); - - this.closeGoalLeaderModal(); - } - - /** - * Открывает модальное окно выбора лидера для конкретной цели - * @param index - индекс цели - */ - public openGoalLeaderModal(index: number): void { - this.activeGoalIndex.set(index); - - const currentLeader = this.goals.at(index)?.get("responsible")?.value; - this.selectedLeaderId.set(currentLeader || ""); - - this.goalLeaderShowModal.set(true); - } - - /** - * Закрывает модальное окно выбора лидера - */ - public closeGoalLeaderModal(): void { - this.goalLeaderShowModal.set(false); - this.activeGoalIndex.set(null); - this.selectedLeaderId.set(""); - } - - /** - * Переключает состояние модального окна выбора лидера - * @param index - индекс цели (опционально) - */ - public toggleGoalLeaderModal(index?: number): void { - if (this.goalLeaderShowModal()) { - this.closeGoalLeaderModal(); - } else if (index !== undefined) { - this.openGoalLeaderModal(index); - } - } - - /** - * Сбрасывает все ошибки валидации во всех контролах 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: GoalPostForm = { - 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.closeGoalLeaderModal(); - } - - /** - * Очищает 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/office/projects/edit/services/project-partner.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts deleted file mode 100644 index d94183e54..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** @format */ - -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 { catchError, forkJoin, map, Observable, of, tap } from "rxjs"; - -@Injectable({ - providedIn: "root", -}) -export class ProjectPartnerService { - private readonly fb = inject(FormBuilder); - private partnerForm!: FormGroup; - private readonly projectService = inject(ProjectService); - public readonly partnerItems = signal([]); - - /** Флаг инициализации сервиса */ - 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([]); - } - } - - /** - * Возвращает форму партнеров и ресурсов. - * @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): void { - const partnerFormArray = this.partners; - - this.partnerItems.update(items => items.filter((_, i) => i !== index)); - partnerFormArray.removeAt(index); - } - - /** - * Сбрасывает все ошибки валидации во всех контролах 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: PartnerPostForm = { - 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/office/projects/edit/services/project-resources.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts deleted file mode 100644 index 15b661535..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** @format */ - -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"; - -@Injectable({ - providedIn: "root", -}) -export class ProjectResourceService { - private readonly fb = inject(FormBuilder); - private readonly projectService = inject(ProjectService); - private resourceForm!: FormGroup; - public readonly resourceItems = signal([]); - - /** Флаг инициализации сервиса */ - 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([]); - } - } - - /** - * Возвращает форму партнеров и ресурсов. - * @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): void { - const resourceFormArray = this.resources; - - this.resourceItems.update(items => items.filter((_, i) => i !== index)); - resourceFormArray.removeAt(index); - } - - /** - * Сбрасывает все ошибки валидации во всех контролах 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: any) => ({ - 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(); - console.log(resources); - - 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/office/projects/edit/services/project-team.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts deleted file mode 100644 index be1597dd9..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** @format */ - -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"; - -/** - * Сервис для управления приглашениями участников команды проекта. - * Предоставляет функциональность для создания и валидации формы приглашения, - * отправки, редактирования и удаления приглашений, управления состоянием модального окна и ошибок. - */ -@Injectable({ providedIn: "root" }) -export class ProjectTeamService { - private inviteForm!: FormGroup; - private readonly fb = inject(FormBuilder); - private readonly inviteService = inject(InviteService); - private readonly validationService = inject(ValidationService); - - public readonly invites = signal([]); - public readonly collaborators = signal([]); - public readonly isInviteModalOpen = signal(false); - public readonly inviteNotExistingError = signal(null); - - // Состояние отправки формы - readonly inviteSubmitInitiated = signal(false); - readonly inviteFormIsSubmitting = signal(false); - - constructor() { - this.initializeInviteForm(); - } - - /** - * Создает форму приглашения с контролами role, link и specialization, устанавливая валидаторы. - */ - private initializeInviteForm(): void { - this.inviteForm = this.fb.group({ - role: ["", [Validators.required]], - link: [ - "", - [ - Validators.required, - Validators.pattern(/^http(s)?:\/\/.+(:[0-9]*)?\/office\/profile\/\d+$/), - ], - ], - specialization: [null], - }); - } - - /** - * Возвращает инстанс формы приглашения. - * @returns FormGroup inviteForm - */ - public getInviteForm(): FormGroup { - return this.inviteForm; - } - - /** - * Устанавливает список приглашений. - * @param invites массив Invite - */ - public setInvites(invites: Invite[]): void { - this.invites.set(invites); - } - - /** - * Устанавливает список команды - * @param collaborators массив Collaborator - */ - public setCollaborators(collaborators: Collaborator[]): void { - this.collaborators.set(collaborators); - } - - /** - * Возвращает текущий список команды. - * @returns Collaborator[] массив команды - */ - public getCollaborators(): Collaborator[] { - return this.collaborators(); - } - - /** - * Возвращает текущий список приглашений. - * @returns Invite[] массив приглашений - */ - public getInvites(): Invite[] { - return this.invites(); - } - - // Геттеры для контролов формы приглашения - public get role() { - return this.inviteForm.get("role"); - } - - public get link() { - return this.inviteForm.get("link"); - } - - public get specialization() { - return this.inviteForm.get("specialization"); - } - - /** - * Открывает модальное окно для отправки приглашения. - */ - public openInviteModal(): void { - this.isInviteModalOpen.set(true); - } - - /** - * Закрывает модальное окно для отправки приглашения. - */ - public closeInviteModal(): void { - this.isInviteModalOpen.set(false); - } - - /** - * Сбрасывает ошибку отсутствия пользователя при изменении ссылки. - */ - public clearLinkError(): void { - if (this.inviteNotExistingError()) { - this.inviteNotExistingError.set(null); - } - } - - /** - * Отправляет приглашение пользователю по ссылке. - * @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 - ) - .subscribe({ - next: invite => { - this.invites.update(list => [...list, invite]); - this.resetInviteForm(); - this.closeInviteModal(); - }, - error: err => { - this.inviteNotExistingError.set(err); - this.inviteFormIsSubmitting.set(false); - }, - }); - } - - /** - * Обновляет параметры существующего приглашения. - * @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).subscribe(() => { - this.invites.update(list => - list.map(i => (i.id === inviteId ? { ...i, role, specialization } : i)) - ); - }); - } - - /** - * Удаляет приглашение по идентификатору. - * @param invitationId идентификатор приглашения - */ - public removeInvitation(invitationId: number): void { - this.inviteService.revokeInvite(invitationId).subscribe(() => { - this.invites.update(list => list.filter(i => i.id !== invitationId)); - }); - } - - /** - * Удаляет участника по идентификатору. - * @param collaboratorId идентификатор приглашения - */ - public removeCollaborator(collaboratorId: number): void { - this.collaborators.update(list => list.filter(i => i.userId !== collaboratorId)); - } - - /** - * Проверяет валидность формы приглашения. - * @returns boolean true если форма валидна - */ - public validateInviteForm(): boolean { - return this.inviteForm.valid; - } - - /** - * Возвращает текущее значение формы приглашения. - * @returns any объект значений формы - */ - public getInviteFormValue(): any { - return this.inviteForm.value; - } - - /** - * Сбрасывает форму приглашения и очищает ошибки. - */ - public 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); - } - - /** - * Настроивает динамическую валидацию для поля link: - * сбрасывает валидаторы при пустом значении и очищает ошибку. - */ - public setupDynamicValidation(): void { - this.inviteForm.get("link")?.valueChanges.subscribe(value => { - if (value === "") { - this.inviteForm.get("link")?.clearValidators(); - this.inviteForm.get("link")?.updateValueAndValidity(); - } - this.clearLinkError(); - }); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts deleted file mode 100644 index 0eb24ec3d..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** @format */ - -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.model"; -import { Vacancy } from "@office/models/vacancy.model"; -import { VacancyService } from "@office/services/vacancy.service"; -import { stripNullish } from "@utils/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"; - -/** - * Сервис для управления вакансиями проекта. - * Обеспечивает создание, валидацию, отправку, - * редактирование и удаление вакансий, а также работу с формой вакансии - * и синхронизацию с API. - */ -@Injectable({ providedIn: "root" }) -export class ProjectVacancyService { - /** Форма для создания и редактирования вакансии */ - private vacancyForm!: FormGroup; - private readonly fb = inject(FormBuilder); - private readonly route = inject(ActivatedRoute); - private readonly vacancyService = inject(VacancyService); - 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 vacancySubmitInitiated = signal(false); - readonly vacancyIsSubmitting = signal(false); - - public vacancies = signal([]); - public onEditClicked = signal(false); - - constructor() { - this.initializeVacancyForm(); - } - - /** - * Инициализирует форму вакансии с необходимыми контролами и без валидаторов. - */ - private initializeVacancyForm(): void { - this.vacancyForm = this.fb.group({ - role: [null], - skills: [[]], - description: ["", [Validators.maxLength(3500)]], - requiredExperience: [null], - workFormat: [null], - salary: [""], - workSchedule: [null], - specialization: [null], - }); - } - - /** - * Возвращает форму вакансии. - * @returns FormGroup экземпляр формы вакансии - */ - public getVacancyForm(): FormGroup { - return this.vacancyForm; - } - - /** - * Устанавливает список вакансий. - * @param vacancies массив объектов Vacancy - */ - public setVacancies(vacancies: Vacancy[]): void { - this.vacancies.set(vacancies); - } - - /** - * Возвращает текущий список вакансий. - * @returns Vacancy[] массив вакансий - */ - public getVacancies(): Vacancy[] { - return this.vacancies(); - } - - /** - * Проставляет значения в форму вакансии. - * @param values частичные поля Vacancy для патчинга - */ - public patchFormValues(values: Partial): void { - this.vacancyForm.patchValue(values); - } - - /** - * Проверяет валидность формы вакансии. - * @returns true если форма валидна - */ - public validateForm(): boolean { - return this.vacancyForm.valid; - } - - /** - * Возвращает очищенные от nullish значения формы. - * @returns объект значений формы без null и undefined - */ - public getFormValue(): any { - return stripNullish(this.vacancyForm.value); - } - - // Геттеры для быстрого доступа к контролам формы - public get role() { - return this.vacancyForm.get("role"); - } - - public get skills() { - return this.vacancyForm.get("skills"); - } - - public get description() { - return this.vacancyForm.get("description"); - } - - public get requiredExperience() { - return this.vacancyForm.get("requiredExperience"); - } - - public get workFormat() { - return this.vacancyForm.get("workFormat"); - } - - public get salary() { - return this.vacancyForm.get("salary"); - } - - public get workSchedule() { - return this.vacancyForm.get("workSchedule"); - } - - public get specialization() { - return this.vacancyForm.get("specialization"); - } - - /** - * Отправляет форму вакансии: настраивает валидаторы, проверяет форму, - * создаёт вакансию через 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 vacancy = this.vacancyForm.value; - const payload = { - ...vacancy, - requiredSkillsIds: vacancy.skills.map((s: Skill) => s.id), - salary: typeof vacancy.salary === "string" ? +vacancy.salary : null, - }; - - // Вызов API для создания вакансии - this.vacancyService.postVacancy(projectId, payload).subscribe({ - next: vacancy => { - this.vacancies.update(list => [...list, vacancy]); - this.resetVacancyForm(); - }, - error: () => { - this.vacancyIsSubmitting.set(false); - }, - }); - } - - /** - * Сбрасывает форму вакансии к начальному состоянию: - * очищает значения, валидаторы и состояния контролов, - * сбрасывает сигналы выбранных селектов. - */ - private resetVacancyForm(): 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); - } - - /** - * Удаляет вакансию по её идентификатору с подтверждением пользователя. - * @param vacancyId идентификатор вакансии для удаления - */ - public removeVacancy(vacancyId: number): void { - if (!confirm("Вы точно хотите удалить вакансию?")) return; - this.vacancyService.deleteVacancy(vacancyId).subscribe(() => { - this.vacancies.update(list => list.filter(v => v.id !== vacancyId)); - }); - } - - /** - * Инициализирует редактирование вакансии по индексу в массиве: - * заполняет форму, выставляет сигналы и переключает режим редактирования. - * @param index индекс вакансии в списке vacancies - */ - public editVacancy(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); - } - - /** - * Добавляет навык к списку requiredSkills, если его там нет. - * @param newSkill объект Skill для добавления - */ - public onAddSkill(newSkill: Skill): void { - const skills: Skill[] = this.vacancyForm.value.skills; - if (!skills.some(s => s.id === newSkill.id)) { - this.vacancyForm.patchValue({ skills: [newSkill, ...skills] }); - } - } - - /** - * Удаляет навык из списка requiredSkills. - * @param oldSkill объект Skill для удаления - */ - public onRemoveSkill(oldSkill: Skill): void { - const skills: Skill[] = this.vacancyForm.value.skills; - this.vacancyForm.patchValue({ - skills: skills.filter(s => s.id !== oldSkill.id), - }); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.ts deleted file mode 100644 index feea7d422..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input } from "@angular/core"; -import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { InputComponent, ButtonComponent } from "@ui/components"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { ProjectFormService } from "../../services/project-form.service"; -import { ProjectAchievementsService } from "../../services/project-achievements.service"; -import { IconComponent } from "@uilib"; - -@Component({ - selector: "app-project-achievement-step", - templateUrl: "./project-achievement-step.component.html", - styleUrl: "./project-achievement-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - ], -}) -export class ProjectAchievementStepComponent { - @Input() projSubmitInitiated = false; - - private readonly projectAchievementService = inject(ProjectAchievementsService); - private readonly projectFormService = inject(ProjectFormService); - private readonly fb = inject(FormBuilder); - - readonly errorMessage = ErrorMessage; - - // Состояние для показа полей ввода - public showInputFields = false; - - // Получаем форму из сервиса - get projectForm(): FormGroup { - return this.projectFormService.getForm(); - } - - // Геттеры для FormArray и полей - get achievements(): FormArray { - return this.projectFormService.achievements; - } - - get achievementsName() { - return this.projectForm.get("achievementsName"); - } - - get achievementsDate() { - return this.projectForm.get("achievementsDate"); - } - - get achievementsItems() { - return this.projectAchievementService.achievementsItems; - } - - get editIndex() { - return this.projectFormService.editIndex; - } - - /** - * Проверяет, есть ли достижения для отображения - */ - get hasAchievements(): boolean { - return this.achievementsItems().length > 0 || this.achievements.length > 0; - } - - /** - * Показывает поля для ввода достижения - */ - showFields(): void { - this.showInputFields = true; - } - - /** - * Скрывает поля ввода и очищает их - */ - hideFields(): void { - this.showInputFields = false; - this.clearInputFields(); - } - - /** - * Очищает поля ввода - */ - private clearInputFields(): void { - this.projectForm.get("achievementsName")?.reset(); - this.projectForm.get("achievementsName")?.setValue(""); - - if (this.editIndex() !== null) { - this.projectFormService.editIndex.set(null); - } - } - - /** - * Добавление достижения - */ - addAchievement(id?: number, achievementsName?: string, achievementsDate?: string): void { - const currentYear = new Date().getFullYear(); - this.achievements.push( - this.fb.group({ - id: [id], - title: [achievementsName ?? "", [Validators.required]], - status: [ - achievementsDate ?? "", - [ - Validators.required, - Validators.min(2000), - Validators.max(currentYear), - Validators.pattern(/^\d{4}$/), - ], - ], - }) - ); - - this.projectAchievementService.addAchievement(this.achievements, this.projectForm); - } - - /** - * Редактирование достижения - * @param index - индекс достижения - */ - editAchievement(index: number): void { - this.showInputFields = true; - this.projectAchievementService.editAchievement(index, this.achievements, this.projectForm); - } - - /** - * Удаление достижения - * @param index - индекс достижения - */ - removeAchievement(index: number): void { - this.projectAchievementService.removeAchievement(index, this.achievements); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.ts deleted file mode 100644 index e4e0486a3..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, Input, OnInit, inject, ChangeDetectorRef } from "@angular/core"; -import { FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { - InputComponent, - CheckboxComponent, - SelectComponent, - ButtonComponent, -} from "@ui/components"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { SwitchComponent } from "@ui/components/switch/switch.component"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { ToSelectOptionsPipe } from "projects/core/src/lib/pipes/options-transform.pipe"; -import { ProjectAdditionalService } from "../../services/project-additional.service"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { RouterLink } from "@angular/router"; -import { IconComponent } from "@uilib"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; - -@Component({ - selector: "app-project-additional-step", - templateUrl: "./project-additional-step.component.html", - styleUrl: "./project-additional-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - IconComponent, - CheckboxComponent, - SwitchComponent, - SelectComponent, - TextareaComponent, - ControlErrorPipe, - ToSelectOptionsPipe, - ButtonComponent, - RouterLink, - TooltipComponent, - ], -}) -export class ProjectAdditionalStepComponent implements OnInit { - private readonly projectAdditionalService = inject(ProjectAdditionalService); - private readonly cdRef = inject(ChangeDetectorRef); - - readonly errorMessage = ErrorMessage; - - @Input() isProjectAssignToProgram?: boolean; - - ngOnInit(): void { - // Инициализация уже должна быть выполнена в родительском компоненте - this.cdRef.detectChanges(); - } - - // Геттеры для получения данных из сервиса - get additionalForm(): FormGroup { - return this.projectAdditionalService.getAdditionalForm(); - } - - get partnerProgramFields(): PartnerProgramFields[] { - return this.projectAdditionalService.getPartnerProgramFields(); - } - - get isSendingDecision() { - return this.projectAdditionalService.getIsSendingDecision(); - } - - get isAssignProjectToProgramError() { - return this.projectAdditionalService.getIsAssignProjectToProgramError(); - } - - get errorAssignProjectToProgramModalMessage() { - return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); - } - - /** Наличие подсказки */ - haveHint = false; - - /** Текст для подсказки */ - tooltipText?: string; - - /** Позиция подсказки */ - tooltipPosition: "left" | "right" = "right"; - - /** Состояние видимости подсказки */ - isTooltipVisible = false; - - /** Показать подсказку */ - showTooltip(): void { - this.isTooltipVisible = true; - } - - /** Скрыть подсказку */ - hideTooltip(): void { - this.isTooltipVisible = false; - } - - /** - * Переключение значения для checkbox и radio полей - * @param fieldType - тип поля - * @param fieldName - имя поля - */ - toggleAdditionalFormValues( - fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", - fieldName: string - ): void { - this.projectAdditionalService.toggleAdditionalFormValues(fieldType, fieldName); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.ts deleted file mode 100644 index b499a4bc1..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** @format */ - -import { Component, Input, inject, OnInit, OnDestroy, signal } from "@angular/core"; -import { - FormArray, - FormBuilder, - FormGroup, - FormsModule, - ReactiveFormsModule, - Validators, -} from "@angular/forms"; -import { AuthService } from "@auth/services"; -import { ErrorMessage } from "@error/models/error-message"; -import { directionProjectList } from "projects/core/src/consts/lists/ldirection-project-list.const"; -import { trackProjectList } from "projects/core/src/consts/lists/track-project-list.const"; -import { Observable, Subscription } from "rxjs"; -import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; -import { InputComponent, SelectComponent, ButtonComponent } from "@ui/components"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; -import { AsyncPipe, CommonModule } from "@angular/common"; -import { ControlErrorPipe } from "@corelib"; -import { ProjectFormService } from "../../services/project-form.service"; -import { IconComponent } from "@uilib"; -import { ProjectContactsService } from "../../services/project-contacts.service"; -import { ProjectGoalService } from "../../services/project-goals.service"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ProjectTeamService } from "../../services/project-team.service"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { ProjectService } from "@office/services/project.service"; -import { RouterLink } from "@angular/router"; -import { generateOptionsList } from "@utils/generate-options-list"; -import { optionalUrlOrMentionValidator } from "@utils/optionalUrl.validator"; - -@Component({ - selector: "app-project-main-step", - templateUrl: "./project-main-step.component.html", - styleUrl: "./project-main-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - AvatarControlComponent, - InputComponent, - SelectComponent, - IconComponent, - TextareaComponent, - ButtonComponent, - UploadFileComponent, - AsyncPipe, - ControlErrorPipe, - ModalComponent, - AvatarComponent, - FormsModule, - RouterLink, - ], -}) -export class ProjectMainStepComponent implements OnInit, OnDestroy { - @Input() industries$!: Observable; - @Input() leaderId = 0; - @Input() projSubmitInitiated = false; - @Input() projectId!: number; - @Input() isProjectBoundToProgram = false; - - private subscription = new Subscription(); - - readonly authService = inject(AuthService); - private readonly projectService = inject(ProjectService); - private readonly projectFormService = inject(ProjectFormService); - private readonly projectContactsService = inject(ProjectContactsService); - private readonly projectGoalsService = inject(ProjectGoalService); - private readonly projectTeamService = inject(ProjectTeamService); - private readonly fb = inject(FormBuilder); - - readonly errorMessage = ErrorMessage; - readonly trackList = trackProjectList; - readonly directionList = directionProjectList; - readonly trlList = generateOptionsList(9, "numbers"); - - goalLeaderShowModal = false; - activeGoalIndex = signal(null); - selectedLeaderId = ""; - - // Получаем форму из сервиса - get projectForm(): FormGroup { - return this.projectFormService.getForm(); - } - - get goalForm(): FormGroup { - return this.projectGoalsService.getForm(); - } - - ngOnInit(): void {} - - ngOnDestroy(): void { - this.subscription.unsubscribe(); - } - - // Геттеры для удобного доступа к контролам формы - get name() { - return this.projectFormService.name; - } - - get region() { - return this.projectFormService.region; - } - - get industry() { - return this.projectFormService.industry; - } - - get description() { - return this.projectFormService.description; - } - - get actuality() { - return this.projectFormService.actuality; - } - - get implementationDeadline() { - return this.projectFormService.implementationDeadline; - } - - get problem() { - return this.projectFormService.problem; - } - - get targetAudience() { - return this.projectFormService.targetAudience; - } - - get trl() { - return this.projectFormService.trl; - } - - get presentationAddress() { - return this.projectFormService.presentationAddress; - } - - get coverImageAddress() { - return this.projectFormService.coverImageAddress; - } - - get imageAddress() { - return this.projectFormService.imageAddress; - } - - get partnerProgramId() { - return this.projectFormService.partnerProgramId; - } - - // Геттеры для работы со ссылками - get link() { - return this.projectContactsService.link; - } - - get links(): FormArray { - return this.projectForm.get("links") as FormArray; - } - - // Геттеры для работы с целями - get goals(): FormArray { - return this.projectGoalsService.goals; - } - - get goalItems() { - return this.projectGoalsService.goalItems; - } - - get goalName() { - return this.projectGoalsService.goalName; - } - - get goalDate() { - return this.projectGoalsService.goalDate; - } - - get goalLeader() { - return this.projectGoalsService.goalLeader; - } - - get editIndex() { - return this.projectFormService.editIndex; - } - - get collaborators() { - return this.projectTeamService.getCollaborators(); - } - - /** - * Проверяет, есть ли ссылки для отображения - */ - get hasLinks(): boolean { - return this.links.length > 0; - } - - /** - * Проверяет, есть ли цели для отображения - */ - get hasGoals(): boolean { - return this.goals.length > 0; - } - - /** - * Добавление ссылки - */ - addLink(): void { - this.links.push(this.fb.control("", optionalUrlOrMentionValidator)); - } - - /** - * Редактирование ссылки - * @param index - индекс ссылки - */ - editLink(index: number): void { - this.projectContactsService.editLink(index, this.links, this.projectForm); - } - - /** - * Удаление ссылки - * @param index - индекс ссылки - */ - removeLink(index: number): void { - this.links.removeAt(index); - } - - /** - * Добавление цели - */ - addGoal(goalName?: string, goalDate?: string, goalLeader?: string): void { - this.goals.push( - this.fb.group({ - title: [goalName, [Validators.required]], - completionDate: [goalDate, [Validators.required]], - responsible: [goalLeader, [Validators.required]], - }) - ); - - this.projectGoalsService.addGoal(goalName, goalDate, goalLeader); - } - - /** - * Удаление цели - * @param index - индекс цели - */ - removeGoal(index: number, goalId: number): void { - this.projectGoalsService.removeGoal(index); - this.projectService.deleteGoals(this.projectId, goalId).subscribe(); - } - - /** - * Получить выбранного лидера для конкретной цели - */ - getSelectedLeaderForGoal(goalIndex: number) { - const goalFormGroup = this.goals.at(goalIndex); - const leaderId = goalFormGroup?.get("responsible")?.value; - - if (!leaderId) return null; - - return this.collaborators.find(collab => collab.userId.toString() === leaderId.toString()); - } - - /** - * Обработчик изменения радио-кнопки для выбора лидера - */ - onLeaderRadioChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.selectedLeaderId = target.value; - } - - /** - * Добавление лидера на определенную цель - */ - addLeader(): void { - const goalIndex = this.activeGoalIndex(); - - if (goalIndex === null) { - return; - } - - if (!this.selectedLeaderId) { - return; - } - - // Устанавливаем выбранного лидера в форму - const goalFormGroup = this.goals.at(goalIndex); - goalFormGroup?.get("responsible")?.setValue(Number(this.selectedLeaderId)); - - this.toggleGoalLeaderModal(); - this.selectedLeaderId = ""; - } - - /** - * Переключатель для модалки выбора лидера - */ - toggleGoalLeaderModal(index?: number): void { - this.goalLeaderShowModal = !this.goalLeaderShowModal; - - if (index !== undefined) { - this.activeGoalIndex.set(index); - const currentLeader = this.goals.at(index)?.get("responsible")?.value; - this.selectedLeaderId = currentLeader || ""; - } else { - this.activeGoalIndex.set(null); - this.selectedLeaderId = ""; - } - } - - trackByIndex(index: number): number { - return index; - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.html deleted file mode 100644 index 87246e721..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.html +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.ts deleted file mode 100644 index 112d15c55..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { Component, inject, Output, EventEmitter } from "@angular/core"; -import { EditStep, ProjectStepService } from "../../services/project-step.service"; -import { IconComponent } from "@uilib"; -import { CommonModule } from "@angular/common"; -import { navProjectItems } from "projects/core/src/consts/navigation/nav-project-items.const"; - -@Component({ - selector: "app-project-navigation", - templateUrl: "./project-navigation.component.html", - styleUrl: "project-navigation.component.scss", - standalone: true, - imports: [IconComponent, CommonModule], -}) -export class ProjectNavigationComponent { - @Output() stepChange = new EventEmitter(); - - readonly navProjectItems = navProjectItems; - private stepService = inject(ProjectStepService); - - currentStep = this.stepService.getCurrentStep(); - - onStepClick(step: EditStep): void { - this.stepChange.emit(step); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.ts deleted file mode 100644 index 169f75a8f..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input, OnDestroy } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { IconComponent } from "@uilib"; -import { ProjectPartnerService } from "../../services/project-partner.service"; -import { ProjectResourceService } from "../../services/project-resources.service"; -import { ButtonComponent, InputComponent, SelectComponent } from "@ui/components"; -import { Subscription } from "rxjs"; -import { ControlErrorPipe } from "@corelib"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { ProjectService } from "@office/services/project.service"; -import { optionsListElement } from "@utils/generate-options-list"; -import { resourceOptionsList } from "projects/core/src/consts/lists/resource-options-list.const"; - -@Component({ - selector: "app-project-partner-resources-step", - templateUrl: "./project-partner-resources-step.component.html", - styleUrl: "./project-partner-resources-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - IconComponent, - ButtonComponent, - InputComponent, - ControlErrorPipe, - TextareaComponent, - SelectComponent, - ], -}) -export class ProjectPartnerResourcesStepComponent implements OnDestroy { - @Input() projectId!: number; - - private readonly projectPartnerService = inject(ProjectPartnerService); - private readonly projectResourceService = inject(ProjectResourceService); - private readonly projectService = inject(ProjectService); - private readonly fb = inject(FormBuilder); - - readonly errorMessage = ErrorMessage; - private subscription = new Subscription(); - - // Получаем форму из сервиса - get partnerForm(): FormGroup { - return this.projectPartnerService.getForm(); - } - - get resourceForm(): FormGroup { - return this.projectResourceService.getForm(); - } - - ngOnDestroy(): void { - this.subscription.unsubscribe(); - } - - // Геттеры для удобного доступа к контролам формы - get resources() { - return this.projectResourceService.resources; - } - - get type() { - return this.projectResourceService.resoruceType; - } - - get description() { - return this.projectResourceService.resoruceDescription; - } - - get partnerCompany() { - return this.projectResourceService.resourcePartner; - } - - get partners() { - return this.projectPartnerService.partners; - } - - get name() { - return this.projectPartnerService.partnerName; - } - - get inn() { - return this.projectPartnerService.partnerINN; - } - - get contribution() { - return this.projectPartnerService.partnerMention; - } - - get decisionMaker() { - return this.projectPartnerService.partnerProfileLink; - } - - get hasPartners() { - return this.partners.length > 0; - } - - get hasResources() { - return this.resources.length > 0; - } - - get resourcesCompanyOptions(): optionsListElement[] { - const partners = this.partners.value || []; - - const partnerOptions: optionsListElement[] = partners.map((partner: any, index: number) => { - const id = partner?.company?.id ?? partner?.id ?? index; - const value = partner?.company?.id ?? partner?.id ?? null; - const label = partner?.name; - - return { - id, - value, - label, - } as optionsListElement; - }); - - partnerOptions.push({ - id: -1, - value: "запрос к рынку", - label: "запрос к рынку", - }); - - return partnerOptions; - } - - get resourcesTypeOptions(): optionsListElement[] { - return resourceOptionsList; - } - - /** - * Добавление партнера - */ - addPartner(name?: string, inn?: string, contribution?: string, decisionMaker?: string): void { - this.partners.push( - this.fb.group({ - name: [name, [Validators.required]], - inn: [inn, [Validators.required]], - contribution: [contribution, [Validators.required]], - decisionMaker: [decisionMaker, Validators.required], - }) - ); - - this.projectPartnerService.addPartner(name, inn, contribution, decisionMaker); - } - - /** - * Удаление партнера - * @param index - индекс партнера - */ - removePartner(index: number, partnersId: number) { - this.projectPartnerService.removePartner(index); - this.projectService.deletePartner(this.projectId, partnersId).subscribe(); - } - - /** - * Добавление ресурса - */ - addResource(type?: string, description?: string, partnerCompany?: string): void { - this.resources.push( - this.fb.group({ - type: [type, [Validators.required]], - description: [description, [Validators.required]], - partnerCompany: [partnerCompany, [Validators.required]], - }) - ); - - this.projectResourceService.addResource(type, description, partnerCompany); - } - - /** - * Удаление ресурса - * @param index - индекс ресурса - */ - removeResource(index: number, resourceId: number) { - this.projectResourceService.removeResource(index); - this.projectService.deleteResource(this.projectId, resourceId).subscribe(); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.ts deleted file mode 100644 index 9e4198e18..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnInit, signal } from "@angular/core"; -import { FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { InputComponent, ButtonComponent, SelectComponent } from "@ui/components"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { InviteCardComponent } from "@office/features/invite-card/invite-card.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ProjectTeamService } from "../../services/project-team.service"; -import { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; -import { ActivatedRoute } from "@angular/router"; -import { IconComponent } from "@uilib"; -import { CollaboratorCardComponent } from "@office/shared/collaborator-card/collaborator-card.component"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { Collaborator } from "@office/models/collaborator.model"; - -@Component({ - selector: "app-project-team-step", - templateUrl: "./project-team-step.component.html", - styleUrl: "./project-team-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - InviteCardComponent, - CollaboratorCardComponent, - TooltipComponent, - ModalComponent, - ], -}) -export class ProjectTeamStepComponent implements OnInit { - private readonly projectTeamService = inject(ProjectTeamService); - private readonly route = inject(ActivatedRoute); - - readonly errorMessage = ErrorMessage; - - // Константы для селектов - readonly rolesMembersList = rolesMembersList; - - showFields = false; - readonly isHintTeamVisible = signal(false); - readonly isHintTeamModal = signal(false); - - ngOnInit(): void { - this.projectTeamService.setInvites(this.invites); - this.projectTeamService.setCollaborators(this.collaborators); - - // Настраиваем динамическую валидацию - this.projectTeamService.setupDynamicValidation(); - } - - // Геттеры для формы - get inviteForm(): FormGroup { - return this.projectTeamService.getInviteForm(); - } - - get role() { - return this.projectTeamService.role; - } - - get link() { - return this.projectTeamService.link; - } - - get specialization() { - return this.projectTeamService.specialization; - } - - // Геттеры для данных - get invites() { - return this.projectTeamService.getInvites(); - } - - get collaborators() { - return this.projectTeamService.getCollaborators(); - } - - get invitesFill(): boolean { - return this.invites.some(inv => inv.isAccepted === null); - } - - get isInviteModalOpen() { - return this.projectTeamService.isInviteModalOpen; - } - - get inviteNotExistingError() { - return this.projectTeamService.inviteNotExistingError; - } - - get inviteSubmitInitiated() { - return this.projectTeamService.inviteSubmitInitiated; - } - - get inviteFormIsSubmitting() { - return this.projectTeamService.inviteFormIsSubmitting; - } - - /** Наличие подсказки */ - haveHint = false; - - /** Текст для подсказки */ - tooltipText?: string; - - /** Позиция подсказки */ - tooltipPosition: "left" | "right" = "right"; - - /** Состояние видимости подсказки */ - isTooltipVisible = false; - - /** Показать подсказку */ - showTooltip(): void { - this.isTooltipVisible = true; - } - - /** Скрыть подсказку */ - hideTooltip(): void { - this.isTooltipVisible = false; - } - - /** - * Открытие блоков для создания приглашения - */ - createInvitationBlock(): void { - this.showFields = true; - } - - /** - * Открытие модального окна приглашения - */ - openInviteModal(): void { - this.projectTeamService.openInviteModal(); - } - - /** - * Закрытие модального окна приглашения - */ - closeInviteModal(): void { - this.projectTeamService.closeInviteModal(); - } - - /** - * Отправка приглашения - */ - submitInvite(): void { - const projectId = Number(this.route.snapshot.paramMap.get("projectId")); - - if (this.link?.value.trim() || this.role?.value.trim()) { - this.projectTeamService.submitInvite(projectId); - this.showFields = false; - return; - } - - this.showFields = false; - } - - /** - * Редактирование приглашения - */ - editInvitation(params: { inviteId: number; role: string; specialization: string }): void { - this.projectTeamService.editInvitation(params); - } - - /** - * Удаление приглашения - */ - removeInvitation(invitationId: number): void { - this.projectTeamService.removeInvitation(invitationId); - } - - /** - * Обработка изменения состояния модального окна - */ - onModalOpenChange(open: boolean): void { - if (!open) { - this.closeInviteModal(); - } - } - - onCollaboratorRemove(collaboratorId: number): void { - this.projectTeamService.removeCollaborator(collaboratorId); - } - - openHintModal(event: Event): void { - event.preventDefault(); - this.isHintTeamVisible.set(false); - this.isHintTeamModal.set(true); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.scss b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.scss deleted file mode 100644 index 9e82d49e5..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.scss +++ /dev/null @@ -1,103 +0,0 @@ -/** @format */ - -@use "styles/responsive"; -@use "styles/typography"; - -.project { - position: relative; - - &__inner { - width: 100%; - margin-bottom: 25px; - - @include responsive.apply-desktop { - display: flex; - gap: 20px; - justify-content: space-between; - margin-bottom: 0; - margin-bottom: 20px; - } - } - - &__inner > fieldset:not(:last-child) { - margin-bottom: 20px; - } - - &__left { - flex-basis: 60%; - margin-bottom: 20px; - } - - &__right { - flex-basis: 30%; - - :first-child & :not(span, fieldset, label, h4, p, i) { - margin-top: 26px; - margin-bottom: 10px; - } - - :last-child & :not(i, span) { - margin-top: 10px; - } - } - - &__no-items { - position: absolute; - bottom: 0%; - left: 50%; - } -} - -.invite { - &__item { - margin-bottom: 12px; - } -} - -.vacancy { - &__item { - margin-bottom: 12px; - } - - fieldset { - margin-bottom: 12px; - } - - &__form-list { - display: flex; - flex-wrap: wrap; - } - - &__skill { - margin-bottom: 12px; - - &:not(:last-child) { - margin-right: 10px; - } - } - - &__info, - &__additional { - display: flex; - gap: 20px; - align-items: center; - - :first-child, - :last-child { - flex-basis: 50%; - } - } - - &__submit { - display: block; - } -} - -.vacancies { - display: flex; - - &__input { - flex-grow: 1; - margin-right: 6px; - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts deleted file mode 100644 index b5a14958e..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input, Output, EventEmitter, OnInit } from "@angular/core"; -import { FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { InputComponent, ButtonComponent, SelectComponent } from "@ui/components"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; -import { VacancyCardComponent } from "@office/features/vacancy-card/vacancy-card.component"; -import { Skill } from "@office/models/skill.model"; -import { ProjectVacancyService } from "../../services/project-vacancy.service"; -import { ActivatedRoute } from "@angular/router"; -import { IconComponent } from "@uilib"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; - -@Component({ - selector: "app-project-vacancy-step", - templateUrl: "./project-vacancy-step.component.html", - styleUrl: "./project-vacancy-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - SelectComponent, - AutoCompleteInputComponent, - SkillsBasketComponent, - VacancyCardComponent, - TextareaComponent, - ], -}) -export class ProjectVacancyStepComponent implements OnInit { - @Input() inlineSkills: Skill[] = []; - - @Output() searchSkill = new EventEmitter(); - @Output() addSkill = new EventEmitter(); - @Output() removeSkill = new EventEmitter(); - @Output() toggleSkillsGroupsModal = new EventEmitter(); - - private readonly projectVacancyService = inject(ProjectVacancyService); - private readonly route = inject(ActivatedRoute); - - readonly errorMessage = ErrorMessage; - showFields = false; - - ngOnInit(): void { - this.projectVacancyService.setVacancies(this.vacancies); - } - - // Геттеры для формы - get vacancyForm(): FormGroup { - return this.projectVacancyService.getVacancyForm(); - } - - get role() { - return this.projectVacancyService.role; - } - - get description() { - return this.projectVacancyService.description; - } - - get requiredExperience() { - return this.projectVacancyService.requiredExperience; - } - - get workFormat() { - return this.projectVacancyService.workFormat; - } - - get salary() { - return this.projectVacancyService.salary; - } - - get workSchedule() { - return this.projectVacancyService.workSchedule; - } - - get skills() { - return this.projectVacancyService.skills; - } - - get specialization() { - return this.projectVacancyService.specialization; - } - - // Геттеры для данных - get vacancies() { - return this.projectVacancyService.getVacancies(); - } - - get experienceList() { - return this.projectVacancyService.workExperienceList; - } - - get formatList() { - return this.projectVacancyService.workFormatList; - } - - get scheludeList() { - return this.projectVacancyService.workScheludeList; - } - - get rolesMembersList() { - return this.projectVacancyService.rolesMembersList; - } - - get selectedRequiredExperienceId() { - return this.projectVacancyService.selectedRequiredExperienceId; - } - - get selectedWorkFormatId() { - return this.projectVacancyService.selectedWorkFormatId; - } - - get selectedWorkScheduleId() { - return this.projectVacancyService.selectedWorkScheduleId; - } - - get selectedVacanciesSpecializationId() { - return this.projectVacancyService.selectedVacanciesSpecializationId; - } - - get vacancySubmitInitiated() { - return this.projectVacancyService.vacancySubmitInitiated; - } - - get vacancyIsSubmitting() { - return this.projectVacancyService.vacancyIsSubmitting; - } - - /** - * Отображение блока вакансий - */ - createVacancyBlock(): void { - this.showFields = true; - } - - /** - * Отправка формы вакансии - */ - submitVacancy(): void { - const projectId = Number(this.route.snapshot.paramMap.get("projectId")); - this.projectVacancyService.submitVacancy(projectId); - } - - /** - * Удаление вакансии - */ - removeVacancy(vacancyId: number): void { - this.projectVacancyService.removeVacancy(vacancyId); - } - - /** - * Редактирование вакансии - */ - editVacancy(index: number): void { - this.projectVacancyService.editVacancy(index); - } - - /** - * Обработчики событий для навыков - */ - onSearchSkill(query: string): void { - this.searchSkill.emit(query); - } - - onAddSkill(skill: Skill): void { - this.projectVacancyService.onAddSkill(skill); - this.addSkill.emit(skill); - } - - onRemoveSkill(skill: Skill): void { - this.projectVacancyService.onRemoveSkill(skill); - this.removeSkill.emit(skill); - } - - onToggleSkillsGroupsModal(): void { - this.toggleSkillsGroupsModal.emit(); - } -} diff --git a/projects/social_platform/src/app/office/projects/list/invites.resolver.ts b/projects/social_platform/src/app/office/projects/list/invites.resolver.ts deleted file mode 100644 index c280a11c4..000000000 --- a/projects/social_platform/src/app/office/projects/list/invites.resolver.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ResolveFn } from "@angular/router"; -import { Invite } from "@office/models/invite.model"; -import { InviteService } from "@office/services/invite.service"; - -/** - * Резолвер для предзагрузки приглашений пользователя - * Загружает данные о приглашениях перед инициализацией компонента офиса - * - * Принимает: - * - Контекст маршрута (неявно через Angular DI) - * - * Возвращает: - * - Observable - массив приглашений пользователя - */ -export const ProjectsInvitesResolver: ResolveFn = () => { - const inviteService = inject(InviteService); - - return inviteService.getMy(); -}; diff --git a/projects/social_platform/src/app/office/projects/list/list.component.html b/projects/social_platform/src/app/office/projects/list/list.component.html deleted file mode 100644 index 5cfd4f614..000000000 --- a/projects/social_platform/src/app/office/projects/list/list.component.html +++ /dev/null @@ -1,26 +0,0 @@ - - -
    - @if (isAll) { -
    - Фильтр - -
    - } -
      - @for (project of projects; track project.id) { - -
    • - -
    • -
      - } -
    -
    diff --git a/projects/social_platform/src/app/office/projects/list/list.component.spec.ts b/projects/social_platform/src/app/office/projects/list/list.component.spec.ts deleted file mode 100644 index f2552c331..000000000 --- a/projects/social_platform/src/app/office/projects/list/list.component.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectsListComponent } from "./list.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProjectsListComponent", () => { - let component: ProjectsListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProjectsListComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProjectsListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/list/list.component.ts b/projects/social_platform/src/app/office/projects/list/list.component.ts deleted file mode 100644 index 3cba85c12..000000000 --- a/projects/social_platform/src/app/office/projects/list/list.component.ts +++ /dev/null @@ -1,369 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - inject, - OnDestroy, - OnInit, - Renderer2, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, NavigationEnd, Params, Router, RouterLink } from "@angular/router"; -import { - concatMap, - distinctUntilChanged, - fromEvent, - map, - noop, - of, - Subscription, - switchMap, - tap, - throttleTime, -} from "rxjs"; -import { AuthService } from "@auth/services"; -import { Project } from "@models/project.model"; -import { User } from "@auth/models/user.model"; -import { NavService } from "@services/nav.service"; -import { ProjectService } from "@services/project.service"; -import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { InfoCardComponent } from "../../features/info-card/info-card.component"; -import { IconComponent } from "@ui/components"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { inviteToProjectMapper } from "@utils/inviteToProjectMapper"; - -/** - * КОМПОНЕНТ СПИСКА ПРОЕКТОВ - * - * Назначение: - * - Отображает список проектов в различных режимах (все/мои/подписки) - * - Реализует функциональность поиска и фильтрации проектов - * - Обеспечивает бесконечную прокрутку для загрузки дополнительных проектов - * - Управляет состоянием фильтров на мобильных устройствах - * - * 1. Отображение проектов в виде карточек - * 2. Поиск по названию проекта (используя библиотеку Fuse.js) - * 3. Фильтрация по различным критериям (индустрия, этап, количество участников и т.д.) - * 4. Создание и удаление проектов - * 5. Подписка/отписка от проектов - * 6. Адаптивный интерфейс с поддержкой свайпов на мобильных - * - * @param: - * - Данные маршрута (route.data) - предзагруженные проекты через резолверы - * - Параметры запроса (route.queryParams) - фильтры и поисковый запрос - * - Профиль пользователя (authService.profile) - * - Подписки пользователя (subscriptionService) - * - * @return - * - Отображение списка проектов - * - Навигация к детальной странице проекта - * - Создание нового проекта - * - Удаление проекта (только для владельца) - * - * Состояние компонента: - * - projects[] - полный список проектов - * - searchedProjects[] - отфильтрованный список для отображения - * - profile - данные текущего пользователя - * - isFilterOpen - состояние панели фильтров (мобильные) - * - isAll/isMy/isSubs/isInvites - флаги текущего режима просмотра - * - * Жизненный цикл: - * - OnInit: настройка подписок, инициализация данных - * - AfterViewInit: настройка обработчика прокрутки - * - OnDestroy: отписка от всех подписок - * - * - Использует RxJS для реактивного программирования - * - Реализует паттерн "бесконечная прокрутка" для оптимизации производительности - * - Поддерживает жесты свайпа для закрытия фильтров на мобильных - * - Использует Fuse.js для нечеткого поиска по названиям проектов - * - Кэширует запросы фильтрации для избежания дублирующих HTTP-запросов - */ -@Component({ - selector: "app-list", - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", - standalone: true, - imports: [IconComponent, RouterLink, InfoCardComponent], -}) -export class ProjectsListComponent implements OnInit, AfterViewInit, OnDestroy { - private readonly renderer = inject(Renderer2); - private readonly route = inject(ActivatedRoute); - private readonly authService = inject(AuthService); - private readonly navService = inject(NavService); - private readonly projectService = inject(ProjectService); - private readonly cdref = inject(ChangeDetectorRef); - private readonly router = inject(Router); - private readonly subscriptionService = inject(SubscriptionService); - - @ViewChild("filterBody") filterBody!: ElementRef; - - ngOnInit(): void { - this.navService.setNavTitle("Проекты"); - - const routeUrl$ = this.router.events.subscribe(event => { - if (event instanceof NavigationEnd) { - this.isMy = location.href.includes("/my"); - this.isAll = location.href.includes("/all"); - this.isSubs = location.href.includes("/subsription"); - this.isInvites = location.href.includes("/invites"); - } - }); - routeUrl$ && this.subscriptions$.push(routeUrl$); - - const profile$ = this.authService.profile - .pipe( - switchMap(p => { - this.profile = p; - return this.subscriptionService.getSubscriptions(p.id).pipe( - map(resp => { - this.profileProjSubsIds = resp.results.map(sub => sub.id); - }) - ); - }) - ) - .subscribe(); - - profile$ && this.subscriptions$.push(profile$); - - const querySearch$ = this.route.queryParams - .pipe(map(q => q["name__contains"])) - .subscribe(search => { - if (search !== this.currentSearchQuery) { - this.currentSearchQuery = search; - this.currentPage = 1; - } - }); - - querySearch$ && this.subscriptions$.push(querySearch$); - - 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 = reqQuery; - return this.projectService.getAll(new HttpParams({ fromObject: reqQuery })); - } catch (e) { - console.error(e); - this.previousReqQuery = reqQuery; - return this.projectService.getAll(); - } - } - - this.previousReqQuery = reqQuery; - - return of(0); - }) - ); - - const queryIndustry$ = observable.subscribe(projects => { - if (typeof projects === "number") return; - - this.projects = projects.results; - - this.cdref.detectChanges(); - }); - - queryIndustry$ && this.subscriptions$.push(queryIndustry$); - } - - const projects$ = this.route.data.pipe(map(r => r["data"])).subscribe(projects => { - this.projectsCount = projects.count; - - if (this.isInvites) { - this.projects = inviteToProjectMapper(projects ?? []); - } else { - this.projects = projects.results ?? []; - } - }); - - projects$ && this.subscriptions$.push(projects$); - } - - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvent$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(500) - ) - .subscribe(noop); - this.subscriptions$.push(scrollEvent$); - } - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - 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; - } - - isFilterOpen = false; - - isAll = location.href.includes("/all"); - isMy = location.href.includes("/my"); - isSubs = location.href.includes("/subscriptions"); - isInvites = location.href.includes("/invites"); - - profile?: User; - profileProjSubsIds?: number[]; - subscriptions$: Subscription[] = []; - - projectsCount = 0; - currentPage = 1; - projectsPerFetch = 15; - projects: Project[] = []; - - currentSearchQuery?: string; - - @ViewChild("listRoot") listRoot?: ElementRef; - - private previousReqQuery: Record = {}; - - onAcceptInvite(event: number): void { - this.sliceInvitesArray(event); - } - - onRejectInvite(event: number): void { - this.sliceInvitesArray(event); - } - - private sliceInvitesArray(inviteId: number): void { - const index = this.projects.findIndex(p => p.inviteId === inviteId); - if (index !== -1) { - this.projects.splice(index, 1); - this.projectsCount = Math.max(0, this.projectsCount - 1); - } - } - - private swipeStartY = 0; - private swipeThreshold = 50; - private isSwiping = false; - - onSwipeStart(event: TouchEvent): void { - this.swipeStartY = event.touches[0].clientY; - this.isSwiping = true; - } - - onSwipeMove(event: TouchEvent): 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( - this.filterBody.nativeElement, - "transform", - `translateY(${progress * 100}px)` - ); - } - - onSwipeEnd(event: TouchEvent): void { - if (!this.isSwiping) return; - - const endY = event.changedTouches[0].clientY; - const deltaY = endY - this.swipeStartY; - - if (deltaY > this.swipeThreshold) { - this.closeFilter(); - } - - this.isSwiping = false; - - this.renderer.setStyle(this.filterBody.nativeElement, "transform", "translateY(0)"); - } - - closeFilter(): void { - this.isFilterOpen = false; - } - - private onScroll() { - if (this.isSubs || this.isInvites) { - return of({}); - } - - if (this.projectsCount && this.projects.length >= this.projectsCount) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.listRoot) return of({}); - - const diff = - target.scrollTop - - this.listRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - return this.onFetch(this.currentPage * this.projectsPerFetch, this.projectsPerFetch).pipe( - tap(chunk => { - this.currentPage++; - this.projects = [...this.projects, ...chunk]; - - this.cdref.detectChanges(); - }) - ); - } - - return of({}); - } - - 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 = projects.count; - return projects.results; - }) - ); - } - } -} diff --git a/projects/social_platform/src/app/office/projects/list/my.resolver.ts b/projects/social_platform/src/app/office/projects/list/my.resolver.ts deleted file mode 100644 index d2547601d..000000000 --- a/projects/social_platform/src/app/office/projects/list/my.resolver.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { Project } from "@models/project.model"; -import { ProjectService } from "@services/project.service"; -import { ApiPagination } from "@models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; -import { ResolveFn } from "@angular/router"; - -/** - * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ПРОЕКТОВ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ - * - * Назначение: - * - Предзагружает данные проектов, принадлежащих текущему пользователю - * - Обеспечивает наличие данных в компоненте на момент его инициализации - * - Используется в роутинге Angular для маршрута "мои проекты" - * - * @params: - * - Неявно: внедряется ProjectService через inject() - * - Параметры маршрута и состояние роутера (не используются в данной реализации) - * - * @returns: - * - Observable> - пагинированный список проектов пользователя - * - Первая страница с лимитом 16 проектов - * - * 1. Внедряет ProjectService через функцию inject() - * 2. Вызывает метод getMy() с параметрами пагинации (limit: 16) - * 3. Возвращает Observable, который будет разрешен перед активацией маршрута - * - * - Подключается к маршруту в конфигурации роутера - * - Результат доступен в компоненте через route.data['data'] - * - * Особенности: - * - Использует функциональный подход (ResolveFn) вместо класса - * - Загружает только проекты текущего авторизованного пользователя - * - Загружает только первые 16 проектов для оптимизации производительности - * - Дополнительные проекты загружаются по мере прокрутки (infinite scroll) - */ - -export const ProjectsMyResolver: ResolveFn> = () => { - const projectService = inject(ProjectService); - - return projectService.getMy(new HttpParams({ fromObject: { limit: 16 } })); -}; diff --git a/projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts b/projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts deleted file mode 100644 index 51d13c4b2..000000000 --- a/projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** @format */ - -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; - -export class ProjectAdditionalFields { - programId!: number; - canSubmit!: boolean; - submissionDeadline!: string; - programFields!: PartnerProgramFields[]; -} diff --git a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.html b/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.html deleted file mode 100644 index 5c98e3fb1..000000000 --- a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.html +++ /dev/null @@ -1,65 +0,0 @@ - - -
    -
    -
    -

    фильтры

    -
    - - - - - - - -
    -
    - -
    -
    -
    -
    diff --git a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.spec.ts b/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.spec.ts deleted file mode 100644 index 70eca8f89..000000000 --- a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectsFilterComponent } from "./projects-filter.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProjectsFilterComponent", () => { - let component: ProjectsFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProjectsFilterComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProjectsFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.ts b/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.ts deleted file mode 100644 index 99ac9784c..000000000 --- a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** @format */ - -import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { map, Subscription } from "rxjs"; -import { IndustryService } from "@services/industry.service"; -import { SelectComponent } from "@ui/components"; -import { FormControl, ReactiveFormsModule } from "@angular/forms"; -import { tagsFilter } from "projects/core/src/consts/filters/tags-filter.const"; -import { optionsListElement } from "@utils/generate-options-list"; - -/** - * Компонент фильтрации проектов - * - * Функциональность: - * - Предоставляет интерфейс для фильтрации списка проектов - * - Управляет фильтрами по различным критериям: - * - Этап проекта (идея, разработка, тестирование и т.д.) - * - Отрасль/направление проекта - * - Количество участников в команде - * - Наличие открытых вакансий - * - Принадлежность к программе МосПолитех - * - Тип проекта (оценен экспертами или нет) - * - * Принимает: - * - Query параметры из URL для восстановления состояния фильтров - * - Данные об отраслях и этапах проектов из сервисов - * - * Возвращает: - * - Обновляет query параметры URL при изменении фильтров - * - Эмитит события для закрытия панели фильтров - * - * Особенности: - * - Синхронизирует состояние фильтров с URL - * - Поддерживает сброс всех фильтров - * - Адаптивный интерфейс для мобильных устройств - */ -@Component({ - selector: "app-projects-filter", - templateUrl: "./projects-filter.component.html", - styleUrl: "./projects-filter.component.scss", - standalone: true, - imports: [SelectComponent, ReactiveFormsModule], -}) -export class ProjectsFilterComponent implements OnInit { - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly industryService: IndustryService - ) {} - - // Константы для фильтрации по типу проекта - readonly tagsFilter = tagsFilter; - - ngOnInit(): void { - // Подписка на данные об отраслях - this.industries$ = this.industryService.industries - .pipe( - map(industries => - industries.map(industry => ({ - id: industry.id, - label: industry.name, - value: industry.name, - })) - ) - ) - .subscribe(industries => { - this.industries = industries; - }); - - this.industryControl.valueChanges.subscribe(value => { - const industryId = this.industries.find(industry => industry.value === value); - this.onFilterByIndustry(industryId?.id); - }); - - // Восстановление состояния фильтров из query параметров - this.queries$ = this.route.queryParams.subscribe(queries => { - this.currentIndustry = parseInt(queries["industry"]); - this.currentMembersCount = parseInt(queries["membersCount"]); - this.hasVacancies = queries["anyVacancies"] === "true"; - this.isMospolytech = queries["is_mospolytech"] === "true"; - - const tagParam = queries["is_rated_by_expert"]; - if (tagParam === undefined || isNaN(Number.parseInt(tagParam))) { - this.currentFilterTag = 2; - } else { - this.currentFilterTag = Number.parseInt(tagParam); - } - }); - } - - // Подписки для управления жизненным циклом - queries$?: Subscription; - - industryControl = new FormControl(null); - - // Состояние фильтра по отрасли - currentIndustry: number | null = null; - industries: optionsListElement[] = []; - industries$?: Subscription; - - // Состояние остальных фильтров - hasVacancies = false; - isMospolytech = false; - - // Опции для фильтра по количеству участников - membersCountOptions = [1, 2, 3, 4, 5, 6]; - currentMembersCount: number | null = null; - - // Текущий тип проекта (по умолчанию - все проекты) - currentFilterTag = 2; - - /** - * Обработчик фильтрации по отрасли - * @param event - событие клика - * @param industryId - ID отрасли (undefined для сброса) - */ - onFilterByIndustry(industryId?: number | null): void { - this.router - .navigate([], { - queryParams: { industry: industryId === this.currentIndustry ? undefined : industryId }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Обработчик фильтрации по количеству участников - * @param count - количество участников (undefined для сброса) - */ - onFilterByMembersCount(count?: number): void { - this.router - .navigate([], { - queryParams: { - membersCount: count === this.currentMembersCount ? undefined : count, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Обработчик фильтрации по наличию вакансий - * @param has - наличие вакансий - */ - onFilterVacancies(has: boolean): void { - this.router - .navigate([], { - queryParams: { - anyVacancies: has, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Обработчик фильтрации по принадлежности к МосПолитех - * @param isMospolytech - принадлежность к программе - */ - onFilterMospolytech(isMospolytech: boolean): void { - this.router - .navigate([], { - queryParams: { - is_mospolytech: isMospolytech, - partner_program: 3, // TODO: заменить когда появится итоговое id программы для политеха - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Обработчик фильтрации по типу проекта - * @param event - событие клика - * @param tagId - ID типа проекта (null для сброса) - */ - onFilterProjectType(event: Event, tagId?: number | null): void { - event.stopPropagation(); - - this.router.navigate([], { - queryParams: { is_rated_by_expert: tagId === this.currentFilterTag ? undefined : tagId }, - relativeTo: this.route, - queryParamsHandling: "merge", - }); - } - - /** - * Сброс всех активных фильтров - * Очищает все query параметры и возвращает к состоянию по умолчанию - */ - clearFilters(): void { - this.currentFilterTag = 2; - - this.router - .navigate([], { - queryParams: { - step: undefined, - anyVacancies: undefined, - membersCount: undefined, - industry: undefined, - is_rated_by_expert: undefined, - is_mospolytech: undefined, - partner_program: undefined, - name__contains: undefined, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.log("Query change from ProjectsComponent")); - } -} diff --git a/projects/social_platform/src/app/office/projects/projects.component.html b/projects/social_platform/src/app/office/projects/projects.component.html deleted file mode 100644 index ede266c82..000000000 --- a/projects/social_platform/src/app/office/projects/projects.component.html +++ /dev/null @@ -1,114 +0,0 @@ - - -
    -
    - - -
    -
    -
    - -
    - - @if(!isDashboard) { - - - } - - -
    - -
    - - создать проект - - - -
    - @if (isDashboard) { -
    -
    -

    мои приглашения

    - -
    - - @if (myInvites.length) { -
      - @for (invite of myInvites; track invite.id) { - - } -
    - } @else { -
    -

    пока нет приглашений

    -
    - } -
    - } @if (isMy || isDashboard) { - - - } @if (isAll) { -
    -
    -
    -
    - -
    -
    - } -
    -
    -
    -
    -
    diff --git a/projects/social_platform/src/app/office/projects/projects.component.spec.ts b/projects/social_platform/src/app/office/projects/projects.component.spec.ts deleted file mode 100644 index 8baa5c909..000000000 --- a/projects/social_platform/src/app/office/projects/projects.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -// import { ComponentFixture, TestBed } from "@angular/core/testing"; -// -// import { ProjectsComponent } from "./projects.component"; -// import { RouterTestingModule } from "@angular/router/testing"; -// import { HttpClientTestingModule } from "@angular/common/http/testing"; -// import { ReactiveFormsModule } from "@angular/forms"; -// import { of } from "rxjs"; -// import { AuthService } from "../../auth/services"; -// import { ProjectService } from "../services/project.service"; -// import { User } from "../../auth/models/user.model"; -// import { Project } from "../models/project.model"; -// -// describe("ProjectsComponent", () => { -// let component: ProjectsComponent; -// let fixture: ComponentFixture; -// -// beforeEach(async () => { -// const projectSpy = { -// create: of({}), -// }; -// const authSpy = { -// profile: of({}), -// }; -// -// await TestBed.configureTestingModule({ -// imports: [RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], -// providers: [ -// { providers: ProjectService, useValue: projectSpy }, -// { providers: AuthService, useValue: authSpy }, -// ], -// declarations: [ProjectsComponent], -// }).compileComponents(); -// }); -// -// beforeEach(() => { -// fixture = TestBed.createComponent(ProjectsComponent); -// component = fixture.componentInstance; -// fixture.detectChanges(); -// }); -// -// it("should create", () => { -// expect(component).toBeTruthy(); -// }); -// }); diff --git a/projects/social_platform/src/app/office/projects/projects.component.ts b/projects/social_platform/src/app/office/projects/projects.component.ts deleted file mode 100644 index 7243d9616..000000000 --- a/projects/social_platform/src/app/office/projects/projects.component.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** @format */ - -import { Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild } from "@angular/core"; -import { NavService } from "@services/nav.service"; -import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { map, Subscription } from "rxjs"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { BarNewComponent } from "@ui/components/bar-new/bar.component"; -import { BackComponent } from "@uilib"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; -import { ProjectsFilterComponent } from "./projects-filter/projects-filter.component"; -import { Project } from "@office/models/project.model"; -import { inviteToProjectMapper } from "@utils/inviteToProjectMapper"; -import { ProjectsService } from "./services/projects.service"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; - -/** - * Главный компонент модуля проектов - * Управляет отображением списка проектов, поиском и созданием новых проектов - * - * Принимает: - * - Счетчики проектов через резолвер - * - Параметры поиска из URL - * - * Возвращает: - * - Интерфейс управления проектами с поиском и фильтрацией - * - Навигацию между разделами "Мои", "Все", "Подписки" - */ -@Component({ - selector: "app-projects", - templateUrl: "./projects.component.html", - styleUrl: "./projects.component.scss", - standalone: true, - imports: [ - IconComponent, - ReactiveFormsModule, - SearchComponent, - ButtonComponent, - RouterOutlet, - BarNewComponent, - BackComponent, - SoonCardComponent, - ProjectsFilterComponent, - InfoCardComponent, - ], -}) -export class ProjectsComponent implements OnInit, OnDestroy { - constructor( - private readonly navService: NavService, - private readonly route: ActivatedRoute, - private readonly projectsService: ProjectsService, - private readonly router: Router, - private readonly renderer: Renderer2, - private readonly fb: FormBuilder - ) { - this.searchForm = this.fb.group({ - search: [""], - }); - } - - @ViewChild("filterBody") filterBody!: ElementRef; - - ngOnInit(): void { - this.navService.setNavTitle("Проекты"); - - this.route.data.pipe(map(r => r["data"])).subscribe({ - next: invites => { - this.allInvites = inviteToProjectMapper(invites); - this.myInvites = inviteToProjectMapper(invites.slice(0, 1)); - }, - }); - - const searchFormSearch$ = this.searchForm.get("search")?.valueChanges.subscribe(search => { - this.router - .navigate([], { - queryParams: { name__contains: search }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("QueryParams changed from ProjectsComponent")); - }); - - searchFormSearch$ && this.subscriptions$.push(searchFormSearch$); - - const routeUrl$ = this.router.events.subscribe(event => { - if (event instanceof NavigationEnd) { - this.isMy = location.href.includes("/my"); - this.isAll = location.href.includes("/all"); - this.isSubs = location.href.includes("/subscriptions"); - this.isInvites = location.href.includes("/invites"); - this.isDashboard = location.href.includes("/dashboard"); - } - }); - routeUrl$ && this.subscriptions$.push(routeUrl$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - searchForm: FormGroup; - - myInvites: Project[] = []; - private allInvites: Project[] = []; - - isMy = location.href.includes("/my"); - isAll = location.href.includes("/all"); - isSubs = location.href.includes("/subscriptions"); - isInvites = location.href.includes("/invites"); - isDashboard = location.href.includes("/dashboard"); - - isFilterOpen = false; - - subscriptions$: Subscription[] = []; - - private swipeStartY = 0; - private swipeThreshold = 50; - private isSwiping = false; - - onSwipeStart(event: TouchEvent): void { - this.swipeStartY = event.touches[0].clientY; - this.isSwiping = true; - } - - onSwipeMove(event: TouchEvent): 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( - this.filterBody.nativeElement, - "transform", - `translateY(${progress * 100}px)` - ); - } - - onSwipeEnd(event: TouchEvent): void { - if (!this.isSwiping) return; - - const endY = event.changedTouches[0].clientY; - const deltaY = endY - this.swipeStartY; - - if (deltaY > this.swipeThreshold) { - this.closeFilter(); - } - - this.isSwiping = false; - - this.renderer.setStyle(this.filterBody.nativeElement, "transform", "translateY(0)"); - } - - acceptOrRejectInvite(inviteId: number): void { - this.allInvites = this.allInvites.filter(invite => invite.inviteId !== inviteId); - - this.myInvites = this.allInvites.slice(0, 1); - } - - closeFilter(): void { - this.isFilterOpen = false; - } - - addProject(): void { - this.projectsService.addProject(); - } -} diff --git a/projects/social_platform/src/app/office/projects/projects.resolver.ts b/projects/social_platform/src/app/office/projects/projects.resolver.ts deleted file mode 100644 index c32dadf54..000000000 --- a/projects/social_platform/src/app/office/projects/projects.resolver.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { forkJoin, switchMap } from "rxjs"; -import { Project } from "@models/project.model"; -import { ProjectService } from "@services/project.service"; -import { AuthService } from "@auth/services"; -import { ResolveFn } from "@angular/router"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@office/models/api-pagination.model"; - -/** - * Resolver для загрузки данных о количестве проектов - * - * Функциональность: - * - Загружает количество проектов пользователя в разных категориях - * - Получает количество подписок пользователя - * - Объединяет данные в единый объект ProjectCount - * - * Принимает: - * - Не принимает параметры (использует текущего пользователя) - * - * Возвращает: - * - Observable с данными: - * - my: количество собственных проектов - * - all: общее количество проектов в системе - * - subs: количество подписок пользователя - * - * Используется перед загрузкой ProjectsComponent для предварительной - * загрузки необходимых данных. - */ - -export interface DashboardProjectsData { - all: ApiPagination; - my: ApiPagination; - subs: ApiPagination; -} - -export const ProjectsResolver: ResolveFn = () => { - const projectService = inject(ProjectService); - const authService = inject(AuthService); - const subscriptionService = inject(SubscriptionService); - - return authService.profile.pipe( - switchMap(user => - forkJoin({ - all: projectService.getAll(new HttpParams({ fromObject: { limit: 16 } })), - my: projectService.getMy(new HttpParams({ fromObject: { limit: 16 } })), - subs: subscriptionService.getSubscriptions(user.id), - }) - ) - ); -}; diff --git a/projects/social_platform/src/app/office/projects/projects.routes.ts b/projects/social_platform/src/app/office/projects/projects.routes.ts deleted file mode 100644 index 2b9196cfe..000000000 --- a/projects/social_platform/src/app/office/projects/projects.routes.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProjectsComponent } from "./projects.component"; -import { ProjectsResolver } from "./projects.resolver"; -import { ProjectsListComponent } from "./list/list.component"; -import { ProjectsMyResolver } from "./list/my.resolver"; -import { ProjectsAllResolver } from "./list/all.resolver"; -import { ProjectEditComponent } from "./edit/edit.component"; -import { ProjectEditResolver } from "./edit/edit.resolver"; -import { ProjectsSubscriptionsResolver } from "./list/subscriptions.resolver"; -import { ProjectEditRequiredGuard } from "./edit/guards/projects-edit.guard"; -import { ProjectsInvitesResolver } from "./list/invites.resolver"; -import { DashboardProjectsComponent } from "./dashboard/dashboard.component"; - -/** - * Конфигурация маршрутов для модуля проектов - * - * Определяет структуру навигации: - * - * Основные маршруты: - * - '' (root) - ProjectsComponent с дочерними маршрутами: - * - 'my' - список собственных проектов - * - 'subscriptions' - список проектов по подписке - * - 'all' - список всех проектов - * - ':projectId/edit' - редактирование проекта - * - ':projectId' - детальная информация о проекте (lazy loading) - * - * Каждый маршрут имеет свой resolver для предварительной загрузки данных: - * - ProjectsResolver - загружает счетчики проектов - * - ProjectsMyResolver - загружает собственные проекты - * - ProjectsAllResolver - загружает все проекты - * - ProjectsSubscriptionsResolver - загружает проекты по подписке - * - ProjectEditResolver - загружает данные для редактирования - * - * Использует lazy loading для детальной информации о проекте - * для оптимизации загрузки приложения. - */ -export const PROJECTS_ROUTES: Routes = [ - { - path: "", - component: ProjectsComponent, - resolve: { - data: ProjectsInvitesResolver, - }, - children: [ - { - path: "", - pathMatch: "full", - redirectTo: "dashboard", - }, - { - path: "dashboard", - component: DashboardProjectsComponent, - resolve: { - data: ProjectsResolver, - }, - }, - { - path: "my", - component: ProjectsListComponent, - resolve: { - data: ProjectsMyResolver, - }, - }, - { - path: "subscriptions", - component: ProjectsListComponent, - resolve: { - data: ProjectsSubscriptionsResolver, - }, - }, - { - path: "invites", - component: ProjectsListComponent, - resolve: { - data: ProjectsInvitesResolver, - }, - }, - { - path: "all", - component: ProjectsListComponent, - resolve: { - data: ProjectsAllResolver, - }, - }, - ], - }, - { - path: ":projectId/edit", - component: ProjectEditComponent, - resolve: { - data: ProjectEditResolver, - }, - canActivate: [ProjectEditRequiredGuard], - }, - { - path: ":projectId", - loadChildren: () => import("./detail/detail.routes").then(c => c.PROJECT_DETAIL_ROUTES), - }, -]; diff --git a/projects/social_platform/src/app/office/projects/services/projects.service.ts b/projects/social_platform/src/app/office/projects/services/projects.service.ts deleted file mode 100644 index 745c0ea34..000000000 --- a/projects/social_platform/src/app/office/projects/services/projects.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { inject, Injectable } from "@angular/core"; -import { Router } from "@angular/router"; -import { ProjectService } from "@office/services/project.service"; - -@Injectable({ - providedIn: "root", -}) -export class ProjectsService { - private readonly projectService = inject(ProjectService); - private readonly router = inject(Router); - - addProject(): void { - this.projectService.create().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" }, - }) - .then(() => console.debug("Route change from ProjectsComponent")); - }); - } -} diff --git a/projects/social_platform/src/app/office/services/industry.service.spec.ts b/projects/social_platform/src/app/office/services/industry.service.spec.ts deleted file mode 100644 index 589d0ae80..000000000 --- a/projects/social_platform/src/app/office/services/industry.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { IndustryService } from "./industry.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("IndustryService", () => { - let service: IndustryService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(IndustryService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/industry.service.ts b/projects/social_platform/src/app/office/services/industry.service.ts deleted file mode 100644 index 6fb9b8f1e..000000000 --- a/projects/social_platform/src/app/office/services/industry.service.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** @format */ - -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 { plainToInstance } from "class-transformer"; - -/** - * Сервис для работы с отраслями (индустриями) - * - * Предоставляет функциональность для: - * - Получения списка всех доступных отраслей - * - Кеширования отраслей в памяти для быстрого доступа - * - Поиска конкретной отрасли по идентификатору - * - Обработки ошибок при загрузке данных - */ -@Injectable({ - providedIn: "root", -}) -export class IndustryService { - private readonly INDUSTRIES_URL = "/industries"; - - constructor(private readonly apiService: ApiService) {} - - /** - * BehaviorSubject для хранения списка отраслей в памяти - * Обеспечивает кеширование и реактивное обновление данных - */ - private industries$ = new BehaviorSubject([]); - - /** - * Observable для подписки на изменения списка отраслей - * @returns Observable - поток данных с отраслями - */ - industries = this.industries$.asObservable(); - - /** - * Получает список всех доступных отраслей с сервера - * Преобразует данные в типизированные объекты и кеширует их - * Обрабатывает ошибки и обновляет локальный кеш при успешной загрузке - * - * @returns Observable - массив отраслей с названиями и идентификаторами - */ - getAll(): Observable { - return this.apiService.get(`${this.INDUSTRIES_URL}/`).pipe( - catchError(err => throwError(err)), - map(industries => plainToInstance(Industry, industries)), - tap(industries => { - this.industries$.next(industries); - }) - ); - } - - /** - * Находит конкретную отрасль в переданном массиве по идентификатору - * Вспомогательный метод для поиска отрасли без дополнительных запросов к серверу - * - * @param industries - массив отраслей для поиска - * @param industryId - идентификатор искомой отрасли - * @returns Industry | undefined - найденная отрасль или undefined, если не найдена - */ - getIndustry(industries: Industry[], industryId: number): Industry | undefined { - return industries.find(industry => industry.id === industryId); - } -} diff --git a/projects/social_platform/src/app/office/services/invite.service.spec.ts b/projects/social_platform/src/app/office/services/invite.service.spec.ts deleted file mode 100644 index b4e18f320..000000000 --- a/projects/social_platform/src/app/office/services/invite.service.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { InviteService } from "./invite.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; - -describe("InviteService", () => { - let service: InviteService; - - beforeEach(() => { - const authSpy = { - profile: of({}), - }; - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: AuthService, useValue: authSpy }], - }); - service = TestBed.inject(InviteService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/invite.service.ts b/projects/social_platform/src/app/office/services/invite.service.ts deleted file mode 100644 index b8fe6193d..000000000 --- a/projects/social_platform/src/app/office/services/invite.service.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** @format */ - -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 { HttpParams } from "@angular/common/http"; -import { AuthService } from "@auth/services"; - -/** - * Сервис для управления приглашениями в проекты - * - * Предоставляет функциональность для: - * - Отправки приглашений пользователям в проекты - * - Принятия и отклонения приглашений - * - Отзыва отправленных приглашений - * - Обновления информации о приглашениях - * - Получения списков приглашений для пользователей и проектов - */ -@Injectable({ - providedIn: "root", -}) -export class InviteService { - private readonly INVITES_URL = "/invites"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Отправляет приглашение пользователю для участия в проекте - * - * @param userId - идентификатор пользователя, которому отправляется приглашение - * @param projectId - идентификатор проекта, в который приглашается пользователь - * @param role - роль пользователя в проекте (например, "developer", "designer") - * @param specialization - специализация пользователя в проекте (необязательно) - * @returns Observable - созданное приглашение со всеми полями - */ - sendForUser( - userId: number, - projectId: number, - role: string, - specialization?: string - ): Observable { - return this.apiService - .post(`${this.INVITES_URL}/`, { user: userId, project: projectId, role, specialization }) - .pipe(map(profile => plainToInstance(Invite, profile))); - } - - /** - * Отзывает (удаляет) отправленное приглашение - * Используется отправителем приглашения для его отмены - * - * @param invitationId - идентификатор приглашения для отзыва - * @returns Observable - информация об отозванном приглашении - */ - revokeInvite(invitationId: number): Observable { - return this.apiService.delete(`${this.INVITES_URL}/${invitationId}`); - } - - /** - * Принимает приглашение в проект - * Используется получателем приглашения для присоединения к проекту - * - * @param inviteId - идентификатор приглашения для принятия - * @returns Observable - информация о принятом приглашении - */ - acceptInvite(inviteId: number): Observable { - return this.apiService.post(`${this.INVITES_URL}/${inviteId}/accept/`, {}); - } - - /** - * Отклоняет приглашение в проект - * Используется получателем приглашения для отказа от участия - * - * @param inviteId - идентификатор приглашения для отклонения - * @returns Observable - информация об отклоненном приглашении - */ - rejectInvite(inviteId: number): Observable { - return this.apiService.post(`${this.INVITES_URL}/${inviteId}/decline/`, {}); - } - - /** - * Обновляет информацию о приглашении (роль и специализацию) - * Используется отправителем для изменения условий приглашения - * - * @param inviteId - идентификатор приглашения для обновления - * @param role - новая роль в проекте - * @param specialization - новая специализация (необязательно) - * @returns Observable - обновленное приглашение - */ - updateInvite(inviteId: number, role: string, specialization?: string): Observable { - return this.apiService.patch(`${this.INVITES_URL}/${inviteId}`, { role, specialization }); - } - - /** - * Получает список приглашений для текущего пользователя - * Использует профиль текущего пользователя для фильтрации приглашений - * - * @returns Observable - массив приглашений, адресованных текущему пользователю - */ - getMy(): Observable { - return this.apiService - .get(`${this.INVITES_URL}/`) - .pipe(map(invites => plainToInstance(Invite, invites))); - } - - /** - * Получает список всех приглашений для конкретного проекта - * Используется владельцами проекта для просмотра отправленных приглашений - * - * @param projectId - идентификатор проекта - * @returns Observable - массив всех приглашений, связанных с проектом - */ - getByProject(projectId: number): Observable { - return this.apiService - .get( - `${this.INVITES_URL}/`, - new HttpParams({ fromObject: { project: projectId, user: "any" } }) - ) - .pipe(map(profiles => plainToInstance(Invite, profiles))); - } -} diff --git a/projects/social_platform/src/app/office/services/member.service.spec.ts b/projects/social_platform/src/app/office/services/member.service.spec.ts deleted file mode 100644 index adcb05050..000000000 --- a/projects/social_platform/src/app/office/services/member.service.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { MemberService } from "./member.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("MemberService", () => { - let service: MemberService; - - beforeEach(() => { - const memberSpy = jasmine.createSpyObj(["getMembers"]); - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: MemberService, useValue: memberSpy }], - }); - service = TestBed.inject(MemberService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/member.service.ts b/projects/social_platform/src/app/office/services/member.service.ts deleted file mode 100644 index 47467f72a..000000000 --- a/projects/social_platform/src/app/office/services/member.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** @format */ - -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"; - -/** - * Сервис для работы с участниками платформы - * - * Предоставляет функциональность для: - * - Получения списка участников с пагинацией и фильтрацией - * - Получения списка менторов - * - Поиска пользователей по различным критериям - * - Работы с публичными профилями пользователей - */ -@Injectable({ - providedIn: "root", -}) -export class MemberService { - private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Получает список участников платформы с пагинацией и дополнительными фильтрами - * По умолчанию получает только обычных пользователей (user_type: 1) - * - * @param skip - количество пропускаемых записей (offset для пагинации) - * @param take - максимальное количество записей на странице (limit) - * @param otherParams - дополнительные параметры фильтрации (навыки, специализации, опыт и т.д.) - * @returns Observable> - объект с массивом пользователей и метаданными пагинации - */ - getMembers( - skip: number, - take: number, - otherParams?: Record - ): Observable> { - let allParams = new HttpParams({ fromObject: { user_type: 1, limit: take, offset: skip } }); - if (otherParams) { - allParams = allParams.appendAll(otherParams); - } - return this.apiService.get>(`${this.AUTH_PUBLIC_USERS_URL}/`, allParams); - } - - /** - * Получает список менторов и экспертов платформы - * Включает пользователей с типами 2, 3, 4 (менторы, эксперты, консультанты) - * - * @param skip - количество пропускаемых записей (offset для пагинации) - * @param take - максимальное количество записей на странице (limit) - * @returns Observable> - объект с массивом менторов и метаданными пагинации - */ - getMentors(skip: number, take: number): Observable> { - return this.apiService.get>( - `${this.AUTH_PUBLIC_USERS_URL}/`, - new HttpParams({ fromObject: { user_type: "2,3,4", limit: take, offset: skip } }) - ); - } -} diff --git a/projects/social_platform/src/app/office/services/project.service.spec.ts b/projects/social_platform/src/app/office/services/project.service.spec.ts deleted file mode 100644 index f14d9fd1c..000000000 --- a/projects/social_platform/src/app/office/services/project.service.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -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"; - -describe("ProjectService", () => { - let service: ProjectService; - - beforeEach(() => { - const authSpy = jasmine.createSpyObj([{ profile: of({}) }]); - - TestBed.configureTestingModule({ - providers: [{ provide: AuthService, useValue: authSpy }], - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(ProjectService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/project.service.ts b/projects/social_platform/src/app/office/services/project.service.ts deleted file mode 100644 index b99c0444a..000000000 --- a/projects/social_platform/src/app/office/services/project.service.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** @format */ - -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"; - -/** - * Сервис для управления проектами - * - * Предоставляет функциональность для: - * - Получения списка проектов с пагинацией - * - Создания, обновления и удаления проектов - * - Управления этапами проектов - * - Работы с коллабораторами проектов - * - Получения статистики по проектам - */ -@Injectable({ - providedIn: "root", -}) -export class ProjectService { - private readonly PROJECTS_URL = "/projects"; - private readonly AUTH_USERS_URL = "/auth/users"; - - constructor(private readonly apiService: ApiService) {} - - /** - * BehaviorSubject для хранения этапов проектов - * Используется для кеширования и реактивного обновления данных об этапах - */ - readonly steps$ = new BehaviorSubject([]); - - /** - * Получает список всех проектов с пагинацией - * - * @param params - HttpParams с параметрами запроса (limit, offset, фильтры) - * @returns Observable> - объект с массивом проектов и метаданными пагинации - */ - getAll(params?: HttpParams): Observable> { - return this.apiService.get>(`${this.PROJECTS_URL}/`, params); - } - - /** - * Получает один проект по его идентификатору - * Преобразует полученные данные в экземпляр класса Project - * - * @param id - уникальный идентификатор проекта - * @returns Observable - объект проекта со всеми полями - */ - getOne(id: number): Observable { - return this.apiService - .get(`${this.PROJECTS_URL}/${id}/`) - .pipe(map(project => plainToInstance(Project, project))); - } - - /** - * Получает список проектов текущего пользователя с пагинацией - * - * @param params - HttpParams с параметрами запроса (limit, offset, фильтры) - * @returns Observable> - объект с массивом проектов пользователя и метаданными пагинации - */ - getMy(params?: HttpParams): Observable> { - return this.apiService.get>(`${this.AUTH_USERS_URL}/projects/`, params); - } - - /** - * - * @param projectId - * @param params - * @returns Создать или привязать компанию к проекту. - * Если компания с таким ИНН уже существует — создаёт или обновляет связь ProjectCompany. - * Если компании нет — создаёт новую и тут же привязывает. - */ - addPartner(projectId: number, params: PartnerPostForm) { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/companies/`, params); - } - - /** - * Получить список всех компаний-партнёров (связей ProjectCompany) конкретного проекта. - * - * @param projectId - * - * @returns данные компании - * @returns вклад - * @returns ответственного - */ - getPartners(projectId: number): Observable { - return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/companies/list/`); - } - - /** - * @param projectId - * @param companyId - * - * @returns Обновить информацию о связи проекта с компанией. - * Можно изменить вклад (contribution) и/или ответственное лицо (decision_maker). - * Компания остаётся без изменений. - */ - editParter( - projectId: number, - companyId: number, - params: Pick - ) { - return this.apiService.patch( - `${this.PROJECTS_URL}/${projectId}/companies/${companyId}/`, - params - ); - } - - /** - * @param projectId - * @param companyId - * - * @returns Удалить связь проекта с компанией. Компания в базе остаётся, удаляется только запись ProjectCompany. - */ - deletePartner(projectId: number, companyId: number) { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/companies/${companyId}/`); - } - - /** - * - * @param projectId - * @param params - * @returns Создать новый ресурс в проекте. - * Если partner_company указана, проверяется, что она действительно является партнёром данного проекта. - */ - addResource(projectId: number, params: Omit) { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/resources/`, { - project_id: projectId, - ...params, - }); - } - - /** - * - * @param projectId - * @returns Получить список всех ресурсов проекта. - * Каждый ресурс содержит тип, описание и партнёра (если назначен) - */ - getResources(projectId: number): Observable { - return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/resources/`); - } - - /** - * @param projectId - * @param resourceId - * - * @returns Полностью обновить данные ресурса. - * Используется, если нужно заменить все поля сразу. - */ - editResource(projectId: number, resourceId: number, params: Omit) { - return this.apiService.patch(`${this.PROJECTS_URL}/${projectId}/resources/${resourceId}/`, { - project_id: projectId, - ...params, - }); - } - - /** - * @param projectId - * @param resourceId - * - * @returns Удалить ресурс проекта. - * Удаляется только сам ресурс, проект и компании не затрагиваются. - */ - deleteResource(projectId: number, resourceId: number) { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/resources/${resourceId}/`); - } - - /** - * Получает список целей проекта - * - * @returns Observable - объект с массивом целей проекта - */ - getGoals(projectId: number): Observable { - return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/goals/`); - } - - /** - * Отправляем цель - */ - addGoals(projectId: number, params: GoalPostForm[]) { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/goals/`, params); - } - - /** - * Редактирование цели - */ - editGoal(projectId: number, goalId: number, params: GoalPostForm) { - return this.apiService.put(`${this.PROJECTS_URL}/${projectId}/goals/${goalId}`, params); - } - - /** - * Удаляем цель - */ - deleteGoals(projectId: number, goalId: number) { - return this.apiService.delete( - `${this.PROJECTS_URL}/${projectId}/goals/${goalId}` - ); - } - - /** - * BehaviorSubject для хранения счетчиков проектов - * Содержит количество собственных проектов, всех проектов и подписок - */ - projectsCount = new BehaviorSubject({ my: 0, all: 0, subs: 0 }); - - /** - * Observable для подписки на изменения счетчиков проектов - * @returns Observable - объект с количеством проектов разных типов - */ - projectsCount$ = this.projectsCount.asObservable(); - - /** - * Получает статистику по количеству проектов - * Преобразует данные в экземпляр класса ProjectCount - * - * @returns Observable - объект с полями my, all, subs (количество проектов) - */ - getCount(): Observable { - return this.apiService - .get(`${this.PROJECTS_URL}/count/`) - .pipe(map(count => plainToInstance(ProjectCount, count))); - } - - /** - * Удаляет проект по его идентификатору - * - * @param projectId - уникальный идентификатор проекта для удаления - * @returns Observable - завершается при успешном удалении - */ - remove(projectId: number): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/`); - } - - /** - * Покидает проект (удаляет текущего пользователя из коллабораторов) - * - * @param projectId - идентификатор проекта, который нужно покинуть - * @returns Observable - завершается при успешном выходе из проекта - */ - leave(projectId: Project["id"]): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators/leave`); - } - - /** - * Создает новый пустой проект - * Преобразует полученные данные в экземпляр класса Project - * - * @returns Observable - созданный проект со всеми полями - */ - create(): Observable { - return this.apiService - .post(`${this.PROJECTS_URL}/`, {}) - .pipe(map(project => plainToInstance(Project, project))); - } - - /** - * Ссоздаёт привязывает проект к программе с указанным ID. - * После чего в БД появляется новый проект в черновиках - * - * @param projectId - идентификатор проекта - * @param partnerProgramId - идентификатор программы, к которой привязывается проект - * @returns Observable - ответ с названием программы и инфой краткой о проекте - */ - assignProjectToProgram(projectId: number, partnerProgramId: number): Observable { - return this.apiService.post(`${this.PROJECTS_URL}/assign-to-program/`, { - project_id: projectId, - partner_program_id: partnerProgramId, - }); - } - - /** - * Ссоздаёт привязывает проект к программе с указанным ID. - * После чего в БД появляется новый проект в черновиках - * - * @param projectId - id проекта - * @param fieldId - идентификатор доп поля - * @param valueText - идентификатор программы, к которой привязывается проект - * @returns Observable - измененный проект - */ - sendNewProjectFieldsValues( - projectId: number, - newValues: projectNewAdditionalProgramVields[] - ): Observable { - return this.apiService.put(`${this.PROJECTS_URL}/${projectId}/program-fields/`, newValues); - } - - /** - * Обновляет существующий проект - * Отправляет частичные данные проекта для обновления - * - * @param projectId - идентификатор проекта для обновления - * @param newProject - объект с полями проекта для обновления (частичный) - * @returns Observable - обновленный проект со всеми полями - */ - updateProject(projectId: number, newProject: Partial): Observable { - return this.apiService - .put(`${this.PROJECTS_URL}/${projectId}/`, newProject) - .pipe(map(project => plainToInstance(Project, project))); - } - - /** - * Удаляет коллаборатора из проекта - * - * @param projectId - идентификатор проекта - * @param userId - идентификатор пользователя для удаления из коллабораторов - * @returns Observable - завершается при успешном удалении коллаборатора - */ - removeColloborator(projectId: Project["id"], userId: Collaborator["userId"]): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators?id=${userId}`); - } - - /** - * Передает лидерство в проекте другому пользователю - * - * @param projectId - идентификатор проекта - * @param userId - идентификатор пользователя, которому передается лидерство - * @returns Observable - завершается при успешной передаче лидерства - */ - switchLeader(projectId: Project["id"], userId: Collaborator["userId"]): Observable { - return this.apiService.patch( - `${this.PROJECTS_URL}/${projectId}/collaborators/${userId}/switch-leader/`, - {} - ); - } -} diff --git a/projects/social_platform/src/app/office/services/skills.service.ts b/projects/social_platform/src/app/office/services/skills.service.ts deleted file mode 100644 index a9f0852fb..000000000 --- a/projects/social_platform/src/app/office/services/skills.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { SkillsGroup } from "../models/skills-group.model"; -import { Observable } from "rxjs"; -import { ApiService } from "@corelib"; -import { Skill } from "../models/skill.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * Сервис для работы с навыками пользователей - * - * Предоставляет функциональность для: - * - Получения навыков в виде иерархической структуры (группы) - * - Получения навыков в виде плоского списка с поиском и пагинацией - * - Поиска навыков по названию - */ -@Injectable({ - providedIn: "root", -}) -export class SkillsService { - private readonly CORE_SKILLS_URL = "/core/skills"; - - constructor(private apiService: ApiService) {} - - /** - * Получает навыки в виде иерархической структуры (группы и подгруппы) - * Используется для отображения в виде дерева или категорий навыков - * - * @returns Observable - массив групп навыков с вложенными элементами - */ - getSkillsNested(): Observable { - return this.apiService.get(`${this.CORE_SKILLS_URL}/nested`); - } - - /** - * Получает навыки в виде плоского списка с поддержкой поиска и пагинации - * Используется для автокомплита, выпадающих списков и поиска навыков - * - * @param search - строка поиска для фильтрации по названию навыка - * @param limit - максимальное количество результатов на странице - * @param offset - количество пропускаемых результатов (для пагинации) - * @returns Observable> - объект с массивом навыков и метаданными пагинации - */ - getSkillsInline(search: string, limit: number, offset: number): Observable> { - return this.apiService.get( - `${this.CORE_SKILLS_URL}/inline`, - new HttpParams({ fromObject: { limit, offset, name__icontains: search } }) - ); - } -} diff --git a/projects/social_platform/src/app/office/services/specializations.service.ts b/projects/social_platform/src/app/office/services/specializations.service.ts deleted file mode 100644 index 786749a84..000000000 --- a/projects/social_platform/src/app/office/services/specializations.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { SpecializationsGroup } from "../models/specializations-group.model"; -import { Observable } from "rxjs"; -import { ApiService } from "@corelib"; -import { Specialization } from "../models/specialization.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * Сервис для работы со специализациями пользователей - * - * Предоставляет функциональность для: - * - Получения специализаций в виде иерархической структуры (группы) - * - Получения специализаций в виде плоского списка с поиском и пагинацией - * - Поиска специализаций по названию - */ -@Injectable({ - providedIn: "root", -}) -export class SpecializationsService { - private readonly AUTH_USERS_SPECIALIZATIONS_URL = "/auth/users/specializations"; - - constructor(private apiService: ApiService) {} - - /** - * Получает специализации в виде иерархической структуры (группы и подгруппы) - * Используется для отображения в виде дерева или категорий - * - * @returns Observable - массив групп специализаций с вложенными элементами - */ - getSpecializationsNested(): Observable { - return this.apiService.get(`${this.AUTH_USERS_SPECIALIZATIONS_URL}/nested`); - } - - /** - * Получает специализации в виде плоского списка с поддержкой поиска и пагинации - * Используется для автокомплита, выпадающих списков и поиска - * - * @param search - строка поиска для фильтрации по названию специализации - * @param limit - максимальное количество результатов на странице - * @param offset - количество пропускаемых результатов (для пагинации) - * @returns Observable> - объект с массивом специализаций и метаданными пагинации - */ - getSpecializationsInline( - search: string, - limit: number, - offset: number - ): Observable> { - return this.apiService.get( - `${this.AUTH_USERS_SPECIALIZATIONS_URL}/inline`, - new HttpParams({ fromObject: { limit, offset, name__icontains: search } }) - ); - } -} diff --git a/projects/social_platform/src/app/office/services/subscription.service.spec.ts b/projects/social_platform/src/app/office/services/subscription.service.spec.ts deleted file mode 100644 index b6efc39cd..000000000 --- a/projects/social_platform/src/app/office/services/subscription.service.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { SubscriptionService } from "./subscription.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("SubscriptionService", () => { - let service: SubscriptionService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(SubscriptionService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/subscription.service.ts b/projects/social_platform/src/app/office/services/subscription.service.ts deleted file mode 100644 index 16785f7cb..000000000 --- a/projects/social_platform/src/app/office/services/subscription.service.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** @format */ - -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 { HttpParams } from "@angular/common/http"; - -/** - * Сервис для управления подписками на проекты - * - * Предоставляет функциональность для: - * - Получения списка подписчиков проекта - * - Подписки на проекты и отписки от них - * - Получения списка проектов, на которые подписан пользователь - */ -@Injectable({ - providedIn: "root", -}) -export class SubscriptionService { - private readonly PROJECTS_URL = "/projects"; - private readonly AUTH_USERS_URL = "/auth/users"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Получает список всех подписчиков конкретного проекта - * - * @param projectId - идентификатор проекта - * @returns Observable - массив подписчиков с информацией о пользователях - */ - getSubscribers(projectId: number): Observable { - return this.apiService.get( - `${this.PROJECTS_URL}/${projectId}/subscribers/` - ); - } - - /** - * Подписывает текущего пользователя на проект - * После подписки пользователь будет получать уведомления об обновлениях проекта - * - * @param projectId - идентификатор проекта для подписки - * @returns Observable - завершается при успешной подписке - */ - addSubscription(projectId: number): Observable { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/subscribe/`, {}); - } - - /** - * Получает список проектов, на которые подписан указанный пользователь - * Поддерживает пагинацию и фильтрацию - * - * @param userId - идентификатор пользователя - * @param params - параметры запроса (limit, offset, фильтры) - * @returns Observable> - объект с массивом проектов и метаданными пагинации - */ - getSubscriptions(userId: number, params?: HttpParams): Observable> { - return this.apiService.get(`${this.AUTH_USERS_URL}/${userId}/subscribed_projects/`, params); - } - - /** - * Отписывает текущего пользователя от проекта - * После отписки пользователь перестанет получать уведомления об обновлениях проекта - * - * @param projectId - идентификатор проекта для отписки - * @returns Observable - завершается при успешной отписке - */ - deleteSubscription(projectId: number): Observable { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/unsubscribe/`, {}); - } -} diff --git a/projects/social_platform/src/app/office/services/vacancy.service.spec.ts b/projects/social_platform/src/app/office/services/vacancy.service.spec.ts deleted file mode 100644 index 7295177fd..000000000 --- a/projects/social_platform/src/app/office/services/vacancy.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { VacancyService } from "./vacancy.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("VacancyService", () => { - let service: VacancyService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(VacancyService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/vacancy.service.ts b/projects/social_platform/src/app/office/services/vacancy.service.ts deleted file mode 100644 index fa2fcde51..000000000 --- a/projects/social_platform/src/app/office/services/vacancy.service.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { map, Observable } from "rxjs"; -import { Vacancy } from "@models/vacancy.model"; -import { plainToInstance } from "class-transformer"; -import { VacancyResponse } from "@models/vacancy-response.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * Сервис для управления вакансиями и откликами на них - * - * Предоставляет функциональность для: - * - Получения списка вакансий с фильтрацией и поиском - * - Создания, обновления и удаления вакансий - * - Отправки откликов на вакансии - * - Управления откликами (принятие/отклонение) - * - Получения откликов по проектам - */ -@Injectable({ - providedIn: "root", -}) -export class VacancyService { - private readonly VACANCIES_URL = "/vacancies"; - private readonly PROJECTS_URL = "/projects"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Получает список вакансий с расширенной фильтрацией - * Поддерживает фильтрацию по опыту, формату работы, зарплате и поиск по тексту - * - * @param limit - максимальное количество вакансий на странице - * @param offset - количество пропускаемых вакансий (для пагинации) - * @param projectId - фильтр по идентификатору проекта (необязательно) - * @param requiredExperience - фильтр по требуемому опыту работы (необязательно) - * @param workFormat - фильтр по формату работы (удаленно/офис/гибрид) (необязательно) - * @param workSchedule - фильтр по графику работы (полный день/частичная занятость) (необязательно) - * @param salaryMin - минимальная зарплата для фильтрации (необязательно) - * @param salaryMax - максимальная зарплата для фильтрации (необязательно) - * @param searchValue - строка поиска по названию роли (необязательно) - * @returns Observable - массив вакансий, соответствующих критериям - */ - getForProject( - limit: number, - offset: number, - projectId?: number, - requiredExperience?: string, - workFormat?: string, - workSchedule?: string, - salaryMin?: string, - salaryMax?: string, - searchValue?: string - ): any { - let params = new HttpParams().set("limit", limit.toString()).set("offset", offset.toString()); - - if (projectId !== undefined) { - params = params.set("project_id", projectId.toString()); - } - - if (requiredExperience) { - params = params.set("required_experience", requiredExperience); - } - - if (workFormat) { - params = params.set("work_format", workFormat); - } - - if (workSchedule) { - params = params.set("work_schedule", workSchedule); - } - - if (salaryMin) { - params = params.set("salary_min", salaryMin); - } - - if (salaryMax) { - params = params.set("salary_max", salaryMax); - } - - if (searchValue) { - params = params.set("role_contains", searchValue); - } - - return this.apiService - .get(`${this.VACANCIES_URL}/`, params) - .pipe(map(vacancies => plainToInstance(Vacancy, vacancies))); - } - - /** - * Получает список откликов текущего пользователя на вакансии - * - * @param limit - максимальное количество откликов на странице - * @param offset - количество пропускаемых откликов (для пагинации) - * @returns Observable - массив откликов пользователя с информацией о вакансиях - */ - getMyVacancies(limit: number, offset: number): Observable { - const params = new HttpParams(); - - params.set("limit", limit); - params.set("offset", offset); - - return this.apiService - .get(`${this.VACANCIES_URL}/responses/self`, params) - .pipe(map(vacancies => plainToInstance(VacancyResponse, vacancies))); - } - - /** - * Получает детальную информацию о конкретной вакансии - * - * @param vacancyId - идентификатор вакансии - * @returns Observable - объект вакансии со всеми полями - */ - getOne(vacancyId: number) { - return this.apiService - .get(`${this.VACANCIES_URL}/${vacancyId}`) - .pipe(map(vacancy => plainToInstance(Vacancy, vacancy))); - } - - /** - * Создает новую вакансию для проекта - * - * @param projectId - идентификатор проекта, к которому привязывается вакансия - * @param vacancy - объект вакансии с описанием, требованиями и условиями - * @returns Observable - созданная вакансия со всеми полями - */ - postVacancy(projectId: number, vacancy: Vacancy): Observable { - return this.apiService - .post(`${this.VACANCIES_URL}/`, { - ...vacancy, - project: projectId, - }) - .pipe(map(vacancy => plainToInstance(Vacancy, vacancy))); - } - - /** - * Обновляет существующую вакансию - * - * @param vacancyId - идентификатор вакансии для обновления - * @param vacancy - объект с обновленными данными вакансии - * @returns Observable - обновленная вакансия - */ - updateVacancy(vacancyId: number, vacancy: Vacancy) { - return this.apiService.patch(`${this.VACANCIES_URL}/${vacancyId}`, { ...vacancy }); - } - - /** - * Удаляет вакансию - * - * @param vacancyId - идентификатор вакансии для удаления - * @returns Observable - завершается при успешном удалении - */ - deleteVacancy(vacancyId: number): Observable { - return this.apiService.delete(`${this.VACANCIES_URL}/${vacancyId}`); - } - - /** - * Отправляет отклик на вакансию - * - * @param vacancyId - идентификатор вакансии - * @param body - объект с мотивационным письмом (поле whyMe) - * @returns Observable - завершается при успешной отправке отклика - */ - sendResponse(vacancyId: number, body: { whyMe: string }): Observable { - return this.apiService.post(`${this.VACANCIES_URL}/${vacancyId}/responses/`, body); - } - - /** - * Получает все отклики на вакансии конкретного проекта - * - * @param projectId - идентификатор проекта - * @returns Observable - массив откликов с информацией о кандидатах - */ - responsesByProject(projectId: number): Observable { - return this.apiService - .get(`${this.PROJECTS_URL}/${projectId}/responses/`) - .pipe(map(response => plainToInstance(VacancyResponse, response))); - } - - /** - * Принимает отклик кандидата на вакансию - * - * @param responseId - идентификатор отклика - * @returns Observable - завершается при успешном принятии отклика - */ - acceptResponse(responseId: number): Observable { - return this.apiService.post(`${this.VACANCIES_URL}/responses/${responseId}/accept/`, {}); - } - - /** - * Отклоняет отклик кандидата на вакансию - * - * @param responseId - идентификатор отклика - * @returns Observable - завершается при успешном отклонении отклика - */ - rejectResponse(responseId: number): Observable { - return this.apiService.post(`${this.VACANCIES_URL}/responses/${responseId}/decline/`, {}); - } -} diff --git a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts b/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts deleted file mode 100644 index 984311dce..000000000 --- a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, Input } from "@angular/core"; -import { PluralizePipe } from "@corelib"; -import { Skill } from "@office/models/skill.model"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; - -@Component({ - selector: "app-approve-skill-people", - templateUrl: "./approve-skill-people.component.html", - styleUrl: "./approve-skill-people.component.scss", - imports: [CommonModule, AvatarComponent, PluralizePipe], - standalone: true, -}) -export class ApproveSkillPeopleComponent { - @Input({ required: true }) approves!: Skill["approves"]; -} diff --git a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.spec.ts b/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.spec.ts deleted file mode 100644 index a14a0ba25..000000000 --- a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { InviteCardComponent } from "./collaborator-card.component"; - -describe("VacancyCardComponent", () => { - let component: InviteCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [InviteCardComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(InviteCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.ts b/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.ts deleted file mode 100644 index cdd3e7416..000000000 --- a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, inject, Input, OnInit, Output } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; -import { ErrorMessage } from "@error/models/error-message"; -import { Collaborator } from "@office/models/collaborator.model"; -import { ProjectService } from "@office/services/project.service"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { IconComponent } from "@uilib"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * Компонент карточки участника команды или проект - * - * Функциональность: - * - Отображает информацию о участнике (роль, специализация) - * - * Входные параметры: - * @Input invite - объект участника (обязательный) - */ -@Component({ - selector: "app-collaborator-card", - templateUrl: "./collaborator-card.component.html", - styleUrl: "./collaborator-card.component.scss", - standalone: true, - imports: [CommonModule, ReactiveFormsModule, AvatarComponent, IconComponent, TruncatePipe], -}) -export class CollaboratorCardComponent implements OnInit { - private readonly projectService = inject(ProjectService); - private readonly route = inject(ActivatedRoute); - private readonly fb = inject(FormBuilder); - - constructor() { - this.inviteForm = this.fb.group({ - role: [""], - specializations: this.fb.array([]), - }); - } - - inviteForm: FormGroup; - errorMessage = ErrorMessage; - - @Input({ required: true }) collaborator!: Collaborator; - @Output() collaboratorRemoved = new EventEmitter(); - - ngOnInit(): void { - if (this.collaborator) { - this.inviteForm.patchValue({ - role: this.collaborator.role, - specialization: this.collaborator.skills, - }); - } - } - - onDeleteCollaborator(collaboratorId: number): void { - const projectId = this.route.snapshot.params["projectId"]; - - if (!confirm("Вы точно хотите удалить участника проекта?")) return; - - this.projectService.removeColloborator(+projectId, collaboratorId).subscribe({ - next: () => { - this.collaboratorRemoved.emit(collaboratorId); - }, - }); - } -} diff --git a/projects/social_platform/src/app/office/shared/header/header.component.ts b/projects/social_platform/src/app/office/shared/header/header.component.ts deleted file mode 100644 index 8f07acc2e..000000000 --- a/projects/social_platform/src/app/office/shared/header/header.component.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; -import { NotificationService } from "@services/notification.service"; -import { AuthService } from "@auth/services"; -import { Invite } from "@models/invite.model"; -import { InviteService } from "@services/invite.service"; -import { Router } from "@angular/router"; -import { IconComponent } from "@ui/components"; -import { AsyncPipe } from "@angular/common"; -import { ClickOutsideModule } from "ng-click-outside"; -import { InviteManageCardComponent, ProfileInfoComponent } from "@uilib"; - -/** - * Компонент заголовка приложения - * - * Функциональность: - * - Отображает верхнюю панель приложения с уведомлениями - * - Управляет отображением панели уведомлений - * - Показывает индикатор наличия уведомлений (красный шарик) - * - Обрабатывает приглашения пользователя (принятие/отклонение) - * - Отображает информацию о профиле пользователя - * - Закрывает панель уведомлений при клике вне её области - * - Перенаправляет на страницу проекта при принятии приглашения - * - * Входные параметры: - * @Input invites - массив приглашений пользователя - * - * Внутренние свойства: - * - showBall - индикатор наличия уведомлений (из NotificationService) - * - showNotifications - флаг отображения панели уведомлений - * - hasInvites - вычисляемое свойство наличия непрочитанных приглашений - * - * Сервисы: - * - notificationService - управление уведомлениями - * - authService - аутентификация и профиль пользователя - * - inviteService - работа с приглашениями - * - router - навигация по приложению - */ -@Component({ - selector: "app-header", - templateUrl: "./header.component.html", - styleUrl: "./header.component.scss", - standalone: true, - imports: [ - ClickOutsideModule, - IconComponent, - InviteManageCardComponent, - ProfileInfoComponent, - AsyncPipe, - ], -}) -export class HeaderComponent implements OnInit { - constructor( - private readonly notificationService: NotificationService, - public readonly authService: AuthService, - private readonly inviteService: InviteService, - private readonly router: Router - ) {} - - @Input() invites: Invite[] = []; - - ngOnInit(): void {} - - showBall = this.notificationService.hasNotifications; - showNotifications = false; - - /** - * Проверка наличия непринятых приглашений - * Возвращает true если есть приглашения со статусом null (не принято/не отклонено) - */ - get hasInvites(): boolean { - return !!this.invites.filter(invite => invite.isAccepted === null).length; - } - - /** - * Обработчик клика вне панели уведомлений - * Закрывает панель уведомлений - */ - onClickOutside() { - this.showNotifications = false; - } - - /** - * Обработчик отклонения приглашения - * Отправляет запрос на отклонение и удаляет приглашение из списка - */ - onRejectInvite(inviteId: number): void { - this.inviteService.rejectInvite(inviteId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === inviteId); - this.invites.splice(index, 1); - - this.showNotifications = false; - }); - } - - /** - * Обработчик принятия приглашения - * Отправляет запрос на принятие, удаляет приглашение из списка - * и перенаправляет пользователя на страницу проекта - */ - onAcceptInvite(inviteId: number): void { - this.inviteService.acceptInvite(inviteId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === inviteId); - const invite = JSON.parse(JSON.stringify(this.invites[index])); - this.invites.splice(index, 1); - - this.showNotifications = false; - this.router - .navigateByUrl(`/office/projects/${invite.project.id}`) - .then(() => console.debug("Route changed from HeaderComponent")); - }); - } -} diff --git a/projects/social_platform/src/app/office/shared/link-card/link-card.component.html b/projects/social_platform/src/app/office/shared/link-card/link-card.component.html deleted file mode 100644 index 716f4b6b2..000000000 --- a/projects/social_platform/src/app/office/shared/link-card/link-card.component.html +++ /dev/null @@ -1,20 +0,0 @@ - - -@if (data) { -
    -
    -
    -

    - {{ type === "link" ? (data | linkTransform | uppercase) : data.title }} -

    -

    - {{ type === "link" ? data : data.status }} -

    -
    -
    -
    - - -
    -
    -} diff --git a/projects/social_platform/src/app/office/shared/link-card/link-card.component.scss b/projects/social_platform/src/app/office/shared/link-card/link-card.component.scss deleted file mode 100644 index 29f36d128..000000000 --- a/projects/social_platform/src/app/office/shared/link-card/link-card.component.scss +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -.vacancy { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px; - background-color: var(--light-gray); - border-radius: var(--rounded-md); - - &__role { - color: var(--black); - } - - &__requirements { - color: var(--dark-grey); - } - - &__icons { - display: flex; - gap: 15px; - align-items: center; - } - - &__basket { - color: var(--red); - cursor: pointer; - } - - &__edit { - color: var(--dark-grey); - cursor: pointer; - } -} diff --git a/projects/social_platform/src/app/office/shared/link-card/link-card.component.spec.ts b/projects/social_platform/src/app/office/shared/link-card/link-card.component.spec.ts deleted file mode 100644 index 294d83b9f..000000000 --- a/projects/social_platform/src/app/office/shared/link-card/link-card.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { LinkCardComponent } from "./link-card.component"; - -describe("VacancyCardComponent", () => { - let component: LinkCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - roles: of([]), - }; - - await TestBed.configureTestingModule({ - imports: [LinkCardComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LinkCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/shared/link-card/link-card.component.ts b/projects/social_platform/src/app/office/shared/link-card/link-card.component.ts deleted file mode 100644 index 7418a4c40..000000000 --- a/projects/social_platform/src/app/office/shared/link-card/link-card.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** @format */ - -import { UpperCasePipe } from "@angular/common"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { IconComponent } from "@ui/components"; -import { LinkTransformPipe } from "projects/core/src/lib/pipes/link-transform.pipe"; - -/** - * Компонент карточки ссылки или достижения - * - * Функциональность: - * - Отображает ссылку или достижение в виде карточки - * - Поддерживает два типа: "link" (ссылка) и "achievement" (достижение) - * - Предоставляет кнопки для редактирования и удаления - * - Использует трансформацию ссылок через LinkTransformPipe - * - Отображает данные в формате JSON для отладки - * - * Входные параметры: - * @Input data - данные ссылки или достижения (любой объект) - * @Input type - тип карточки: "link" или "achievement" (по умолчанию "link") - * - * Выходные события: - * @Output remove - событие удаления, передает ID элемента - * @Output edit - событие редактирования, передает ID элемента - */ -@Component({ - selector: "app-link-card", - templateUrl: "./link-card.component.html", - styleUrl: "./link-card.component.scss", - standalone: true, - imports: [IconComponent, LinkTransformPipe, UpperCasePipe], -}) -export class LinkCardComponent { - constructor() {} - - @Input() data?: any; - @Input() type: "link" | "achievement" = "link"; - @Output() remove = new EventEmitter(); - @Output() edit = new EventEmitter(); - - /** - * Обработчик удаления элемента - * Предотвращает всплытие события и эмитит событие удаления - */ - onRemove(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - - this.remove.emit(this.data?.id); - } - - /** - * Обработчик редактирования элемента - * Предотвращает всплытие события и эмитит событие редактирования - */ - onEdit(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - - this.edit.emit(this.data?.id); - } -} diff --git a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.ts b/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.ts deleted file mode 100644 index 42824d0b8..000000000 --- a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, Input } from "@angular/core"; -import { ButtonComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; - -@Component({ - selector: "app-soon-card", - templateUrl: "./soon-card.component.html", - styleUrl: "./soon-card.component.scss", - imports: [CommonModule, IconComponent, ButtonComponent], - standalone: true, -}) -export class SoonCardComponent { - @Input({ required: true }) title!: string; - - @Input({ required: true }) description!: string; -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.html b/projects/social_platform/src/app/office/vacancies/detail/info/info.component.html deleted file mode 100644 index e0c8cb6e0..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.html +++ /dev/null @@ -1,258 +0,0 @@ - - -@if (vacancy) { -
    -
    -
    - @if (vacancy.description) { -
    -
    -

    описание вакансии

    - -
    -
    -

    - @if (descriptionExpandable) { -
    - {{ readFullDescription ? "скрыть" : "подробнее" }} -
    - } -
    -
    - } - -
    - @if (vacancy.requiredSkills.length; as skillsLength) { -
    -
    -

    навыки

    - -
    - @if (vacancy.requiredSkills; as requiredSkills) { @if (requiredSkills) { -
      - @for (skill of requiredSkills.slice(0, 8); track $index) { - {{ skill.name }} - } -
    - } -
    - @if (requiredSkills) { -
      - @for (skill of requiredSkills.slice(0, 8); track $index) { - {{ skill.name }} - } -
    - } -
    - } @if (skillsLength > 8) { -
    - {{ readFullSkills ? "скрыть" : "подробнее" }} -
    - } -
    - } -
    -
    - - @if (vacancy.project; as project) { -
    -
    - -

    {{ project.name | truncate: 20 }}

    - - откликнуться -
    - -
    -
    -

    метаданные

    - -
    - -
      -
    • - -

      - {{ project.region ? (project.region | capitalize | truncate: 20) : "не указан" }} -

      -
    • - -
    • - -

      - {{ - vacancy.workFormat ? (vacancy.workFormat | capitalize) : "формат работы не указан" - }} -

      -
    • - -
    • - -

      - {{ - vacancy.requiredExperience - ? (vacancy.requiredExperience.toLowerCase().includes("без опыта") - ? "" - : "опыт" + " ") + (vacancy.requiredExperience | capitalize) - : "опыт не указан" - }} -

      -
    • - -
    • - -

      - {{ vacancy.workSchedule ? (vacancy.workSchedule | capitalize) : "график не указан" }} -

      -
    • - -
    • - -

      - {{ - vacancy.salary - ? (vacancy.salary | salaryTransform | capitalize) + " " + "рублей" - : "по договоренности" - }} -

      -
    • -
    -
    - - @if (project.links.length) { -
    -
    -

    контакты

    - -
    - - -
    - } -
    - } -
    -
    - - -
    -
    -

    отклик на вакансию

    - -
    - -
    - @if (sendForm.get("whyMe"); as whyMe) { -
    - - - @if (whyMe | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } @if (whyMe | controlError: "maxlength") { -
    - {{ errorMessage.VALIDATION_TOO_LONG }} - @if (whyMe.errors) { - {{ whyMe.errors["maxlength"]["requiredLength"] }} - } -
    - } @if (whyMe | controlError: "minlength") { -
    - {{ errorMessage.VALIDATION_TOO_SHORT }} - @if (whyMe.errors) { - {{ whyMe.errors["minlength"]["requiredLength"] }} - } -
    - } -
    - } - - прикрепить резюме PROCOLLAB - -

    или

    - - @if (sendForm.get("accompanyingFile"); as accompanyingFile) { - -
    - - -
    - -

    - файл резюме в формате
    .pdf, .word весом до 50МБ -

    -
    - @if (accompanyingFile | controlError: "required") { -

    загрузите файл

    - } -
    -
    -
    - } - - отправить отклик -
    -
    -
    - - -
    - -

    отклик отправлен

    - перейти к вакансиям -
    -
    -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.scss b/projects/social_platform/src/app/office/vacancies/detail/info/info.component.scss deleted file mode 100644 index 65ba97902..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.scss +++ /dev/null @@ -1,308 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -@mixin expandable-list { - &__remaining { - display: grid; - grid-template-rows: 0fr; - overflow: hidden; - transition: all 0.5s ease-in-out; - - &--show { - grid-template-rows: 1fr; - margin-top: 12px; - } - - ul { - min-height: 0; - } - - li { - &:first-child { - margin-top: 12px; - } - - &:not(:last-child) { - margin-bottom: 12px; - } - } - } -} - -.lists { - &__section { - display: flex; - justify-content: space-between; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); - } - - &__list { - display: flex; - flex-direction: column; - gap: 8px; - } - - &__icon { - color: var(--accent); - } - - &__title { - margin-bottom: 8px; - color: var(--accent); - } - - &__item { - display: flex; - gap: 6px; - align-items: center; - - &--status { - padding: 8px; - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &--title { - color: var(--black); - } - - i { - padding: 6px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - p { - color: var(--accent); - } - - span { - cursor: pointer; - } - } -} - -.vacancy { - &__content { - padding: 24px; - margin-bottom: 20px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - } - - &__right { - display: flex; - flex-direction: column; - gap: 20px; - text-align: center; - - &--title { - margin: 12px 0; - } - - &--project { - position: relative; - padding: 48px 24px 24px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - - &-image { - position: absolute; - top: -70px; - left: 50%; - display: block; - transform: translate(-50%, 50%); - } - } - } - - .skills { - &__list { - display: flex; - flex-wrap: wrap; - gap: 10px; - } - - li { - &:not(:last-child) { - margin-bottom: 12px; - } - } - - @include expandable-list; - } - - &__split { - display: grid; - grid-template-columns: 7fr 3fr; - gap: 20px; - } - - .read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } - } - - .about { - - /* stylelint-disable value-no-vendor-prefix */ - &__text { - p { - display: -webkit-box; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } - } - - /* stylelint-enable value-no-vendor-prefix */ - - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } - } - - &__form { - display: flex; - flex-direction: column; - gap: 10px; - - label { - color: var(--black); - } - - &--or { - color: var(--grey-for-text); - text-align: center; - } - - &-error { - border: 0.5px solid var(--red); - } - - &--cv { - ::ng-deep { - app-upload-file { - .control { - height: 80px; - border-radius: var(--rounded-xl); - } - } - } - - &-empty { - display: flex; - flex-direction: column; - gap: 12px; - align-items: center; - color: var(--grey-for-text); - } - } - } -} - -.cancel { - display: flex; - flex-direction: column; - width: 600px; - max-height: calc(100vh - 40px); - overflow-y: auto; - - &__top { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - } - - &__image { - display: flex; - flex-direction: column; - gap: 15px; - align-items: center; - justify-content: space-between; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - gap: 15px; - align-items: center; - justify-content: space-between; - margin: 30px 0; - } - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__title { - margin-top: 15px; - margin-bottom: 15px; - text-align: center; - } -} - -$succeed-modal-width: 310px; - -.succeed { - display: flex; - flex-direction: column; - align-items: center; - width: $succeed-modal-width; - - &__check { - margin-bottom: 18px; - color: var(--green); - } - - &__text { - margin-bottom: 18px; - color: var(--black); - } - - &__link { - width: $succeed-modal-width; - } -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.spec.ts b/projects/social_platform/src/app/office/vacancies/detail/info/info.component.spec.ts deleted file mode 100644 index 6ced6bd14..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { InfoComponent } from "./info.component"; - -describe("InfoComponent", () => { - let component: InfoComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [InfoComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(InfoComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.ts b/projects/social_platform/src/app/office/vacancies/detail/info/info.component.ts deleted file mode 100644 index c1cd65256..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** @format */ - -import { - ChangeDetectorRef, - Component, - ElementRef, - inject, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { AuthService } from "@auth/services"; -import { - ControlErrorPipe, - ParseBreaksPipe, - ParseLinksPipe, - SubscriptionPlan, - SubscriptionPlansService, - ValidationService, -} from "@corelib"; -import { Project } from "@office/models/project.model"; -import { Vacancy } from "@office/models/vacancy.model"; -import { ProjectService } from "@office/services/project.service"; -import { ButtonComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { IconComponent } from "@uilib"; -import { expandElement } from "@utils/expand-element"; -import { SalaryTransformPipe } from "projects/core/src/lib/pipes/salary-transform.pipe"; -import { map, Subscription } from "rxjs"; -import { CapitalizePipe } from "projects/core/src/lib/pipes/capitalize.pipe"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { UserLinksPipe } from "@core/pipes/user-links.pipe"; -import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { VacancyService } from "@office/services/vacancy.service"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * Компонент отображения детальной информации о вакансии - * - * Основная функциональность: - * - Отображение полной информации о вакансии (описание, навыки, условия) - * - Показ информации о проекте, к которому относится вакансия - * - Кнопки действий: "Откликнуться" и "Прокачать себя" - * - Модальное окно с предложением подписки на обучение - * - Адаптивное отображение с возможностью сворачивания/разворачивания контента - * - * Управление контентом: - * - Автоматическое определение необходимости кнопки "Читать полностью" - * - Сворачивание длинного описания и списка навыков - * - Парсинг ссылок и переносов строк в описании - * - * Интеграция с сервисами: - * - VacancyService - получение данных вакансии через резолвер - * - ProjectService - загрузка информации о проекте - * - SubscriptionPlansService - получение планов подписки - * - AuthService - информация о текущем пользователе - * - * Жизненный цикл: - * - OnInit: загрузка данных вакансии и проекта, подписка на планы - * - AfterViewInit: определение необходимости кнопок "Читать полностью" - * - OnDestroy: отписка от всех активных подписок - * - * @property {Vacancy} vacancy - объект вакансии с полной информацией - * @property {Project} project - объект проекта, к которому относится вакансия - * @property {boolean} readFullDescription - состояние развернутого описания - * @property {boolean} readFullSkills - состояние развернутого списка навыков - * - * @selector app-detail - * @standalone true - автономный компонент - */ -@Component({ - selector: "app-detail", - standalone: true, - imports: [ - IconComponent, - TagComponent, - ButtonComponent, - ModalComponent, - RouterModule, - ReactiveFormsModule, - ParseBreaksPipe, - ParseLinksPipe, - TruncatePipe, - SalaryTransformPipe, - CapitalizePipe, - UserLinksPipe, - ControlErrorPipe, - AvatarComponent, - UploadFileComponent, - TextareaComponent, - ], - templateUrl: "./info.component.html", - styleUrl: "./info.component.scss", -}) -export class VacancyInfoComponent implements OnInit { - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly vacancyService = inject(VacancyService); - private readonly validationService = inject(ValidationService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly fb = inject(FormBuilder); - - constructor() { - // Создание формы отклика с валидацией - this.sendForm = this.fb.group({ - whyMe: ["", [Validators.required, Validators.minLength(20), Validators.maxLength(2000)]], - accompanyingFile: ["", Validators.required], - }); - } - - vacancy!: Vacancy; - - /** Объект с сообщениями об ошибках */ - errorMessage = ErrorMessage; - - descriptionExpandable!: boolean; - skillsExpandable!: boolean; - - /** Форма отправки отклика */ - sendForm: FormGroup; - - /** Флаг состояния отправки формы */ - sendFormIsSubmitting = false; - - /** Флаг отображения модального окна с результатом */ - resultModal = false; - - openModal = signal(false); - readFullDescription = false; - readFullSkills = false; - - private subscriptions$: Subscription[] = []; - - @ViewChild("skillsEl") skillsEl?: ElementRef; - @ViewChild("descEl") descEl?: ElementRef; - - ngOnInit(): void { - this.route.data.pipe(map(r => r["data"])).subscribe((vacancy: Vacancy) => { - this.vacancy = vacancy; - }); - - this.route.queryParams.subscribe({ - next: r => { - if (r["sendResponse"]) { - this.openModal.set(true); - } - }, - }); - } - - ngAfterViewInit(): void { - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - - const skillsElement = this.skillsEl?.nativeElement; - this.skillsExpandable = skillsElement?.clientHeight < skillsElement?.scrollHeight; - - this.cdRef.detectChanges(); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - closeSendResponseModal(): void { - this.openModal.set(false); - - this.router.navigate([], { - queryParams: {}, - replaceUrl: true, - }); - } - - /** - * Обработчик отправки формы - * Валидирует форму и отправляет отклик на сервер - */ - onSubmit(): void { - // Проверка валидности формы - if (!this.validationService.getFormValidation(this.sendForm)) { - return; - } - - // Установка флага загрузки - this.sendFormIsSubmitting = true; - - // Отправка отклика на сервер - this.vacancyService - .sendResponse(Number(this.route.snapshot.paramMap.get("vacancyId")), this.sendForm.value) - .subscribe({ - next: () => { - // Успешная отправка - показываем модальное окно - this.sendFormIsSubmitting = false; - this.resultModal = true; - this.openModal.set(false); - }, - error: () => { - // Ошибка отправки - снимаем флаг загрузки - this.sendFormIsSubmitting = false; - }, - }); - } - - /** - * Раскрытие/сворачивание описания профиля - * @param elem - DOM элемент описания - * @param expandedClass - CSS класс для раскрытого состояния - * @param isExpanded - текущее состояние (раскрыто/свернуто) - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - onExpandSkills(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullSkills = !isExpanded; - } - - openSkills() { - location.href = "https://skills.procollab.ru"; - } -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.html b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.html deleted file mode 100644 index d32471d20..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - -@if (vacancy) { -
    - -
    - -
    - -
    -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.ts b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.ts deleted file mode 100644 index 3621c2dce..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, RouterOutlet } from "@angular/router"; -import { Vacancy } from "@office/models/vacancy.model"; -import { BarComponent } from "@ui/components"; -import { map, Subscription } from "rxjs"; -import { BackComponent } from "@uilib"; - -/** - * Компонент детального просмотра вакансии - * - * Функциональность: - * - Получает данные вакансии из резолвера через ActivatedRoute - * - Отображает навигационную панель с кнопкой "Назад" - * - Содержит router-outlet для дочерних компонентов (информация о вакансии) - * - Управляет подписками для предотвращения утечек памяти - * - * Жизненный цикл: - * - OnInit: подписывается на данные маршрута и извлекает объект вакансии - * - OnDestroy: отписывается от всех активных подписок - * - * @property {Vacancy} vacancy - объект вакансии, полученный из резолвера - * @property {Subscription[]} subscriptions$ - массив подписок для управления памятью - * - * @selector app-vacancies-detail - * @standalone true - автономный компонент - */ -@Component({ - selector: "app-vacancies-detail", - standalone: true, - imports: [CommonModule, BarComponent, RouterOutlet, BackComponent], - templateUrl: "./vacancies-detail.component.html", - styleUrl: "./vacancies-detail.component.scss", -}) -export class VacanciesDetailComponent implements OnInit, OnDestroy { - route = inject(ActivatedRoute); - - subscriptions$: Subscription[] = []; - - vacancy?: Vacancy; - - ngOnInit(): void { - const vacancySub$ = this.route.data.pipe(map(r => r["data"])).subscribe(vacancy => { - this.vacancy = vacancy; - }); - - vacancySub$ && this.subscriptions$.push(vacancySub$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/vacancies/list/list.component.spec.ts b/projects/social_platform/src/app/office/vacancies/list/list.component.spec.ts deleted file mode 100644 index ab755dc02..000000000 --- a/projects/social_platform/src/app/office/vacancies/list/list.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ListComponent } from "./VacanciesList.component"; - -describe("ListComponent", () => { - let component: ListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ListComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/vacancies/list/list.component.ts b/projects/social_platform/src/app/office/vacancies/list/list.component.ts deleted file mode 100644 index cce757e48..000000000 --- a/projects/social_platform/src/app/office/vacancies/list/list.component.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** @format */ - -// list.component.ts -/** @format */ - -import { Component, inject, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { - concatMap, - debounceTime, - fromEvent, - map, - noop, - of, - Subscription, - switchMap, - tap, - throttleTime, -} from "rxjs"; -import { VacancyService } from "@office/services/vacancy.service"; -import { Vacancy } from "@office/models/vacancy.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { VacancyResponse } from "@office/models/vacancy-response.model"; -import { ResponseCardComponent } from "@office/features/response-card/response-card.component"; -import { ProjectVacancyCardComponent } from "@office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; - -@Component({ - selector: "app-vacancies-list", - standalone: true, - imports: [ - CommonModule, - ResponseCardComponent, - ProjectVacancyCardComponent, - ButtonComponent, - IconComponent, - ModalComponent, - RouterLink, - ], - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", -}) -export class VacanciesListComponent { - route = inject(ActivatedRoute); - router = inject(Router); - vacancyService = inject(VacancyService); - - totalItemsCount = signal(0); - vacancyList = signal([]); - responsesList = signal([]); - vacancyPage = signal(1); - perFetchTake = signal(20); - type = signal<"all" | "my" | null>(null); - - requiredExperience = signal(undefined); - roleContains = signal(undefined); - workFormat = signal(undefined); - workSchedule = signal(undefined); - salary = signal(undefined); - - isMyModal = signal(false); - - subscriptions$ = signal([]); - - ngOnInit() { - const urlSegment = this.router.url.split("/").slice(-1)[0]; - const trimmedSegment = urlSegment.split("?")[0]; - this.type.set(trimmedSegment as "all" | "my"); - - const routeData$ = - this.type() === "all" - ? this.route.data.pipe(map(r => r["data"])) - : this.route.data.pipe(map(r => r["data"])); - - const subscription = routeData$.subscribe( - (vacancy: ApiPagination | ApiPagination) => { - if (this.type() === "all") { - this.vacancyList.set(vacancy.results as Vacancy[]); - } else if (this.type() === "my") { - this.responsesList.set(vacancy.results as VacancyResponse[]); - } - this.totalItemsCount.set(vacancy.count); - } - ); - - const queryParams$ = this.route.queryParams - .pipe( - debounceTime(200), - 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.requiredExperience.set(requiredExperience); - this.roleContains.set(roleContains); - this.workFormat.set(workFormat); - this.workSchedule.set(workSchedule); - this.salary.set(salary); - }), - switchMap(() => this.onFetch(0, 20)) - ) - .subscribe((result: any) => { - if (this.type() === "all") { - this.vacancyList.set(result.results); - } - this.totalItemsCount.set(result.count); - this.vacancyPage.set(1); - }); - - this.subscriptions$().push(subscription, queryParams$); - - this.myModalSetup(); - } - - ngAfterViewInit() { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(500) - ) - .subscribe(noop); - - this.subscriptions$().push(scrollEvents$); - } - } - - ngOnDestroy() { - this.subscriptions$().forEach(($: any) => $.unsubscribe()); - } - - private onScroll() { - if (this.totalItemsCount() && this.vacancyList().length >= this.totalItemsCount()) - return of({}); - - const target = document.querySelector(".office__body"); - if (!target) return of({}); - - const diff = target.scrollTop - target.scrollHeight + target.clientHeight; - - if (diff > 0) { - return this.onFetch(this.vacancyPage() * this.perFetchTake(), this.perFetchTake()).pipe( - tap((result: any) => { - this.vacancyPage.update(page => page + 1); - this.vacancyList.update(items => [...items, ...result.results]); - }) - ); - } - - return of({}); - } - - private 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)); - } - - private myModalSetup() { - if (this.type() === "my" && this.responsesList().length === 0) { - this.isMyModal.set(true); - } else { - this.isMyModal.set(false); - } - } -} diff --git a/projects/social_platform/src/app/office/vacancies/list/my.resolver.ts b/projects/social_platform/src/app/office/vacancies/list/my.resolver.ts deleted file mode 100644 index 68fc83468..000000000 --- a/projects/social_platform/src/app/office/vacancies/list/my.resolver.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ResolveFn } from "@angular/router"; -import { VacancyResponse } from "@office/models/vacancy-response.model"; -import { VacancyService } from "@office/services/vacancy.service"; - -/** - * Резолвер для загрузки откликов пользователя на вакансии - * - * Функциональность: - * - Выполняется перед активацией маршрута '/office/vacancies/my' - * - Загружает первые 20 откликов пользователя с offset 0 - * - Возвращает массив объектов VacancyResponse с информацией об откликах - * - * Использование: - * - Данные становятся доступными в компоненте через ActivatedRoute.data['data'] - * - Позволяет отобразить список вакансий, на которые пользователь уже откликнулся - * - * @param {VacancyService} vacanciesService - сервис для работы с API вакансий - * @returns {Observable} Observable с массивом откликов пользователя - * - * Параметры запроса: - * - limit: 20 - количество откликов на страницу - * - offset: 0 - смещение для пагинации - */ -export const VacanciesMyResolver: ResolveFn = () => { - const vacanciesService = inject(VacancyService); - - return vacanciesService.getMyVacancies(20, 0); -}; diff --git a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.ts b/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.ts deleted file mode 100644 index 3ce19cde0..000000000 --- a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** @format */ - -import { animate, style, transition, trigger } from "@angular/animations"; -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - inject, - Input, - OnInit, - Output, - signal, -} from "@angular/core"; -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 { VacancyService } from "@office/services/vacancy.service"; -import { map, Subscription, tap } from "rxjs"; -import { workFormatFilter } from "projects/core/src/consts/filters/work-format-filter.const"; -import { workScheduleFilter } from "projects/core/src/consts/filters/work-schedule-filter.const"; -import { workExperienceFilter } from "projects/core/src/consts/filters/work-experience-filter.const"; - -/** - * Компонент фильтра вакансий без использования реактивных форм - * Использует сигналы для управления состоянием полей зарплаты - */ -@Component({ - selector: "app-vacancy-filter", - standalone: true, - imports: [ - CommonModule, - CheckboxComponent, - ClickOutsideModule, - IconComponent, - ButtonComponent, - RouterLink, - ], - templateUrl: "./vacancy-filter.component.html", - styleUrl: "./vacancy-filter.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [ - trigger("dropdownAnimation", [ - transition(":enter", [ - style({ opacity: 0, transform: "scaleY(0.8)" }), - animate(".12s cubic-bezier(0, 0, 0.2, 1)"), - ]), - transition(":leave", [animate(".1s linear", style({ opacity: 0 }))]), - ]), - ], -}) -export class VacancyFilterComponent implements OnInit { - /** Сервис роутера для навигации */ - router = inject(Router); - /** Сервис текущего маршрута */ - route = inject(ActivatedRoute); - /** Сервис ленты новостей */ - feedService = inject(FeedService); - /** Сервис для работы с вакансиями */ - vacancyService = inject(VacancyService); - - constructor() {} - - /** Приватное поле для хранения значения поиска */ - private _searchValue: string | undefined; - - /** - * Сеттер для значения поиска - * @param value - новое значение поиска - */ - @Input() set searchValue(value: string | undefined) { - this._searchValue = value; - } - - /** - * Геттер для получения значения поиска - * @returns текущее значение поиска - */ - get searchValue(): string | undefined { - return this._searchValue; - } - - /** Событие изменения значения поиска */ - @Output() searchValueChange = new EventEmitter(); - - /** Подписка на параметры запроса */ - queries$?: Subscription; - - /** Состояние открытия фильтра (для мобильной версии) */ - filterOpen = signal(false); - - /** Общее количество элементов */ - totalItemsCount = signal(0); - - // Сигналы для текущих значений фильтров - /** Текущий фильтр по опыту */ - currentExperience = signal(undefined); - /** Текущий фильтр по формату работы */ - currentWorkFormat = signal(undefined); - /** Текущий фильтр по графику работы */ - currentWorkSchedule = signal(undefined); - /** Текущая зарплата */ - currentSalary = signal(undefined); - - /** Опции фильтра по опыту работы */ - readonly workExperienceFilterOptions = workExperienceFilter; - - /** Опции фильтра по формату работы */ - readonly workFormatFilterOptions = workFormatFilter; - - /** Опции фильтра по графику работы */ - readonly workScheduleFilterOptions = workScheduleFilter; - - /** - * Инициализация компонента - */ - ngOnInit() { - // Подписка на изменения параметров запроса - this.queries$ = this.route.queryParams.subscribe(queries => { - // Синхронизация текущих значений фильтров с URL - this.currentExperience.set(queries["required_experience"]); - this.currentWorkFormat.set(queries["work_format"]); - this.currentWorkSchedule.set(queries["work_schedule"]); - this.currentSalary.set(queries["salary"]); - this.searchValue = queries["role_contains"]; - }); - } - - /** - * Установка фильтра по опыту работы - * @param event - событие клика - * @param experienceId - идентификатор выбранного опыта - */ - setExperienceFilter(event: Event, experienceId: string): void { - event.stopPropagation(); - // Переключение фильтра (снятие если уже выбран) - this.currentExperience.set( - experienceId === this.currentExperience() ? undefined : experienceId - ); - - // Обновление URL с новым параметром - this.router - .navigate([], { - queryParams: { required_experience: this.currentExperience() }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Установка фильтра по формату работы - * @param event - событие клика - * @param formatId - идентификатор выбранного формата - */ - setWorkFormatFilter(event: Event, formatId: string): void { - event.stopPropagation(); - this.currentWorkFormat.set(formatId === this.currentWorkFormat() ? undefined : formatId); - - this.router - .navigate([], { - queryParams: { work_format: this.currentWorkFormat() }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Установка фильтра по графику работы - * @param event - событие клика - * @param scheduleId - идентификатор выбранного графика - */ - setWorkScheduleFilter(event: Event, scheduleId: string): void { - event.stopPropagation(); - this.currentWorkSchedule.set( - scheduleId === this.currentWorkSchedule() ? undefined : scheduleId - ); - - this.router - .navigate([], { - queryParams: { - work_schedule: this.currentWorkSchedule(), - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Сброс всех фильтров - * Очищает все параметры фильтрации и обновляет URL - */ - resetFilter(): void { - this.currentExperience.set(undefined); - this.currentWorkFormat.set(undefined); - this.currentWorkSchedule.set(undefined); - - this.onSearchValueChanged(""); - - this.router - .navigate([], { - queryParams: { - required_experience: null, - work_format: null, - work_schedule: null, - role_contains: null, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Filters reset from VacancyFilterComponent")); - } - - /** - * Обработчик изменения значения поиска - * @param value - новое значение поиска - */ - onSearchValueChanged(value: string) { - this.searchValueChange.emit(value); - } - - /** - * Обработчик клика вне компонента - * Закрывает мобильное меню фильтров - */ - onClickOutside(): void { - this.filterOpen.set(false); - } - - /** - * Загрузка данных с применением текущих фильтров - * @param offset - смещение для пагинации - * @param limit - количество элементов для загрузки - * @param projectId - идентификатор проекта (опционально) - * @returns Observable с отфильтрованными данными - */ - onFetch(offset: number, limit: number, projectId?: number) { - return this.vacancyService - .getForProject( - limit, - offset, - projectId, - this.currentExperience(), - this.currentWorkFormat(), - this.currentWorkSchedule(), - this.searchValue - ) - .pipe( - tap((res: any) => { - this.totalItemsCount.set(res.length); - }), - map(res => res) - ); - } - - ngOnDestroy() { - this.queries$?.unsubscribe(); - } -} diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.html b/projects/social_platform/src/app/office/vacancies/vacancies.component.html deleted file mode 100644 index cce86b38a..000000000 --- a/projects/social_platform/src/app/office/vacancies/vacancies.component.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - -
    -
    - - -
    -
    - @if(isAll) { -
    - -
    - } - - -
    - - @if (isAll) { -
    - -
    - } -
    -
    -
    diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.ts b/projects/social_platform/src/app/office/vacancies/vacancies.component.ts deleted file mode 100644 index 81b2bd86c..000000000 --- a/projects/social_platform/src/app/office/vacancies/vacancies.component.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** @format */ - -// vacancies.component.ts -/** @format */ - -import { Component, inject, OnInit } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { BarComponent } from "@ui/components"; -import { ActivatedRoute, Router, RouterOutlet } from "@angular/router"; -import { BackComponent } from "@uilib"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { VacancyFilterComponent } from "./shared/filter/vacancy-filter.component"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { debounceTime, distinctUntilChanged, tap } from "rxjs"; - -@Component({ - selector: "app-vacancies", - standalone: true, - imports: [ - CommonModule, - BarComponent, - RouterOutlet, - BackComponent, - SearchComponent, - VacancyFilterComponent, - ReactiveFormsModule, - ], - templateUrl: "./vacancies.component.html", - styleUrl: "./vacancies.component.scss", -}) -export class VacanciesComponent implements OnInit { - route = inject(ActivatedRoute); - router = inject(Router); - fb = inject(FormBuilder); - - searchForm: FormGroup; - - basePath = "/office/"; - - get isAll(): boolean { - return this.router.url.includes("/vacancies/all"); - } - - get isMy(): boolean { - return this.router.url.includes("/vacancies/my"); - } - - constructor() { - this.searchForm = this.fb.group({ - search: [""], - }); - } - - ngOnInit() { - this.searchForm - .get("search") - ?.valueChanges.pipe( - debounceTime(300), - distinctUntilChanged(), - tap(value => { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { role_contains: value || null }, - queryParamsHandling: "merge", - }); - }) - ) - .subscribe(); - } - - onSearchSubmit() { - const value = this.searchForm.get("search")?.value; - this.router.navigate([], { - queryParams: { role_contains: value || null }, - queryParamsHandling: "merge", - relativeTo: this.route, - }); - } - - onSearhValueChanged(event: string) { - this.searchForm.get("search")?.setValue(event); - } -} diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts b/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts deleted file mode 100644 index 4b7ffcefb..000000000 --- a/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { VacancyService } from "@office/services/vacancy.service"; - -/** - * Резолвер для предзагрузки списка вакансий - * Загружает данные вакансий до активации маршрута, обеспечивая - * мгновенное отображение контента без состояния загрузки - * - * @returns Observable с данными вакансий (первые 20 элементов) - */ -export const VacanciesResolver = () => { - const vacanciesService = inject(VacancyService); - - // Загрузка первых 20 вакансий с нулевым смещением - return vacanciesService.getForProject(20, 0); -}; diff --git a/projects/social_platform/src/app/ui/components/bar-new/bar.component.ts b/projects/social_platform/src/app/ui/components/bar-new/bar.component.ts deleted file mode 100644 index afca50931..000000000 --- a/projects/social_platform/src/app/ui/components/bar-new/bar.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { RouterLink, RouterLinkActive } from "@angular/router"; -import { IconComponent } from "@uilib"; - -/** - * Отображает горизонтальный список ссылок с индикаторами активности и счетчиками. - * - * Входящие параметры: - * - links: массив объектов навигационных ссылок с настройками - * - link: URL ссылки - * - linkText: текст ссылки - * - isRouterLinkActiveOptions: настройки активности ссылки) - * - * Использование: - * - Навигация между разделами приложения - */ -@Component({ - selector: "app-bar-new", - standalone: true, - imports: [CommonModule, RouterLink, RouterLinkActive, IconComponent], - templateUrl: "./bar.component.html", - styleUrl: "./bar.component.scss", -}) -export class BarNewComponent { - constructor() {} - - /** Массив навигационных ссылок */ - @Input() links!: { - link: string; - linkText: string; - iconName: string; - isRouterLinkActiveOptions: boolean; - }[]; -} diff --git a/projects/social_platform/src/app/ui/components/bar/bar.component.spec.ts b/projects/social_platform/src/app/ui/components/bar/bar.component.spec.ts deleted file mode 100644 index 503f07460..000000000 --- a/projects/social_platform/src/app/ui/components/bar/bar.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { BarComponent } from "./bar.component"; - -describe("BarComponent", () => { - let component: BarComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BarComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(BarComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/bar/bar.component.ts b/projects/social_platform/src/app/ui/components/bar/bar.component.ts deleted file mode 100644 index eabb00e75..000000000 --- a/projects/social_platform/src/app/ui/components/bar/bar.component.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { RouterLink, RouterLinkActive } from "@angular/router"; -import { BackComponent } from "@uilib"; - -/** - * Компонент навигационной панели с табами и кнопкой "Назад". - * Отображает горизонтальный список ссылок с индикаторами активности и счетчиками. - * - * Входящие параметры: - * - links: массив объектов навигационных ссылок с настройками - * - link: URL ссылки - * - linkText: текст ссылки - * - isRouterLinkActiveOptions: настройки активности ссылки - * - count: количество элементов для отображения бейджа (опционально) - * - backRoute: маршрут для кнопки "Назад" (опционально) - * - backHave: показывать ли кнопку "Назад" (опционально) - * - ballHave: показывать ли индикатор в виде шарика (по умолчанию false) - * - * Использование: - * - Навигация между разделами приложения - * - Отображение количества элементов в разделах - * - Навигация назад к предыдущему экрану - */ -@Component({ - selector: "app-bar", - standalone: true, - imports: [CommonModule, RouterLink, RouterLinkActive, BackComponent], - templateUrl: "./bar.component.html", - styleUrl: "./bar.component.scss", -}) -export class BarComponent { - constructor() {} - - /** Массив навигационных ссылок */ - @Input() links!: { - link: string; - linkText: string; - isRouterLinkActiveOptions: boolean; - count?: number; - }[]; - - /** Показывать индикатор в виде шарика */ - @Input() ballHave?: boolean = false; - - /** Маршрут для кнопки "Назад" */ - @Input() backRoute?: string; - - /** Показывать кнопку "Назад" */ - @Input() backHave?: boolean; -} 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 deleted file mode 100644 index 27a13b455..000000000 --- a/projects/social_platform/src/app/ui/components/modal/modal.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.html b/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.html deleted file mode 100644 index 722596a5e..000000000 --- a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.html +++ /dev/null @@ -1,27 +0,0 @@ - - -@if (nums.sort(); as sortedNums) { -
    - @if (value !== null) { -
    -
    -
    -
    -
    - } -
    - @for (num of sortedNums; let index = $index; track index) { - - {{ num }} - - } -
    -
    -} diff --git a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.scss b/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.scss deleted file mode 100644 index 11982b8a5..000000000 --- a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.scss +++ /dev/null @@ -1,54 +0,0 @@ -/** @format */ - -.num-slider { - padding-bottom: 15px; // to give space for numbers - - &__range { - position: relative; - height: 16px; - } - - &__placeholder { - position: absolute; - top: 50%; - right: 0; - left: 0; - height: 4px; - background-color: var(--light-gray); - border-radius: var(--rounded-md); - transform: translateY(-50%); - } - - &__fill { - position: absolute; - top: 50%; - right: 0; - left: 0; - width: 0; - height: 4px; - background-color: var(--accent); - border-radius: var(--rounded-md); - transform: translateY(-50%); - } - - &__button { - position: absolute; - width: 16px; - height: 16px; - cursor: pointer; - background-color: var(--accent); - border-radius: 50%; - transform: translateX(-50%); - } - - &__nums { - position: relative; - } - - &__num { - position: absolute; - font-size: 10px; - color: var(--dark-grey); - transform: translateX(-50%); - } -} diff --git a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.spec.ts b/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.spec.ts deleted file mode 100644 index e6f88d78c..000000000 --- a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { FormsModule } from "@angular/forms"; -import { NumSliderComponent } from "./num-slider.component"; - -describe("NumSliderComponent", () => { - let component: NumSliderComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, NumSliderComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NumSliderComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - it("should set value to null when initialized", () => { - expect(component.appValue).toBeNull(); - }); - - it("should set disabled state", () => { - component.setDisabledState(true); - expect(component.disabled).toBeTrue(); - }); - - it("should change value on move", () => { - component.nums = [0, 1, 2, 3]; - component.appValue = 1; - fixture.detectChanges(); - - const rangeEl = fixture.nativeElement.querySelector(".num-slider__range"); - const rangeWidth = rangeEl.getBoundingClientRect().width; - const moveEvent = new MouseEvent("mousemove", { clientX: rangeWidth / 2 }); - rangeEl.dispatchEvent(moveEvent); - - const pointEl = fixture.nativeElement.querySelector(".num-slider__button"); - const pressEvent = new MouseEvent("mousedown"); - pointEl.dispatchEvent(pressEvent); - - fixture.detectChanges(); - expect(component.appValue).toBe(1); - expect(component.mousePressed).toBeTrue(); - }); - - it("should emit appValueChange event on stop interaction", () => { - spyOn(component.appValueChange, "emit"); - component.nums = [0, 1, 2, 3]; - component.appValue = 1; - fixture.detectChanges(); - const buttonEl = fixture.nativeElement.querySelector(".num-slider__button"); - const event = new MouseEvent("mouseup"); - buttonEl.dispatchEvent(event); - fixture.detectChanges(); - expect(component.mousePressed).toBeFalse(); - expect(component.appValueChange.emit).toHaveBeenCalled(); - }); - - it("should set elements on setElements call", () => { - component.nums = [0, 1, 2, 3]; - component.appValue = 1; - fixture.detectChanges(); - component.setElements(); - fixture.detectChanges(); - - expect(component.pointEl?.nativeElement.style.left).toEqual("33.3333%"); - expect(component.fillEl?.nativeElement.style.width).toEqual("33.3333%"); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.ts b/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.ts deleted file mode 100644 index 2cd12fbf9..000000000 --- a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** @format */ - -import { - Component, - ElementRef, - EventEmitter, - forwardRef, - Input, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { NG_VALUE_ACCESSOR } from "@angular/forms"; -import { Subscription } from "rxjs"; - -/** - * Компонент числового слайдера для выбора значения из предопределенного набора чисел. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Поддерживает перетаскивание мышью и ка��ание для мобильных устройств. - * - * Входящие параметры: - * - appNums: массив доступных чисел для выбора - * - appValue: текущее выбранное значение - * - * События: - * - appValueChange: изменение выбранного значения - * - * Возвращает: - * - Выбранное число через ControlValueAccessor - */ -@Component({ - selector: "app-num-slider", - templateUrl: "./num-slider.component.html", - styleUrl: "./num-slider.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => NumSliderComponent), - multi: true, - }, - ], - standalone: true, -}) -export class NumSliderComponent implements OnInit, OnDestroy { - constructor() {} - - /** Массив доступных чисел */ - @Input() - set appNums(value: number[]) { - this.nums = value; - } - - get appNums(): number[] { - return this.nums; - } - - /** Текущее выбранное значение */ - @Input() - set appValue(value: number | null) { - this.value = !value || isNaN(value) ? this.nums.sort()[0] : value; - - setTimeout(() => { - this.setElements(); - }); - } - - get appValue(): number | null { - return this.value; - } - - /** Событие изменения значения */ - @Output() appValueChange = new EventEmitter(); - - ngOnInit(): void {} - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** Ссылка на элемент точки слайдера */ - @ViewChild("pointEl") pointEl?: ElementRef; - - /** Ссылка на элемент диапазона */ - @ViewChild("rangeEl") rangeEl?: ElementRef; - - /** Ссылка на элемент заливки */ - @ViewChild("fillEl") fillEl?: ElementRef; - - /** Массив подписок */ - subscriptions$: Subscription[] = []; - - /** Текущее значение */ - value: number | null = null; - - /** Массив доступных чисел */ - nums: number[] = []; - - /** Состояние нажатия мыши */ - mousePressed = false; - - /** Обработчик потери фокуса */ - onBlur(): void { - this.onTouch(); - } - - // Методы ControlValueAccessor - onChange: (value: number) => void = () => {}; - - registerOnChange(fn: (v: number) => void): void { - this.onChange = fn; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: () => void): void { - this.onTouch = fn; - } - - disabled = false; - - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - /** Обработчик движения мыши/касания */ - onMove(event: MouseEvent | TouchEvent) { - if (!this.mousePressed) return; - - const range = event.currentTarget as HTMLElement; - const { width: totalWidth, x } = range.getBoundingClientRect(); - let xChange: number; - if (event instanceof MouseEvent) xChange = event.clientX - x; - else if (event instanceof TouchEvent) xChange = event.touches[0].clientX - x; - else throw Error("Non existing type"); - - if (this.pointEl && xChange > 0 && xChange < totalWidth && this.fillEl) { - this.pointEl.nativeElement.style.left = `${xChange}px`; - this.fillEl.nativeElement.style.width = `${xChange}px`; - } - - if (xChange < 0 && xChange > totalWidth) this.onStopInteraction(event); - } - - /** Получение индекса шага по координате X */ - private getStepIdxFromX(totalWidth: number, x: number): number { - const intervalWidth = Number.parseInt((totalWidth / (this.nums.length - 1)).toFixed()); - - for (let i = 0; i < this.nums.length; i++) { - const halfInterval = Number.parseInt((intervalWidth / 2).toFixed()); - const startX = i === 0 ? 0 : i * intervalWidth - halfInterval; - const endX = i === this.nums.length - 1 ? totalWidth : i * intervalWidth + halfInterval; - - if (startX < x && x < endX) { - return i; - } - } - - return 0; - } - - /** Начало взаимодействия (нажатие мыши/касание) */ - onStartInteraction() { - this.mousePressed = true; - } - - /** Получение координаты кнопки в процентах */ - private getButtonCoordinate(): number { - return this.value ? (100 / (this.nums.length - 1)) * this.nums.indexOf(this.value) : 0; - } - - /** Окончание взаимодействия */ - onStopInteraction(event: MouseEvent | TouchEvent) { - event.stopPropagation(); - - this.renderRange(); - this.stopMoving(); - } - - /** Отрисовка положения слайдера */ - private renderRange() { - if (!this.pointEl || !this.rangeEl || !this.fillEl) return; - const { width: rangeWidth, x: rangeX } = this.rangeEl.nativeElement.getBoundingClientRect(); - const { x } = this.pointEl.nativeElement.getBoundingClientRect(); - const stepIdx = this.getStepIdxFromX(rangeWidth, x - rangeX); - this.value = this.nums[stepIdx]; - - this.setElements(); - } - - /** Установка позиции элементов слайдера */ - setElements() { - if (!this.pointEl || !this.fillEl) return; - - this.pointEl.nativeElement.style.left = `${this.getButtonCoordinate()}%`; - this.fillEl.nativeElement.style.width = `${this.getButtonCoordinate()}%`; - } - - /** Завершение движения слайдера */ - private stopMoving() { - this.mousePressed = false; - this.onChange(this.value ?? 0); - this.appValueChange.emit(this.value ?? 0); - } -} diff --git a/projects/social_platform/src/app/ui/components/range-input/range-input.component.html b/projects/social_platform/src/app/ui/components/range-input/range-input.component.html deleted file mode 100644 index 3a12d7792..000000000 --- a/projects/social_platform/src/app/ui/components/range-input/range-input.component.html +++ /dev/null @@ -1,34 +0,0 @@ - - -
    - - - - - - - - - - -
    diff --git a/projects/social_platform/src/app/ui/components/range-input/range-input.component.scss b/projects/social_platform/src/app/ui/components/range-input/range-input.component.scss deleted file mode 100644 index f9f3601ab..000000000 --- a/projects/social_platform/src/app/ui/components/range-input/range-input.component.scss +++ /dev/null @@ -1,51 +0,0 @@ -.range { - display: flex; - gap: 10px; - align-items: center; - width: 100%; - color: var(--black); - - label { - margin-right: 5px; - color: var(--gray); - } - - &__divider { - flex-shrink: 0; - width: 10px; - height: 2px; - margin: 0 10px; - background-color: var(--dark-grey); - } - - &__start, - &__end { - width: 100px; - padding: 8px 10px; - background-color: var(--white); - border: 0.5px solid var(--gray); - border-radius: var(--rounded-lg); - } - - &__point { - font-size: 12px; - outline: none; - transition: all 0.2s; - - &:focus { - border-color: var(--accent); - } - } - - // stylelint-disable property-no-vendor-prefix - input { - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - -webkit-appearance: none; - } - - &[type="number"] { - -moz-appearance: textfield; - } - } -} diff --git a/projects/social_platform/src/app/ui/components/range-input/range-input.component.spec.ts b/projects/social_platform/src/app/ui/components/range-input/range-input.component.spec.ts deleted file mode 100644 index 87565e5be..000000000 --- a/projects/social_platform/src/app/ui/components/range-input/range-input.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { RangeInputComponent } from "./range-input.component"; - -describe("RangeInputComponent", () => { - let component: RangeInputComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RangeInputComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RangeInputComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/range-input/range-input.component.ts b/projects/social_platform/src/app/ui/components/range-input/range-input.component.ts deleted file mode 100644 index cc5441c3b..000000000 --- a/projects/social_platform/src/app/ui/components/range-input/range-input.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, forwardRef } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { NgxMaskModule } from "ngx-mask"; - -/** - * Компонент для ввода диапазона значений с двумя ползунками. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Позволяет выбрать минимальное и максимальное значение в диапазоне. - * - * Возвращает: - * - Кортеж [number, number] с минимальным и максимальным значениями - * - * Функциональность: - * - Два связанных ползунка для выбора диапазона - * - Автоматическое обновление значений при изменении - * - Поддержка маски ввода через NgxMask - */ -@Component({ - selector: "app-range-input", - standalone: true, - imports: [CommonModule, NgxMaskModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RangeInputComponent), - multi: true, - }, - ], - templateUrl: "./range-input.component.html", - styleUrl: "./range-input.component.scss", -}) -export class RangeInputComponent implements ControlValueAccessor { - constructor(private readonly cdref: ChangeDetectorRef) {} - - /** Обработчик изменения левого ползунка (минимальное значение) */ - onInputLeft(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - - const value = target.value; - this.value[0] = Number.parseInt(value); - this.onChange([Number.parseInt(value), this.value[1]]); - } - - /** Обработчик изменения правого ползунка (максимальное значение) */ - onInputRight(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - - const value = target.value; - this.value[1] = Number.parseInt(value); - this.onChange([this.value[0], Number.parseInt(value)]); - } - - /** Обработчик потери фокуса */ - onBlur(): void { - this.onTouch(); - } - - /** Текущее значение диапазона [мин, макс] */ - value: [number, number] = [0, 0]; - - // Методы ControlValueAccessor - writeValue(value: [number, number]): void { - setTimeout(() => { - this.value = value; - this.cdref.detectChanges(); - }); - } - - onChange: (value: [number, number]) => void = () => {}; - - registerOnChange(fn: (v: [number, number]) => void): void { - this.onChange = fn; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: () => void): void { - this.onTouch = fn; - } - - /** Состояние блокировки */ - disabled = false; - - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } -} diff --git a/projects/social_platform/src/app/ui/components/select/select.component.html b/projects/social_platform/src/app/ui/components/select/select.component.html deleted file mode 100644 index 45dc784ca..000000000 --- a/projects/social_platform/src/app/ui/components/select/select.component.html +++ /dev/null @@ -1,48 +0,0 @@ - - -
    -
    - {{ - selectedId === -1 - ? "Ничего" - : selectedId || selectedId === 0 - ? getLabel(selectedId) || placeholder - : placeholder - }} - @if (error) { - - } @else { - - } -
    - @if (!isDisabled) { @if (isOpen) { -
      - @for (option of options; track option.id; let index = $index) { -
    • - {{ option.label }} - -
    • - } -
    - } } -
    diff --git a/projects/social_platform/src/app/ui/components/select/select.component.scss b/projects/social_platform/src/app/ui/components/select/select.component.scss deleted file mode 100644 index b0e07eaa5..000000000 --- a/projects/social_platform/src/app/ui/components/select/select.component.scss +++ /dev/null @@ -1,103 +0,0 @@ -/** @format */ - -@use "styles/typography"; -@use "styles/responsive"; - -.field { - position: relative; - width: 100%; - - &__input { - position: relative; - z-index: 2; - display: flex; - align-items: center; - justify-content: space-between; - color: var(--black); - cursor: pointer; - background-color: var(--white); - border: 0.5px solid var(--gray); - border-radius: var(--rounded-xxl); - outline: none; - - &--small { - max-width: 70px; - padding: 12px; - text-align: center; - } - - &--big { - width: 100%; - padding: 12px; - } - - &--placeholder { - color: var(--dark-grey); - } - - &--open { - border-color: var(--accent); - box-shadow: 0 0 6px rgb(109 40 255 / 30%); - } - - &--error { - border-color: var(--red) !important; - } - - &--disabled { - cursor: not-allowed; - opacity: 0.5; - } - - @include typography.body-12; - } - - &__options { - position: absolute; - right: 0; - bottom: -6px; - left: 5%; - z-index: 11; - width: 90%; - max-height: 200px; - overflow-y: auto; - background-color: var(--white); - border: 0.5px solid var(--gray); - border-radius: var(--rounded-lg); - transform: translateY(100%); - } - - &__option { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 18px; - color: var(--dark-grey); - cursor: pointer; - border-radius: var(--rounded-lg); - transition: background-color 0.2s; - - &--highlighted { - background-color: var(--light-gray); - } - - &:hover { - color: var(--accent); - } - - &--point { - color: var(--accent); - } - } - - &__arrow { - color: var(--dark-grey); - transition: all 0.2s; - transform: rotate(180deg); - - &--filpped { - color: var(--accent); - transform: rotate(0deg); - } - } -} diff --git a/projects/social_platform/src/app/ui/components/select/select.component.ts b/projects/social_platform/src/app/ui/components/select/select.component.ts deleted file mode 100644 index 28ad1fdb9..000000000 --- a/projects/social_platform/src/app/ui/components/select/select.component.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - Component, - ElementRef, - forwardRef, - HostListener, - Input, - Renderer2, - ViewChild, -} from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "@ui/components"; -import { ClickOutsideModule } from "ng-click-outside"; - -/** - * Компонент выпадающего списка для выбора значения из предустановленных опций. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Поддерживает навигацию с клавиатуры и автоматический скролл к выделенному элементу. - * - * Входящие параметры: - * - placeholder: текст подсказки при отсутствии выбора - * - selectedId: ID выбранной опции - * - options: массив опций для выбора с полями value, label, id - * - * Возвращает: - * - Значение выбранной опции через ControlValueAccessor - * - * Функциональность: - * - Навигация стрелками вверх/вниз - * - Выбор по Enter, закрытие по Escape - * - Автоматический скролл к выделенному элементу - * - Закрытие при клике вне компонента - */ -@Component({ - selector: "app-select", - templateUrl: "./select.component.html", - styleUrl: "./select.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SelectComponent), - multi: true, - }, - ], - standalone: true, - imports: [ClickOutsideModule, IconComponent, CommonModule], -}) -export class SelectComponent implements ControlValueAccessor { - /** Текст подсказки */ - @Input() placeholder = ""; - - /** ID выбранной опции */ - @Input() selectedId?: number; - - @Input() size: "small" | "big" = "small"; - - /** Массив доступных опций */ - @Input({ required: true }) options: { - value: string | number | boolean | null; - label: string; - id: number; - }[] = []; - - @Input() error = false; - - @Input() set isDisabled(value: boolean) { - this.setDisabledState(value); - } - - get isDisabled(): boolean { - return this.disabled; - } - - /** Состояние открытия выпадающего списка */ - isOpen = false; - - /** Индекс подсвеченного элемента при навигации */ - highlightedIndex = -1; - - constructor(private readonly renderer: Renderer2) {} - - /** Ссылка на элемент выпадающего списка */ - @ViewChild("dropdown") dropdown!: ElementRef; - - /** Обработчик клавиатурных событий для навигации */ - @HostListener("document:keydown", ["$event"]) - onKeyDown(event: KeyboardEvent): void { - if (!this.isOpen || this.disabled) { - return; - } - - event.preventDefault(); - - const i = this.highlightedIndex; - - if (event.code === "ArrowUp") { - if (i < 0) this.highlightedIndex = 0; - if (i > 0) this.highlightedIndex--; - } - if (event.code === "ArrowDown") { - if (i < this.options.length - 1) { - this.highlightedIndex++; - } - } - if (event.code === "Enter") { - if (i >= 0) { - this.onUpdate(event, this.options[this.highlightedIndex].id); - } - } - if (event.code === "Escape") { - this.hideDropdown(); - } - - if (this.isOpen) { - setTimeout(() => this.trackHighlightScroll()); - } - } - - /** Автоматический скролл к выделенному элементу */ - trackHighlightScroll(): void { - const ddElem = this.dropdown.nativeElement; - - const highlightedElem = ddElem.children[this.highlightedIndex]; - - const ddBox = ddElem.getBoundingClientRect(); - const optBox = highlightedElem.getBoundingClientRect(); - - if (optBox.bottom > ddBox.bottom) { - const scrollAmount = optBox.bottom - ddBox.bottom + ddElem.scrollTop; - this.renderer.setProperty(ddElem, "scrollTop", scrollAmount); - } else if (optBox.top < ddBox.top) { - const scrollAmount = optBox.top - ddBox.top + ddElem.scrollTop; - this.renderer.setProperty(ddElem, "scrollTop", scrollAmount); - } - } - - // Методы ControlValueAccessor - writeValue(value: number | string) { - if (typeof value === "string") { - // Найти ID по значению или label - this.selectedId = this.getIdByValue(value) || this.getId(value); - } else { - this.selectedId = value; - } - } - - getIdByValue(value: string | number): number | undefined { - return this.options.find(el => el.value === value)?.id; - } - - disabled = false; - - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - onChange: (value: string | number | null | boolean) => void = () => {}; - - registerOnChange(fn: any) { - this.onChange = fn; - } - - onTouched: () => void = () => {}; - - registerOnTouched(fn: any) { - this.onTouched = fn; - } - - /** Обработчик выбора опции */ - onUpdate(event: Event, id: number): void { - event.stopPropagation(); - if (this.disabled) { - return; - } - - this.selectedId = id; - this.onChange(this.getValue(id) ?? this.options[0].value); - - this.hideDropdown(); - } - - /** Получение текста метки по ID опции */ - getLabel(optionId: number): string | undefined { - return this.options.find(el => el.id === optionId)?.label; - } - - /** Получение значения по ID опции */ - getValue(optionId: number): string | number | null | undefined | boolean { - return this.options.find(el => el.id === optionId)?.value; - } - - /** Получение ID по тексту метки */ - getId(label: string): number | undefined { - return this.options.find(el => el.label === label)?.id; - } - - /** Скрытие выпадающего списка */ - hideDropdown() { - this.isOpen = false; - this.highlightedIndex = -1; - } - - /** Обработчик клика вне компонента */ - onClickOutside() { - this.hideDropdown(); - } -} diff --git a/projects/social_platform/src/app/ui/components/tag/tag.component.html b/projects/social_platform/src/app/ui/components/tag/tag.component.html deleted file mode 100644 index a6eac461d..000000000 --- a/projects/social_platform/src/app/ui/components/tag/tag.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - -
    - -
    diff --git a/projects/social_platform/src/app/ui/components/tag/tag.component.scss b/projects/social_platform/src/app/ui/components/tag/tag.component.scss deleted file mode 100644 index a84018718..000000000 --- a/projects/social_platform/src/app/ui/components/tag/tag.component.scss +++ /dev/null @@ -1,97 +0,0 @@ -/** @format */ - -.tag { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - max-width: 300px; - padding: 2px 20px; - overflow: hidden; - color: var(--accent); - text-overflow: ellipsis; - white-space: nowrap; - border-radius: var(--rounded-xxl); - - &--inline { - color: var(--white); - border: 0.5px solid transparent; - - &.tag--primary { - color: var(--white); - background-color: var(--accent); - } - - &.tag--secondary { - background: var(--dark-grey); - border: 0.5px solid var(--grey-for-text); - } - - &.tag--accent { - color: var(--white); - background-color: var(--accent); - } - - &.tag--complete { - color: var(--white); - background-color: var(--green); - } - - &.tag--soft { - color: var(--light-white); - background: var(--gold); - border: transparent; - } - - &:not(:last-child) { - margin-right: 5px; - } - } - - &--outline { - color: var(--accent); - background: transparent; - border: 0.5px solid var(--accent); - - &.tag--primary { - color: var(--accent); - border: 0.5px solid var(--accent); - } - - &.tag--secondary { - color: var(--grey-for-text); - background: transparent; - border: 0.5px solid var(--dark-grey); - } - - &.tag--accent { - color: var(--accent); - background: transparent; - border: 0.5px solid var(--accent); - } - - &.tag--complete { - color: var(--green); - background: transparent; - border: 0.5px solid var(--green); - } - - &.tag--soft { - color: var(--gold); - background: transparent; - border: 0.5px solid var(--gold); - } - - &:not(:last-child) { - margin-right: 5px; - } - } - - ::ng-deep { - >* { - &:not(:last-child) { - margin-right: 10px; - } - } - } -} diff --git a/projects/social_platform/src/app/ui/components/tag/tag.component.ts b/projects/social_platform/src/app/ui/components/tag/tag.component.ts deleted file mode 100644 index 44cc9d36d..000000000 --- a/projects/social_platform/src/app/ui/components/tag/tag.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; - -/** - * Компонент тега для отображения статусов, категорий или меток. - * Поддерживает различные цветовые схемы для визуального разделения типов. - * - * Входящие параметры: - * - color: цветовая схема тега ("primary" | "accent" | "complete") - * - * Использование: - * - Отображение статусов задач, заказов - * - Категоризация контента - * - Визуальные метки и индикаторы - * - Контент передается через ng-content - */ -@Component({ - selector: "app-tag", - templateUrl: "./tag.component.html", - styleUrl: "./tag.component.scss", - standalone: true, -}) -export class TagComponent implements OnInit { - constructor() {} - - /** Цветовая схема тега */ - @Input() color: "primary" | "secondary" | "accent" | "complete" | "soft" = "primary"; - - /** Стиль отображения */ - @Input() appearance: "inline" | "outline" = "inline"; - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts b/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts deleted file mode 100644 index 2c8223666..000000000 --- a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { FileService } from "@core/services/file.service"; -import { nanoid } from "nanoid"; -import { IconComponent } from "@ui/components"; -import { SlicePipe } from "@angular/common"; -import { LoaderComponent } from "../loader/loader.component"; - -/** - * Компонент для загрузки файлов с предварительным просмотром. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Поддерживает ограничения по типу файлов и показывает состояние загрузки. - * - * Входящие параметры: - * - accept: ограничения по типу файлов (MIME-типы) - * - error: состояние ошибки для стилизации - * - * Возвращает: - * - URL загруженного файла через ControlValueAccessor - * - * Функциональность: - * - Drag & drop и выбор файлов через диалог - * - Предварительный просмотр выбранного файла - * - Индикатор загрузки - * - Возможность удаления загруженного файла - */ -@Component({ - selector: "app-upload-file", - templateUrl: "./upload-file.component.html", - styleUrl: "./upload-file.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UploadFileComponent), - multi: true, - }, - ], - standalone: true, - imports: [IconComponent, LoaderComponent], -}) -export class UploadFileComponent implements OnInit, ControlValueAccessor { - constructor(private fileService: FileService) {} - - /** Ограничения по типу файлов */ - @Input() accept = ""; - - /** Состояние ошибки */ - @Input() error = false; - - /** Режим: после загрузки сбросить в пустое состояние и не показывать "файл успешно загружен" */ - @Input() resetAfterUpload = false; - - /** Событие с данными загруженного файла (url + метаданные оригинального файла) */ - @Output() uploaded = new EventEmitter<{ - url: string; - name: string; - size: number; - mimeType: string; - }>(); - - ngOnInit(): void {} - - /** Уникальный ID для элемента input */ - controlId = nanoid(3); - - /** URL загруженного файла */ - value = ""; - - // Методы ControlValueAccessor - writeValue(url: string) { - this.value = url; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: any) { - this.onTouch = fn; - } - - onChange: (url: string) => void = () => {}; - - registerOnChange(fn: any) { - this.onChange = fn; - } - - /** Состояние загрузки */ - loading = false; - - /** Обработчик загрузки файла */ - onUpdate(event: Event): void { - const input = event.currentTarget as HTMLInputElement; - const files = input.files; - if (!files?.length) { - return; - } - - const originalFile = files[0]; - this.loading = true; - - this.fileService.uploadFile(originalFile).subscribe(res => { - this.loading = false; - - if (this.resetAfterUpload) { - this.uploaded.emit({ - url: res.url, - name: originalFile.name, - size: originalFile.size, - mimeType: originalFile.type, - }); - input.value = ""; - } else { - this.value = res.url; - this.onChange(res.url); - } - }); - } - - /** Обработчик удаления файла */ - onRemove(): void { - this.fileService.deleteFile(this.value).subscribe({ - next: () => { - this.value = ""; - - this.onTouch(); - this.onChange(""); - }, - error: () => { - this.value = ""; - - this.onTouch(); - this.onChange(""); - }, - }); - } -} diff --git a/projects/social_platform/src/app/auth/README.md b/projects/social_platform/src/app/ui/pages/auth/README.md similarity index 100% rename from projects/social_platform/src/app/auth/README.md rename to projects/social_platform/src/app/ui/pages/auth/README.md diff --git a/projects/social_platform/src/app/auth/auth.component.html b/projects/social_platform/src/app/ui/pages/auth/auth.component.html similarity index 100% rename from projects/social_platform/src/app/auth/auth.component.html rename to projects/social_platform/src/app/ui/pages/auth/auth.component.html diff --git a/projects/social_platform/src/app/auth/auth.component.scss b/projects/social_platform/src/app/ui/pages/auth/auth.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/auth.component.scss rename to projects/social_platform/src/app/ui/pages/auth/auth.component.scss diff --git a/projects/social_platform/src/app/auth/auth.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/auth.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/auth/auth.component.spec.ts rename to projects/social_platform/src/app/ui/pages/auth/auth.component.spec.ts diff --git a/projects/social_platform/src/app/auth/auth.component.ts b/projects/social_platform/src/app/ui/pages/auth/auth.component.ts similarity index 89% rename from projects/social_platform/src/app/auth/auth.component.ts rename to projects/social_platform/src/app/ui/pages/auth/auth.component.ts index e9f5ee570..73f4c9364 100644 --- a/projects/social_platform/src/app/auth/auth.component.ts +++ b/projects/social_platform/src/app/ui/pages/auth/auth.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; import { RouterOutlet } from "@angular/router"; /** @@ -20,6 +20,7 @@ import { RouterOutlet } from "@angular/router"; styleUrl: "./auth.component.scss", standalone: true, imports: [RouterOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AuthComponent implements OnInit { constructor() {} diff --git a/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.html b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.html similarity index 100% rename from projects/social_platform/src/app/auth/confirm-email/confirm-email.component.html rename to projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.html diff --git a/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.scss b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/confirm-email/confirm-email.component.scss rename to projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.scss diff --git a/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.spec.ts similarity index 84% rename from projects/social_platform/src/app/auth/confirm-email/confirm-email.component.spec.ts rename to projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.spec.ts index 20969d345..fa49b0f4a 100644 --- a/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ConfirmEmailComponent } from "./confirm-email.component"; -import { AuthService } from "../services"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; import { RouterTestingModule } from "@angular/router/testing"; describe("ConfirmEmailComponent", () => { @@ -15,7 +15,7 @@ describe("ConfirmEmailComponent", () => { await TestBed.configureTestingModule({ imports: [RouterTestingModule, ConfirmEmailComponent], - providers: [{ provide: AuthService, useValue: authSpy }], + providers: [{ provide: AuthRepository, useValue: authSpy }], }).compileComponents(); }); diff --git a/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.ts new file mode 100644 index 000000000..9d036f9a1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { AuthEmailService } from "@api/auth/facades/auth-email.service"; + +/** + * Компонент подтверждения email адреса + * + * Назначение: Обрабатывает подтверждение email через ссылку из письма + * Принимает: Access и refresh токены из query параметров URL + * Возвращает: Перенаправление в офис при успешном подтверждении + * + * Функциональность: + * - Получает токены из query параметров URL + * - Сохраняет токены в TokenService + * - Перенаправляет пользователя в офис при успешной аутентификации + * - Автоматически выполняется при переходе по ссылке из письма + */ +@Component({ + selector: "app-confirm-email", + templateUrl: "./confirm-email.component.html", + styleUrl: "./confirm-email.component.scss", + providers: [AuthEmailService], + imports: [CommonModule], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmEmailComponent implements OnInit { + private readonly authEmailService = inject(AuthEmailService); + + ngOnInit(): void { + this.authEmailService.initializationTokens(); + } +} diff --git a/projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.html b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.html similarity index 100% rename from projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.html rename to projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.html diff --git a/projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.scss b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.scss rename to projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.scss diff --git a/projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.spec.ts rename to projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts new file mode 100644 index 000000000..2ea15f093 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts @@ -0,0 +1,38 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { AsyncPipe } from "@angular/common"; +import { AuthPasswordService } from "@api/auth/facades/auth-password.service"; + +/** + * Компонент подтверждения сброса пароля + * + * Назначение: Отображает страницу с инструкциями после запроса сброса пароля + * Принимает: email адрес через query параметры маршрута + * Возвращает: Информационное сообщение о отправке письма для сброса пароля + * + * Функциональность: + * - Получает email из query параметров + * - Отображает подтверждение отправки письма для сброса пароля + * - Информирует пользователя о следующих шагах + */ +@Component({ + selector: "app-confirm-password-reset", + templateUrl: "./confirm-password-reset.component.html", + styleUrl: "./confirm-password-reset.component.scss", + providers: [AuthPasswordService], + imports: [AsyncPipe], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmPasswordResetComponent implements OnInit { + private readonly authPasswordService = inject(AuthPasswordService); + + protected readonly email = this.authPasswordService.email; + + ngOnInit(): void {} + + ngOnDestroy(): void { + this.authPasswordService.destroy(); + } +} diff --git a/projects/social_platform/src/app/auth/email-verification/email-verification.component.html b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html similarity index 78% rename from projects/social_platform/src/app/auth/email-verification/email-verification.component.html rename to projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html index 50e0cb484..20a6c734a 100644 --- a/projects/social_platform/src/app/auth/email-verification/email-verification.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html @@ -1,7 +1,13 @@
    - +
    @@ -11,7 +17,7 @@

    Мы отправили ссылку подтвержд отправили тебе письмо со ссылкой подтверждения

    - @if (counter > 0) { + @if (counter() > 0) {
    Письмо отправлено, отправить еще раз через {{ counter }}
    } @else {
    @@ -22,6 +28,12 @@

    Мы отправили ссылку подтвержд

    }
    - email + email
    diff --git a/projects/social_platform/src/app/auth/email-verification/email-verification.component.scss b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/email-verification/email-verification.component.scss rename to projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.scss diff --git a/projects/social_platform/src/app/auth/email-verification/email-verification.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.spec.ts similarity index 85% rename from projects/social_platform/src/app/auth/email-verification/email-verification.component.spec.ts rename to projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.spec.ts index 4ed400181..a8ca916ab 100644 --- a/projects/social_platform/src/app/auth/email-verification/email-verification.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.spec.ts @@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { EmailVerificationComponent } from "./email-verification.component"; import { RouterTestingModule } from "@angular/router/testing"; -import { AuthService } from "../services"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; describe("EmailVerificationComponent", () => { let component: EmailVerificationComponent; @@ -15,7 +15,7 @@ describe("EmailVerificationComponent", () => { await TestBed.configureTestingModule({ imports: [RouterTestingModule, EmailVerificationComponent], - providers: [{ provide: AuthService, useValue: authSpy }], + providers: [{ provide: AuthRepository, useValue: authSpy }], }).compileComponents(); }); diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts new file mode 100644 index 000000000..a55aae962 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts @@ -0,0 +1,48 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { IconComponent } from "@ui/primitives"; +import { AuthEmailService } from "@api/auth/facades/auth-email.service"; +import { CommonModule } from "@angular/common"; + +/** + * Компонент подтверждения email адреса + * + * Назначение: Отображает страницу ожидания подтверждения email после регистрации + * Принимает: email адрес через query параметры маршрута + * Возвращает: Интерфейс с возможностью повторной отправки письма подтверждения + * + * Функциональность: + * - Показывает инструкции по подтверждению email + * - Реализует таймер для повторной отправки письма (60 секунд) + * - Позволяет отправить письмо подтверждения повторно + * - Получает email из query параметров маршрута + */ +@Component({ + selector: "app-email-verification", + templateUrl: "./email-verification.component.html", + styleUrl: "./email-verification.component.scss", + providers: [AuthEmailService], + imports: [CommonModule, IconComponent], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmailVerificationComponent implements OnInit, OnDestroy { + private readonly authEmailService = inject(AuthEmailService); + + protected readonly counter = this.authEmailService.counter; + + ngOnInit(): void { + this.authEmailService.initializationEmail(); + + this.authEmailService.initializationTimer(); + } + + ngOnDestroy(): void { + this.authEmailService.destroy(); + } + + onResend(): void { + this.authEmailService.onResend(); + } +} diff --git a/projects/social_platform/src/app/auth/login/login.component.html b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html similarity index 90% rename from projects/social_platform/src/app/auth/login/login.component.html rename to projects/social_platform/src/app/ui/pages/auth/login/login.component.html index 55424a776..7c1d9f3c9 100644 --- a/projects/social_platform/src/app/auth/login/login.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html @@ -5,7 +5,13 @@
    @@ -30,7 +32,7 @@

    отклики на вакансии

    - @if (vacancies.length) { @for (vacancy of vacancies; track vacancy.id) { } } + @if (vacancies().length) { @for (vacancy of vacancies(); track vacancy.id) { } } diff --git a/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.scss rename to projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.ts new file mode 100644 index 000000000..5ec469fe0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.ts @@ -0,0 +1,55 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { ButtonComponent } from "@ui/primitives"; +import { IconComponent } from "@uilib"; +import { RouterLink } from "@angular/router"; +import { ProjectsDetailWorkSectionInfoService } from "@api/project/facades/detail/work-section/projects-detail-work-section-info.service"; +import { ProjectsDetailWorkSectionUIInfoService } from "@api/project/facades/detail/work-section/ui/projects-detail-work-section-ui-info.service"; + +@Component({ + selector: "app-work-section", + templateUrl: "./work-section.component.html", + styleUrl: "./work-section.component.scss", + imports: [CommonModule, IconComponent, ButtonComponent, RouterLink], + providers: [ProjectsDetailWorkSectionInfoService, ProjectsDetailWorkSectionUIInfoService], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectWorkSectionComponent implements OnInit, OnDestroy { + private readonly projectsDetailWorkSectionInfoService = inject( + ProjectsDetailWorkSectionInfoService + ); + + private readonly projectsDetailWorkSectionUIInfoService = inject( + ProjectsDetailWorkSectionUIInfoService + ); + + protected readonly vacancies = this.projectsDetailWorkSectionUIInfoService.vacancies; + protected readonly projectId = this.projectsDetailWorkSectionInfoService.projectId; + + ngOnInit(): void { + this.projectsDetailWorkSectionInfoService.initializationWorkSection(); + } + + ngOnDestroy(): void { + this.projectsDetailWorkSectionInfoService.destroy(); + } + + /** + * Принятие отклика на вакансию + * @param responseId - ID отклика для принятия + */ + acceptResponse(responseId: number) { + this.projectsDetailWorkSectionInfoService.acceptResponse(responseId); + } + + /** + * Отклонение отклика на вакансию + * @param responseId - ID отклика для отклонения + */ + rejectResponse(responseId: number) { + this.projectsDetailWorkSectionInfoService.rejectResponse(responseId); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.html similarity index 95% rename from projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.html index dd1cb1c6f..903e4aa0c 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.html @@ -2,6 +2,7 @@
    + @if (hasAchievements()) {
      @for (control of achievements.controls; track control.value.id; let i = $index) {
    • @@ -61,9 +62,10 @@
    • }
    + }
    -
    +
    - @if (!achievements.length) { + @if (!hasAchievements()) { }
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.ts new file mode 100644 index 000000000..9293d57ad --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.ts @@ -0,0 +1,109 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + Input, + OnInit, +} from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { InputComponent, ButtonComponent } from "@ui/primitives"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ProjectFormService } from "@api/project/facades/edit/project-form.service"; +import { IconComponent } from "@uilib"; +import { ProjectAchievementsService } from "@api/project/facades/edit/project-achievements.service"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; + +@Component({ + selector: "app-project-achievement-step", + templateUrl: "./project-achievement-step.component.html", + styleUrl: "./project-achievement-step.component.scss", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + ButtonComponent, + IconComponent, + ControlErrorPipe, + ], + providers: [ToggleFieldsInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectAchievementStepComponent implements OnInit { + @Input() projSubmitInitiated = false; + + private readonly projectAchievementService = inject(ProjectAchievementsService); + private readonly toggleFieldsInfoService = inject(ToggleFieldsInfoService); + private readonly projectFormService = inject(ProjectFormService); + private readonly fb = inject(FormBuilder); + private readonly cdr = inject(ChangeDetectorRef); + + // Получаем форму из сервиса + protected readonly projectForm = this.projectFormService.getForm(); + + // Состояние для показа полей ввода + protected readonly showInputFields = this.toggleFieldsInfoService.showInputFields; + + // Геттеры для FormArray и полей + protected readonly achievements = this.projectFormService.achievements; + protected readonly achievementsItems = this.projectAchievementService.achievementsItems; + + protected readonly editIndex = this.projectFormService.editIndex; + + /** + * Проверяет, есть ли достижения для отображения + */ + protected readonly hasAchievements = this.projectAchievementService.hasAchievements; + + protected readonly errorMessage = ErrorMessage; + + ngOnInit(): void { + this.projectAchievementService.syncAchievementsItems(this.achievements); + } + + /** + * Добавление достижения + */ + addAchievement(id?: number, achievementsName?: string, achievementsDate?: string): void { + const currentYear = new Date().getFullYear(); + this.achievements.push( + this.fb.group({ + id: [id], + title: [achievementsName ?? "", [Validators.required]], + status: [ + achievementsDate ?? "", + [ + Validators.required, + Validators.min(2000), + Validators.max(currentYear), + Validators.pattern(/^\d{4}$/), + ], + ], + }) + ); + + this.projectAchievementService.addAchievement(this.achievements); + } + + /** + * Редактирование достижения + * @param index - индекс достижения + */ + editAchievement(index: number): void { + this.toggleFieldsInfoService.showFields(); + this.projectAchievementService.editAchievement(index, this.achievements); + } + + /** + * Удаление достижения + * @param index - индекс достижения + */ + removeAchievement(index: number): void { + this.projectAchievementService.removeAchievement(index, this.achievements); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html similarity index 95% rename from projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html index 9840b4478..ed3d9cc8c 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html @@ -3,7 +3,7 @@
    - @if (partnerProgramFields.length) { @for (field of partnerProgramFields; track field.id) { + @if (partnerProgramFields().length) { @for (field of partnerProgramFields(); track field.id) {
    @switch (field.fieldType) { @case ("text") { @if (additionalForm.get(field.name); as @@ -109,12 +109,12 @@ программы diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts new file mode 100644 index 000000000..7bf117466 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts @@ -0,0 +1,111 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + Component, + computed, + Input, + OnInit, + inject, + ChangeDetectorRef, + ChangeDetectionStrategy, +} from "@angular/core"; +import { isFailure, isLoading } from "@domain/shared/async-state"; +import { ReactiveFormsModule } from "@angular/forms"; +import { + InputComponent, + CheckboxComponent, + SelectComponent, + ButtonComponent, +} from "@ui/primitives"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { SwitchComponent } from "@ui/primitives/switch/switch.component"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ToSelectOptionsPipe } from "@core/lib/pipes/transformers/options-transform.pipe"; +import { RouterLink } from "@angular/router"; +import { IconComponent } from "@uilib"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; + +@Component({ + selector: "app-project-additional-step", + templateUrl: "./project-additional-step.component.html", + styleUrl: "./project-additional-step.component.scss", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + IconComponent, + CheckboxComponent, + SwitchComponent, + SelectComponent, + TextareaComponent, + ControlErrorPipe, + ToSelectOptionsPipe, + ButtonComponent, + RouterLink, + TooltipComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectAdditionalStepComponent implements OnInit { + private readonly projectAdditionalService = inject(ProjectAdditionalService); + private readonly tooltipInfoService = inject(TooltipInfoService); + + private readonly cdRef = inject(ChangeDetectorRef); + + @Input() isProjectAssignToProgram?: boolean; + + ngOnInit(): void { + // Инициализация уже должна быть выполнена в родительском компоненте + this.cdRef.detectChanges(); + } + + // Геттеры для получения данных из сервиса + protected readonly additionalForm = this.projectAdditionalService.getAdditionalForm(); + + protected readonly partnerProgramFields = this.projectAdditionalService.partnerProgramFields; + protected readonly isSendingDecision = computed(() => + isLoading(this.projectAdditionalService.isSend$()) + ); + + protected readonly isAssignProjectToProgramError = computed(() => + isFailure(this.projectAdditionalService.isSend$()) + ); + + protected readonly errorAssignProjectToProgramModalMessage = + this.projectAdditionalService.errorAssignProjectToProgramModalMessage; + + /** Наличие подсказки */ + protected readonly haveHint = this.tooltipInfoService.haveHint; + + /** Позиция подсказки */ + protected readonly tooltipPosition = this.tooltipInfoService.tooltipPosition; + + /** Состояние видимости подсказки */ + protected readonly isTooltipVisible = this.tooltipInfoService.isTooltipVisible; + + protected readonly errorMessage = ErrorMessage; + + /** Показать подсказку */ + toggleTooltip(option: "show" | "hide"): void { + option === "show" + ? this.tooltipInfoService.showTooltip() + : this.tooltipInfoService.hideTooltip(); + } + + /** + * Переключение значения для checkbox и radio полей + * @param fieldType - тип поля + * @param fieldName - имя поля + */ + toggleAdditionalFormValues( + fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + fieldName: string + ): void { + this.projectAdditionalService.toggleAdditionalFormValues(fieldType, fieldName); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html similarity index 96% rename from projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html index 8e291bab7..e053369de 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html @@ -269,12 +269,12 @@
    - @if (hasGoals) { + @if (hasGoals()) { } - +
      - @for (collaborator of collaborators; track collaborator.userId) { + @for (collaborator of collaborators(); track collaborator.userId) {
    • @@ -383,7 +383,7 @@ type="radio" name="goalLeaderSelection" [appValue]="collaborator.userId.toString()" - [checked]="selectedLeaderId === collaborator.userId.toString()" + [checked]="selectedLeaderId() === collaborator.userId.toString()" (change)="onLeaderRadioChange($event)" >
    • @@ -410,7 +410,7 @@ size="big" appearance="outline" customTypographyClass="text-body-12" - [style.margin-bottom]="hasGoals && hasLinks ? '12px' : hasGoals ? '35px' : '35px'" + [style.margin-bottom]="hasGoals() && hasLinks() ? '12px' : hasGoals() ? '35px' : '35px'" > добавить краткосрочную цель проекта @@ -419,7 +419,7 @@
      - @if (hasLinks) { + @if (hasLinks()) { }
      - - + + diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.ts new file mode 100644 index 000000000..91828d175 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { + Component, + inject, + Output, + EventEmitter, + Input, + ChangeDetectionStrategy, +} from "@angular/core"; +import { EditStep, ProjectStepService } from "@api/project/project-step.service"; +import { IconComponent } from "@uilib"; +import { CommonModule } from "@angular/common"; +import { Navigation } from "@domain/other/navigation.model"; + +@Component({ + selector: "app-project-navigation", + templateUrl: "./project-navigation.component.html", + styleUrl: "project-navigation.component.scss", + standalone: true, + imports: [IconComponent, CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectNavigationComponent { + @Input() navItems!: Navigation[]; + @Output() stepChange = new EventEmitter(); + + private stepService = inject(ProjectStepService); + + protected readonly currentStep = this.stepService.currentStep; + + onStepClick(step: EditStep): void { + this.stepChange.emit(step); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.html similarity index 99% rename from projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.html rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.html index 18ae67739..aaf42fe33 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.html @@ -2,7 +2,7 @@
      - @if (hasPartners) { + @if (hasPartners()) {
        @for (control of partners.controls; track i; let i = $index) {
      • @@ -106,7 +106,7 @@
      - @if (hasResources) { + @if (hasResources()) {
        @for (control of resources.controls; track i; let i = $index) {
      • diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.ts new file mode 100644 index 000000000..12a0140de --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.ts @@ -0,0 +1,140 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { IconComponent } from "@uilib"; +import { ButtonComponent, InputComponent, SelectComponent } from "@ui/primitives"; +import { ControlErrorPipe } from "@corelib"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { optionsListElement } from "@utils/generate-options-list"; +import { resourceOptionsList } from "@core/consts/lists/resource-options-list.const"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ProjectPartnerService } from "@api/project/facades/edit/project-partner.service"; +import { ProjectResourceService } from "@api/project/facades/edit/project-resources.service"; + +@Component({ + selector: "app-project-partner-resources-step", + templateUrl: "./project-partner-resources-step.component.html", + styleUrl: "./project-partner-resources-step.component.scss", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + IconComponent, + ButtonComponent, + InputComponent, + ControlErrorPipe, + TextareaComponent, + SelectComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectPartnerResourcesStepComponent implements OnDestroy { + private readonly fb = inject(FormBuilder); + + private readonly projectPartnerService = inject(ProjectPartnerService); + private readonly projectResourceService = inject(ProjectResourceService); + private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + + protected readonly projectId = this.projectsEditInfoService.profileId; + + // Получаем форму из сервиса + protected readonly partnerForm = this.projectPartnerService.getForm(); + protected readonly resourceForm = this.projectResourceService.getForm(); + + // Геттеры для удобного доступа к контролам формы + protected readonly resources = this.projectResourceService.resources; + protected readonly type = this.projectResourceService.resoruceType; + protected readonly description = this.projectResourceService.resoruceDescription; + protected readonly partnerCompany = this.projectResourceService.resourcePartner; + protected readonly partners = this.projectPartnerService.partners; + + protected readonly name = this.projectPartnerService.partnerName; + protected readonly inn = this.projectPartnerService.partnerINN; + protected readonly contribution = this.projectPartnerService.partnerMention; + protected readonly decisionMaker = this.projectPartnerService.partnerProfileLink; + + protected readonly hasPartners = this.projectPartnerService.hasPartners; + protected readonly hasResources = this.projectResourceService.hasResources; + + protected readonly resourcesTypeOptions = resourceOptionsList; + protected readonly errorMessage = ErrorMessage; + + get resourcesCompanyOptions(): optionsListElement[] { + const partners = this.partners.value || []; + + const partnerOptions: optionsListElement[] = partners.map((partner: any, index: number) => { + const id = partner?.company?.id ?? partner?.id ?? index; + const value = partner?.company?.id ?? partner?.id ?? null; + const label = partner?.name; + + return { + id, + value, + label, + } as optionsListElement; + }); + + partnerOptions.push({ + id: -1, + value: "запрос к рынку", + label: "запрос к рынку", + }); + + return partnerOptions; + } + + ngOnDestroy(): void { + this.projectPartnerService.destroy(); + this.projectResourceService.destroy(); + } + + /** + * Добавление партнера + */ + addPartner(name?: string, inn?: string, contribution?: string, decisionMaker?: string): void { + this.partners.push( + this.fb.group({ + name: [name, [Validators.required]], + inn: [inn, [Validators.required]], + contribution: [contribution, [Validators.required]], + decisionMaker: [decisionMaker, Validators.required], + }) + ); + + this.projectPartnerService.addPartner(name, inn, contribution, decisionMaker); + } + + /** + * Удаление партнера + * @param index - индекс партнера + */ + removePartner(index: number, partnersId: number) { + this.projectPartnerService.removePartner(index, partnersId, this.projectId()); + } + + /** + * Добавление ресурса + */ + addResource(type?: string, description?: string, partnerCompany?: string): void { + this.resources.push( + this.fb.group({ + type: [type, [Validators.required]], + description: [description, [Validators.required]], + partnerCompany: [partnerCompany, [Validators.required]], + }) + ); + + this.projectResourceService.addResource(type, description, partnerCompany); + } + + /** + * Удаление ресурса + * @param index - индекс ресурса + */ + removeResource(index: number, resourceId: number) { + this.projectResourceService.removeResource(index, resourceId, this.projectId()); + } +} diff --git a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html similarity index 100% rename from projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.html rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html diff --git a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts new file mode 100644 index 000000000..f1dfcca0c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { CollaboratorCardComponent } from "./collaborator-card.component"; + +describe("CollaboratorCardComponent", () => { + let component: CollaboratorCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CollaboratorCardComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CollaboratorCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts new file mode 100644 index 000000000..283ce409c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts @@ -0,0 +1,87 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + EventEmitter, + inject, + Input, + OnInit, + Output, +} from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { Collaborator } from "@domain/project/collaborator.model"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { IconComponent } from "@uilib"; +import { TruncatePipe } from "@core/lib/pipes/formatters/truncate.pipe"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { RemoveProjectCollaboratorUseCase } from "@api/project/use-case/remove-project-collaborator.use-case"; + +/** + * Компонент карточки участника команды или проект + * + * Функциональность: + * - Отображает информацию о участнике (роль, специализация) + * + * Входные параметры: + * @Input invite - объект участника (обязательный) + */ +@Component({ + selector: "app-collaborator-card", + templateUrl: "./collaborator-card.component.html", + styleUrl: "./collaborator-card.component.scss", + standalone: true, + imports: [CommonModule, ReactiveFormsModule, AvatarComponent, IconComponent, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CollaboratorCardComponent implements OnInit { + private readonly removeProjectCollaboratorUseCase = inject(RemoveProjectCollaboratorUseCase); + private readonly route = inject(ActivatedRoute); + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); + + constructor() { + this.inviteForm = this.fb.group({ + role: [""], + specializations: this.fb.array([]), + }); + } + + inviteForm: FormGroup; + errorMessage = ErrorMessage; + + @Input({ required: true }) collaborator!: Collaborator; + @Output() collaboratorRemoved = new EventEmitter(); + + ngOnInit(): void { + if (this.collaborator) { + this.inviteForm.patchValue({ + role: this.collaborator.role, + specialization: this.collaborator.skills, + }); + } + } + + onDeleteCollaborator(collaboratorId: number): void { + const projectId = this.route.snapshot.params["projectId"]; + + if (!confirm("Вы точно хотите удалить участника проекта?")) return; + + this.removeProjectCollaboratorUseCase + .execute(+projectId, collaboratorId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + if (!result.ok) { + return; + } + + this.collaboratorRemoved.emit(result.value); + }, + }); + } +} diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/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/pages/projects/edit/components/project-team-step/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/pages/projects/edit/components/project-team-step/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/pages/projects/edit/components/project-team-step/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/pages/projects/edit/components/project-team-step/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/pages/projects/edit/components/project-team-step/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/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts similarity index 85% rename from projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts index 8afa54445..a1cb4c689 100644 --- a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts @@ -1,15 +1,23 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output, signal } from "@angular/core"; +import { + ChangeDetectionStrategy, + 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 { 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 { ErrorMessage } from "@core/lib/models/error/error-message"; +import { Invite } from "@domain/invite/invite.model"; +import { IconComponent, ButtonComponent, SelectComponent, InputComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { rolesMembersList } from "@core/consts/lists/roles-members-list.const"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { TruncatePipe } from "@core/lib/pipes/formatters/truncate.pipe"; /** * Компонент карточки приглашения в команду или проект @@ -44,6 +52,7 @@ import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; InputComponent, AvatarComponent, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class InviteCardComponent implements OnInit { constructor(private readonly fb: FormBuilder) { diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html similarity index 87% rename from projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html index 5bb81d584..0152625c4 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html @@ -4,10 +4,10 @@
          - @for (collaborator of collaborators; track collaborator.userId) { + @for (collaborator of collaborators(); track collaborator.userId) {
        - @if (showFields) { + @if (showInputFields()) {
        @@ -102,13 +102,13 @@
        } -
    - } @if (description; as description) { + } @if (description) {
    - {{ showFields ? "добавить вакансию" : "создать вакансию" }} + {{ showInputFields() ? "добавить вакансию" : "создать вакансию" }}
      - @for (vacancy of vacancies; track vacancy.id) { + @for (vacancy of vacancies(); track vacancy.id) {
    • @@ -186,7 +186,33 @@
    - @if (!vacancies.length) { + @if (!vacancies().length) { }
    + + + + diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.scss new file mode 100644 index 000000000..eb0eb31ee --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.scss @@ -0,0 +1,147 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + position: relative; + + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 20px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 60%; + margin-bottom: 20px; + } + + &__right { + flex-basis: 30%; + + :first-child & :not(span, fieldset, label, h4, p, i) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } + + &__no-items { + position: absolute; + bottom: 0%; + left: 50%; + } +} + +.invite { + &__item { + margin-bottom: 12px; + } +} + +.vacancy { + &__item { + margin-bottom: 12px; + } + + fieldset { + margin-bottom: 12px; + } + + &__form-list { + display: flex; + flex-wrap: wrap; + } + + &__skill { + margin-bottom: 12px; + + &:not(:last-child) { + margin-right: 10px; + } + } + + &__info, + &__additional { + display: flex; + gap: 20px; + align-items: center; + + :first-child, + :last-child { + flex-basis: 50%; + } + } + + &__submit { + display: block; + } +} + +.vacancies { + display: flex; + + &__input { + flex-grow: 1; + margin-right: 6px; + } +} + +.modal { + &__wrapper { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + width: 672px; + } + + &__content { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 536px; + height: 480px; + background-color: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + box-shadow: 5px 5px 25px 0 var(--gray-for-shadow); + } + + &__specs-groups, + &__skills-groups { + height: 100%; + overflow: auto; + scrollbar-width: thin; + + ul { + display: flex; + flex-direction: column; + gap: 20px; + padding: 14px; + } + + li { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.ts new file mode 100644 index 000000000..8af814987 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.ts @@ -0,0 +1,171 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { InputComponent, ButtonComponent, SelectComponent } from "@ui/primitives"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { AutoCompleteInputComponent } from "@ui/primitives/autocomplete-input/autocomplete-input.component"; +import { SkillsBasketComponent } from "@ui/widgets/skills-basket/skills-basket.component"; +import { VacancyCardComponent } from "@ui/widgets/vacancy-card/vacancy-card.component"; +import { IconComponent } from "@uilib"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { Skill } from "@domain/skills/skill"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { SkillsGroupComponent } from "@ui/widgets/skills-group/skills-group.component"; +import { ProjectVacancyUIService } from "@api/project/facades/edit/ui/project-vacancy-ui.service"; +import { SkillsInfoService } from "@api/skills/facades/skills-info.service"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; +import { ProjectVacancyService } from "@api/project/facades/edit/project-vacancy.service"; + +@Component({ + selector: "app-project-vacancy-step", + templateUrl: "./project-vacancy-step.component.html", + styleUrl: "./project-vacancy-step.component.scss", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + ButtonComponent, + IconComponent, + ControlErrorPipe, + SelectComponent, + AutoCompleteInputComponent, + SkillsBasketComponent, + VacancyCardComponent, + TextareaComponent, + ModalComponent, + SkillsGroupComponent, + ], + providers: [ProjectsEditInfoService, ProjectVacancyService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectVacancyStepComponent implements OnInit { + private readonly projectVacancyInfoService = inject(ProjectVacancyService); + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + private readonly skillsInfoService = inject(SkillsInfoService); + private readonly toggleFieldsInfoService = inject(ToggleFieldsInfoService); + + ngOnInit(): void { + this.projectVacancyUIService.applySetVacancies(this.vacancies()); + } + + // Геттеры для формы + protected readonly vacancyForm = this.projectVacancyUIService.vacancyForm; + + protected readonly role = this.projectVacancyUIService.role; + protected readonly description = this.projectVacancyUIService.description; + protected readonly requiredExperience = this.projectVacancyUIService.requiredExperience; + protected readonly workFormat = this.projectVacancyUIService.workFormat; + protected readonly salary = this.projectVacancyUIService.salary; + protected readonly workSchedule = this.projectVacancyUIService.workSchedule; + protected readonly skills = this.projectVacancyUIService.skills; + protected readonly specialization = this.projectVacancyUIService.specialization; + + // Геттеры для данных + protected readonly vacancies = this.projectVacancyUIService.vacancies; + + protected readonly experienceList = this.projectVacancyUIService.workExperienceList; + protected readonly formatList = this.projectVacancyUIService.workFormatList; + protected readonly scheludeList = this.projectVacancyUIService.workScheludeList; + protected readonly rolesMembersList = this.projectVacancyUIService.rolesMembersList; + + protected readonly selectedRequiredExperienceId = + this.projectVacancyUIService.selectedRequiredExperienceId; + + protected readonly selectedWorkFormatId = this.projectVacancyUIService.selectedWorkFormatId; + protected readonly selectedWorkScheduleId = this.projectVacancyUIService.selectedWorkScheduleId; + protected readonly selectedVacanciesSpecializationId = + this.projectVacancyUIService.selectedVacanciesSpecializationId; + + protected readonly vacancySubmitInitiated = this.projectVacancyUIService.vacancySubmitInitiated; + protected readonly vacancyIsSubmitting = this.projectVacancyUIService.vacancyIsSubmittingFlag; + + protected readonly inlineSkills = this.projectsEditInfoService.inlineSkills; + protected readonly projectId = this.projectsEditInfoService.profileId; + protected readonly showInputFields = this.toggleFieldsInfoService.showInputFields; + + // Сигналы для управления состоянием + protected readonly nestedSkills$ = this.projectsEditInfoService.nestedSkills$; + protected readonly skillsGroupsModalOpen = this.projectVacancyUIService.skillsGroupsModalOpen; + + protected readonly hasOpenSkillsGroups = this.projectsEditInfoService.hasOpenSkillsGroups; + protected readonly openGroupIds = this.projectsEditInfoService.openGroupIds; + + protected readonly errorMessage = ErrorMessage; + + /** + * Отображение блока вакансий + */ + createVacancyBlock(): void { + this.toggleFieldsInfoService.showFields(); + } + + /** + * Отправка формы вакансии + */ + submitVacancy(): void { + this.projectVacancyInfoService.submitVacancy(this.projectId()); + } + + /** + * Удаление вакансии + */ + removeVacancy(vacancyId: number): void { + this.projectVacancyInfoService.removeVacancy(vacancyId); + } + + /** + * Редактирование вакансии + */ + editVacancy(index: number): void { + this.projectVacancyUIService.applyEditVacancy(index); + } + + /** + * Добавление навыка + * @param newSkill - новый навык + */ + onAddSkill(newSkill: Skill): void { + this.skillsInfoService.onAddSkill(newSkill, this.vacancyForm); + } + + /** + * Удаление навыка + * @param oddSkill - навык для удаления + */ + onRemoveSkill(oddSkill: Skill): void { + this.skillsInfoService.onRemoveSkill(oddSkill, this.vacancyForm); + } + + /** + * Переключение навыка в списке выбранных + * @param toggledSkill - навык для переключения + */ + onToggleSkill(toggledSkill: Skill): void { + this.skillsInfoService.onToggleSkill(toggledSkill, this.vacancyForm); + } + + /** + * Поиск навыков + * @param query - поисковый запрос + */ + onSearchSkill(query: string): void { + this.projectsEditInfoService.onSearchSkill(query); + } + + /** + * Переключение модального окна групп навыков + */ + onToggleSkillsGroupsModal(): void { + this.skillsGroupsModalOpen.update(open => !open); + } + + onGroupToggled(isOpen: boolean, skillsGroupId: number): void { + this.projectsEditInfoService.onGroupToggled(isOpen, skillsGroupId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html new file mode 100644 index 000000000..cd831606f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html @@ -0,0 +1,227 @@ + +
    +
    +
    + +

    редактировать проект

    +
    +
    + + удалить проект + + + сохранить черновик + + + {{ isCompetitive() ? "отправить заявку" : "сохранить" }} + +
    +
    + +
    + + + +
    + @if (editingStep() === "main") { + + } @else if (editingStep() === "contacts") { + + } @else if (editingStep() === "achievements") { + + } @else if (editingStep() === "vacancies"){ + + } @else if (editingStep() === "team") { + + } @else if (editingStep() === "additional") { + + } +
    + + +
    +

    📢 внимание!

    +

    + для публикации проекта, нужно заполнить все обязательные поля (они будут + подсвечены красным). Если вы пока не знаете что написать, можно сохранить черновик проекта и заполнить поля + позже :) + + {{ + fromProgram() || isProjectAssignToProgram() + ? 'также проверь вкладку "данные для конкурсов"' + : "" + }} +

    + понятно +
    +
    +
    +
    + + +
    +
    + +

    Проект завершен!

    +
    + + end + +

    + Этот проект был успешно завершён в рамках программы {{ errorModalMessage()?.program_name }}. + Редактирование или удаление проекта больше недоступно. +

    +
    +
    + + +
    +
    + +

    Подача проектов завершена!

    +
    + +

    Срок подачи проектов в программу завершён

    +
    +
    + + +
    +
    +

    Начнем создавать историю!

    +
    + +
    +

    + Вы находитесь в проектной мастерской – здесь мы с нуля создаем и редактируем проектные идеи +

    + +

    + Есть несколько вкладок – заполнив каждую, вы полностью опишите свой проект.
    + Обязательные поля отмечены красным, обязательно не забудь про вкладку «данные для конкурсов» +

    + +

    + Будьте внимательны: проект единожды создается лидером, команда приглашается в уже созданный + проект +

    + +

    + Если вы понимаете, что заполнить каждую графу пока нет времени (или не хватает информации!), + нажмите «сохранить черновик» – так вы сохраните проект, но не опубликуете его для + пользователей всей платформы +

    + +

    Расскажите миру о вашем проекте!

    +
    + + спасибо, понятно +
    +
    + + +
    +
    + + end +

    Отправить заявку?

    +
    + +

    + После отправки заявку нельзя будет редактировать до окончания конкурса. +
    Вы уверены, что хотите отправить заявку сейчас? +

    + +
    + Отмена + Отправить +
    +
    +
    diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.scss similarity index 79% rename from projects/social_platform/src/app/office/projects/edit/edit.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/edit.component.scss index 87fbbf3c1..0b7f1bb9c 100644 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.scss +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.scss @@ -143,50 +143,6 @@ margin-bottom: 12px; } -.modal { - &__wrapper { - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - width: 672px; - } - - &__content { - display: flex; - flex-direction: column; - gap: 20px; - width: 100%; - max-width: 536px; - height: 480px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 8px; - box-shadow: 5px 5px 25px 0 var(--gray-for-shadow); - } - - &__specs-groups, - &__skills-groups { - height: 100%; - overflow: auto; - scrollbar-width: thin; - - ul { - display: flex; - flex-direction: column; - gap: 20px; - padding: 14px; - } - - li { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - } - } -} - .cancel { display: flex; flex-direction: column; diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts new file mode 100644 index 000000000..f06f0e029 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts @@ -0,0 +1,40 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ProjectEditComponent } from "./edit.component"; +import { RouterTestingModule } from "@angular/router/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { NgxMaskModule } from "ngx-mask"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; + +describe("ProjectEditComponent", () => { + let component: ProjectEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = {}; + + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + ReactiveFormsModule, + HttpClientTestingModule, + NgxMaskModule.forRoot(), + ProjectEditComponent, + ], + providers: [{ provide: AuthRepository, useValue: authSpy }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts new file mode 100644 index 000000000..36080b469 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts @@ -0,0 +1,212 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + inject, + OnDestroy, + OnInit, +} from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { RouterModule } from "@angular/router"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { CommonModule } from "@angular/common"; +import { ProjectNavigationComponent } from "./components/project-navigation/project-navigation.component"; +import { EditStep, ProjectStepService } from "@api/project/project-step.service"; +import { ProjectMainStepComponent } from "./components/project-main-step/project-main-step.component"; +import { ProjectFormService } from "@api/project/facades/edit/project-form.service"; +import { ProjectPartnerResourcesStepComponent } from "./components/project-partner-resources-step/project-partner-resources-step.component"; +import { ProjectAchievementStepComponent } from "./components/project-achievement-step/project-achievement-step.component"; +import { ProjectVacancyStepComponent } from "./components/project-vacancy-step/project-vacancy-step.component"; +import { ProjectTeamStepComponent } from "./components/project-team-step/project-team-step.component"; +import { ProjectAdditionalStepComponent } from "./components/project-additional-step/project-additional-step.component"; +import { navProjectItems } from "@core/consts/navigation/nav-project-items.const"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ProjectsEditUIInfoService } from "@api/project/facades/edit/ui/projects-edit-ui-info.service"; +import { ProjectVacancyUIService } from "@api/project/facades/edit/ui/project-vacancy-ui.service"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; +import { ProjectGoalService } from "@api/project/facades/edit/project-goals.service"; +import { ProjectPartnerService } from "@api/project/facades/edit/project-partner.service"; +import { ProjectResourceService } from "@api/project/facades/edit/project-resources.service"; +import { ProjectAchievementsService } from "@api/project/facades/edit/project-achievements.service"; +import { ProjectVacancyService } from "@api/project/facades/edit/project-vacancy.service"; +import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; +import { ProjectTeamService } from "@api/project/facades/edit/project-team.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; + +/** + * Компонент редактирования проекта + * + * Функциональность: + * - Многошаговое редактирование проекта (основная информация, контакты, достижения, вакансии, команда) + * - Управление формами для проекта, вакансий и приглашений + * - Загрузка файлов (презентация, обложка, аватар) + * - Создание и редактирование вакансий с навыками + * - Приглашение участников в команду + * - Управление достижениями, ссылками и целями проекта + * - Сохранение как черновик или публикация + */ +@Component({ + selector: "app-edit", + templateUrl: "./edit.component.html", + styleUrl: "./edit.component.scss", + standalone: true, + imports: [ + ReactiveFormsModule, + CommonModule, + RouterModule, + IconComponent, + ButtonComponent, + ModalComponent, + ProjectNavigationComponent, + ProjectMainStepComponent, + ProjectAchievementStepComponent, + ProjectVacancyStepComponent, + ProjectTeamStepComponent, + ProjectAdditionalStepComponent, + ProjectPartnerResourcesStepComponent, + ], + providers: [ + ProjectFormService, + ProjectVacancyService, + ProjectVacancyUIService, + ProjectTeamService, + ProjectTeamUIService, + ProjectAdditionalService, + ProjectGoalService, + ProjectAchievementsService, + ProjectPartnerService, + ProjectResourceService, + ProjectsEditInfoService, + ProjectsEditUIInfoService, + TooltipInfoService, + ToggleFieldsInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); + + private readonly projectStepService = inject(ProjectStepService); + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly projectGoalService = inject(ProjectGoalService); + + // Получаем форму проекта из сервиса + protected readonly projectForm = this.projectsEditInfoService.projectForm; + + // Получаем форму вакансии из сервиса + protected readonly vacancyForm = this.projectVacancyUIService.vacancyForm; + + // Получаем форму дополнительных полей из сервиса + protected readonly additionalForm = this.projectsEditInfoService.additionalForm; + + protected readonly fromProgram = this.projectsEditUIInfoService.fromProgram; + protected readonly fromProgramOpen = this.projectsEditUIInfoService.fromProgramOpen; + + // Маркер того является ли проект привязанный к конкурсной программе + protected readonly isCompetitive = this.projectsEditUIInfoService.isCompetitive; + protected readonly isProjectAssignToProgram = + this.projectsEditUIInfoService.isProjectAssignToProgram; + + // Маркер что проект привязан + protected readonly isProjectBoundToProgram = + this.projectsEditUIInfoService.isProjectBoundToProgram; + + // Текущий шаг редактирования + protected readonly editingStep = this.projectStepService.currentStep; + + // Состояние компонента + protected readonly isCompleted = this.projectsEditUIInfoService.isCompleted; + protected readonly isSendDescisionLate = this.projectsEditUIInfoService.isSendDescisionLate; + protected readonly isSendDescisionToPartnerProgramProject = + this.projectsEditUIInfoService.isSendDescisionToPartnerProgramProject; + + // Сигналы для работы с модальными окнами с ошибкой + protected readonly errorModalMessage = this.projectsEditUIInfoService.errorModalMessage; + + protected readonly onEditClicked = this.projectsEditUIInfoService.onEditClicked; + protected readonly warningModalSeen = this.projectsEditUIInfoService.warningModalSeen; + + protected readonly profileId = this.projectsEditInfoService.profileId; + + // Состояние отправки форм + protected readonly projSubmitInitiated = this.projectsEditInfoService.projSubmitInitiated; + protected readonly projFormIsSubmittingAsPublished = + this.projectsEditInfoService.projFormIsSubmittingAsPublished; + + protected readonly projFormIsSubmittingAsDraft = + this.projectsEditInfoService.projFormIsSubmittingAsDraft; + + protected readonly navProjectItems = navProjectItems; + protected readonly errorMessage = ErrorMessage; + + ngOnInit(): void { + this.projectsEditInfoService.initializationEditInfo(); + } + + ngAfterViewInit(): void { + // Загрузка данных программных тегов и проекта + this.projectsEditInfoService.loadProgramTagsAndProject(); + } + + ngOnDestroy(): void { + this.projectsEditInfoService.destroy(); + + // Сброс состояния ProjectGoalService при уничтожении компонента + this.projectGoalService.reset(); + } + + /** + * Навигация между шагами редактирования + * @param step - название шага + */ + navigateStep(step: EditStep): void { + this.projectStepService.navigateToStep(step); + } + + /** + * Удаление проекта с проверкой удаления у пользователя + */ + deleteProject(): void { + if (!confirm("Вы точно хотите удалить проект?")) { + return; + } + + this.projectsEditInfoService.deleteProject(); + } + + /** + * Сохранение проекта как опубликованного с проверкой доп. полей + */ + saveProjectAsPublished(): void { + this.projectsEditInfoService.saveProjectAsPublished(); + } + + /** + * Сохранение проекта как черновика + */ + saveProjectAsDraft(): void { + this.projectsEditInfoService.saveProjectAsDraft(); + } + + /** + * Отправка формы проекта + */ + submitProjectForm(): void { + this.projectsEditInfoService.submitProjectForm(); + } + + // Методы для работы с модальными окнами + closeWarningModal(): void { + this.projectsEditUIInfoService.applyCloseWarningModal(); + } + + closeSendingDescisionModal(): void { + this.projectsEditInfoService.closeSendingDescisionModal(); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/edit.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.spec.ts similarity index 85% rename from projects/social_platform/src/app/office/projects/edit/edit.resolver.spec.ts rename to projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.spec.ts index f8c36b032..24f6eb9ec 100644 --- a/projects/social_platform/src/app/office/projects/edit/edit.resolver.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.spec.ts @@ -4,7 +4,7 @@ import { TestBed } from "@angular/core/testing"; import { ProjectEditResolver } from "./edit.resolver"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { of } from "rxjs"; -import { AuthService } from "@auth/services"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; import { ActivatedRouteSnapshot, convertToParamMap, RouterStateSnapshot } from "@angular/router"; describe("ProjectEditResolver", () => { @@ -19,7 +19,7 @@ describe("ProjectEditResolver", () => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [{ provide: AuthService, useValue: authSpy }], + providers: [{ provide: AuthRepository, useValue: authSpy }], }); }); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.ts new file mode 100644 index 000000000..8977f1a85 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.ts @@ -0,0 +1,65 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { forkJoin, map } from "rxjs"; +import { Invite } from "@domain/invite/invite.model"; +import { Project } from "@domain/project/project.model"; +import { Goal } from "@domain/project/goals.model"; +import { Partner } from "@domain/project/partner.model"; +import { Resource } from "@domain/project/resource.model"; +import { GetProjectInvitesUseCase } from "@api/invite/use-cases/get-project-invites.use-case"; +import { GetProjectUseCase } from "@api/project/use-case/get-project.use-case"; +import { GetProjectGoalsUseCase } from "@api/project/use-case/get-project-goals.use-case"; +import { GetProjectPartnersUseCase } from "@api/project/use-case/get-project-partners.use-case"; +import { GetProjectResourcesUseCase } from "@api/project/use-case/get-project-resources.use-case"; + +/** + * Resolver для загрузки данных редактирования проекта + * + * Функциональность: + * - Загружает данные проекта по ID из параметров маршрута + * - Получает список приглашений для проекта + * - Объединяет данные в единый массив для компонента + * + * Принимает: + * - ActivatedRouteSnapshot с параметром projectId + * + * Возвращает: + * - Observable<[Project, Invite[]]> с данными: + * - Project: полная информация о проекте + * - Invite[]: массив приглашений в проект + * + * Используется перед загрузкой ProjectEditComponent для предварительной + * загрузки всех необходимых данных для редактирования. + * + * Применяет forkJoin для параллельной загрузки данных проекта и приглашений, + * что оптимизирует время загрузки страницы. + */ +export const ProjectEditResolver: ResolveFn<[Project, Goal[], Partner[], Resource[], Invite[]]> = ( + route: ActivatedRouteSnapshot +) => { + const getProjectUseCase = inject(GetProjectUseCase); + const getProjectGoalsUseCase = inject(GetProjectGoalsUseCase); + const getProjectPartnersUseCase = inject(GetProjectPartnersUseCase); + const getProjectResourcesUseCase = inject(GetProjectResourcesUseCase); + const getProjectInvitesUseCase = inject(GetProjectInvitesUseCase); + + const projectId = Number(route.paramMap.get("projectId")); + + return forkJoin<[Project, Goal[], Partner[], Resource[], Invite[]]>([ + getProjectUseCase + .execute(projectId) + .pipe(map(result => (result.ok ? result.value : new Project()))), + getProjectGoalsUseCase.execute(projectId).pipe(map(result => (result.ok ? result.value : []))), + getProjectPartnersUseCase + .execute(projectId) + .pipe(map(result => (result.ok ? result.value : []))), + getProjectResourcesUseCase + .execute(projectId) + .pipe(map(result => (result.ok ? result.value : []))), + getProjectInvitesUseCase + .execute(projectId) + .pipe(map(result => (result.ok ? result.value : []))), + ]); +}; diff --git a/projects/social_platform/src/app/office/projects/list/all.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.spec.ts similarity index 79% rename from projects/social_platform/src/app/office/projects/list/all.resolver.spec.ts rename to projects/social_platform/src/app/ui/pages/projects/list/all.resolver.spec.ts index 24ca08b68..f2f56ed2a 100644 --- a/projects/social_platform/src/app/office/projects/list/all.resolver.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.spec.ts @@ -4,7 +4,7 @@ import { TestBed } from "@angular/core/testing"; import { ProjectsAllResolver } from "./all.resolver"; import { of } from "rxjs"; -import { ProjectService } from "@services/project.service"; +import { ProjectRepository } from "@infrastructure/repository/project/project.repository"; import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; describe("ProjectsAllResolver", () => { @@ -12,7 +12,7 @@ describe("ProjectsAllResolver", () => { const projectSpy = jasmine.createSpyObj({ getAll: of([]) }); TestBed.configureTestingModule({ - providers: [{ provide: ProjectService, useValue: projectSpy }], + providers: [{ provide: ProjectRepository, useValue: projectSpy }], }); }); diff --git a/projects/social_platform/src/app/office/projects/list/all.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.ts similarity index 77% rename from projects/social_platform/src/app/office/projects/list/all.resolver.ts rename to projects/social_platform/src/app/ui/pages/projects/list/all.resolver.ts index 99866ef48..ba5df866d 100644 --- a/projects/social_platform/src/app/office/projects/list/all.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.ts @@ -1,11 +1,12 @@ /** @format */ import { inject } from "@angular/core"; -import { Project } from "@models/project.model"; -import { ProjectService } from "@services/project.service"; -import { ApiPagination } from "@models/api-pagination.model"; +import { ApiPagination } from "@domain/other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; import { ResolveFn } from "@angular/router"; +import { map } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { GetAllProjectsUseCase } from "@api/project/use-case/get-all-projects.use-case"; /** * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ВСЕХ ПРОЕКТОВ @@ -36,7 +37,18 @@ import { ResolveFn } from "@angular/router"; * - Дополнительные проекты загружаются по мере прокрутки (infinite scroll) */ export const ProjectsAllResolver: ResolveFn> = () => { - const projectService = inject(ProjectService); + const getAllProjectsUseCase = inject(GetAllProjectsUseCase); - return projectService.getAll(new HttpParams({ fromObject: { limit: 16 } })); + return getAllProjectsUseCase.execute(new HttpParams({ fromObject: { limit: 16 } })).pipe( + map(result => + result.ok + ? result.value + : { + count: 0, + results: [], + next: "", + previous: "", + } + ) + ); }; diff --git a/projects/social_platform/src/app/ui/pages/projects/list/invites.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/list/invites.resolver.ts new file mode 100644 index 000000000..72a910169 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/invites.resolver.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { GetMyInvitesUseCase } from "@api/invite/use-cases/get-my-invites.use-case"; +import { Invite } from "@domain/invite/invite.model"; +import { map } from "rxjs"; + +/** + * Резолвер для предзагрузки приглашений пользователя + * Загружает данные о приглашениях перед инициализацией компонента офиса + * + * Принимает: + * - Контекст маршрута (неявно через Angular DI) + * + * Возвращает: + * - Observable - массив приглашений пользователя + */ +export const ProjectsInvitesResolver: ResolveFn = () => { + const getMyInvitesUseCase = inject(GetMyInvitesUseCase); + + return getMyInvitesUseCase.execute().pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.html b/projects/social_platform/src/app/ui/pages/projects/list/list.component.html new file mode 100644 index 000000000..6b04fe1b6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.html @@ -0,0 +1,26 @@ + + +
    + @if (isAll()) { +
    + Фильтр + +
    + } +
      + @for (project of projects(); track project.id) { + +
    • + +
    • +
      + } +
    +
    diff --git a/projects/social_platform/src/app/office/projects/list/list.component.scss b/projects/social_platform/src/app/ui/pages/projects/list/list.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/list/list.component.scss rename to projects/social_platform/src/app/ui/pages/projects/list/list.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts new file mode 100644 index 000000000..39f793ee7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ProjectsListComponent } from "./list.component"; +import { RouterTestingModule } from "@angular/router/testing"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; + +describe("ProjectsListComponent", () => { + let component: ProjectsListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + }; + + await TestBed.configureTestingModule({ + imports: [RouterTestingModule, HttpClientTestingModule, ProjectsListComponent], + providers: [{ provide: AuthRepository, useValue: authSpy }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectsListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts b/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts new file mode 100644 index 000000000..93ffc716f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts @@ -0,0 +1,145 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + OnDestroy, + OnInit, + ViewChild, +} from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { IconComponent } from "@ui/primitives"; +import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { ProjectsListInfoService } from "@api/project/facades/list/projects-list-info.service"; +import { SwipeService } from "@api/swipe/swipe.service"; +import { ProjectsInfoService } from "@api/project/facades/projects-info.service"; +import { OfficeInfoService } from "@api/office/facades/office-info.service"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ProgramDetailListInfoService } from "@api/program/facades/detail/program-detail-list-info.service"; +import { OfficeUIInfoService } from "@api/office/facades/ui/office-ui-info.service"; + +/** + * КОМПОНЕНТ СПИСКА ПРОЕКТОВ + * + * Назначение: + * - Отображает список проектов в различных режимах (все/мои/подписки) + * - Реализует функциональность поиска и фильтрации проектов + * - Обеспечивает бесконечную прокрутку для загрузки дополнительных проектов + * - Управляет состоянием фильтров на мобильных устройствах + * + * 1. Отображение проектов в виде карточек + * 2. Поиск по названию проекта (используя библиотеку Fuse.js) + * 3. Фильтрация по различным критериям (индустрия, этап, количество участников и т.д.) + * 4. Создание и удаление проектов + * 5. Подписка/отписка от проектов + * 6. Адаптивный интерфейс с поддержкой свайпов на мобильных + * + * @param: + * - Данные маршрута (route.data) - предзагруженные проекты через резолверы + * - Параметры запроса (route.queryParams) - фильтры и поисковый запрос + * - Профиль пользователя (authService.profile) + * - Подписки пользователя (subscriptionService) + * + * @return + * - Отображение списка проектов + * - Навигация к детальной странице проекта + * - Создание нового проекта + * - Удаление проекта (только для владельца) + * + * Состояние компонента: + * - projects[] - полный список проектов + * - searchedProjects[] - отфильтрованный список для отображения + * - profile - данные текущего пользователя + * - isFilterOpen - состояние панели фильтров (мобильные) + * - isAll/isMy/isSubs/isInvites - флаги текущего режима просмотра + * + * Жизненный цикл: + * - OnInit: настройка подписок, инициализация данных + * - AfterViewInit: настройка обработчика прокрутки + * - OnDestroy: отписка от всех подписок + * + * - Использует RxJS для реактивного программирования + * - Реализует паттерн "бесконечная прокрутка" для оптимизации производительности + * - Поддерживает жесты свайпа для закрытия фильтров на мобильных + * - Использует Fuse.js для нечеткого поиска по названиям проектов + * - Кэширует запросы фильтрации для избежания дублирующих HTTP-запросов + */ +@Component({ + selector: "app-list", + templateUrl: "./list.component.html", + styleUrl: "./list.component.scss", + imports: [IconComponent, RouterLink, InfoCardComponent], + providers: [ + ProjectsListInfoService, + ProjectsInfoService, + ProgramDetailListInfoService, + ProgramDetailListUIInfoService, + OfficeInfoService, + OfficeUIInfoService, + SwipeService, + ], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectsListComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild("filterBody") filterBody!: ElementRef; + @ViewChild("listRoot") listRoot?: ElementRef; + + private readonly projectsListInfoService = inject(ProjectsListInfoService); + private readonly projectsInfoService = inject(ProjectsInfoService); + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + private readonly officeInfoService = inject(OfficeInfoService); + private readonly swipeService = inject(SwipeService); + + protected readonly isFilterOpen = this.swipeService.isFilterOpen; + + protected readonly projects = this.projectsListInfoService.projects; + protected readonly profileProjSubsIds = this.programDetailListUIInfoService.profileProjSubsIds; + + protected readonly isAll = this.projectsInfoService.isAll; + protected readonly isMy = this.projectsInfoService.isMy; + protected readonly isSubs = this.projectsInfoService.isSubs; + protected readonly isInvites = this.projectsInfoService.isInvites; + + ngOnInit(): void { + this.projectsListInfoService.initializationProjectsList(); + } + + ngAfterViewInit(): void { + const target = document.querySelector(".office__body") as HTMLElement; + if (target || this.listRoot) { + this.projectsListInfoService.initScroll(target, this.listRoot!); + } + } + + ngOnDestroy(): void { + this.projectsListInfoService.destroy(); + } + + onAcceptInvite(event: number): void { + this.officeInfoService.onAcceptInvite(event); + } + + onRejectInvite(event: number): void { + this.officeInfoService.onRejectInvite(event); + } + + onSwipeStart(event: TouchEvent): void { + this.swipeService.onSwipeStart(event); + } + + onSwipeMove(event: TouchEvent): void { + this.swipeService.onSwipeMove(event, this.filterBody); + } + + onSwipeEnd(event: TouchEvent): void { + this.swipeService.onSwipeEnd(event, this.filterBody); + } + + closeFilter(): void { + this.swipeService.closeFilter(); + } +} diff --git a/projects/social_platform/src/app/office/projects/list/my.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.spec.ts similarity index 79% rename from projects/social_platform/src/app/office/projects/list/my.resolver.spec.ts rename to projects/social_platform/src/app/ui/pages/projects/list/my.resolver.spec.ts index 672f73f11..ad67dccc9 100644 --- a/projects/social_platform/src/app/office/projects/list/my.resolver.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.spec.ts @@ -4,7 +4,7 @@ import { TestBed } from "@angular/core/testing"; import { ProjectsMyResolver } from "./my.resolver"; import { of } from "rxjs"; -import { ProjectService } from "@services/project.service"; +import { ProjectRepository } from "@infrastructure/repository/project/project.repository"; import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; describe("ProjectsMyResolver", () => { @@ -12,7 +12,7 @@ describe("ProjectsMyResolver", () => { const projectSpy = jasmine.createSpyObj({ getMy: of([]) }); TestBed.configureTestingModule({ - providers: [{ provide: ProjectService, useValue: projectSpy }], + providers: [{ provide: ProjectRepository, useValue: projectSpy }], }); }); diff --git a/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.ts new file mode 100644 index 000000000..46261122c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.ts @@ -0,0 +1,56 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { HttpParams } from "@angular/common/http"; +import { ResolveFn } from "@angular/router"; +import { map } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { GetMyProjectsUseCase } from "@api/project/use-case/get-my-projects.use-case"; + +/** + * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ПРОЕКТОВ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ + * + * Назначение: + * - Предзагружает данные проектов, принадлежащих текущему пользователю + * - Обеспечивает наличие данных в компоненте на момент его инициализации + * - Используется в роутинге Angular для маршрута "мои проекты" + * + * @params: + * - Неявно: внедряется ProjectService через inject() + * - Параметры маршрута и состояние роутера (не используются в данной реализации) + * + * @returns: + * - Observable> - пагинированный список проектов пользователя + * - Первая страница с лимитом 16 проектов + * + * 1. Внедряет ProjectService через функцию inject() + * 2. Вызывает метод getMy() с параметрами пагинации (limit: 16) + * 3. Возвращает Observable, который будет разрешен перед активацией маршрута + * + * - Подключается к маршруту в конфигурации роутера + * - Результат доступен в компоненте через route.data['data'] + * + * Особенности: + * - Использует функциональный подход (ResolveFn) вместо класса + * - Загружает только проекты текущего авторизованного пользователя + * - Загружает только первые 16 проектов для оптимизации производительности + * - Дополнительные проекты загружаются по мере прокрутки (infinite scroll) + */ + +export const ProjectsMyResolver: ResolveFn> = () => { + const getMyProjectsUseCase = inject(GetMyProjectsUseCase); + + return getMyProjectsUseCase.execute(new HttpParams({ fromObject: { limit: 16 } })).pipe( + map(result => + result.ok + ? result.value + : { + count: 0, + results: [], + next: "", + previous: "", + } + ) + ); +}; diff --git a/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/list/subscriptions.resolver.ts similarity index 81% rename from projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts rename to projects/social_platform/src/app/ui/pages/projects/list/subscriptions.resolver.ts index 0ca78a23e..9cfe90fef 100644 --- a/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/projects/list/subscriptions.resolver.ts @@ -2,10 +2,10 @@ import { ResolveFn } from "@angular/router"; import { inject } from "@angular/core"; -import { AuthService } from "@auth/services"; -import { switchMap } from "rxjs"; -import { Project } from "@office/models/project.model"; -import { SubscriptionService } from "@office/services/subscription.service"; +import { map, switchMap } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { GetProjectSubscriptionsUseCase } from "@api/project/use-case/get-project-subscriptions.use-case"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; /** * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ПОДПИСОК ПОЛЬЗОВАТЕЛЯ @@ -42,8 +42,11 @@ import { SubscriptionService } from "@office/services/subscription.service"; * - SubscriptionService - для получения списка подписок пользователя */ export const ProjectsSubscriptionsResolver: ResolveFn<{ results: Project[] }> = () => { - const authService = inject(AuthService); - const subscriptionService = inject(SubscriptionService); + const authRepository = inject(AuthInfoService); + const getProjectSubscriptionsUseCase = inject(GetProjectSubscriptionsUseCase); - return authService.profile.pipe(switchMap(p => subscriptionService.getSubscriptions(p.id))); + return authRepository.profile.pipe( + switchMap(p => getProjectSubscriptionsUseCase.execute(p.id)), + map(result => (result.ok ? result.value : { count: 0, results: [], next: "", previous: "" })) + ); }; diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.html b/projects/social_platform/src/app/ui/pages/projects/projects.component.html new file mode 100644 index 000000000..541a855af --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.html @@ -0,0 +1,114 @@ + + +
    +
    + + +
    +
    +
    + +
    + + @if(!isDashboard()) { + + + } + + +
    + +
    + + создать проект + + + +
    + @if (isDashboard()) { +
    +
    +

    мои приглашения

    + +
    + + @if (myInvites().length) { +
      + @for (invite of myInvites(); track invite.id) { + + } +
    + } @else { +
    +

    пока нет приглашений

    +
    + } +
    + } @if (isMy() || isDashboard()) { + + + } @if (isAll()) { +
    +
    +
    +
    + +
    +
    + } +
    +
    +
    +
    +
    diff --git a/projects/social_platform/src/app/office/projects/projects.component.scss b/projects/social_platform/src/app/ui/pages/projects/projects.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/projects.component.scss rename to projects/social_platform/src/app/ui/pages/projects/projects.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts new file mode 100644 index 000000000..75bd9012b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts @@ -0,0 +1,46 @@ +/** @format */ + +// import { ComponentFixture, TestBed } from "@angular/core/testing"; +// +// import { ProjectsComponent } from "./projects.component"; +// import { RouterTestingModule } from "@angular/router/testing"; +// import { HttpClientTestingModule } from "@angular/common/http/testing"; +// import { ReactiveFormsModule } from "@angular/forms"; +// import { of } from "rxjs"; +// import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +// import { ProjectRepository } from "../services/project.service"; +// import { User } from "../../auth/models/user.model"; +// import { Project } from "../models/project.model"; +// +// describe("ProjectsComponent", () => { +// let component: ProjectsComponent; +// let fixture: ComponentFixture; +// +// beforeEach(async () => { +// const projectSpy = { +// create: of({}), +// }; +// const authSpy = { +// profile: of({}), +// }; +// +// await TestBed.configureTestingModule({ +// imports: [RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], +// providers: [ +// { providers: ProjectRepository, useValue: projectSpy }, +// { providers: AuthRepository, useValue: authSpy }, +// ], +// declarations: [ProjectsComponent], +// }).compileComponents(); +// }); +// +// beforeEach(() => { +// fixture = TestBed.createComponent(ProjectsComponent); +// component = fixture.componentInstance; +// fixture.detectChanges(); +// }); +// +// it("should create", () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.ts b/projects/social_platform/src/app/ui/pages/projects/projects.component.ts new file mode 100644 index 000000000..44876bbdc --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.ts @@ -0,0 +1,107 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + OnDestroy, + OnInit, + ViewChild, +} from "@angular/core"; +import { RouterOutlet } from "@angular/router"; +import { ReactiveFormsModule } from "@angular/forms"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { BarNewComponent } from "./bar-new/bar.component"; +import { BackComponent } from "@uilib"; +import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; +import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { ProjectsUIInfoService } from "@api/project/facades/ui/projects-ui-info.service"; +import { ProjectsInfoService } from "@api/project/facades/projects-info.service"; +import { SwipeService } from "@api/swipe/swipe.service"; +import { ProjectsFilterComponent } from "@ui/widgets/projects-filter/projects-filter.component"; + +/** + * Главный компонент модуля проектов + * Управляет отображением списка проектов, поиском и созданием новых проектов + * + * Принимает: + * - Счетчики проектов через резолвер + * - Параметры поиска из URL + * + * Возвращает: + * - Интерфейс управления проектами с поиском и фильтрацией + * - Навигацию между разделами "Мои", "Все", "Подписки" + */ +@Component({ + selector: "app-projects", + templateUrl: "./projects.component.html", + styleUrl: "./projects.component.scss", + imports: [ + IconComponent, + ReactiveFormsModule, + SearchComponent, + ButtonComponent, + RouterOutlet, + BarNewComponent, + BackComponent, + SoonCardComponent, + ProjectsFilterComponent, + InfoCardComponent, + ], + providers: [ProjectsInfoService, ProjectsUIInfoService, SwipeService], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectsComponent implements OnInit, OnDestroy { + @ViewChild("filterBody") filterBody!: ElementRef; + + private readonly projectsInfoService = inject(ProjectsInfoService); + private readonly projectsUIInfoService = inject(ProjectsUIInfoService); + private readonly swipeService = inject(SwipeService); + + ngOnInit(): void { + this.projectsInfoService.initializationProjects(); + } + + ngOnDestroy(): void { + this.projectsInfoService.destroy(); + } + + protected readonly searchForm = this.projectsUIInfoService.searchForm; + + protected readonly myInvites = this.projectsUIInfoService.myInvites; + + protected readonly isMy = this.projectsInfoService.isMy; + protected readonly isAll = this.projectsInfoService.isAll; + protected readonly isSubs = this.projectsInfoService.isSubs; + protected readonly isInvites = this.projectsInfoService.isInvites; + protected readonly isDashboard = this.projectsInfoService.isDashboard; + + protected readonly isFilterOpen = this.swipeService.isFilterOpen; + + onSwipeStart(event: TouchEvent): void { + this.swipeService.onSwipeStart(event); + } + + onSwipeMove(event: TouchEvent): void { + this.swipeService.onSwipeMove(event, this.filterBody); + } + + onSwipeEnd(event: TouchEvent): void { + this.swipeService.onSwipeEnd(event, this.filterBody); + } + + acceptOrRejectInvite(inviteId: number): void { + this.projectsUIInfoService.applyAcceptOrRejectInvite(inviteId); + } + + closeFilter(): void { + this.swipeService.closeFilter(); + } + + addProject(): void { + this.projectsInfoService.addProject(); + } +} diff --git a/projects/social_platform/src/app/office/projects/projects.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/projects.resolver.spec.ts similarity index 83% rename from projects/social_platform/src/app/office/projects/projects.resolver.spec.ts rename to projects/social_platform/src/app/ui/pages/projects/projects.resolver.spec.ts index df5f8c3e0..2e9875cbf 100644 --- a/projects/social_platform/src/app/office/projects/projects.resolver.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/projects.resolver.spec.ts @@ -4,7 +4,7 @@ import { TestBed } from "@angular/core/testing"; import { ProjectsResolver } from "./projects.resolver"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { of } from "rxjs"; -import { AuthService } from "@auth/services"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; describe("ProjectsResolver", () => { @@ -13,7 +13,7 @@ describe("ProjectsResolver", () => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [{ provide: AuthService, useValue: authSpy }], + providers: [{ provide: AuthRepository, useValue: authSpy }], }); }); diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/projects.resolver.ts new file mode 100644 index 000000000..58a9a19bd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.resolver.ts @@ -0,0 +1,68 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { forkJoin, map, switchMap } from "rxjs"; +import { ResolveFn } from "@angular/router"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Project } from "@domain/project/project.model"; +import { GetAllProjectsUseCase } from "@api/project/use-case/get-all-projects.use-case"; +import { GetMyProjectsUseCase } from "@api/project/use-case/get-my-projects.use-case"; +import { GetProjectSubscriptionsUseCase } from "@api/project/use-case/get-project-subscriptions.use-case"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; + +/** + * Resolver для загрузки данных о количестве проектов + * + * Функциональность: + * - Загружает количество проектов пользователя в разных категориях + * - Получает количество подписок пользователя + * - Объединяет данные в единый объект ProjectCount + * + * Принимает: + * - Не принимает параметры (использует текущего пользователя) + * + * Возвращает: + * - Observable с данными: + * - my: количество собственных проектов + * - all: общее количество проектов в системе + * - subs: количество подписок пользователя + * + * Используется перед загрузкой ProjectsComponent для предварительной + * загрузки необходимых данных. + */ + +export interface DashboardProjectsData { + all: ApiPagination; + my: ApiPagination; + subs: ApiPagination; +} + +export const ProjectsResolver: ResolveFn = () => { + const authRepository = inject(AuthInfoService); + const getAllProjectsUseCase = inject(GetAllProjectsUseCase); + const getMyProjectsUseCase = inject(GetMyProjectsUseCase); + const getProjectSubscriptionsUseCase = inject(GetProjectSubscriptionsUseCase); + const emptyProjectsPage = (): ApiPagination => ({ + count: 0, + next: "", + previous: "", + results: [], + }); + + return authRepository.profile.pipe( + switchMap(user => + forkJoin({ + all: getAllProjectsUseCase + .execute(new HttpParams({ fromObject: { limit: 16 } })) + .pipe(map(result => (result.ok ? result.value : emptyProjectsPage()))), + my: getMyProjectsUseCase + .execute(new HttpParams({ fromObject: { limit: 16 } })) + .pipe(map(result => (result.ok ? result.value : emptyProjectsPage()))), + subs: getProjectSubscriptionsUseCase + .execute(user.id) + .pipe(map(result => (result.ok ? result.value : emptyProjectsPage()))), + }) + ) + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.html b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.html new file mode 100644 index 000000000..f6518de56 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.html @@ -0,0 +1,59 @@ + + +
    + @if (vacancy()!.description) { +
    +
    +

    описание вакансии

    + +
    +
    +

    + @if (descriptionExpandable()) { +
    + {{ readFullDescription() ? "скрыть" : "подробнее" }} +
    + } +
    +
    + } + +
    + @if (vacancy()!.requiredSkills.length; as skillsLength) { +
    +
    +

    навыки

    + +
    + @if (vacancy()!.requiredSkills; as requiredSkills) { @if (requiredSkills) { +
      + @for (skill of requiredSkills.slice(0, 8); track $index) { + {{ skill.name }} + } +
    + } +
    + @if (requiredSkills) { +
      + @for (skill of requiredSkills.slice(0, 8); track $index) { + {{ skill.name }} + } +
    + } +
    + } @if (skillsLength > 8) { +
    + {{ readFullSkills() ? "скрыть" : "подробнее" }} +
    + } +
    + } +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.scss new file mode 100644 index 000000000..da7470f12 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.scss @@ -0,0 +1,161 @@ +@use "styles/responsive"; + +@mixin expandable-list { + &__remaining { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: all 0.5s ease-in-out; + + &--show { + grid-template-rows: 1fr; + margin-top: 12px; + } + + ul { + min-height: 0; + } + + li { + &:first-child { + margin-top: 12px; + } + + &:not(:last-child) { + margin-bottom: 12px; + } + } + } +} + +.vacancy { + &__content { + padding: 24px; + margin-bottom: 20px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + .read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } + } + + .about { + + /* stylelint-disable value-no-vendor-prefix */ + &__text { + p { + display: -webkit-box; + overflow: hidden; + color: var(--black); + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; + transition: all 0.7s ease-in-out; + + &.expanded { + -webkit-line-clamp: unset; + } + } + + ::ng-deep a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 3px; + transition: text-decoration-color 0.2s; + + &:hover { + text-decoration-color: var(--accent); + } + } + } + + /* stylelint-enable value-no-vendor-prefix */ + + &__read-full { + margin-top: 2px; + color: var(--accent); + cursor: pointer; + } + } + + .skills { + &__list { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + li { + &:not(:last-child) { + margin-bottom: 12px; + } + } + + @include expandable-list; + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + i { + padding: 6px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.ts new file mode 100644 index 000000000..bedfb8f5c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.ts @@ -0,0 +1,63 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + Input, + ViewChild, + WritableSignal, +} from "@angular/core"; +import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { ExpandService } from "@api/expand/expand.service"; +import { VacancyDetailInfoService } from "@api/vacancy/facades/vacancy-detail-info.service"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; + +@Component({ + selector: "app-vacancies-left-side", + templateUrl: "./vacancies-left-side.component.html", + styleUrl: "./vacancies-left-side.component.scss", + imports: [CommonModule, ParseBreaksPipe, ParseLinksPipe, TagComponent], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacanciesLeftSideComponent { + @Input() vacancy!: WritableSignal; + + @ViewChild("skillsEl") skillsEl?: ElementRef; + @ViewChild("descEl") descEl?: ElementRef; + + private readonly vacancyDetailInfoService = inject(VacancyDetailInfoService); + private readonly expandService = inject(ExpandService); + + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; + protected readonly skillsExpandable = this.expandService.skillsExpandable; + + protected readonly readFullDescription = this.expandService.readFullDescription; + protected readonly readFullSkills = this.expandService.readFullSkills; + + ngAfterViewInit(): void { + const descElement = this.descEl?.nativeElement; + this.vacancyDetailInfoService.initCheckDescription(descElement); + + const skillsElement = this.skillsEl?.nativeElement; + this.vacancyDetailInfoService.initCheckSkills(skillsElement); + } + + /** + * Раскрытие/сворачивание описания профиля + * @param elem - DOM элемент описания + * @param expandedClass - CSS класс для раскрытого состояния + * @param isExpanded - текущее состояние (раскрыто/свернуто) + */ + onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { + this.expandService.onExpand("description", elem, expandedClass, isExpanded); + } + + onExpandSkills(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { + this.expandService.onExpand("skills", elem, expandedClass, isExpanded); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.html b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.html new file mode 100644 index 000000000..bcf808011 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.html @@ -0,0 +1,100 @@ + + +@if (vacancy()!.project; as project) { +
    +
    + +

    {{ project.name | truncate: 20 }}

    + + откликнуться +
    + +
    +
    +

    метаданные

    + +
    + +
      +
    • + +

      + {{ project.region ? (project.region | capitalize | truncate: 20) : "не указан" }} +

      +
    • + +
    • + +

      + {{ + vacancy()!.workFormat ? (vacancy()!.workFormat | capitalize) : "формат работы не указан" + }} +

      +
    • + +
    • + +

      + {{ + vacancy()!.requiredExperience + ? (vacancy()!.requiredExperience.toLowerCase().includes("без опыта") + ? "" + : "опыт" + " ") + (vacancy()!.requiredExperience | capitalize) + : "опыт не указан" + }} +

      +
    • + +
    • + +

      + {{ + vacancy()!.workSchedule ? (vacancy()!.workSchedule | capitalize) : "график не указан" + }} +

      +
    • + +
    • + +

      + {{ + vacancy()!.salary + ? (vacancy()!.salary | salaryTransform | capitalize) + " " + "рублей" + : "по договоренности" + }} +

      +
    • +
    +
    + + @if (project.links.length) { +
    +
    +

    контакты

    + +
    + + +
    + } +
    +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.scss new file mode 100644 index 000000000..01286d01e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.scss @@ -0,0 +1,145 @@ +@use "styles/responsive"; + +.vacancy { + &__content { + padding: 24px; + margin-bottom: 20px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__right { + display: flex; + flex-direction: column; + gap: 20px; + text-align: center; + + &--title { + margin: 12px 0; + } + + &--project { + position: relative; + padding: 48px 24px 24px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &-image { + position: absolute; + top: -70px; + left: 50%; + display: block; + transform: translate(-50%, 50%); + } + } + } +} + +.cancel { + display: flex; + flex-direction: column; + width: 600px; + max-height: calc(100vh - 40px); + overflow-y: auto; + + &__top { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + &__image { + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; + justify-content: space-between; + + @include responsive.apply-desktop { + display: flex; + flex-direction: row; + gap: 15px; + align-items: center; + justify-content: space-between; + margin: 30px 0; + } + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__title { + margin-top: 15px; + margin-bottom: 15px; + text-align: center; + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + i { + padding: 6px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.ts new file mode 100644 index 000000000..a854962e7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.ts @@ -0,0 +1,56 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + inject, + Input, + Output, + WritableSignal, +} from "@angular/core"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { UserLinksPipe } from "@core/lib/pipes/user/user-links.pipe"; +import { TruncatePipe } from "@core/lib/pipes/formatters/truncate.pipe"; +import { CapitalizePipe } from "@core/lib/pipes/formatters/capitalize.pipe"; +import { RouterModule } from "@angular/router"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { SalaryTransformPipe } from "@core/lib/pipes/transformers/salary-transform.pipe"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { ControlErrorPipe } from "@corelib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { VacancyDetailUIInfoService } from "@api/vacancy/facades/ui/vacancy-detail-ui-info.service"; +import { VacancyDetailInfoService } from "@api/vacancy/facades/vacancy-detail-info.service"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; + +@Component({ + selector: "app-vacancies-right-side", + templateUrl: "./vacancies-right-side.component.html", + styleUrl: "./vacancies-right-side.component.scss", + imports: [ + CommonModule, + AvatarComponent, + ButtonComponent, + ReactiveFormsModule, + RouterModule, + UserLinksPipe, + TruncatePipe, + CapitalizePipe, + IconComponent, + SalaryTransformPipe, + ], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacanciesRightSideComponent { + @Input() vacancy!: WritableSignal; + @Output() sendResponse = new EventEmitter(); + + onSendResponseClick(): void { + this.sendResponse.emit(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.html b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.html new file mode 100644 index 000000000..d14e43940 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.html @@ -0,0 +1,110 @@ + + +@if (vacancy()) { +
    +
    + + + +
    +
    + + +
    + +

    отклик отправлен

    + перейти к вакансиям +
    +
    + + +
    +
    +

    отклик на вакансию

    + +
    + +
    + @if (sendForm.get("whyMe"); as whyMe) { +
    + + + @if (whyMe | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } @if (whyMe | controlError: "maxlength") { +
    + {{ errorMessage.VALIDATION_TOO_LONG }} + @if (whyMe.errors) { + {{ whyMe.errors["maxlength"]["requiredLength"] }} + } +
    + } @if (whyMe | controlError: "minlength") { +
    + {{ errorMessage.VALIDATION_TOO_SHORT }} + @if (whyMe.errors) { + {{ whyMe.errors["minlength"]["requiredLength"] }} + } +
    + } +
    + } + + прикрепить резюме PROCOLLAB + +

    или

    + + @if (sendForm.get("accompanyingFile"); as accompanyingFile) { + +
    + + +
    + +

    + файл резюме в формате
    .pdf, .word весом до 50МБ +

    +
    + @if (accompanyingFile | controlError: "required") { +

    загрузите файл

    + } +
    +
    +
    + } + + отправить отклик +
    +
    +
    +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.scss new file mode 100644 index 000000000..347534ead --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.scss @@ -0,0 +1,186 @@ +@use "styles/responsive"; +@use "styles/typography"; + +.vacancy { + &__content { + padding: 24px; + margin-bottom: 20px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__split { + display: grid; + grid-template-columns: 7fr 3fr; + gap: 20px; + } + + &__form { + display: flex; + flex-direction: column; + gap: 10px; + + label { + color: var(--black); + } + + &--or { + color: var(--grey-for-text); + text-align: center; + } + + &-error { + border: 0.5px solid var(--red); + } + + &--cv { + ::ng-deep { + app-upload-file { + .control { + height: 80px; + border-radius: var(--rounded-xl); + } + } + } + + &-empty { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + color: var(--grey-for-text); + } + } + } +} + +$succeed-modal-width: 310px; + +.succeed { + display: flex; + flex-direction: column; + align-items: center; + width: $succeed-modal-width; + + &__check { + margin-bottom: 18px; + color: var(--green); + } + + &__text { + margin-bottom: 18px; + color: var(--black); + } + + &__link { + width: $succeed-modal-width; + } +} + +.cancel { + display: flex; + flex-direction: column; + width: 600px; + max-height: calc(100vh - 40px); + overflow-y: auto; + + &__top { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + &__image { + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; + justify-content: space-between; + + @include responsive.apply-desktop { + display: flex; + flex-direction: row; + gap: 15px; + align-items: center; + justify-content: space-between; + margin: 30px 0; + } + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__title { + margin-top: 15px; + margin-bottom: 15px; + text-align: center; + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + i { + padding: 6px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.spec.ts new file mode 100644 index 000000000..175f054da --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.spec.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { VacancyInfoComponent } from "./info.component"; + +describe("VacancyInfoComponent", () => { + let component: VacancyInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VacancyInfoComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(VacancyInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.ts new file mode 100644 index 000000000..0e7b380c5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.ts @@ -0,0 +1,118 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { ButtonComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { IconComponent } from "@uilib"; +import { ReactiveFormsModule } from "@angular/forms"; +import { VacancyDetailInfoService } from "@api/vacancy/facades/vacancy-detail-info.service"; +import { VacancyDetailUIInfoService } from "@api/vacancy/facades/ui/vacancy-detail-ui-info.service"; +import { VacanciesRightSideComponent } from "./components/vacancies-right-side/vacancies-right-side.component"; +import { VacanciesLeftSideComponent } from "./components/vacancies-left-side/vacancies-left-side.component"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ControlErrorPipe } from "@corelib"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; + +/** + * Компонент отображения детальной информации о вакансии + * + * Основная функциональность: + * - Отображение полной информации о вакансии (описание, навыки, условия) + * - Показ информации о проекте, к которому относится вакансия + * - Кнопки действий: "Откликнуться" и "Прокачать себя" + * - Модальное окно с предложением подписки на обучение + * - Адаптивное отображение с возможностью сворачивания/разворачивания контента + * + * Управление контентом: + * - Автоматическое определение необходимости кнопки "Читать полностью" + * - Сворачивание длинного описания и списка навыков + * - Парсинг ссылок и переносов строк в описании + * + * Интеграция с сервисами: + * - VacancyService - получение данных вакансии через резолвер + * - ProjectService - загрузка информации о проекте + * - SubscriptionPlansService - получение планов подписки + * - AuthService - информация о текущем пользователе + * + * Жизненный цикл: + * - OnInit: загрузка данных вакансии и проекта, подписка на планы + * - AfterViewInit: определение необходимости кнопок "Читать полностью" + * - OnDestroy: отписка от всех активных подписок + * + * @property {Vacancy} vacancy - объект вакансии с полной информацией + * @property {Project} project - объект проекта, к которому относится вакансия + * @property {boolean} readFullDescription - состояние развернутого описания + * @property {boolean} readFullSkills - состояние развернутого списка навыков + * + * @selector app-detail + * @standalone true - автономный компонент + */ +@Component({ + selector: "app-detail", + templateUrl: "./info.component.html", + styleUrl: "./info.component.scss", + imports: [ + IconComponent, + ButtonComponent, + ModalComponent, + RouterModule, + ReactiveFormsModule, + VacanciesRightSideComponent, + VacanciesLeftSideComponent, + TextareaComponent, + ControlErrorPipe, + UploadFileComponent, + ], + providers: [VacancyDetailInfoService, VacancyDetailUIInfoService], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacancyInfoComponent implements OnInit { + private readonly vacancyDetailInfoService = inject(VacancyDetailInfoService); + private readonly vacancyDetailUIInfoService = inject(VacancyDetailUIInfoService); + + protected readonly vacancy = this.vacancyDetailUIInfoService.vacancy; + + /** Флаг отображения модального окна с результатом */ + protected readonly resultModal = this.vacancyDetailUIInfoService.resultModal; + protected readonly openModal = this.vacancyDetailUIInfoService.openModal; + + /** Форма отправки отклика */ + protected readonly sendForm = this.vacancyDetailUIInfoService.sendForm; + protected readonly sendFormIsSubmitting = + this.vacancyDetailUIInfoService.sendFormIsSubmittingFlag; + + /** Объект с сообщениями об ошибках */ + protected readonly errorMessage = ErrorMessage; + + ngOnInit(): void { + this.vacancyDetailInfoService.initializeDetailInfo(); + this.vacancyDetailInfoService.initializeDetailInfoQueryParams(); + } + + ngOnDestroy(): void { + this.vacancyDetailInfoService.destroy(); + } + + onOpenResponseModal(): void { + this.vacancyDetailUIInfoService.applyResponseModalOpen(); + } + + /** + * Обработчик отправки формы + * Валидирует форму и отправляет отклик на сервер + */ + onSubmit(): void { + this.vacancyDetailInfoService.submitVacancyResponse(); + } + + closeSendResponseModal(): void { + this.vacancyDetailInfoService.closeSendResponseModal(); + } + + protected openSkills() { + location.href = "https://skills.procollab.ru"; + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.html b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.html new file mode 100644 index 000000000..d691851c9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.html @@ -0,0 +1,11 @@ + + +@if (vacancy()) { +
    + +
    + +
    + +
    +} diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.scss similarity index 100% rename from projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.scss rename to projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.scss diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.spec.ts rename to projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.ts new file mode 100644 index 000000000..4af9fa64e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.ts @@ -0,0 +1,54 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, RouterOutlet } from "@angular/router"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { BarComponent } from "@ui/primitives"; +import { map, Subscription } from "rxjs"; +import { BackComponent } from "@uilib"; +import { VacancyDetailInfoService } from "@api/vacancy/facades/vacancy-detail-info.service"; +import { VacancyDetailUIInfoService } from "@api/vacancy/facades/ui/vacancy-detail-ui-info.service"; + +/** + * Компонент детального просмотра вакансии + * + * Функциональность: + * - Получает данные вакансии из резолвера через ActivatedRoute + * - Отображает навигационную панель с кнопкой "Назад" + * - Содержит router-outlet для дочерних компонентов (информация о вакансии) + * - Управляет подписками для предотвращения утечек памяти + * + * Жизненный цикл: + * - OnInit: подписывается на данные маршрута и извлекает объект вакансии + * - OnDestroy: отписывается от всех активных подписок + * + * @property {Vacancy} vacancy - объект вакансии, полученный из резолвера + * @property {Subscription[]} subscriptions$ - массив подписок для управления памятью + * + * @selector app-vacancies-detail + * @standalone true - автономный компонент + */ +@Component({ + selector: "app-vacancies-detail", + templateUrl: "./vacancies-detail.component.html", + styleUrl: "./vacancies-detail.component.scss", + imports: [CommonModule, BarComponent, RouterOutlet, BackComponent], + providers: [VacancyDetailInfoService, VacancyDetailUIInfoService], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacanciesDetailComponent implements OnInit, OnDestroy { + private readonly vacancyDetailInfoService = inject(VacancyDetailInfoService); + private readonly vacancyDetailUIInfoService = inject(VacancyDetailUIInfoService); + + protected readonly vacancy = this.vacancyDetailUIInfoService.vacancy; + + ngOnInit(): void { + this.vacancyDetailInfoService.initializeDetailInfo(); + } + + ngOnDestroy(): void { + this.vacancyDetailInfoService.destroy(); + } +} diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.resolver.ts similarity index 79% rename from projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts rename to projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.resolver.ts index 52c14bb37..1c9605c02 100644 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.resolver.ts @@ -2,7 +2,8 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot } from "@angular/router"; -import { VacancyService } from "@office/services/vacancy.service"; +import { map } from "rxjs"; +import { GetVacancyDetailUseCase } from "@api/vacancy/use-cases/get-vacancy-detail.use-case"; /** * Резолвер для загрузки детальной информации о конкретной вакансии @@ -20,8 +21,10 @@ import { VacancyService } from "@office/services/vacancy.service"; * - vacancyId - ID вакансии из URL параметров (например: /vacancies/123) */ export const VacanciesDetailResolver = (route: ActivatedRouteSnapshot) => { - const vacancyService = inject(VacancyService); + const getVacancyDetailUseCase = inject(GetVacancyDetailUseCase); const vacancyId = route.params["vacancyId"]; - return vacancyService.getOne(vacancyId); + return getVacancyDetailUseCase + .execute(vacancyId) + .pipe(map(result => (result.ok ? result.value : null))); }; diff --git a/projects/social_platform/src/app/office/vacancies/list/list.component.html b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.html similarity index 100% rename from projects/social_platform/src/app/office/vacancies/list/list.component.html rename to projects/social_platform/src/app/ui/pages/vacancies/list/list.component.html diff --git a/projects/social_platform/src/app/office/vacancies/list/list.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.scss similarity index 100% rename from projects/social_platform/src/app/office/vacancies/list/list.component.scss rename to projects/social_platform/src/app/ui/pages/vacancies/list/list.component.scss diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.spec.ts new file mode 100644 index 000000000..572a2d4d5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.spec.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { VacanciesListComponent } from "./list.component"; + +describe("VacanciesListComponent", () => { + let component: VacanciesListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VacanciesListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(VacanciesListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.ts new file mode 100644 index 000000000..e0eb4590d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.ts @@ -0,0 +1,56 @@ +/** @format */ + +// list.component.ts +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ProjectVacancyCardComponent } from "@ui/widgets/project-vacancy-card/project-vacancy-card.component"; +import { ResponseCardComponent } from "./response-card/response-card.component"; +import { VacancyUIInfoService } from "@api/vacancy/facades/ui/vacancy-ui-info.service"; +import { VacancyInfoService } from "@api/vacancy/facades/vacancy-info.service"; + +@Component({ + selector: "app-vacancies-list", + templateUrl: "./list.component.html", + styleUrl: "./list.component.scss", + imports: [ + CommonModule, + ResponseCardComponent, + ProjectVacancyCardComponent, + ButtonComponent, + IconComponent, + ModalComponent, + RouterLink, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [VacancyInfoService, VacancyUIInfoService], + standalone: true, +}) +export class VacanciesListComponent { + private readonly vacancyInfoService = inject(VacancyInfoService); + private readonly vacancyUIInfoService = inject(VacancyUIInfoService); + + protected readonly type = this.vacancyUIInfoService.listType; + protected readonly vacancyList = this.vacancyUIInfoService.vacancyList; + protected readonly responsesList = this.vacancyUIInfoService.responsesList; + protected readonly isMyModal = this.vacancyUIInfoService.isMyModal; + + ngOnInit() { + this.vacancyInfoService.init(); + } + + ngAfterViewInit() { + const target = document.querySelector(".office__body") as HTMLElement; + if (target) { + this.vacancyInfoService.initScroll(target); + } + } + + ngOnDestroy() { + this.vacancyInfoService.destroy(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/my.resolver.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/my.resolver.ts new file mode 100644 index 000000000..d32c36892 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/my.resolver.ts @@ -0,0 +1,32 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { map } from "rxjs"; +import { GetMyVacanciesUseCase } from "@api/vacancy/use-cases/get-my-vacancies.use-case"; + +/** + * Резолвер для загрузки откликов пользователя на вакансии + * + * Функциональность: + * - Выполняется перед активацией маршрута '/office/vacancies/my' + * - Загружает первые 20 откликов пользователя с offset 0 + * - Возвращает массив объектов VacancyResponse с информацией об откликах + * + * Использование: + * - Данные становятся доступными в компоненте через ActivatedRoute.data['data'] + * - Позволяет отобразить список вакансий, на которые пользователь уже откликнулся + * + * @param {VacancyService} vacanciesRepository - сервис для работы с API вакансий + * @returns {Observable} Observable с массивом откликов пользователя + * + * Параметры запроса: + * - limit: 20 - количество откликов на страницу + * - offset: 0 - смещение для пагинации + */ +export const VacanciesMyResolver: ResolveFn = () => { + const getMyVacanciesUseCase = inject(GetMyVacanciesUseCase); + + return getMyVacanciesUseCase.execute(20, 0).pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/office/features/response-card/response-card.component.html b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.html similarity index 100% rename from projects/social_platform/src/app/office/features/response-card/response-card.component.html rename to projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.html diff --git a/projects/social_platform/src/app/office/features/response-card/response-card.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/response-card/response-card.component.scss rename to projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.scss diff --git a/projects/social_platform/src/app/office/features/response-card/response-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/features/response-card/response-card.component.spec.ts rename to projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.ts new file mode 100644 index 000000000..7b060b33a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.ts @@ -0,0 +1,84 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + EventEmitter, + inject, + Input, + OnInit, + Output, +} from "@angular/core"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { IconComponent } from "@uilib"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; + +/** + * Компонент карточки отклика на вакансию + * + * Функциональность: + * - Отображает информацию об отклике на вакансию (кандидат, роль, файлы) + * - Показывает аватар и основную информацию о кандидате + * - Отображает прикрепленные файлы (резюме, портфолио) + * - Предоставляет кнопки для принятия или отклонения отклика + * - Ссылка на профиль кандидата + * - Получает ID текущего пользователя для проверки прав доступа + * + * Входные параметры: + * @Input response - объект отклика на вакансию (обязательный) + * + * Выходные события: + * @Output reject - событие отклонения отклика, передает ID отклика + * @Output accept - событие принятия отклика, передает ID отклика + * + * Внутренние свойства: + * - profileId - ID текущего пользователя для проверки прав + */ +@Component({ + selector: "app-response-card", + templateUrl: "./response-card.component.html", + styleUrl: "./response-card.component.scss", + standalone: true, + imports: [IconComponent, FileItemComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResponseCardComponent implements OnInit { + private readonly authRepository = inject(AuthInfoService); + private readonly destroyRef = inject(DestroyRef); + + @Input({ required: true }) response!: VacancyResponse; + @Output() reject = new EventEmitter(); + @Output() accept = new EventEmitter(); + + profileId!: number; + + ngOnInit(): void { + this.authRepository + .fetchProfile() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: profile => { + this.profileId = profile.id; + }, + }); + } + + /** + * Обработчик принятия отклика + * Эмитит событие с ID отклика + */ + onAccept(responseId: number) { + this.accept.emit(responseId); + } + + /** + * Обработчик отклонения отклика + * Эмитит событие с ID отклика + */ + onReject(responseId: number) { + this.reject.emit(responseId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.html b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.html new file mode 100644 index 000000000..e620b0af5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.html @@ -0,0 +1,35 @@ + + + + + +
    +
    + + +
    +
    + @if(isAll() === 'all') { +
    + +
    + } + + +
    + + @if (isAll() === 'all') { +
    + +
    + } +
    +
    +
    diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.scss similarity index 100% rename from projects/social_platform/src/app/office/vacancies/vacancies.component.scss rename to projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.scss diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/vacancies/vacancies.component.spec.ts rename to projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.ts new file mode 100644 index 000000000..bc87c72b3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.ts @@ -0,0 +1,56 @@ +/** @format */ + +// vacancies.component.ts +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterOutlet } from "@angular/router"; +import { BackComponent } from "@uilib"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { ReactiveFormsModule } from "@angular/forms"; +import { VacancyFilterComponent } from "@ui/widgets/vacancy-filter/vacancy-filter.component"; +import { VacancyInfoService } from "@api/vacancy/facades/vacancy-info.service"; +import { VacancyUIInfoService } from "@api/vacancy/facades/ui/vacancy-ui-info.service"; + +@Component({ + selector: "app-vacancies", + templateUrl: "./vacancies.component.html", + styleUrl: "./vacancies.component.scss", + imports: [ + CommonModule, + RouterOutlet, + BackComponent, + SearchComponent, + VacancyFilterComponent, + ReactiveFormsModule, + ], + providers: [VacancyInfoService, VacancyUIInfoService], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacanciesComponent implements OnInit { + private readonly vacancyInfoService = inject(VacancyInfoService); + private readonly vacancyUIInfoService = inject(VacancyUIInfoService); + + protected readonly searchForm = this.vacancyUIInfoService.searchForm; + protected readonly isAll = this.vacancyUIInfoService.listType; + protected readonly basePath = "/office/"; + + ngOnInit() { + this.vacancyInfoService.initializationSearchValueForm(); + this.vacancyInfoService.init(); + } + + ngOnDestroy(): void { + this.vacancyInfoService.destroy(); + } + + onSearchSubmit() { + this.vacancyInfoService.onSearchSubmit(); + } + + onSearhValueChanged(event: string) { + this.vacancyUIInfoService.applySearhValueChanged(event); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/vacancies.resolver.ts b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.resolver.ts new file mode 100644 index 000000000..ffa0eef5b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.resolver.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { map } from "rxjs"; +import { GetVacanciesUseCase } from "@api/vacancy/use-cases/get-vacancies.use-case"; + +/** + * Резолвер для предзагрузки списка вакансий + * Загружает данные вакансий до активации маршрута, обеспечивая + * мгновенное отображение контента без состояния загрузки + * + * @returns Observable с данными вакансий (первые 20 элементов) + */ +export const VacanciesResolver = () => { + const getVacanciesUseCase = inject(GetVacanciesUseCase); + + // Загрузка первых 20 вакансий с нулевым смещением + return getVacanciesUseCase + .execute({ limit: 20, offset: 0 }) + .pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.html b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.html rename to projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.html diff --git a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.scss b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.scss rename to projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.scss diff --git a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.ts similarity index 93% rename from projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts rename to projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.ts index 71b66e79a..4853f9928 100644 --- a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts +++ b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.ts @@ -5,9 +5,11 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + DestroyRef, ElementRef, EventEmitter, forwardRef, + inject, Input, Output, signal, @@ -16,9 +18,10 @@ import { import { IconComponent } from "@uilib"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { ClickOutsideModule } from "ng-click-outside"; -import { debounce, distinctUntilChanged, fromEvent, map, of, Subscription, timer } from "rxjs"; +import { debounce, distinctUntilChanged, fromEvent, map, of, timer } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { animate, style, transition, trigger } from "@angular/animations"; -import { LoaderComponent } from "@ui/components/loader/loader.component"; +import { LoaderComponent } from "@ui/primitives/loader/loader.component"; /** * Компонент автодополнения с поиском и выбором из списка предложений. @@ -146,22 +149,21 @@ export class AutoCompleteInputComponent { /** Состояние блокировки */ disabled = signal(false); - /** Массив подписок */ - subscriptions$ = signal([]); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); - constructor(private readonly cdRef: ChangeDetectorRef) {} + constructor() {} /** Инициализация отслеживания ввода после загрузки представления */ ngAfterViewInit(): void { - const input$ = fromEvent(this.inputElem.nativeElement, "input") + fromEvent(this.inputElem.nativeElement, "input") .pipe( map(e => (e.target as HTMLInputElement).value.trim()), debounce(val => (val ? timer(this.delay) : of({}))), - distinctUntilChanged() + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) ) .subscribe(val => this.handleSearch(val)); - - this.subscriptions$().push(input$); } ngOnInit(): void {} @@ -301,9 +303,4 @@ export class AutoCompleteInputComponent { s => String(s[this.fieldToDisplay]).toLowerCase() === this.inputValue().toLocaleLowerCase() ); } - - /** Очистка подписок при уничтожении компонента */ - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } } diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.html similarity index 97% rename from projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html rename to projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.html index 1e3cf0453..7f10ce24d 100644 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html +++ b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.html @@ -22,7 +22,7 @@
    } @if (value) { - avatar + avatar } @if(type === 'avatar') { { let component: AvatarControlComponent; @@ -15,7 +15,7 @@ describe("AvatarControlComponent", () => { await TestBed.configureTestingModule({ imports: [HttpClientTestingModule, AvatarControlComponent], - providers: [{ provide: AuthService, useValue: autSpy }], + providers: [{ provide: AuthRepository, useValue: autSpy }], }).compileComponents(); }); diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.ts similarity index 84% rename from projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts rename to projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.ts index f48c594b6..a2143d8ff 100644 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts +++ b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.ts @@ -1,17 +1,28 @@ /** @format */ -import { Component, forwardRef, Input, OnInit } from "@angular/core"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + inject, + 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 "@core/lib/services/file/file.service"; import { catchError, concatMap, map, of } from "rxjs"; -import { IconComponent, ButtonComponent } from "@ui/components"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { IconComponent, ButtonComponent } from "@ui/primitives"; import { LoaderComponent } from "../loader/loader.component"; import { CommonModule } from "@angular/common"; import { ImageCroppedEvent, ImageCropperComponent } from "ngx-image-cropper"; import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; import { ModalComponent } from "../modal/modal.component"; import { TooltipComponent } from "../tooltip/tooltip.component"; +import { LoggerService } from "@corelib"; /** * Компонент для управления аватаром пользователя. @@ -37,6 +48,7 @@ import { TooltipComponent } from "../tooltip/tooltip.component"; multi: true, }, ], + changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ LoaderComponent, @@ -49,7 +61,14 @@ import { TooltipComponent } from "../tooltip/tooltip.component"; ], }) export class AvatarControlComponent implements OnInit, ControlValueAccessor { - constructor(private fileService: FileService, private sanitizer: DomSanitizer) {} + private readonly destroyRef = inject(DestroyRef); + + constructor( + private fileService: FileService, + private sanitizer: DomSanitizer, + private readonly loggerService: LoggerService, + private readonly cdr: ChangeDetectorRef + ) {} /** Размер аватара в пикселях */ @Input() size = 140; @@ -101,6 +120,7 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { /** Записывает значение URL изображения */ writeValue(address: string) { this.value = address; + this.cdr.markForCheck(); } onTouch: () => void = () => {}; @@ -156,8 +176,9 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { * Обработчик ошибки загрузки */ loadImageFailed() { - console.error("Не удалось загрузить изображение"); + this.loggerService.error("Не удалось загрузить изображение"); this.showCropperModalErrorMessage = "Не удалось загрузить изображение. Попробуйте ещё раз!"; + this.cdr.markForCheck(); } /** @@ -170,6 +191,7 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { this.loading = true; this.showCropperModal = false; + this.cdr.markForCheck(); // Создаем файл из blob const file = new File([this.croppedBlob], "cropped-avatar.jpg", { @@ -180,13 +202,17 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { const source = this.value ? this.fileService.deleteFile(this.value).pipe( catchError(err => { - console.error(err); + this.loggerService.error(err); return of({}); }), concatMap(() => this.fileService.uploadFile(file)), - map(r => r["url"]) + map(r => r["url"]), + takeUntilDestroyed(this.destroyRef) ) - : this.fileService.uploadFile(file).pipe(map(r => r.url)); + : this.fileService.uploadFile(file).pipe( + map(r => r.url), + takeUntilDestroyed(this.destroyRef) + ); source.subscribe(this.updateValue.bind(this)); } @@ -199,6 +225,7 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { this.imageChangedEvent = null; this.croppedImage = ""; this.croppedBlob = null; + this.cdr.markForCheck(); // Сбрасываем значение input const input = document.getElementById(this.controlId) as HTMLInputElement; @@ -228,5 +255,6 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { this.value = url; this.onTouch(); + this.cdr.markForCheck(); } } diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.html b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/avatar/avatar.component.html rename to projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.scss b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/avatar/avatar.component.scss rename to projects/social_platform/src/app/ui/primitives/avatar/avatar.component.scss diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.spec.ts b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/avatar/avatar.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/avatar/avatar.component.spec.ts diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.ts b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.ts similarity index 94% rename from projects/social_platform/src/app/ui/components/avatar/avatar.component.ts rename to projects/social_platform/src/app/ui/primitives/avatar/avatar.component.ts index 1a499949e..a3ce11aa4 100644 --- a/projects/social_platform/src/app/ui/components/avatar/avatar.component.ts +++ b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, type OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Input, type OnInit } from "@angular/core"; /** * Компонент для отображения аватара пользователя. @@ -26,6 +26,7 @@ import { Component, Input, type OnInit } from "@angular/core"; templateUrl: "./avatar.component.html", styleUrl: "./avatar.component.scss", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AvatarComponent implements OnInit { /** URL изображения аватара */ diff --git a/projects/social_platform/src/app/ui/components/bar/bar.component.html b/projects/social_platform/src/app/ui/primitives/bar/bar.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/bar/bar.component.html rename to projects/social_platform/src/app/ui/primitives/bar/bar.component.html diff --git a/projects/social_platform/src/app/ui/components/bar/bar.component.scss b/projects/social_platform/src/app/ui/primitives/bar/bar.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/bar/bar.component.scss rename to projects/social_platform/src/app/ui/primitives/bar/bar.component.scss diff --git a/projects/social_platform/src/app/ui/components/bar-new/bar.component.spec.ts b/projects/social_platform/src/app/ui/primitives/bar/bar.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/bar-new/bar.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/bar/bar.component.spec.ts diff --git a/projects/social_platform/src/app/ui/primitives/bar/bar.component.ts b/projects/social_platform/src/app/ui/primitives/bar/bar.component.ts new file mode 100644 index 000000000..2c80cb319 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/bar/bar.component.ts @@ -0,0 +1,54 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterLink, RouterLinkActive } from "@angular/router"; +import { BackComponent } from "@uilib"; + +/** + * Компонент навигационной панели с табами и кнопкой "Назад". + * Отображает горизонтальный список ссылок с индикаторами активности и счетчиками. + * + * Входящие параметры: + * - links: массив объектов навигационных ссылок с настройками + * - link: URL ссылки + * - linkText: текст ссылки + * - isRouterLinkActiveOptions: настройки активности ссылки + * - count: количество элементов для отображения бейджа (опционально) + * - backRoute: маршрут для кнопки "Назад" (опционально) + * - backHave: показывать ли кнопку "Назад" (опционально) + * - ballHave: показывать ли индикатор в виде шарика (по умолчанию false) + * + * Использование: + * - Навигация между разделами приложения + * - Отображение количества элементов в разделах + * - Навигация назад к предыдущему экрану + */ +@Component({ + selector: "app-bar", + standalone: true, + imports: [CommonModule, RouterLink, RouterLinkActive, BackComponent], + templateUrl: "./bar.component.html", + styleUrl: "./bar.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BarComponent { + constructor() {} + + /** Массив навигационных ссылок */ + @Input() links!: { + link: string; + linkText: string; + isRouterLinkActiveOptions: boolean; + count?: number; + }[]; + + /** Показывать индикатор в виде шарика */ + @Input() ballHave?: boolean = false; + + /** Маршрут для кнопки "Назад" */ + @Input() backRoute?: string; + + /** Показывать кнопку "Назад" */ + @Input() backHave?: boolean; +} diff --git a/projects/social_platform/src/app/ui/components/button/button.component.html b/projects/social_platform/src/app/ui/primitives/button/button.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/button/button.component.html rename to projects/social_platform/src/app/ui/primitives/button/button.component.html diff --git a/projects/social_platform/src/app/ui/components/button/button.component.scss b/projects/social_platform/src/app/ui/primitives/button/button.component.scss similarity index 87% rename from projects/social_platform/src/app/ui/components/button/button.component.scss rename to projects/social_platform/src/app/ui/primitives/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/primitives/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/button/button.component.spec.ts b/projects/social_platform/src/app/ui/primitives/button/button.component.spec.ts similarity index 97% rename from projects/social_platform/src/app/ui/components/button/button.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/button/button.component.spec.ts index 6dd0acb08..f30b8cc70 100644 --- a/projects/social_platform/src/app/ui/components/button/button.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/button/button.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { ButtonComponent } from "@ui/components"; +import { ButtonComponent } from "@ui/primitives"; describe("ButtonComponent", () => { let component: ButtonComponent; diff --git a/projects/social_platform/src/app/ui/components/button/button.component.ts b/projects/social_platform/src/app/ui/primitives/button/button.component.ts similarity index 95% rename from projects/social_platform/src/app/ui/components/button/button.component.ts rename to projects/social_platform/src/app/ui/primitives/button/button.component.ts index 14b0d7264..5e94cd1e4 100644 --- a/projects/social_platform/src/app/ui/components/button/button.component.ts +++ b/projects/social_platform/src/app/ui/primitives/button/button.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, type OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Input, type OnInit } from "@angular/core"; import { LoaderComponent } from "../loader/loader.component"; import { CommonModule } from "@angular/common"; @@ -32,6 +32,7 @@ import { CommonModule } from "@angular/common"; styleUrl: "./button.component.scss", standalone: true, imports: [CommonModule, LoaderComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ButtonComponent implements OnInit { constructor() {} diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html rename to projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.html diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.scss b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/checkbox/checkbox.component.scss rename to projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.scss diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.spec.ts b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.spec.ts similarity index 96% rename from projects/social_platform/src/app/ui/components/checkbox/checkbox.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.spec.ts index b7516178a..b84d556f9 100644 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { CheckboxComponent } from "@ui/components"; +import { CheckboxComponent } from "@ui/primitives"; describe("CheckboxComponent", () => { let component: CheckboxComponent; diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.ts similarity index 81% rename from projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts rename to projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.ts index d86affbf9..d2328118e 100644 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts +++ b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.ts @@ -1,7 +1,14 @@ /** @format */ -import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; -import { IconComponent } from "@ui/components/icon/icon.component"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + type OnInit, + Output, +} from "@angular/core"; +import { IconComponent } from "@ui/primitives/icon/icon.component"; /** * Компонент чекбокса для выбора булевых значений. @@ -22,6 +29,7 @@ import { IconComponent } from "@ui/components/icon/icon.component"; styleUrl: "./checkbox.component.scss", standalone: true, imports: [IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CheckboxComponent implements OnInit { /** Состояние чекбокса */ diff --git a/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.html b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.html new file mode 100644 index 000000000..fe0bf3ab3 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/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/primitives/dropdown/dropdown.component.scss b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.scss new file mode 100644 index 000000000..5f95868d1 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/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/primitives/dropdown/dropdown.component.ts b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.ts new file mode 100644 index 000000000..5bd0cd9d3 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.ts @@ -0,0 +1,128 @@ +/** @format */ + +import { ConnectedPosition, OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, +} from "@angular/core"; +import { AvatarComponent } from "../avatar/avatar.component"; +import { IconComponent } from "@uilib"; +import { getPriorityType } from "@utils/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", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DropdownComponent { + /** Состояние для определения списка элементов */ + @Input() options: { + id: number; + label: string; + value: string | number | boolean | null; + 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/ui/components/file-item/file-item.component.html b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/file-item/file-item.component.html rename to projects/social_platform/src/app/ui/primitives/file-item/file-item.component.html diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.scss b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/file-item/file-item.component.scss rename to projects/social_platform/src/app/ui/primitives/file-item/file-item.component.scss diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.spec.ts b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/file-item/file-item.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/file-item/file-item.component.spec.ts diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.ts similarity index 79% rename from projects/social_platform/src/app/ui/components/file-item/file-item.component.ts rename to projects/social_platform/src/app/ui/primitives/file-item/file-item.component.ts index aab60452d..69bba3b07 100644 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts +++ b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.ts @@ -1,10 +1,21 @@ /** @format */ -import { Component, EventEmitter, inject, Input, OnInit, Output } from "@angular/core"; + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + EventEmitter, + inject, + Input, + OnInit, + Output, +} from "@angular/core"; import { FileTypePipe } from "@ui/pipes/file-type.pipe"; -import { IconComponent } from "@ui/components"; +import { IconComponent } from "@ui/primitives"; import { UpperCasePipe } from "@angular/common"; -import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; -import { FileService } from "@core/services/file.service"; +import { FileService } from "@core/lib/services/file/file.service"; +import { FormatedFileSizePipe } from "@core/lib/pipes/transformers/formatted-file-size.pipe"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; /** * Компонент для отображения информации о файле. @@ -27,9 +38,11 @@ import { FileService } from "@core/services/file.service"; styleUrl: "./file-item.component.scss", standalone: true, imports: [IconComponent, FileTypePipe, UpperCasePipe, FormatedFileSizePipe], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileItemComponent implements OnInit { private readonly fileService = inject(FileService); + private readonly destroyRef = inject(DestroyRef); @Input() canDelete = false; @@ -78,9 +91,12 @@ export class FileItemComponent implements OnInit { return; } - this.fileService.deleteFile(this.link).subscribe(() => { - this.link = ""; - this.name = ""; - }); + this.fileService + .deleteFile(this.link) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.link = ""; + this.name = ""; + }); } } diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.html b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.html rename to projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.html diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.scss b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.scss rename to projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.scss diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.spec.ts b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.spec.ts 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/primitives/file-upload-item/file-upload-item.component.ts similarity index 86% rename from projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts rename to projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.ts index a07d56e9e..083aef20d 100644 --- a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts +++ b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.ts @@ -1,11 +1,18 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from "@angular/core"; import { FileTypePipe } from "@ui/pipes/file-type.pipe"; import { LoaderComponent } from "../loader/loader.component"; -import { IconComponent } from "@ui/components"; +import { IconComponent } from "@ui/primitives"; import { UpperCasePipe } from "@angular/common"; -import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; +import { FormatedFileSizePipe } from "@core/lib/pipes/transformers/formatted-file-size.pipe"; /** * Компонент для отображения элемента загружаемого файла. @@ -29,6 +36,7 @@ import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; styleUrl: "./file-upload-item.component.scss", standalone: true, imports: [IconComponent, LoaderComponent, UpperCasePipe, FileTypePipe, FormatedFileSizePipe], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileUploadItemComponent implements OnInit { constructor() {} diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.html b/projects/social_platform/src/app/ui/primitives/icon/icon.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/icon/icon.component.html rename to projects/social_platform/src/app/ui/primitives/icon/icon.component.html diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.scss b/projects/social_platform/src/app/ui/primitives/icon/icon.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/icon/icon.component.scss rename to projects/social_platform/src/app/ui/primitives/icon/icon.component.scss diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.spec.ts b/projects/social_platform/src/app/ui/primitives/icon/icon.component.spec.ts similarity index 97% rename from projects/social_platform/src/app/ui/components/icon/icon.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/icon/icon.component.spec.ts index b786851f6..dd2ad1601 100644 --- a/projects/social_platform/src/app/ui/components/icon/icon.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/icon/icon.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { IconComponent } from "@ui/components"; +import { IconComponent } from "@ui/primitives"; describe("IconComponent", () => { let component: IconComponent; diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.ts b/projects/social_platform/src/app/ui/primitives/icon/icon.component.ts similarity index 95% rename from projects/social_platform/src/app/ui/components/icon/icon.component.ts rename to projects/social_platform/src/app/ui/primitives/icon/icon.component.ts index aac947c28..a46fb4cd5 100644 --- a/projects/social_platform/src/app/ui/components/icon/icon.component.ts +++ b/projects/social_platform/src/app/ui/primitives/icon/icon.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Input, OnInit } from "@angular/core"; /** * Компонент для отображения SVG иконок с настраиваемыми параметрами. @@ -25,6 +25,7 @@ import { Component, Input, OnInit } from "@angular/core"; templateUrl: "./icon.component.html", styleUrl: "./icon.component.scss", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class IconComponent implements OnInit { /** Размер квадратной иконки */ diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.html b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.html similarity index 97% rename from projects/social_platform/src/app/office/shared/img-card/img-card.component.html rename to projects/social_platform/src/app/ui/primitives/img-card/img-card.component.html index 18c77342f..b43f410f8 100644 --- a/projects/social_platform/src/app/office/shared/img-card/img-card.component.html +++ b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.html @@ -2,7 +2,7 @@
    @if (src && !loading && !error) { - user-generated content + user-generated content } @if (error) {
    diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.scss b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/img-card/img-card.component.scss rename to projects/social_platform/src/app/ui/primitives/img-card/img-card.component.scss diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.spec.ts b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/img-card/img-card.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/img-card/img-card.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.ts similarity index 87% rename from projects/social_platform/src/app/office/shared/img-card/img-card.component.ts rename to projects/social_platform/src/app/ui/primitives/img-card/img-card.component.ts index 29154dccf..2c2d24519 100644 --- a/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts +++ b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.ts @@ -1,7 +1,14 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { IconComponent } from "@ui/components"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from "@angular/core"; +import { IconComponent } from "@ui/primitives"; /** * Компонент карточки изображения с состояниями загрузки и ошибки @@ -26,6 +33,7 @@ import { IconComponent } from "@ui/components"; styleUrl: "./img-card.component.scss", standalone: true, imports: [IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ImgCardComponent implements OnInit { constructor() {} diff --git a/projects/social_platform/src/app/ui/components/index.ts b/projects/social_platform/src/app/ui/primitives/index.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/index.ts rename to projects/social_platform/src/app/ui/primitives/index.ts diff --git a/projects/social_platform/src/app/ui/components/input/input.component.html b/projects/social_platform/src/app/ui/primitives/input/input.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/input/input.component.html rename to projects/social_platform/src/app/ui/primitives/input/input.component.html diff --git a/projects/social_platform/src/app/ui/components/input/input.component.scss b/projects/social_platform/src/app/ui/primitives/input/input.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/input/input.component.scss rename to projects/social_platform/src/app/ui/primitives/input/input.component.scss diff --git a/projects/social_platform/src/app/ui/components/input/input.component.spec.ts b/projects/social_platform/src/app/ui/primitives/input/input.component.spec.ts similarity index 98% rename from projects/social_platform/src/app/ui/components/input/input.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/input/input.component.spec.ts index 1c016a88c..f502d4c12 100644 --- a/projects/social_platform/src/app/ui/components/input/input.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/input/input.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormsModule } from "@angular/forms"; -import { InputComponent } from "@ui/components"; +import { InputComponent } from "@ui/primitives"; import { NgxMaskModule } from "ngx-mask"; describe("InputComponent", () => { diff --git a/projects/social_platform/src/app/ui/components/input/input.component.ts b/projects/social_platform/src/app/ui/primitives/input/input.component.ts similarity index 91% rename from projects/social_platform/src/app/ui/components/input/input.component.ts rename to projects/social_platform/src/app/ui/primitives/input/input.component.ts index 69ead4dee..0feaa8625 100644 --- a/projects/social_platform/src/app/ui/components/input/input.component.ts +++ b/projects/social_platform/src/app/ui/primitives/input/input.component.ts @@ -1,10 +1,18 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, forwardRef, Input, Output } from "@angular/core"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + forwardRef, + Input, + Output, +} from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "@ui/components"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; +import { IconComponent } from "@ui/primitives"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; import { NgxMaskModule } from "ngx-mask"; import { MatDatepickerModule } from "@angular/material/datepicker"; import { MatInputModule } from "@angular/material/input"; @@ -33,8 +41,11 @@ import { MatFormFieldModule } from "@angular/material/form-field"; MatNativeDateModule, MatFormFieldModule, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class InputComponent implements ControlValueAccessor { + constructor(private readonly cdr: ChangeDetectorRef) {} + @Input() placeholder = ""; @Input() type: "text" | "password" | "email" | "tel" | "date" | "radio" = "text"; @Input() size: "small" | "big" = "small"; @@ -144,6 +155,7 @@ export class InputComponent implements ControlValueAccessor { writeValue(value: string | null): void { setTimeout(() => { this.value = value ?? ""; + this.cdr.markForCheck(); }); } @@ -163,6 +175,7 @@ export class InputComponent implements ControlValueAccessor { setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; + this.cdr.markForCheck(); } onEnter(event: Event) { diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.html b/projects/social_platform/src/app/ui/primitives/loader/loader.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/loader/loader.component.html rename to projects/social_platform/src/app/ui/primitives/loader/loader.component.html diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.scss b/projects/social_platform/src/app/ui/primitives/loader/loader.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/loader/loader.component.scss rename to projects/social_platform/src/app/ui/primitives/loader/loader.component.scss diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.spec.ts b/projects/social_platform/src/app/ui/primitives/loader/loader.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/loader/loader.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/loader/loader.component.spec.ts diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.ts b/projects/social_platform/src/app/ui/primitives/loader/loader.component.ts similarity index 91% rename from projects/social_platform/src/app/ui/components/loader/loader.component.ts rename to projects/social_platform/src/app/ui/primitives/loader/loader.component.ts index 4010005ec..8f4c9f979 100644 --- a/projects/social_platform/src/app/ui/components/loader/loader.component.ts +++ b/projects/social_platform/src/app/ui/primitives/loader/loader.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, Input, OnInit } from "@angular/core"; /** * Компонент индикатора загрузки с настраиваемым внешним видом. @@ -21,6 +21,7 @@ import { Component, Input, OnInit } from "@angular/core"; templateUrl: "./loader.component.html", styleUrl: "./loader.component.scss", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class LoaderComponent implements OnInit { constructor() {} diff --git a/projects/social_platform/src/app/ui/primitives/modal/modal.component.html b/projects/social_platform/src/app/ui/primitives/modal/modal.component.html new file mode 100644 index 000000000..2f3e96920 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/modal/modal.component.html @@ -0,0 +1,14 @@ + + + + diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.scss b/projects/social_platform/src/app/ui/primitives/modal/modal.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/modal/modal.component.scss rename to projects/social_platform/src/app/ui/primitives/modal/modal.component.scss diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.spec.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/modal/modal.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/modal/modal.component.spec.ts diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.component.ts similarity index 97% rename from projects/social_platform/src/app/ui/components/modal/modal.component.ts rename to projects/social_platform/src/app/ui/primitives/modal/modal.component.ts index 1057c68a3..ce53536b2 100644 --- a/projects/social_platform/src/app/ui/components/modal/modal.component.ts +++ b/projects/social_platform/src/app/ui/primitives/modal/modal.component.ts @@ -2,6 +2,7 @@ import { AfterViewInit, + ChangeDetectionStrategy, Component, EventEmitter, Input, @@ -43,6 +44,7 @@ import { CommonModule } from "@angular/common"; styleUrl: "./modal.component.scss", standalone: true, imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ModalComponent implements OnInit, AfterViewInit, OnDestroy { constructor( diff --git a/projects/social_platform/src/app/ui/models/modal.service.spec.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/models/modal.service.spec.ts rename to projects/social_platform/src/app/ui/primitives/modal/modal.service.spec.ts diff --git a/projects/social_platform/src/app/ui/models/modal.service.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.service.ts similarity index 100% rename from projects/social_platform/src/app/ui/models/modal.service.ts rename to projects/social_platform/src/app/ui/primitives/modal/modal.service.ts diff --git a/projects/social_platform/src/app/ui/components/search/search.component.html b/projects/social_platform/src/app/ui/primitives/search/search.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/search/search.component.html rename to projects/social_platform/src/app/ui/primitives/search/search.component.html diff --git a/projects/social_platform/src/app/ui/components/search/search.component.scss b/projects/social_platform/src/app/ui/primitives/search/search.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/search/search.component.scss rename to projects/social_platform/src/app/ui/primitives/search/search.component.scss diff --git a/projects/social_platform/src/app/ui/components/search/search.component.spec.ts b/projects/social_platform/src/app/ui/primitives/search/search.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/search/search.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/search/search.component.spec.ts diff --git a/projects/social_platform/src/app/ui/components/search/search.component.ts b/projects/social_platform/src/app/ui/primitives/search/search.component.ts similarity index 97% rename from projects/social_platform/src/app/ui/components/search/search.component.ts rename to projects/social_platform/src/app/ui/primitives/search/search.component.ts index 9603c397a..d2a333889 100644 --- a/projects/social_platform/src/app/ui/components/search/search.component.ts +++ b/projects/social_platform/src/app/ui/primitives/search/search.component.ts @@ -1,6 +1,7 @@ /** @format */ import { + ChangeDetectionStrategy, Component, ElementRef, EventEmitter, @@ -11,7 +12,7 @@ import { ViewChild, } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "@ui/components"; +import { IconComponent } from "@ui/primitives"; import { ClickOutsideModule } from "ng-click-outside"; /** @@ -38,6 +39,7 @@ import { ClickOutsideModule } from "ng-click-outside"; selector: "app-search", templateUrl: "./search.component.html", styleUrl: "./search.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/projects/social_platform/src/app/ui/primitives/select/select.component.html b/projects/social_platform/src/app/ui/primitives/select/select.component.html new file mode 100644 index 000000000..fc66a28c6 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.html @@ -0,0 +1,43 @@ + + +
    +
    + {{ + selectedId === -1 + ? "Ничего" + : selectedId || selectedId === 0 + ? getLabel(selectedId) || placeholder + : placeholder + }} + @if (error) { + + } @else { + + } +
    + @if (!isDisabled) { + + } +
    diff --git a/projects/social_platform/src/app/ui/primitives/select/select.component.scss b/projects/social_platform/src/app/ui/primitives/select/select.component.scss new file mode 100644 index 000000000..55cc1e062 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.scss @@ -0,0 +1,65 @@ +/** @format */ + +@use "styles/typography"; +@use "styles/responsive"; + +.field { + position: relative; + width: 100%; + + &__input { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + color: var(--black); + cursor: pointer; + background-color: var(--white); + border: 0.5px solid var(--gray); + border-radius: var(--rounded-xxl); + outline: none; + + &--small { + max-width: 70px; + padding: 12px; + text-align: center; + } + + &--big { + width: 100%; + padding: 12px; + } + + &--placeholder { + color: var(--dark-grey); + } + + &--open { + border-color: var(--accent); + box-shadow: 0 0 6px rgb(109 40 255 / 30%); + } + + &--error { + border-color: var(--red) !important; + } + + &--disabled { + cursor: not-allowed; + opacity: 0.5; + } + + @include typography.body-12; + } + + &__arrow { + color: var(--dark-grey); + transition: all 0.2s; + transform: rotate(180deg); + + &--filpped { + color: var(--accent); + transform: rotate(0deg); + } + } +} diff --git a/projects/social_platform/src/app/ui/components/select/select.component.spec.ts b/projects/social_platform/src/app/ui/primitives/select/select.component.spec.ts similarity index 97% rename from projects/social_platform/src/app/ui/components/select/select.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/select/select.component.spec.ts index 9ae08b4bc..6d946cbe5 100644 --- a/projects/social_platform/src/app/ui/components/select/select.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormsModule } from "@angular/forms"; -import { SelectComponent } from "@ui/components"; +import { SelectComponent } from "@ui/primitives"; describe("SelectComponent", () => { let component: SelectComponent; diff --git a/projects/social_platform/src/app/ui/primitives/select/select.component.ts b/projects/social_platform/src/app/ui/primitives/select/select.component.ts new file mode 100644 index 000000000..573ee380c --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.ts @@ -0,0 +1,258 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + HostListener, + Input, + Renderer2, + ViewChild, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { IconComponent } from "@ui/primitives"; +import { ClickOutsideModule } from "ng-click-outside"; +import { DropdownComponent } from "../dropdown/dropdown.component"; + +/** + * Компонент выпадающего списка для выбора значения из предустановленных опций. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Поддерживает навигацию с клавиатуры и автоматический скролл к выделенному элементу. + * + * Входящие параметры: + * - placeholder: текст подсказки при отсутствии выбора + * - selectedId: ID выбранной опции + * - options: массив опций для выбора с полями value, label, id + * + * Возвращает: + * - Значение выбранной опции через ControlValueAccessor + * + * Функциональность: + * - Навигация стрелками вверх/вниз + * - Выбор по Enter, закрытие по Escape + * - Автоматический скролл к выделенному элементу + * - Закрытие при клике вне компонента + */ +@Component({ + selector: "app-select", + templateUrl: "./select.component.html", + styleUrl: "./select.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectComponent), + multi: true, + }, + ], + standalone: true, + imports: [ClickOutsideModule, IconComponent, CommonModule, DropdownComponent], +}) +export class SelectComponent implements ControlValueAccessor { + /** Текст подсказки */ + @Input() placeholder = ""; + + /** ID выбранной опции */ + @Input() selectedId?: number; + + @Input() size: "small" | "big" = "small"; + + /** Массив доступных опций */ + @Input({ required: true }) options: { + value: string | number | boolean | null; + label: string; + id: number; + }[] = []; + + @Input() error = false; + + @Input() set isDisabled(value: boolean) { + this.setDisabledState(value); + } + + get isDisabled(): boolean { + return this.disabled; + } + + /** Состояние открытия выпадающего списка */ + isOpen = false; + + /** Индекс подсвеченного элемента при навигации */ + highlightedIndex = -1; + + constructor(private readonly renderer: Renderer2, private readonly cdr: ChangeDetectorRef) {} + + /** Ссылка на элемент выпадающего списка */ + @ViewChild("dropdown") dropdown!: ElementRef; + + /** Обработчик клавиатурных событий для навигации */ + @HostListener("document:keydown", ["$event"]) + onKeyDown(event: KeyboardEvent): void { + if (!this.isOpen || this.disabled) { + return; + } + + event.preventDefault(); + + const i = this.highlightedIndex; + + if (event.code === "ArrowUp") { + if (i < 0) this.highlightedIndex = 0; + if (i > 0) this.highlightedIndex--; + } + if (event.code === "ArrowDown") { + if (i < this.options.length - 1) { + this.highlightedIndex++; + } + } + if (event.code === "Enter") { + if (i >= 0) { + this.onUpdate(this.options[this.highlightedIndex].id); + } + } + if (event.code === "Escape") { + this.hideDropdown(); + } + + if (this.isOpen) { + setTimeout(() => this.trackHighlightScroll()); + } + } + + /** Автоматический скролл к выделенному элементу */ + trackHighlightScroll(): void { + const ddElem = this.dropdown.nativeElement; + + const highlightedElem = ddElem.children[this.highlightedIndex]; + + const ddBox = ddElem.getBoundingClientRect(); + const optBox = highlightedElem.getBoundingClientRect(); + + if (optBox.bottom > ddBox.bottom) { + const scrollAmount = optBox.bottom - ddBox.bottom + ddElem.scrollTop; + this.renderer.setProperty(ddElem, "scrollTop", scrollAmount); + } else if (optBox.top < ddBox.top) { + const scrollAmount = optBox.top - ddBox.top + ddElem.scrollTop; + this.renderer.setProperty(ddElem, "scrollTop", scrollAmount); + } + } + + // Методы ControlValueAccessor + writeValue(value: number | string | null) { + if (value === null || value === undefined || value === "") { + this.selectedId = undefined; + this.cdr.markForCheck(); + return; + } + + const optionByValue = this.options.find(option => option.value === value); + if (optionByValue) { + this.selectedId = optionByValue.id; + this.cdr.markForCheck(); + return; + } + + const yearValue = this.extractYear(value); + if (yearValue !== null) { + const yearOption = this.options.find(option => this.extractYear(option.value) === yearValue); + if (yearOption) { + this.selectedId = yearOption.id; + this.cdr.markForCheck(); + return; + } + } + + if (typeof value === "number") { + this.selectedId = this.options.some(option => option.id === value) ? value : undefined; + this.cdr.markForCheck(); + return; + } + + this.selectedId = this.getIdByValue(value) || this.getId(value); + this.cdr.markForCheck(); + } + + getIdByValue(value: string | number): number | undefined { + return this.options.find(el => el.value === value)?.id; + } + + private extractYear(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value) && value >= 1900 && value <= 3000) { + return value; + } + + if (typeof value !== "string") { + return null; + } + + const match = value.match(/\d{4}/); + if (!match) { + return null; + } + + const year = Number(match[0]); + if (!Number.isInteger(year) || year < 1900 || year > 3000) { + return null; + } + + return year; + } + + disabled = false; + + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + this.cdr.markForCheck(); + } + + onChange: (value: string | number | null | boolean) => void = () => {}; + + registerOnChange(fn: any) { + this.onChange = fn; + } + + onTouched: () => void = () => {}; + + registerOnTouched(fn: any) { + this.onTouched = fn; + } + + /** Обработчик выбора опции */ + onUpdate(id: number): void { + if (this.disabled) return; + + this.selectedId = id; + this.onChange(this.getValue(id) ?? this.options[0].value); + + this.hideDropdown(); + } + + /** Получение текста метки по ID опции */ + getLabel(optionId: number): string | undefined { + return this.options.find(el => el.id === optionId)?.label; + } + + /** Получение значения по ID опции */ + getValue(optionId: number): string | number | null | undefined | boolean { + return this.options.find(el => el.id === optionId)?.value; + } + + /** Получение ID по тексту метки */ + getId(label: string): number | undefined { + return this.options.find(el => el.label === label)?.id; + } + + /** Скрытие выпадающего списка */ + hideDropdown() { + this.isOpen = false; + this.highlightedIndex = -1; + } + + /** Обработчик клика вне компонента */ + onClickOutside() { + this.hideDropdown(); + } +} diff --git a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.html b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.html similarity index 100% rename from projects/social_platform/src/app/office/shared/soon-card/soon-card.component.html rename to projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.html diff --git a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.scss b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/soon-card/soon-card.component.scss rename to projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.ts b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.ts new file mode 100644 index 000000000..b652febbb --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; +import { ButtonComponent } from "@ui/primitives"; +import { IconComponent } from "@uilib"; + +@Component({ + selector: "app-soon-card", + templateUrl: "./soon-card.component.html", + styleUrl: "./soon-card.component.scss", + imports: [CommonModule, IconComponent, ButtonComponent], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SoonCardComponent { + @Input({ required: true }) title!: string; + + @Input({ required: true }) description!: string; +} diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.html b/projects/social_platform/src/app/ui/primitives/switch/switch.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/switch/switch.component.html rename to projects/social_platform/src/app/ui/primitives/switch/switch.component.html diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.scss b/projects/social_platform/src/app/ui/primitives/switch/switch.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/switch/switch.component.scss rename to projects/social_platform/src/app/ui/primitives/switch/switch.component.scss diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.spec.ts b/projects/social_platform/src/app/ui/primitives/switch/switch.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/switch/switch.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/switch/switch.component.spec.ts diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.ts b/projects/social_platform/src/app/ui/primitives/switch/switch.component.ts similarity index 87% rename from projects/social_platform/src/app/ui/components/switch/switch.component.ts rename to projects/social_platform/src/app/ui/primitives/switch/switch.component.ts index e333af049..af981d158 100644 --- a/projects/social_platform/src/app/ui/components/switch/switch.component.ts +++ b/projects/social_platform/src/app/ui/primitives/switch/switch.component.ts @@ -1,6 +1,13 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from "@angular/core"; /** * Компонент переключателя (switch) для булевых значений. @@ -24,6 +31,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; templateUrl: "./switch.component.html", styleUrl: "./switch.component.scss", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SwitchComponent implements OnInit { /** Состояние переключателя */ diff --git a/projects/social_platform/src/app/ui/primitives/tag/tag.component.html b/projects/social_platform/src/app/ui/primitives/tag/tag.component.html new file mode 100644 index 000000000..8d10c906a --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tag/tag.component.html @@ -0,0 +1,31 @@ + + +
    +

    + +
    + @if (canEdit) { + + } @if (canDelete) { + + } +
    +
    diff --git a/projects/social_platform/src/app/ui/primitives/tag/tag.component.scss b/projects/social_platform/src/app/ui/primitives/tag/tag.component.scss new file mode 100644 index 000000000..89905e535 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tag/tag.component.scss @@ -0,0 +1,181 @@ +/** @format */ + +.tag { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 120px; + padding: 2px 20px; + overflow: hidden; + color: var(--accent); + text-overflow: ellipsis; + white-space: nowrap; + border-radius: var(--rounded-xxl); + + p { + margin: 0 10px; + } + + &--inline { + color: var(--white); + border: 0.5px solid transparent; + + &.tag--secondary { + background: var(--dark-grey); + border: 0.5px solid var(--grey-for-text); + } + + &.tag--accent { + color: var(--white); + background-color: var(--accent); + } + + &.tag--accent-medium { + color: var(--white); + background-color: var(--accent-medium); + } + + &.tag--blue-dark { + color: var(--white); + background-color: var(--blue-dark); + } + + &.tag--complete { + color: var(--white); + background-color: var(--green); + } + + &.tag--complete-dark { + color: var(--white); + background-color: var(--green-dark); + } + + &.tag--red { + color: var(--white); + background-color: var(--red); + } + + &.tag--cyan { + color: var(--white); + background-color: var(--cyan); + } + + &.tag--soft { + color: var(--light-white); + background: var(--gold); + border: transparent; + } + + &.tag--days { + color: var(--accent); + background-color: var(--light-white); + border: 0.5px solid var(--accent); + } + + &.tag--overdue { + color: var(--red); + background-color: var(--light-white); + border: 0.5px solid var(--red); + } + + &:not(:last-child) { + margin-right: 5px; + } + } + + &--outline { + color: var(--accent); + background: transparent; + border: 0.5px solid var(--accent); + + &.tag--primary { + color: var(--accent); + border: 0.5px solid var(--accent); + } + + &.tag--secondary { + color: var(--grey-for-text); + background: transparent; + border: 0.5px solid var(--dark-grey); + } + + &.tag--accent { + color: var(--accent); + background: transparent; + border: 0.5px solid var(--accent); + } + + &.tag--accent-medium { + color: var(--white); + background: transparent; + border: 0.5px solid var(--accent-medium); + } + + &.tag--blue-dark { + color: var(--white); + background: transparent; + border: 0.5px solid var(--blue-dark); + } + + &.tag--complete { + color: var(--green); + background: transparent; + border: 0.5px solid var(--green); + } + + &.tag--complete-dark { + color: var(--white); + background: transparent; + border: 0.5px solid var(--green-dark); + } + + &.tag--red { + color: var(--white); + background: transparent; + border: 0.5px solid var(--red); + } + + &.tag--cyan { + color: var(--white); + background: transparent; + border: 0.5px solid var(--cyan); + } + + &.tag--soft { + color: var(--gold); + background: transparent; + border: 0.5px solid var(--gold); + } + + &.tag--answer { + color: var(--grey-for-text); + background: transparent; + border: 0.5px solid var(--grey-for-text); + } + + &:not(:last-child) { + margin-right: 5px; + } + } + + &__icons { + position: absolute; + top: 30%; + right: 5%; + display: flex; + flex-shrink: 0; + gap: 3px; + align-items: center; + + i { + cursor: pointer; + opacity: 1; + + &:hover { + opacity: 0.7; + } + } + } +} diff --git a/projects/social_platform/src/app/ui/components/tag/tag.component.spec.ts b/projects/social_platform/src/app/ui/primitives/tag/tag.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/tag/tag.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/tag/tag.component.spec.ts diff --git a/projects/social_platform/src/app/ui/primitives/tag/tag.component.ts b/projects/social_platform/src/app/ui/primitives/tag/tag.component.ts new file mode 100644 index 000000000..98689e853 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tag/tag.component.ts @@ -0,0 +1,114 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, +} from "@angular/core"; +import { IconComponent } from "../icon/icon.component"; +import { tagColors } from "@core/consts/other/tag-colors.const"; +import { NgStyle } from "@angular/common"; + +/** + * Компонент тега для отображения статусов, категорий или меток. + * Поддерживает различные цветовые схемы для визуального разделения типов. + * + * Входящие параметры: + * - color: цветовая схема тега ("primary" | "accent" | "complete") + * + * Использование: + * - Отображение статусов задач, заказов + * - Категоризация контента + * - Визуальные метки и индикаторы + * - Контент передается через ng-content + */ +@Component({ + selector: "app-tag", + templateUrl: "./tag.component.html", + styleUrl: "./tag.component.scss", + standalone: true, + imports: [IconComponent, NgStyle], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TagComponent implements OnInit, OnChanges { + constructor() {} + + /** Цветовая схема тега */ + @Input() color: + | "primary" + | "secondary" + | "accent" + | "accent-medium" + | "blue-dark" + | "cyan" + | "red" + | "complete" + | "complete-dark" + | "soft" = "primary"; + + @Input() type?: "days" | "overdue" | "answer"; + + /** Стиль отображения */ + @Input() appearance: "inline" | "outline" = "inline"; + + /** Возможность редактирования */ + @Input() canEdit?: boolean; + + /** Возможность удаления */ + @Input() canDelete?: boolean; + + @Input() isKanbanTag = false; + + /** Событие для возможности удаления */ + @Output() delete = new EventEmitter(); + + /** Событие для возможности редактирования */ + @Output() edit = new EventEmitter(); + + get tagColors() { + return tagColors; + } + + additionalTagColor = ""; + + ngOnInit(): void { + this.mappingAdditionalTagColors(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes["color"]) { + this.mappingAdditionalTagColors(); + } + } + + /** Метод для вызова удаления элемента */ + onDelete(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.delete.emit(); + } + + /** Метод для вызова редактирования элемента */ + onEdit(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.edit.emit(); + } + + private mappingAdditionalTagColors(): void { + if (!this.isKanbanTag) { + this.additionalTagColor = ""; + return; + } + + const found = tagColors.find(tagColor => tagColor.name === this.color); + if (found) { + this.additionalTagColor = found.color; + } + } +} diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.html b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/textarea/textarea.component.html rename to projects/social_platform/src/app/ui/primitives/textarea/textarea.component.html diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.scss b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/textarea/textarea.component.scss rename to projects/social_platform/src/app/ui/primitives/textarea/textarea.component.scss diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.spec.ts b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/textarea/textarea.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/textarea/textarea.component.spec.ts diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.ts b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.ts similarity index 93% rename from projects/social_platform/src/app/ui/components/textarea/textarea.component.ts rename to projects/social_platform/src/app/ui/primitives/textarea/textarea.component.ts index 31afde32b..f3f2b9592 100644 --- a/projects/social_platform/src/app/ui/components/textarea/textarea.component.ts +++ b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.ts @@ -1,6 +1,16 @@ /** @format */ -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from "@angular/core"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + forwardRef, + inject, + Input, + OnInit, + Output, +} from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { IconComponent } from "@uilib"; import { AutosizeModule } from "ngx-autosize"; @@ -29,6 +39,7 @@ import { NgStyle } from "@angular/common"; selector: "app-textarea", templateUrl: "./textarea.component.html", styleUrl: "./textarea.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, @@ -40,6 +51,8 @@ import { NgStyle } from "@angular/common"; imports: [AutosizeModule, IconComponent, TooltipComponent, NgStyle], }) export class TextareaComponent implements OnInit, ControlValueAccessor { + private readonly cdr = inject(ChangeDetectorRef); + /** Текст подсказки */ @Input() placeholder = ""; @@ -124,6 +137,7 @@ export class TextareaComponent implements OnInit, ControlValueAccessor { // Методы ControlValueAccessor writeValue(value: string): void { this.value = value ?? ""; + this.cdr.markForCheck(); } onChange: (value: string) => void = () => {}; @@ -143,6 +157,7 @@ export class TextareaComponent implements OnInit, ControlValueAccessor { setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; + this.cdr.markForCheck(); } /** Предотвращение перехода на новую строку по Enter */ diff --git a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.html b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/tooltip/tooltip.component.html rename to projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.html diff --git a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.scss b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/tooltip/tooltip.component.scss rename to projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.scss diff --git a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.ts b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.ts similarity index 89% rename from projects/social_platform/src/app/ui/components/tooltip/tooltip.component.ts rename to projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.ts index db0c172bd..0c9016f88 100644 --- a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.ts +++ b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.ts @@ -1,8 +1,8 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { Component, Input, Output, EventEmitter } from "@angular/core"; -import { IconComponent } from "@ui/components"; +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from "@angular/core"; +import { IconComponent } from "@ui/primitives"; /** * Переиспользуемый компонент подсказки с иконкой @@ -25,6 +25,7 @@ import { IconComponent } from "@ui/components"; styleUrl: "./tooltip.component.scss", standalone: true, imports: [CommonModule, IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class TooltipComponent { /** Текст подсказки */ diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.html b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html similarity index 100% rename from projects/social_platform/src/app/ui/components/upload-file/upload-file.component.html rename to projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.scss b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/upload-file/upload-file.component.scss rename to projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.spec.ts b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.spec.ts similarity index 95% rename from projects/social_platform/src/app/ui/components/upload-file/upload-file.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.spec.ts index c00643ec0..a155d6fe9 100644 --- a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormsModule } from "@angular/forms"; import { UploadFileComponent } from "./upload-file.component"; -import { FileService } from "@core/services/file.service"; +import { FileService } from "projects/core/src/lib/services/file/file.service"; import { of } from "rxjs"; describe("UploadFileComponent", () => { diff --git a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts new file mode 100644 index 000000000..b134b68a4 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts @@ -0,0 +1,152 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + forwardRef, + inject, + Input, + OnInit, + Output, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { FileService } from "@core/lib/services/file/file.service"; +import { nanoid } from "nanoid"; +import { IconComponent } from "@ui/primitives"; +import { LoaderComponent } from "../loader/loader.component"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +/** + * Компонент для загрузки файлов с предварительным просмотром. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Поддерживает ограничения по типу файлов и показывает состояние загрузки. + * + * Входящие параметры: + * - accept: ограничения по типу файлов (MIME-типы) + * - error: состояние ошибки для стилизации + * + * Возвращает: + * - URL загруженного файла через ControlValueAccessor + * + * Функциональность: + * - Drag & drop и выбор файлов через диалог + * - Предварительный просмотр выбранного файла + * - Индикатор загрузки + * - Возможность удаления загруженного файла + */ +@Component({ + selector: "app-upload-file", + templateUrl: "./upload-file.component.html", + styleUrl: "./upload-file.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => UploadFileComponent), + multi: true, + }, + ], + standalone: true, + imports: [IconComponent, LoaderComponent], +}) +export class UploadFileComponent implements OnInit, ControlValueAccessor { + private readonly fileService = inject(FileService); + private readonly destroyRef = inject(DestroyRef); + private readonly cdr = inject(ChangeDetectorRef); + + /** Ограничения по типу файлов */ + @Input() accept = ""; + + /** Состояние ошибки */ + @Input() error = false; + + /** Режим: после загрузки сбросить в пустое состояние и не показывать "файл успешно загружен" */ + @Input() resetAfterUpload = false; + + /** Событие с данными загруженного файла (url + метаданные оригинального файла) */ + @Output() uploaded = new EventEmitter<{ + url: string; + name: string; + size: number; + mimeType: string; + }>(); + + ngOnInit(): void {} + + /** Уникальный ID для элемента input */ + controlId = nanoid(3); + + /** URL загруженного файла */ + value = ""; + + // Методы ControlValueAccessor + writeValue(url: string) { + this.value = url; + this.cdr.markForCheck(); + } + + onTouch: () => void = () => {}; + + registerOnTouched(fn: any) { + this.onTouch = fn; + } + + onChange: (url: string) => void = () => {}; + + registerOnChange(fn: any) { + this.onChange = fn; + } + + /** Состояние загрузки */ + loading = false; + + /** Обработчик загрузки файла */ + onUpdate(event: Event): void { + const input = event.currentTarget as HTMLInputElement; + const files = input.files; + if (!files?.length) { + return; + } + + const originalFile = files[0]; + this.loading = true; + this.cdr.markForCheck(); + + this.fileService + .uploadFile(files[0]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(res => { + this.loading = false; + + this.value = res.url; + this.onChange(res.url); + this.cdr.markForCheck(); + }); + } + + /** Обработчик удаления файла */ + onRemove(): void { + this.fileService + .deleteFile(this.value) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.value = ""; + + this.onTouch(); + this.onChange(""); + this.cdr.markForCheck(); + }, + error: () => { + this.value = ""; + + this.onTouch(); + this.onChange(""); + this.cdr.markForCheck(); + }, + }); + } +} diff --git a/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts b/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts new file mode 100644 index 000000000..24493d01b --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts @@ -0,0 +1,65 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { AuthComponent } from "../../pages/auth/auth.component"; +import { ResetPasswordComponent } from "@ui/pages/auth/reset-password/reset-password.component"; +import { SetPasswordComponent } from "@ui/pages/auth/set-password/set-password.component"; +import { ConfirmPasswordResetComponent } from "@ui/pages/auth/confirm-password-reset/confirm-password-reset.component"; +import { LoginComponent } from "@ui/pages/auth/login/login.component"; +import { RegisterComponent } from "@ui/pages/auth/register/register.component"; +import { EmailVerificationComponent } from "@ui/pages/auth/email-verification/email-verification.component"; +import { ConfirmEmailComponent } from "@ui/pages/auth/confirm-email/confirm-email.component"; + +/** + * Конфигурация маршрутов для модуля аутентификации + * + * Назначение: Определяет все маршруты для страниц аутентификации + * Принимает: Не принимает параметров + * Возвращает: Массив конфигураций маршрутов Angular + * + * Функциональность: + * - Настраивает маршруты для входа, регистрации, сброса пароля + * - Определяет дочерние маршруты для AuthComponent + * - Настраивает редиректы и компоненты для каждого пути + */ +export const AUTH_ROUTES: Routes = [ + { + path: "", + component: AuthComponent, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "login", + }, + { + path: "login", + component: LoginComponent, + }, + { + path: "register", + component: RegisterComponent, + }, + { + path: "verification/email", + component: EmailVerificationComponent, + }, + { + path: "reset_password/send_email", + component: ResetPasswordComponent, + }, + { + path: "reset_password", + component: SetPasswordComponent, + }, + { + path: "reset_password/confirm", + component: ConfirmPasswordResetComponent, + }, + ], + }, + { + path: "verification", + component: ConfirmEmailComponent, + }, +]; diff --git a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct.routes.ts b/projects/social_platform/src/app/ui/routes/chat/chat-direct.routes.ts similarity index 80% rename from projects/social_platform/src/app/office/chat/chat-direct/chat-direct.routes.ts rename to projects/social_platform/src/app/ui/routes/chat/chat-direct.routes.ts index 4e68005fb..4e9d09727 100644 --- a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct.routes.ts +++ b/projects/social_platform/src/app/ui/routes/chat/chat-direct.routes.ts @@ -1,8 +1,8 @@ /** @format */ import { Routes } from "@angular/router"; -import { ChatDirectComponent } from "@office/chat/chat-direct/chat-direct/chat-direct.component"; -import { ChatDirectResolver } from "@office/chat/chat-direct/chat-direct/chat-direct.resolver"; +import { ChatDirectComponent } from "@ui/pages/chat/chat-direct/chat-direct.component"; +import { ChatDirectResolver } from "@ui/pages/chat/chat-direct/chat-direct.resolver"; /** * Конфигурация маршрутов для модуля прямого чата diff --git a/projects/social_platform/src/app/ui/routes/chat/chat.routes.ts b/projects/social_platform/src/app/ui/routes/chat/chat.routes.ts new file mode 100644 index 000000000..5f10d6912 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/chat/chat.routes.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ChatGroupsResolver } from "@ui/pages/chat/chat-groups.resolver"; +import { ChatComponent } from "@ui/pages/chat/chat.component"; +import { ChatResolver } from "@ui/pages/chat/chat.resolver"; + +/** + * Маршруты для модуля чатов + * Определяет пути для прямых чатов, групповых чатов и конкретных диалогов + * + * Принимает: + * - URL пути чатов + * + * Возвращает: + * - Конфигурацию маршрутов с соответствующими резолверами + */ +export const CHAT_ROUTES: Routes = [ + { + path: "", + pathMatch: "full", + redirectTo: "directs", + }, + { + path: "directs", + component: ChatComponent, + resolve: { + data: ChatResolver, + }, + }, + { + path: "groups", + component: ChatComponent, + resolve: { + data: ChatGroupsResolver, + }, + }, + { + path: ":chatId", + loadChildren: () => import("../chat/chat-direct.routes").then(c => c.CHAT_DIRECT_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/courses/course-detail.routes.ts b/projects/social_platform/src/app/ui/routes/courses/course-detail.routes.ts new file mode 100644 index 000000000..42c7cc1e0 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/courses/course-detail.routes.ts @@ -0,0 +1,27 @@ +/** @format */ + +import type { Routes } from "@angular/router"; +import { TrajectoryInfoComponent } from "../../pages/courses/detail/info/info.component"; +import { CourseDetailComponent } from "../../pages/courses/detail/course-detail.component"; +import { CoursesDetailResolver } from "../../pages/courses/detail/course-detail.resolver"; + +export const COURSE_DETAIL_ROUTES: Routes = [ + { + path: "", + component: CourseDetailComponent, + runGuardsAndResolvers: "always", + resolve: { + data: CoursesDetailResolver, + }, + children: [ + { + path: "", + component: TrajectoryInfoComponent, + }, + { + path: "lesson", + loadChildren: () => import("./lesson.routes").then(m => m.LESSON_ROUTES), + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/courses/courses.routes.ts b/projects/social_platform/src/app/ui/routes/courses/courses.routes.ts new file mode 100644 index 000000000..b7ab38c47 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/courses/courses.routes.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { CoursesListComponent } from "../../pages/courses/list/list.component"; +import { CoursesComponent } from "../../pages/courses/courses.component"; +import { CoursesResolver } from "../../pages/courses/courses.resolver"; + +/** + * Конфигурация маршрутов для модуля карьерных траекторий + * Определяет структуру навигации: + * - "" - редирект на "all" + * - "all" - список всех доступных траекторий + * - ":courseId" - детальная информация о конкретном курсе + */ + +export const COURSES_ROUTES: Routes = [ + { + path: "", + component: CoursesComponent, + children: [ + { + path: "", + redirectTo: "all", + pathMatch: "full", + }, + { + path: "all", + component: CoursesListComponent, + resolve: { + data: CoursesResolver, + }, + }, + ], + }, + { + path: ":courseId", + loadChildren: () => import("./course-detail.routes").then(c => c.COURSE_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/courses/lesson.routes.ts b/projects/social_platform/src/app/ui/routes/courses/lesson.routes.ts new file mode 100644 index 000000000..4e12a791f --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/courses/lesson.routes.ts @@ -0,0 +1,30 @@ +/** @format */ + +import type { Routes } from "@angular/router"; +import { LessonComponent } from "../../pages/courses/lesson/lesson.component"; +import { TaskCompleteComponent } from "../../pages/courses/lesson/complete/complete.component"; +import { lessonDetailResolver } from "../../pages/courses/lesson/lesson.resolver"; + +/** + * Конфигурация маршрутов для модуля уроков + * Определяет структуру навигации и связывает компоненты с URL-путями + * + * Структура маршрутов: + * - /:lessonId - основной компонент урока + * - /results - компонент результатов выполнения урока + */ +export const LESSON_ROUTES: Routes = [ + { + path: ":lessonId", + component: LessonComponent, + resolve: { + data: lessonDetailResolver, + }, + children: [ + { + path: "results", + component: TaskCompleteComponent, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/error/error.routes.ts b/projects/social_platform/src/app/ui/routes/error/error.routes.ts similarity index 85% rename from projects/social_platform/src/app/error/error.routes.ts rename to projects/social_platform/src/app/ui/routes/error/error.routes.ts index 7d4a1c9c1..249c1063e 100644 --- a/projects/social_platform/src/app/error/error.routes.ts +++ b/projects/social_platform/src/app/ui/routes/error/error.routes.ts @@ -1,9 +1,9 @@ /** @format */ import { Routes } from "@angular/router"; -import { ErrorComponent } from "./error.component"; -import { ErrorCodeComponent } from "./code/error-code.component"; -import { ErrorNotFoundComponent } from "./not-found/error-not-found.component"; +import { ErrorCodeComponent } from "@ui/pages/error/error-code/error-code.component"; +import { ErrorComponent } from "@ui/pages/error/error.component"; +import { ErrorNotFoundComponent } from "@ui/pages/error/not-found/error-not-found.component"; /** * Конфигурация маршрутов для модуля ошибок diff --git a/projects/social_platform/src/app/office/feed/feed.routes.ts b/projects/social_platform/src/app/ui/routes/feed/feed.routes.ts similarity index 94% rename from projects/social_platform/src/app/office/feed/feed.routes.ts rename to projects/social_platform/src/app/ui/routes/feed/feed.routes.ts index c7047e725..9ffac054d 100644 --- a/projects/social_platform/src/app/office/feed/feed.routes.ts +++ b/projects/social_platform/src/app/ui/routes/feed/feed.routes.ts @@ -1,8 +1,8 @@ /** @format */ import { Routes } from "@angular/router"; -import { FeedComponent } from "@office/feed/feed.component"; -import { FeedResolver } from "@office/feed/feed.resolver"; +import { FeedComponent } from "@ui/pages/feed/feed.component"; +import { FeedResolver } from "@ui/pages/feed/feed.resolver"; /** * МАРШРУТЫ ДЛЯ МОДУЛЯ ЛЕНТЫ НОВОСТЕЙ diff --git a/projects/social_platform/src/app/ui/routes/kanban/kanban-detail.routes.ts b/projects/social_platform/src/app/ui/routes/kanban/kanban-detail.routes.ts new file mode 100644 index 000000000..b5a8c5f67 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/kanban/kanban-detail.routes.ts @@ -0,0 +1,17 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { KanbanArhiveComponent } from "@ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component"; +import { KanbanBoardComponent } from "@ui/pages/projects/detail/kanban/pages/board/kanban-board.component"; + +export const KANBAN_DETAIL_ROUTES: Routes = [ + { path: "", pathMatch: "full", redirectTo: "board" }, + { + path: "board", + component: KanbanBoardComponent, + }, + { + path: "archive", + component: KanbanArhiveComponent, + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/kanban/kanban.routes.ts b/projects/social_platform/src/app/ui/routes/kanban/kanban.routes.ts new file mode 100644 index 000000000..f8575dce1 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/kanban/kanban.routes.ts @@ -0,0 +1,10 @@ +/** @format */ + +import { Routes } from "@angular/router"; + +export const KANBAN_ROUTES: Routes = [ + { + path: ":kanbanId", + loadChildren: () => import("./kanban-detail.routes").then(c => c.KANBAN_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/office/office.routes.ts b/projects/social_platform/src/app/ui/routes/office/office.routes.ts new file mode 100644 index 000000000..703ae15e0 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/office/office.routes.ts @@ -0,0 +1,84 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { OfficeComponent } from "../../pages/office/office.component"; +import { ProfileEditComponent } from "../../pages/profile/edit/edit.component"; +import { OfficeResolver } from "../../pages/office/office.resolver"; +import { MembersComponent } from "@ui/pages/members/members.component"; +import { MembersResolver } from "@ui/pages/members/members.resolver"; + +/** + * Конфигурация маршрутов для модуля офиса + * Определяет все доступные пути и их компоненты в рабочем пространстве + * + * Принимает: + * - URL пути от роутера Angular + * + * Возвращает: + * - Конфигурацию маршрутов с ленивой загрузкой модулей + * - Резолверы для предзагрузки данных + */ +export const OFFICE_ROUTES: Routes = [ + { + path: "onboarding", + loadChildren: () => import("../onboarding/onboarding.routes").then(c => c.ONBOARDING_ROUTES), + }, + { + path: "", + component: OfficeComponent, + resolve: { + invites: OfficeResolver, + }, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "program", + }, + { + path: "feed", + loadChildren: () => import("../feed/feed.routes").then(c => c.FEED_ROUTES), + }, + { + path: "vacancies", + loadChildren: () => import("../vacancy/vacancies.routes").then(c => c.VACANCIES_ROUTES), + }, + { + path: "projects", + loadChildren: () => import("../projects/projects.routes").then(c => c.PROJECTS_ROUTES), + }, + { + path: "program", + loadChildren: () => import("../program/program.routes").then(c => c.PROGRAM_ROUTES), + }, + { + path: "chats", + loadChildren: () => import("../chat/chat.routes").then(c => c.CHAT_ROUTES), + }, + { + path: "courses", + loadChildren: () => import("../courses/courses.routes").then(c => c.COURSES_ROUTES), + }, + { + path: "members", + component: MembersComponent, + resolve: { + data: MembersResolver, + }, + }, + { + path: "profile/edit", + component: ProfileEditComponent, + }, + { + path: "profile/:id", + loadChildren: () => + import("../profile/profile-detail.routes").then(c => c.PROFILE_DETAIL_ROUTES), + }, + { + path: "**", + redirectTo: "/error/404", + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/onboarding/onboarding.routes.ts b/projects/social_platform/src/app/ui/routes/onboarding/onboarding.routes.ts new file mode 100644 index 000000000..d7196772e --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/onboarding/onboarding.routes.ts @@ -0,0 +1,61 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { StageOneResolver } from "../../pages/onboarding/stage-one/stage-one.resolver"; +import { OnboardingStageTwoComponent } from "../../pages/onboarding/stage-two/stage-two.component"; +import { StageTwoResolver } from "../../pages/onboarding/stage-two/stage-two.resolver"; +import { OnboardingComponent } from "@ui/pages/onboarding/onboarding.component"; +import { OnboardingStageZeroComponent } from "@ui/pages/onboarding/stage-zero/stage-zero.component"; +import { OnboardingStageOneComponent } from "@ui/pages/onboarding/stage-one/stage-one.component"; +import { OnboardingStageThreeComponent } from "@ui/pages/onboarding/stage-three/stage-three.component"; + +/** + * ФАЙЛ МАРШРУТИЗАЦИИ ОНБОРДИНГА + * + * Назначение: Определяет структуру маршрутов для процесса онбординга новых пользователей + * + * Что делает: + * - Настраивает иерархию маршрутов для 4 этапов онбординга (stage-0, stage-1, stage-2, stage-3) + * - Связывает каждый маршрут с соответствующим компонентом + * - Подключает резолверы для предзагрузки данных на этапах 1 и 2 + * + * Что принимает: Нет входных параметров (статическая конфигурация) + * + * Что возвращает: Массив Routes для Angular Router + * + * Структура этапов: + * - stage-0: Базовая информация профиля (фото, город, образование, опыт работы) + * - stage-1: Выбор специализации пользователя + * - stage-2: Выбор навыков пользователя + * - stage-3: Выбор типа пользователя (ментор/менти) + */ +export const ONBOARDING_ROUTES: Routes = [ + { + path: "", + component: OnboardingComponent, + children: [ + { + path: "stage-0", + component: OnboardingStageZeroComponent, + }, + { + path: "stage-1", + component: OnboardingStageOneComponent, + resolve: { + data: StageOneResolver, + }, + }, + { + path: "stage-2", + component: OnboardingStageTwoComponent, + resolve: { + data: StageTwoResolver, + }, + }, + { + path: "stage-3", + component: OnboardingStageThreeComponent, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts b/projects/social_platform/src/app/ui/routes/profile/profile-detail.routes.ts similarity index 76% rename from projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts rename to projects/social_platform/src/app/ui/routes/profile/profile-detail.routes.ts index c78a7a867..e11bb88cf 100644 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts +++ b/projects/social_platform/src/app/ui/routes/profile/profile-detail.routes.ts @@ -1,12 +1,11 @@ /** @format */ import { Routes } from "@angular/router"; -import { ProfileDetailResolver } from "./profile-detail.resolver"; -import { ProfileMainComponent } from "./main/main.component"; -import { ProfileProjectsComponent } from "./projects/projects.component"; -import { ProfileMainResolver } from "./main/main.resolver"; -import { ProfileNewsComponent } from "../profile-news/profile-news.component"; -import { DeatilComponent } from "@office/features/detail/detail.component"; +import { ProfileDetailResolver } from "../../pages/profile/detail/profile-detail.resolver"; +import { ProfileMainComponent } from "../../pages/profile/detail/main/main.component"; +import { ProfileMainResolver } from "../../pages/profile/detail/main/main.resolver"; +import { DeatilComponent } from "@ui/widgets/detail/detail.component"; +import { ProfileNewsComponent } from "@ui/pages/profile/detail/profile-news/profile-news.component"; /** * Конфигурация маршрутов для детального просмотра профиля пользователя @@ -42,10 +41,6 @@ export const PROFILE_DETAIL_ROUTES: Routes = [ data: ProfileMainResolver, }, }, - { - path: "projects", - component: ProfileProjectsComponent, - }, ], }, ]; diff --git a/projects/social_platform/src/app/ui/routes/program/detail.routes.ts b/projects/social_platform/src/app/ui/routes/program/detail.routes.ts new file mode 100644 index 000000000..756e269be --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/program/detail.routes.ts @@ -0,0 +1,69 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProgramListComponent } from "../../pages/program/detail/list/list.component"; +import { ProgramDetailResolver } from "../../pages/program/detail/detail.resolver"; +import { DeatilComponent } from "@ui/widgets/detail/detail.component"; +import { ProgramDetailMainComponent } from "@ui/pages/program/detail/main/main.component"; +import { ProgramProjectsResolver } from "@ui/pages/program/detail/list/projects.resolver"; +import { ProgramMembersResolver } from "@ui/pages/program/detail/list/members.resolver"; +import { ProgramRegisterComponent } from "@ui/pages/program/detail/register/register.component"; +import { ProgramRegisterResolver } from "@ui/pages/program/detail/register/register.resolver"; + +/** + * Маршруты для детальной страницы программы + * + * Определяет структуру навигации внутри детальной страницы программы: + * - Основная информация (по умолчанию) + * - Список проектов программы + * - Список участников программы + * - Страница регистрации в программе + * + * Все маршруты используют резолверы для предзагрузки данных. + * + * @returns {Routes} Конфигурация маршрутов для детальной страницы программы + */ +export const PROGRAM_DETAIL_ROUTES: Routes = [ + { + path: "", + component: DeatilComponent, + resolve: { + data: ProgramDetailResolver, + }, + data: { listType: "program" }, + children: [ + { + path: "", + component: ProgramDetailMainComponent, + }, + { + path: "projects", + component: ProgramListComponent, + resolve: { + data: ProgramProjectsResolver, + }, + data: { listType: "projects" }, + }, + { + path: "members", + component: ProgramListComponent, + resolve: { + data: ProgramMembersResolver, + }, + data: { listType: "members" }, + }, + { + path: "projects-rating", + component: ProgramListComponent, + data: { listType: "rating" }, + }, + ], + }, + { + path: "register", + component: ProgramRegisterComponent, + resolve: { + data: ProgramRegisterResolver, + }, + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/program/program.routes.ts b/projects/social_platform/src/app/ui/routes/program/program.routes.ts new file mode 100644 index 000000000..3219d6058 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/program/program.routes.ts @@ -0,0 +1,38 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProgramComponent } from "../../pages/program/program.component"; +import { ProgramMainComponent } from "../../pages/program/main/main.component"; +/** + * Конфигурация маршрутов для модуля "Программы" + * + * Описание маршрутов: + * - "" - корневой маршрут программ с дочерними маршрутами + * - "" - редирект на "/all" + * - "all" - список всех программ (данные загружает facade) + * - ":programId" - детальная страница программы (ленивая загрузка) + * - ":programId/projects-rating" - страница оценки проектов программы (ленивая загрузка) + * + * @returns {Routes} Массив конфигураций маршрутов для Angular Router + */ +export const PROGRAM_ROUTES: Routes = [ + { + path: "", + component: ProgramComponent, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "all", + }, + { + path: "all", + component: ProgramMainComponent, + }, + ], + }, + { + path: ":programId", + loadChildren: () => import("./detail.routes").then(c => c.PROGRAM_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/projects/detail.routes.ts b/projects/social_platform/src/app/ui/routes/projects/detail.routes.ts new file mode 100644 index 000000000..c52b29333 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/projects/detail.routes.ts @@ -0,0 +1,89 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProjectInfoComponent } from "../../pages/projects/detail/info/info.component"; +import { ProjectInfoResolver } from "../../pages/projects/detail/info/info.resolver"; +import { ProjectResponsesResolver } from "../../pages/projects/detail/work-section/responses.resolver"; +import { ProjectChatComponent } from "../../pages/projects/detail/chat/chat.component"; +import { ProjectTeamComponent } from "../../pages/projects/detail/team/team.component"; +import { ProjectVacanciesComponent } from "../../pages/projects/detail/vacancies/vacancies.component"; +import { DeatilComponent } from "@ui/widgets/detail/detail.component"; +import { ProjectWorkSectionComponent } from "../../pages/projects/detail/work-section/work-section.component"; +import { KanbanBoardResolver } from "../../pages/projects/detail/kanban/kanban.resolver"; +import { KanbanBoardGuard } from "../../../../../../core/src/lib/guards/kanban/kanban.guard"; +import { KanbanComponent } from "../../pages/projects/detail/kanban/kanban.component"; +import { ProjectDetailResolver } from "@ui/pages/projects/detail/detail.resolver"; +import { NewsDetailComponent } from "@ui/pages/projects/detail/news-detail/news-detail.component"; +import { NewsDetailResolver } from "@ui/pages/projects/detail/news-detail/news-detail.resolver"; +import { ProjectChatResolver } from "@ui/pages/projects/detail/chat/chat.resolver"; + +/** + * Конфигурация маршрутов для детального просмотра проекта + * + * Определяет: + * - Главный маршрут с резолвером для загрузки данных проекта + * - Дочерние маршруты для разных разделов проекта: + * - "" (пустой) - информация о проекте с возможностью просмотра новостей + * - "responses" - отклики на вакансии проекта + * - "chat" - чат проекта + * + * Каждый дочерний маршрут имеет свой резолвер для предзагрузки данных + */ +export const PROJECT_DETAIL_ROUTES: Routes = [ + { + path: "", + component: DeatilComponent, + resolve: { + data: ProjectDetailResolver, + }, + data: { listType: "project" }, + children: [ + { + path: "", + component: ProjectInfoComponent, + resolve: { + data: ProjectInfoResolver, + }, + children: [ + { + path: "news/:newsId", + component: NewsDetailComponent, + resolve: { + data: NewsDetailResolver, + }, + }, + ], + }, + { + path: "vacancies", + component: ProjectVacanciesComponent, + }, + { + path: "team", + component: ProjectTeamComponent, + }, + { + path: "work-section", + component: ProjectWorkSectionComponent, + resolve: { + data: ProjectResponsesResolver, + }, + }, + { + path: "kanban", + canActivate: [KanbanBoardGuard], + component: KanbanComponent, + resolve: { data: KanbanBoardResolver }, + loadChildren: () => import("../kanban/kanban.routes").then(c => c.KANBAN_ROUTES), + runGuardsAndResolvers: "always", + }, + { + path: "chat", + component: ProjectChatComponent, + resolve: { + data: ProjectChatResolver, + }, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/projects/projects.routes.ts b/projects/social_platform/src/app/ui/routes/projects/projects.routes.ts new file mode 100644 index 000000000..1b9880f02 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/projects/projects.routes.ts @@ -0,0 +1,101 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProjectsComponent } from "../../pages/projects/projects.component"; +import { ProjectsResolver } from "../../pages/projects/projects.resolver"; +import { ProjectsListComponent } from "../../pages/projects/list/list.component"; +import { ProjectsMyResolver } from "../../pages/projects/list/my.resolver"; +import { ProjectsAllResolver } from "../../pages/projects/list/all.resolver"; +import { ProjectEditComponent } from "../../pages/projects/edit/edit.component"; +import { ProjectEditResolver } from "../../pages/projects/edit/edit.resolver"; +import { ProjectsSubscriptionsResolver } from "../../pages/projects/list/subscriptions.resolver"; +import { ProjectEditRequiredGuard } from "../../../../../../core/src/lib/guards/projects-edit/projects-edit.guard"; +import { ProjectsInvitesResolver } from "../../pages/projects/list/invites.resolver"; +import { DashboardProjectsComponent } from "../../pages/projects/dashboard/dashboard.component"; + +/** + * Конфигурация маршрутов для модуля проектов + * + * Определяет структуру навигации: + * + * Основные маршруты: + * - '' (root) - ProjectsComponent с дочерними маршрутами: + * - 'my' - список собственных проектов + * - 'subscriptions' - список проектов по подписке + * - 'all' - список всех проектов + * - ':projectId/edit' - редактирование проекта + * - ':projectId' - детальная информация о проекте (lazy loading) + * + * Каждый маршрут имеет свой resolver для предварительной загрузки данных: + * - ProjectsResolver - загружает счетчики проектов + * - ProjectsMyResolver - загружает собственные проекты + * - ProjectsAllResolver - загружает все проекты + * - ProjectsSubscriptionsResolver - загружает проекты по подписке + * - ProjectEditResolver - загружает данные для редактирования + * + * Использует lazy loading для детальной информации о проекте + * для оптимизации загрузки приложения. + */ +export const PROJECTS_ROUTES: Routes = [ + { + path: "", + component: ProjectsComponent, + resolve: { + data: ProjectsInvitesResolver, + }, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "dashboard", + }, + { + path: "dashboard", + component: DashboardProjectsComponent, + resolve: { + data: ProjectsResolver, + }, + }, + { + path: "my", + component: ProjectsListComponent, + resolve: { + data: ProjectsMyResolver, + }, + }, + { + path: "subscriptions", + component: ProjectsListComponent, + resolve: { + data: ProjectsSubscriptionsResolver, + }, + }, + { + path: "invites", + component: ProjectsListComponent, + resolve: { + data: ProjectsInvitesResolver, + }, + }, + { + path: "all", + component: ProjectsListComponent, + resolve: { + data: ProjectsAllResolver, + }, + }, + ], + }, + { + path: ":projectId/edit", + component: ProjectEditComponent, + resolve: { + data: ProjectEditResolver, + }, + canActivate: [ProjectEditRequiredGuard], + }, + { + path: ":projectId", + loadChildren: () => import("./detail.routes").then(c => c.PROJECT_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/office/vacancies/list/list.routes.ts b/projects/social_platform/src/app/ui/routes/vacancy/list.routes.ts similarity index 88% rename from projects/social_platform/src/app/office/vacancies/list/list.routes.ts rename to projects/social_platform/src/app/ui/routes/vacancy/list.routes.ts index e3e6397c0..1d2d172a0 100644 --- a/projects/social_platform/src/app/office/vacancies/list/list.routes.ts +++ b/projects/social_platform/src/app/ui/routes/vacancy/list.routes.ts @@ -1,8 +1,8 @@ /** @format */ import { Routes } from "@angular/router"; -import { VacanciesListComponent } from "./list.component"; -import { VacanciesMyResolver } from "./my.resolver"; +import { VacanciesListComponent } from "@ui/pages/vacancies/list/list.component"; +import { VacanciesMyResolver } from "@ui/pages/vacancies/list/my.resolver"; /** * Конфигурация маршрутов для страницы "Мои отклики" diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts b/projects/social_platform/src/app/ui/routes/vacancy/vacancies-detail.routes.ts similarity index 81% rename from projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts rename to projects/social_platform/src/app/ui/routes/vacancy/vacancies-detail.routes.ts index f440a13d9..fc442763e 100644 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts +++ b/projects/social_platform/src/app/ui/routes/vacancy/vacancies-detail.routes.ts @@ -1,8 +1,8 @@ /** @format */ -import { VacancyInfoComponent } from "./info/info.component"; -import { VacanciesDetailComponent } from "./vacancies-detail.component"; -import { VacanciesDetailResolver } from "./vacancies-detail.resolver"; +import { VacancyInfoComponent } from "../../pages/vacancies/detail/info/info.component"; +import { VacanciesDetailComponent } from "../../pages/vacancies/detail/vacancies-detail.component"; +import { VacanciesDetailResolver } from "../../pages/vacancies/detail/vacancies-detail.resolver"; /** * Конфигурация маршрутов для детального просмотра вакансии diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.routes.ts b/projects/social_platform/src/app/ui/routes/vacancy/vacancies.routes.ts similarity index 76% rename from projects/social_platform/src/app/office/vacancies/vacancies.routes.ts rename to projects/social_platform/src/app/ui/routes/vacancy/vacancies.routes.ts index 912059fd2..63c5705fe 100644 --- a/projects/social_platform/src/app/office/vacancies/vacancies.routes.ts +++ b/projects/social_platform/src/app/ui/routes/vacancy/vacancies.routes.ts @@ -1,9 +1,9 @@ /** @format */ import { Routes } from "@angular/router"; -import { VacanciesComponent } from "./vacancies.component"; -import { VacanciesResolver } from "./vacancies.resolver"; -import { VacanciesListComponent } from "./list/list.component"; +import { VacanciesListComponent } from "@ui/pages/vacancies/list/list.component"; +import { VacanciesComponent } from "@ui/pages/vacancies/vacancies.component"; +import { VacanciesResolver } from "@ui/pages/vacancies/vacancies.resolver"; /** * Маршруты для модуля вакансий @@ -27,7 +27,7 @@ export const VACANCIES_ROUTES: Routes = [ }, { path: "my", - loadChildren: () => import("./list/list.routes").then(c => c.VACANCY_LIST_ROUTES), + loadChildren: () => import("./list.routes").then(c => c.VACANCY_LIST_ROUTES), }, { path: "all", @@ -40,7 +40,6 @@ export const VACANCIES_ROUTES: Routes = [ }, { path: ":vacancyId", - loadChildren: () => - import("./detail/vacancies-detail.routes").then(c => c.VACANCIES_DETAIL_ROUTES), + loadChildren: () => import("./vacancies-detail.routes").then(c => c.VACANCIES_DETAIL_ROUTES), }, ]; diff --git a/projects/social_platform/src/app/office/services/loading.service.ts b/projects/social_platform/src/app/ui/services/loading/loading.service.ts similarity index 100% rename from projects/social_platform/src/app/office/services/loading.service.ts rename to projects/social_platform/src/app/ui/services/loading/loading.service.ts diff --git a/projects/social_platform/src/app/office/services/nav.service.spec.ts b/projects/social_platform/src/app/ui/services/nav/nav.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/services/nav.service.spec.ts rename to projects/social_platform/src/app/ui/services/nav/nav.service.spec.ts diff --git a/projects/social_platform/src/app/office/services/nav.service.ts b/projects/social_platform/src/app/ui/services/nav/nav.service.ts similarity index 100% rename from projects/social_platform/src/app/office/services/nav.service.ts rename to projects/social_platform/src/app/ui/services/nav/nav.service.ts diff --git a/projects/social_platform/src/app/office/services/notification.service.spec.ts b/projects/social_platform/src/app/ui/services/notification/notification.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/services/notification.service.spec.ts rename to projects/social_platform/src/app/ui/services/notification/notification.service.spec.ts diff --git a/projects/social_platform/src/app/office/services/notification.service.ts b/projects/social_platform/src/app/ui/services/notification/notification.service.ts similarity index 96% rename from projects/social_platform/src/app/office/services/notification.service.ts rename to projects/social_platform/src/app/ui/services/notification/notification.service.ts index f784c7b6d..6d466aa0d 100644 --- a/projects/social_platform/src/app/office/services/notification.service.ts +++ b/projects/social_platform/src/app/ui/services/notification/notification.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject, map } from "rxjs"; -import { Notification } from "@models/notification.model"; +import { Notification } from "@domain/other/notification.model"; /** * Сервис для управления уведомлениями пользователя diff --git a/projects/social_platform/src/app/ui/models/snack.model.ts b/projects/social_platform/src/app/ui/services/snackbar/snack.model.ts similarity index 100% rename from projects/social_platform/src/app/ui/models/snack.model.ts rename to projects/social_platform/src/app/ui/services/snackbar/snack.model.ts diff --git a/projects/social_platform/src/app/ui/services/snackbar.service.spec.ts b/projects/social_platform/src/app/ui/services/snackbar/snackbar.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/services/snackbar.service.spec.ts rename to projects/social_platform/src/app/ui/services/snackbar/snackbar.service.spec.ts diff --git a/projects/social_platform/src/app/ui/services/snackbar.service.ts b/projects/social_platform/src/app/ui/services/snackbar/snackbar.service.ts similarity index 97% rename from projects/social_platform/src/app/ui/services/snackbar.service.ts rename to projects/social_platform/src/app/ui/services/snackbar/snackbar.service.ts index e8be8bb55..6d6ebf903 100644 --- a/projects/social_platform/src/app/ui/services/snackbar.service.ts +++ b/projects/social_platform/src/app/ui/services/snackbar/snackbar.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { distinctUntilChanged, Subject } from "rxjs"; -import { Snack } from "@ui/models/snack.model"; +import { Snack } from "@ui/services/snackbar/snack.model"; import { nanoid } from "nanoid"; /** diff --git a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.html b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.html similarity index 98% rename from projects/social_platform/src/app/ui/components/chat-message/chat-message.component.html rename to projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.html index f870f6f6d..25a16d5fb 100644 --- a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.html +++ b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.html @@ -58,7 +58,7 @@
    -@if (authService.profile | async; as profile) { +@if (authRepository.profile | async; as profile) {