diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index bbfe7a99d..f0d6f0e22 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -12,9 +12,9 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/checkout@v2 - - name: Use Node.js 16.x - uses: actions/setup-node@v2 + - uses: actions/checkout@v4 + - name: Use Node.js 18.x + uses: actions/setup-node@v4 with: node-version: 18.13 cache: "npm" diff --git a/projects/core/README.md b/projects/core/README.md index 0fec9d610..849dcd9be 100644 --- a/projects/core/README.md +++ b/projects/core/README.md @@ -1,27 +1,234 @@ -# Core +# Core Library Documentation -This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.0.0. +Основная библиотека Angular приложения, содержащая общие сервисы, модели, пайпы, интерцепторы и константы. -## Code scaffolding +## Структура проекта -Run `ng generate component component-name --project core` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project core`. +src/ +├── consts/ # Константы и списки данных +├── environments/ # Конфигурация окружения +├── lib/ +│ ├── interceptors/ # HTTP интерцепторы +│ ├── models/ # Модели данных +│ ├── pipes/ # Пайпы для трансформации данных +│ ├── providers/ # Провайдеры и токены +│ └── services/ # Сервисы +└── public-api.ts # Публичный API модуля -> Note: Don't forget to add `--project core` or else it will be added to the default project in your `angular.json` file. +## Основные компоненты -## Build +### 🔧 Сервисы (Services) -Run `ng build core` to build the project. The build artifacts will be stored in the `dist/` directory. +#### ApiService -## Publishing +Базовый сервис для работы с HTTP API. -After building your library with `ng build core`, go to the dist folder `cd dist/core` and run `npm publish`. +**Методы:** -## Running unit tests +- `get(path, params?, options?)` - GET запрос +- `post(path, body)` - POST запрос +- `put(path, body)` - PUT запрос +- `patch(path, body)` - PATCH запрос +- `delete(path, params?)` - DELETE запрос -Run `ng test core` to execute the unit tests via [Karma](https://karma-runner.github.io). +#### SkillsApiService -## Further help +Расширенный API сервис для работы с Skills API. -To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. +#### TokenService + +Управление JWT токенами аутентификации. + +**Методы:** + +- `getTokens()` - Получить токены из cookies +- `memTokens(tokens)` - Сохранить токены в cookies +- `clearTokens()` - Очистить токены +- `refreshTokens()` - Обновить токены + +#### ValidationService + +Сервис для валидации форм. + +**Валидаторы:** + +- `useMatchValidator(left, right)` - Проверка совпадения полей +- `useDateFormatValidator()` - Валидация формата даты +- `useAgeValidator(age)` - Проверка возраста +- `useLanguageValidator()` - Проверка русского языка +- `getFormValidation(form)` - Валидация всей формы + +#### YtExtractService + +Извлечение и обработка YouTube ссылок. + +#### SubscriptionPlansService + +Управление подписками пользователей. + +### 🔄 Интерцепторы (Interceptors) + +#### BearerTokenInterceptor + +Автоматически добавляет Bearer токен к HTTP запросам и обрабатывает обновление токенов при 401 ошибке. + +#### CamelcaseInterceptor + +Преобразует snake_case в camelCase для запросов и ответов API. + +### 🔧 Пайпы (Pipes) + +#### Валидация форм + +- `ControlErrorPipe` - Проверка ошибок в контролах форм +- `FormControlPipe` - Приведение AbstractControl к FormControl + +#### Работа с датами + +- `DayjsPipe` - Форматирование дат с помощью dayjs +- `YearsFromBirthdayPipe` - Вычисление возраста по дате рождения + +#### Форматирование текста + +- `ParseBreaksPipe` - Замена \n на
+- `ParseLinksPipe` - Преобразование ссылок в кликабельные +- `CapitalizePipe` - Капитализация первой буквы +- `PluralizePipe` - Склонение слов по числам (русский язык) + +#### Трансформация данных + +- `SalaryTransformPipe` - Форматирование зарплаты +- `LinkTransformPipe` - Извлечение домена из ссылки + +### 🎯 Провайдеры (Providers) + +#### API_URL + +Токен для инъекции базового URL API. + +#### SKILLS_API_URL + +Токен для инъекции URL Skills API. + +#### PRODUCTION + +Токен для определения production окружения. + +### 📋 Константы (Constants) + +#### Навигация + +- `navProjectItems` - Элементы навигации для проектов +- `navProfileItems` - Элементы навигации для профиля + +#### Списки данных + +- `directionProjectList` - Направления проектов +- `trackProjectList` - Треки проектов +- `experienceList` - Уровни опыта +- `formatList` - Форматы работы +- `scheludeList` - Графики работы +- `rolesMembersList` - Роли участников +- `languageNamesList` - Названия языков +- `languageLevelsList` - Уровни языков +- `educationUserType` - Типы образования +- `educationUserLevel` - Уровни образования +- `yearList` - Список годов +- `ratingFiltersList` - Фильтры рейтинга +- `filterTags` - Теги фильтров + +#### Профиль + +- `fieldsProfile` - Поля профиля пользователя +- `trajectoryMore` - Дополнительная информация о траектории + +## Использование + +### Настройка провайдеров + +\`\`\`typescript +providers: [ +{ provide: API_URL, useValue: 'https://api.example.com' }, +{ provide: SKILLS_API_URL, useValue: 'https://skills-api.example.com' }, +{ provide: PRODUCTION, useValue: environment.production } +] +\`\`\` + +### Примеры использования + +#### Работа с API + +\`\`\`typescript +constructor(private apiService: ApiService) {} + +loadData() { +return this.apiService.get('/users'); +} +\`\`\` + +#### Валидация форм + +\`\`\`typescript +constructor(private validationService: ValidationService) {} + +createForm() { +return this.fb.group({ +password: ['', Validators.required], +confirmPassword: ['', Validators.required] +}, { +validators: this.validationService.useMatchValidator('password', 'confirmPassword') +}); +} +\`\`\` + +#### Использование пайпов в шаблонах + +\`\`\`html + + +
+ Поле обязательно для заполнения +
+ + + +{{ user.createdAt | dayjs:'format':'DD.MM.YYYY' }} + + + +{{ count }} {{ count | pluralize:['проект', 'проекта', 'проектов'] }} +\`\`\` + +## Зависимости + +- `@angular/core` +- `@angular/common/http` +- `@angular/forms` +- `dayjs` - Работа с датами +- `js-cookie` - Управление cookies +- `class-transformer` - Трансформация объектов +- `linkify-string` - Обработка ссылок +- `snakecase-keys` - Преобразование ключей в snake_case +- `camelcase-keys` - Преобразование ключей в camelCase + +## Тестирование + +Все сервисы и пайпы покрыты unit тестами с использованием Jasmine и Karma. + +Запуск тестов: +\`\`\`bash +ng test core +\`\`\` + +## Сборка + +Для сборки библиотеки: +\`\`\`bash +ng build core +\`\`\` + +Для production сборки: +\`\`\`bash +ng build core --configuration production diff --git a/projects/core/src/consts/fieldsProfile.ts b/projects/core/src/consts/fieldsProfile.ts index 6de20cf14..b6875cd17 100644 --- a/projects/core/src/consts/fieldsProfile.ts +++ b/projects/core/src/consts/fieldsProfile.ts @@ -1,17 +1,25 @@ /** @format */ +/** + * Конфигурация полей профиля пользователя + * Определяет какие поля являются массивами, а какие строками + * Используется для валидации и обработки данных профиля + */ export const fieldsProfile = [ - { key: "education", type: "array" }, - { key: "workExperience", type: "array" }, - { key: "userLanguages", type: "array" }, - { key: "achievements", type: "array" }, - { key: "skills", type: "array" }, - { key: "birthday", type: "string" }, - { key: "phoneNumber", type: "string" }, - { key: "speciality", type: "string" }, - { key: "aboutMe", type: "string" }, - { key: "avatar", type: "string" }, - { key: "city", type: "string" }, - { key: "firstName", type: "string" }, - { key: "lastName", type: "string" }, + // Поля-массивы (содержат несколько элементов) + { key: "education", type: "array" }, // Образование + { key: "workExperience", type: "array" }, // Опыт работы + { key: "userLanguages", type: "array" }, // Языки пользователя + { key: "achievements", type: "array" }, // Достижения + { key: "skills", type: "array" }, // Навыки + + // Строковые поля (одиночные значения) + { key: "birthday", type: "string" }, // Дата рождения + { key: "phoneNumber", type: "string" }, // Номер телефона + { key: "speciality", type: "string" }, // Специальность + { key: "aboutMe", type: "string" }, // О себе + { key: "avatar", type: "string" }, // Аватар (URL) + { key: "city", type: "string" }, // Город + { key: "firstName", type: "string" }, // Имя + { key: "lastName", type: "string" }, // Фамилия ]; diff --git a/projects/core/src/consts/filter-experience.ts b/projects/core/src/consts/filter-experience.ts new file mode 100644 index 000000000..281b6bf83 --- /dev/null +++ b/projects/core/src/consts/filter-experience.ts @@ -0,0 +1,8 @@ +/** @format */ + +export const filterExperience = [ + { label: "Без опыта", value: "no_experience" }, + { label: "До 1 года", value: "up_to_a_year" }, + { label: "От 1 года до 3 лет", value: "from_one_to_three_years" }, + { label: "От 3 лет и более", value: "from_three_years" }, +]; diff --git a/projects/core/src/consts/filter-work-format.ts b/projects/core/src/consts/filter-work-format.ts new file mode 100644 index 000000000..6c0470259 --- /dev/null +++ b/projects/core/src/consts/filter-work-format.ts @@ -0,0 +1,7 @@ +/** @format */ + +export const filterWorkFormat = [ + { label: "Удаленная работа", value: "remote" }, + { label: "Работа в офисе", value: "office" }, + { label: "Смешанный формат", value: "hybrid" }, +]; diff --git a/projects/core/src/consts/filter-work-schedule.ts b/projects/core/src/consts/filter-work-schedule.ts new file mode 100644 index 000000000..950b9c844 --- /dev/null +++ b/projects/core/src/consts/filter-work-schedule.ts @@ -0,0 +1,9 @@ +/** @format */ + +export const filterWorkSchedule = [ + { label: "Полный рабочий день", value: "full_time" }, + { label: "Сменный график", value: "shift_work" }, + { label: "Гибкий график", value: "flexible_schedule" }, + { label: "Частичная занятость", value: "part_time" }, + { label: "Стажировка", value: "internship" }, +]; diff --git a/projects/core/src/consts/list-direction-project.ts b/projects/core/src/consts/list-direction-project.ts index 9631890b4..6a18d18c3 100644 --- a/projects/core/src/consts/list-direction-project.ts +++ b/projects/core/src/consts/list-direction-project.ts @@ -1,10 +1,14 @@ /** @format */ +/** + * Список направлений проектов + * Используется в формах создания и редактирования проектов + */ export const directionProjectList = [ { id: 0, - value: "Технология", - label: "Технология", + value: "Технология", // Значение для отправки на сервер + label: "Технология", // Отображаемый текст }, { id: 1, @@ -18,7 +22,7 @@ export const directionProjectList = [ }, { id: 3, - value: "им Био", + value: "им Био", // Возможно опечатка, должно быть "Хим Био" label: "Хим Био", }, { diff --git a/projects/core/src/consts/list-years.ts b/projects/core/src/consts/list-years.ts index d44a56860..6024d80ed 100644 --- a/projects/core/src/consts/list-years.ts +++ b/projects/core/src/consts/list-years.ts @@ -164,6 +164,6 @@ export const yearList = [ { value: 2025, id: 25, - label: "н.в", + label: "настоящее время", }, ]; diff --git a/projects/core/src/consts/navProjectItems.ts b/projects/core/src/consts/navProjectItems.ts index 789707ce4..f5b7cf52f 100644 --- a/projects/core/src/consts/navProjectItems.ts +++ b/projects/core/src/consts/navProjectItems.ts @@ -1,29 +1,40 @@ /** @format */ +import { EditStep } from "@office/projects/edit/services/project-step.service"; + +/** + * Элементы навигации для редактирования проекта + * Используется в компоненте пошагового редактирования проекта + */ export const navItems = [ { - step: "main", - src: "/assets/images/projects/edit/main.svg", - label: "Основные данные", + step: "main" as EditStep, // Идентификатор шага + src: "/assets/images/projects/edit/main.svg", // Путь к иконке + label: "Основные данные", // Отображаемый текст }, { - step: "contacts", + step: "contacts" as EditStep, src: "/assets/images/projects/edit/contacts.svg", label: "Контакты и ссылки", }, { - step: "achievements", + step: "achievements" as EditStep, src: "/assets/images/projects/edit/achievements.svg", label: "Достижения", }, { - step: "vacancies", + step: "vacancies" as EditStep, src: "/assets/images/projects/edit/vacancies.svg", label: "Вакансии", }, { - step: "team", + step: "team" as EditStep, src: "/assets/images/projects/edit/team.svg", label: "Команда", }, + { + step: "additional" as EditStep, + src: "/assets/images/projects/edit/additional.svg", + label: "Доп. сведения", + }, ]; diff --git a/projects/core/src/consts/note-list.ts b/projects/core/src/consts/note-list.ts new file mode 100644 index 000000000..d1cdf33a4 --- /dev/null +++ b/projects/core/src/consts/note-list.ts @@ -0,0 +1,34 @@ +/** @format */ + +export const noteList = [ + { + text: "Проверьте описание вакансии.", + }, + { + text: "Адаптируйте резюме.", + }, + { + text: "Напишите сопроводительное письмо.", + }, + { + text: "Проверьте грамматику и орфографию.", + }, + { + text: "Убедитесь в правильности контактной информации.", + }, + { + text: "Подготовьте дополнительные документы.", + }, + { + text: "Проверьте форматирование.", + }, + { + text: "Убедитесь в соблюдении сроков.", + }, + { + text: "Сохраните копию.", + }, + { + text: "Будьте готовы к интервью.", + }, +]; diff --git a/projects/core/src/lib/interceptors/bearer-token.interceptor.ts b/projects/core/src/lib/interceptors/bearer-token.interceptor.ts index c34d19442..032f1d701 100644 --- a/projects/core/src/lib/interceptors/bearer-token.interceptor.ts +++ b/projects/core/src/lib/interceptors/bearer-token.interceptor.ts @@ -9,48 +9,85 @@ import { HttpRequest, } from "@angular/common/http"; import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from "rxjs"; -import { AuthService } from "@auth/services"; import { Router } from "@angular/router"; import { TokenService } from "../services"; +/** + * HTTP интерцептор для автоматического управления JWT токенами + * + * Основные функции: + * 1. Автоматически добавляет Bearer токен к исходящим HTTP запросам + * 2. Перехватывает 401 ошибки и автоматически обновляет токены + * 3. Повторяет неудачные запросы с новым токеном + * 4. Предотвращает множественные одновременные запросы на обновление токена + * 5. Перенаправляет на страницу логина при невозможности обновить токен + * + * Алгоритм работы: + * - Если есть токены → добавляет Authorization header + * - При 401 ошибке → пытается обновить токен + * - При успешном обновлении → повторяет исходный запрос + * - При неудаче обновления → перенаправляет на /auth/login + */ @Injectable() export class BearerTokenInterceptor implements HttpInterceptor { constructor(private readonly tokenService: TokenService, private readonly router: Router) {} + /** Флаг предотвращения множественных запросов на обновление токена */ private isRefreshing = false; + + /** Subject для уведомления ожидающих запросов о получении нового токена */ private refreshTokenSubject = new BehaviorSubject(null); + /** + * Основной метод интерцептора + * @param request - Исходящий HTTP запрос + * @param next - Следующий обработчик в цепочке + * @returns Observable с HTTP событием + */ intercept(request: HttpRequest, next: HttpHandler): Observable> { + // Базовые заголовки для всех запросов const headers: Record = { Accept: "application/json", }; + const tokens = this.tokenService.getTokens(); + // Добавляем Authorization header если токены доступны if (tokens !== null) { - // eslint-disable-next-line headers["Authorization"] = `Bearer ${tokens.access}`; } const req = request.clone({ setHeaders: headers }); + // Если токены есть, обрабатываем запрос с возможностью обновления токенов if (tokens !== null) { return this.handleRequestWithTokens(req, next); } else { + // Если токенов нет, просто выполняем запрос return next.handle(request); } } + /** + * Обрабатывает запросы с токенами и перехватывает ошибки аутентификации + * @param request - HTTP запрос с токеном + * @param next - Следующий обработчик + * @returns Observable с результатом запроса или обработкой ошибки + */ private handleRequestWithTokens( request: HttpRequest, next: HttpHandler ): Observable> { return next.handle(request).pipe( catchError((error: HttpErrorResponse) => { + // Если 401 ошибка на refresh endpoint - токен refresh недействителен if (error.status === 401 && request.url.includes("/api/token/refresh")) { this.router .navigateByUrl("/auth/login") - .then(() => console.debug("Route changed from BearerTokenInterceptor")); - } else if (error.status === 401 && !request.url.includes("/api/token/refresh")) { + .then(() => console.debug("Redirected to login: refresh token expired")); + } + // Если 401 на другом endpoint - пытаемся обновить токен + else if (error.status === 401 && !request.url.includes("/api/token/refresh")) { return this.handle401(request, next); } @@ -59,33 +96,45 @@ export class BearerTokenInterceptor implements HttpInterceptor { ); } + /** + * Обрабатывает 401 ошибку - обновляет токен и повторяет запрос + * Использует механизм блокировки для предотвращения множественных запросов обновления + * @param request - Исходный запрос, который вернул 401 + * @param next - Следующий обработчик + * @returns Observable с повторным запросом или ошибкой + */ private handle401( request: HttpRequest, next: HttpHandler ): Observable> { + // Если токен еще не обновляется, начинаем процесс обновления if (!this.isRefreshing) { this.isRefreshing = true; - this.refreshTokenSubject.next(null); + this.refreshTokenSubject.next(null); // Сбрасываем subject return this.tokenService.refreshTokens().pipe( catchError(err => { + this.isRefreshing = false; return throwError(err); }), switchMap(res => { this.isRefreshing = false; - this.refreshTokenSubject.next(res.access); + this.refreshTokenSubject.next(res.access); // Уведомляем о новом токене + // Сохраняем новые токены в хранилище this.tokenService.memTokens(res); + + // Подготавливаем заголовки с новым токеном const headers: Record = { Accept: "application/json", }; const tokens = this.tokenService.getTokens(); - if (tokens) { headers["Authorization"] = `Bearer ${tokens.access}`; } + // Повторяем исходный запрос с новым токеном return next.handle( request.clone({ setHeaders: headers, @@ -95,10 +144,17 @@ export class BearerTokenInterceptor implements HttpInterceptor { ); } + // Если токен уже обновляется, ждем завершения процесса return this.refreshTokenSubject.pipe( - filter(t => t !== null), - take(1), - switchMap(t => next.handle(request.clone({ setHeaders: { Authorization: `Bearer ${t}` } }))) + filter(token => token !== null), // Ждем получения нового токена + take(1), // Берем только первое значение + switchMap(token => + next.handle( + request.clone({ + setHeaders: { Authorization: `Bearer ${token}` }, + }) + ) + ) ); } } diff --git a/projects/core/src/lib/interceptors/camelcase.interceptor.ts b/projects/core/src/lib/interceptors/camelcase.interceptor.ts index 4f423bf5c..cc1e0b672 100644 --- a/projects/core/src/lib/interceptors/camelcase.interceptor.ts +++ b/projects/core/src/lib/interceptors/camelcase.interceptor.ts @@ -2,41 +2,76 @@ import { Injectable } from "@angular/core"; import { - HttpEvent, - HttpHandler, - HttpInterceptor, - HttpRequest, + type HttpEvent, + type HttpHandler, + type HttpInterceptor, + type HttpRequest, HttpResponse, } from "@angular/common/http"; -import { map, Observable } from "rxjs"; +import { map, type Observable } from "rxjs"; import * as snakecaseKeys from "snakecase-keys"; import camelcaseKeys from "camelcase-keys"; +/** + * HTTP интерцептор для автоматического преобразования стиля именования ключей объектов + * + * Назначение: + * - Обеспечивает совместимость между frontend (camelCase) и backend (snake_case) + * - Автоматически преобразует все ключи объектов в запросах и ответах + * - Работает рекурсивно с вложенными объектами (deep: true) + * + * Преобразования: + * 1. Исходящие запросы: camelCase → snake_case + * Пример: { firstName: "John" } → { first_name: "John" } + * + * 2. Входящие ответы: snake_case → camelCase + * Пример: { first_name: "John" } → { firstName: "John" } + * + * Применяется ко всем HTTP запросам автоматически + */ @Injectable() export class CamelcaseInterceptor implements HttpInterceptor { constructor() {} + /** + * Основной метод интерцептора + * @param request - Исходящий HTTP запрос (может содержать тело с camelCase ключами) + * @param next - Следующий обработчик в цепочке интерцепторов + * @returns Observable с HTTP событием (ответ будет содержать camelCase ключи) + */ intercept( request: HttpRequest>, next: HttpHandler ): Observable> { let req: HttpRequest>; + + // Обрабатываем тело запроса если оно существует if (request.body) { + // Клонируем запрос с преобразованным телом (camelCase → snake_case) req = request.clone({ - body: snakecaseKeys(request.body, { deep: true }), + body: snakecaseKeys(request.body, { + deep: true, // Рекурсивное преобразование вложенных объектов + }), }); } else { + // Если тела нет, просто клонируем запрос req = request.clone(); } + // Выполняем запрос и обрабатываем ответ return next.handle(req).pipe( map((event: HttpEvent) => { + // Обрабатываем только HTTP ответы (не события загрузки и т.д.) if (event instanceof HttpResponse) { + // Клонируем ответ с преобразованным телом (snake_case → camelCase) return event.clone({ - body: camelcaseKeys(event.body, { deep: true }), + body: camelcaseKeys(event.body, { + deep: true, // Рекурсивное преобразование вложенных объектов + }), }); } + // Для других типов событий возвращаем как есть return event; }) ); diff --git a/projects/core/src/lib/models/subscription.model.ts b/projects/core/src/lib/models/subscription.model.ts index e28d9efd8..1d80fbc5d 100644 --- a/projects/core/src/lib/models/subscription.model.ts +++ b/projects/core/src/lib/models/subscription.model.ts @@ -1,37 +1,110 @@ /** @format */ +/** + * Модель плана подписки + * + * Представляет отдельный план подписки с его характеристиками и состоянием + * Используется для отображения доступных планов и управления подписками + */ export interface SubscriptionPlan { + /** Уникальный идентификатор плана */ id: number; + + /** Название плана (например, "Базовый", "Премиум", "Профессиональный") */ name: string; + + /** Стоимость подписки (в рублях или другой валюте) */ price: number; + + /** Список возможностей/функций, включенных в план */ featuresList: string[]; + + /** Флаг активности плана (используется ли план в данный момент пользователем) */ active: boolean; + + /** Флаг доступности плана для покупки (может ли пользователь купить этот план) */ available: boolean; } +/** + * Модель данных о подписке пользователя + * + * Содержит полную информацию о текущем состоянии подписки пользователя, + * включая историю и настройки автоплатежей + */ export interface SubscriptionData { + /** Флаг наличия активной подписки у пользователя */ isSubscribed: boolean; + + /** + * Информация о последнем типе подписки + * null если пользователь никогда не имел подписки + */ lastSubscriptionType: null | SubscriptionPlan; + + /** + * Дата начала последней подписки в ISO формате + * Используется для отображения истории подписок + */ lastSubscriptionDate: string; + + /** + * Дата окончания подписки в ISO формате + * Показывает когда истекает или истекла подписка + */ subscriptionDateOver: string; + + /** + * Флаг разрешения автоматических платежей + * Определяет, будет ли подписка продлеваться автоматически + */ isAutopayAllowed: boolean; } +/** + * Модель статуса платежа + * + * Представляет информацию о платеже в платежной системе (например, ЮKassa) + * Используется для отслеживания состояния оплаты подписки + */ export interface PaymentStatus { + /** Уникальный идентификатор платежа в платежной системе */ id: string; + + /** + * Статус платежа (например, "pending", "succeeded", "canceled") + * Определяет текущее состояние платежа + */ status: string; + + /** Информация о сумме платежа */ amount: { + /** Валюта платежа (например, "RUB") */ currency: string; + /** Сумма в строковом формате (например, "1000.00") */ value: string; }; - createdAt: string; // ISO 8601 formatted date-time string + + /** Дата и время создания платежа в ISO 8601 формате */ + createdAt: string; + + /** Информация для подтверждения платежа */ confirmation: { + /** URL для перенаправления пользователя на страницу оплаты */ confirmationUrl: string; + /** Тип подтверждения (например, "redirect") */ type: string; }; + + /** Флаг успешной оплаты */ paid: boolean; + + /** Флаг тестового платежа (для разработки и тестирования) */ test: boolean; + + /** Дополнительные метаданные платежа */ metadata: { + /** ID профиля пользователя, связанного с платежом */ userProfileId: string; }; } diff --git a/projects/core/src/lib/pipes/capitalize.pipe.ts b/projects/core/src/lib/pipes/capitalize.pipe.ts index e00b6d7a2..bfdb9fbe5 100644 --- a/projects/core/src/lib/pipes/capitalize.pipe.ts +++ b/projects/core/src/lib/pipes/capitalize.pipe.ts @@ -2,12 +2,64 @@ import { Pipe } from "@angular/core"; +/** + * Пайп для капитализации первой буквы строки + * + * Назначение: + * - Преобразует первую букву строки в заглавную + * - Оставляет остальные символы без изменений + * - Полезен для форматирования имен, заголовков, начала предложений + * + * Применение: + * - Форматирование имен пользователей + * - Капитализация начала предложений + * - Стандартизация отображения текста + * - Обработка пользовательского ввода + */ @Pipe({ name: "capitalize", standalone: true, }) export class CapitalizePipe { + /** + * Делает первую букву строки заглавной + * @param value - Исходная строка для обработки + * @returns Строка с заглавной первой буквой + * + * Алгоритм: + * 1. Берет первый символ строки и преобразует в верхний регистр + * 2. Добавляет остальную часть строки начиная со второго символа + * 3. Если строка пустая, возвращает пустую строку + * + * Примеры использования в шаблонах: + * + * + * {{ user.firstName | capitalize }} + * + * + *
  • + * {{ item.name | capitalize }} + *
  • + * + * + *

    {{ section.title | capitalize }}

    + * + * Примеры результатов: + * - "john" → "John" + * - "MARY" → "MARY" (остальные буквы не изменяются) + * - "hello world" → "Hello world" + * - "123abc" → "123abc" (цифра остается цифрой) + * - "" → "" (пустая строка) + * - "a" → "A" (одна буква) + * + * Особенности: + * - Не изменяет регистр остальных символов + * - Работает с любыми символами (буквы, цифры, знаки) + * - Безопасно обрабатывает пустые строки + * - Не выполняет проверку на тип данных (ожидает строку) + */ transform(value: string): string { + // Берем первый символ и делаем заглавным + остальная часть строки return value.charAt(0).toUpperCase() + value.slice(1); } } diff --git a/projects/core/src/lib/pipes/control-error.pipe.ts b/projects/core/src/lib/pipes/control-error.pipe.ts index 85d782272..4ef754433 100644 --- a/projects/core/src/lib/pipes/control-error.pipe.ts +++ b/projects/core/src/lib/pipes/control-error.pipe.ts @@ -1,22 +1,80 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; -import { AbstractControl, ValidationErrors } from "@angular/forms"; +import { Pipe, type PipeTransform } from "@angular/core"; +import type { AbstractControl, ValidationErrors } from "@angular/forms"; +/** + * Пайп для проверки ошибок валидации в контролах Angular Forms + * + * Назначение: + * - Определяет, нужно ли показывать ошибку валидации в UI + * - Проверяет состояние контрола (touched) и наличие ошибок + * - Поддерживает проверку как общих ошибок, так и конкретных типов ошибок + * + * Логика отображения ошибок: + * - Ошибка показывается только если контрол был "затронут" пользователем (touched) + * - И при этом контрол содержит ошибки валидации (invalid) + * + * Применение в шаблонах: + * - Условное отображение сообщений об ошибках + * - Стилизация невалидных полей + * - Показ конкретных типов ошибок + */ @Pipe({ name: "controlError", /** - * Otherwise, don't work + * pure: false - пайп пересчитывается при каждом change detection + * Необходимо для корректной работы с динамическим состоянием форм + * (touched, dirty, errors могут изменяться без изменения самого контрола) */ pure: false, standalone: true, }) export class ControlErrorPipe implements PipeTransform { + /** + * Проверяет, нужно ли отображать ошибку для контрола + * @param value - AbstractControl (FormControl, FormGroup, FormArray) + * @param errorName - Имя конкретной ошибки для проверки (опционально) + * @returns true если ошибку нужно показать, false если нет + * + * Режимы работы: + * + * 1. Без errorName - проверка общей валидности: + * Возвращает true если контрол touched И invalid + * + * 2. С errorName - проверка конкретной ошибки: + * Возвращает true если контрол touched И содержит указанную ошибку + * + * Примеры использования в шаблоне: + * + * + *
    + * Поле содержит ошибки + *
    + * + * + *
    + * Поле обязательно для заполнения + *
    + * + *
    + * Введите корректный email + *
    + * + * + * + */ transform(value: AbstractControl, errorName?: keyof ValidationErrors): boolean { if (!errorName) { + // Режим 1: Проверка общей валидности контрола + // Показываем ошибку если контрол был затронут И невалиден return value.touched && value.invalid; } + // Режим 2: Проверка конкретной ошибки + // Показываем ошибку если контрол был затронут И содержит указанную ошибку return value.touched && (value.errors ? value.errors[errorName] : false); } } diff --git a/projects/core/src/lib/pipes/dayjs.pipe.ts b/projects/core/src/lib/pipes/dayjs.pipe.ts index a5be7a9cc..8f82d96ca 100644 --- a/projects/core/src/lib/pipes/dayjs.pipe.ts +++ b/projects/core/src/lib/pipes/dayjs.pipe.ts @@ -1,38 +1,119 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; import * as dayjs from "dayjs"; import * as relativeTime from "dayjs/plugin/relativeTime"; import * as isToday from "dayjs/plugin/isToday"; import "dayjs/locale/ru"; +// Подключаем необходимые плагины dayjs dayjs.extend(relativeTime); dayjs.extend(isToday); +/** + * Пайп для работы с датами используя библиотеку dayjs + * + * Возможности: + * - Форматирование дат в различных форматах + * - Вычисление относительного времени ("2 часа назад", "через 3 дня") + * - Вычисление разности между датами + * - Проверка специальных условий (сегодняшняя дата) + * - Поддержка русской локализации + * + * Преимущества перед стандартным DatePipe: + * - Более гибкое API для работы с относительным временем + * - Лучшая поддержка локализации + * - Дополнительные утилиты для работы с датами + */ @Pipe({ name: "dayjs", standalone: true, }) export class DayjsPipe implements PipeTransform { constructor() { + // Устанавливаем русскую локаль для всех операций dayjs.locale("ru"); } + /** + * Трансформирует дату в зависимости от типа операции + * @param value - Значение даты (строка, Date, dayjs объект) + * @param type - Тип операции для выполнения + * @param options - Дополнительные параметры (например, формат для format) + * @returns Результат операции (строка, число или boolean) + * + * Поддерживаемые типы операций: + * + * 1. "toX" - время ДО указанной даты (без "назад"/"через") + * Пример: "2 часа", "3 дня" + * + * 2. "fromX" - время С указанной даты (без "назад"/"через") + * Пример: "2 часа", "3 дня" + * + * 3. "diffDay" - разность в днях между датой и текущим моментом + * Возвращает число (положительное если дата в будущем) + * + * 4. "diffHour" - разность в часах между датой и текущим моментом + * Возвращает число (положительное если дата в будущем) + * + * 5. "isToday" - проверка, является ли дата сегодняшней + * Возвращает boolean + * + * 6. "format" - форматирование даты по указанному шаблону + * Требует параметр options с форматом + * + * Примеры использования в шаблонах: + * + * + * Создано {{ post.createdAt | dayjs:'fromX' }} назад + * + * + * + * {{ user.birthday | dayjs:'format':'DD.MM.YYYY' }} + * + * + * + * Сегодня + * + * + * Осталось {{ deadline | dayjs:'diffDay' }} дней + */ transform(value: any, type: string, options?: any): string | number | boolean { - if (type === "toX") { - return dayjs().to(dayjs(value), true); - } else if (type === "diffDay") { - return dayjs(value).diff(dayjs(), "day"); - } else if (type === "diffHour") { - return dayjs(value).diff(dayjs(), "hour"); - } else if (type === "isToday") { - return dayjs(value).isToday(); - } else if (type === "fromX") { - return dayjs(value).from(dayjs(), true); - } else if (type === "format") { - return dayjs(value).format(options); - } else { - throw new Error(`Invalid action type specified: ${type}`); + switch (type) { + case "toX": + // Время до указанной даты (например, "через 2 часа") + // true в конце убирает префикс "через"/"назад" + return dayjs().to(dayjs(value), true); + + case "diffDay": + // Разница в днях между датой и текущим моментом + // Положительное число = дата в будущем + // Отрицательное число = дата в прошлом + return dayjs(value).diff(dayjs(), "day"); + + case "diffHour": + // Разница в часах между датой и текущим моментом + return dayjs(value).diff(dayjs(), "hour"); + + case "isToday": + // Проверка, является ли дата сегодняшней + // Сравнивает только дату, игнорируя время + return dayjs(value).isToday(); + + case "fromX": + // Время с указанной даты (например, "2 часа назад") + // true в конце убирает префикс "назад" + return dayjs(value).from(dayjs(), true); + + case "format": + // Форматирование даты согласно переданному формату + // options должен содержать строку формата + // Примеры форматов: 'DD.MM.YYYY', 'HH:mm', 'DD MMMM YYYY' + return dayjs(value).format(options); + + default: + // Неизвестный тип операции + throw new Error(`Invalid action type specified: ${type}`); } } } diff --git a/projects/core/src/lib/pipes/form-control.pipe.ts b/projects/core/src/lib/pipes/form-control.pipe.ts index 4ecd8c793..3602f9789 100644 --- a/projects/core/src/lib/pipes/form-control.pipe.ts +++ b/projects/core/src/lib/pipes/form-control.pipe.ts @@ -1,13 +1,64 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; -import { AbstractControl, FormControl } from "@angular/forms"; +import { Pipe, type PipeTransform } from "@angular/core"; +import type { AbstractControl, FormControl } from "@angular/forms"; +/** + * Пайп для приведения типа AbstractControl к FormControl + * + * Назначение: + * - Решает проблему типизации в Angular Forms + * - Позволяет использовать специфичные методы FormControl в шаблонах + * - Устраняет необходимость в type assertion в компонентах + * + * Проблема: + * FormGroup.get() возвращает AbstractControl | null, но часто мы знаем, + * что это именно FormControl и хотим использовать его специфичные свойства + * + * Решение: + * Пайп безопасно приводит AbstractControl к FormControl для использования в шаблонах + * + * Применение: + * - Доступ к свойствам FormControl (value, valueChanges) + * - Использование методов FormControl (setValue, patchValue) + * - Типобезопасная работа с контролами в шаблонах + */ @Pipe({ name: "formControl", standalone: true, }) export class FormControlPipe implements PipeTransform { + /** + * Приводит AbstractControl к типу FormControl + * @param value - AbstractControl для приведения типа + * @returns FormControl (приведенный тип) + * + * Важно: Этот пайп не выполняет проверку типа во время выполнения! + * Он только сообщает TypeScript, что объект является FormControl. + * Убедитесь, что передаваемый контрол действительно является FormControl. + * + * Примеры использования в шаблонах: + * + * + *
    Текущее значение: {{ (form.get('email') | formControl).value }}
    + * + * + *
    + * Email изменился: {{ emailValue }} + *
    + * + * + * + * + * Альтернативный подход в компоненте: + * get emailControl() { + * return this.form.get('email') as FormControl; + * } + * + * Но пайп удобнее для одноразового использования в шаблонах. + */ transform(value: AbstractControl): FormControl { return value as FormControl; } diff --git a/projects/core/src/lib/pipes/link-transform.pipe.ts b/projects/core/src/lib/pipes/link-transform.pipe.ts index 0915785e0..e999a3ff2 100644 --- a/projects/core/src/lib/pipes/link-transform.pipe.ts +++ b/projects/core/src/lib/pipes/link-transform.pipe.ts @@ -1,31 +1,107 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; +/** + * Пайп для извлечения доменного имени из URL + * + * Назначение: + * - Извлекает основное доменное имя из полного URL + * - Убирает протокол (https://) и поддомены (www.) + * - Возвращает только основную часть домена для отображения + * + * Применение: + * - Отображение кратких названий ссылок + * - Показ источника ссылки без полного URL + * - Создание читаемых меток для внешних ссылок + * + * Алгоритм: + * - Ищет протокол https:// + * - Извлекает часть между протоколом и первой точкой + * - Возвращает доменное имя без расширения + */ @Pipe({ name: "linkTransform", standalone: true, }) export class LinkTransformPipe implements PipeTransform { + /** + * Извлекает доменное имя из URL + * @param value - Полный URL для обработки + * @returns Доменное имя или пустая строка + * + * Поддерживаемый формат: + * - Только HTTPS URL (https://) + * - URL должен содержать точку в доменной части + * + * Алгоритм извлечения: + * 1. Проверяет наличие значения + * 2. Ищет префикс "https://" в строке + * 3. Находит начало доменного имени после протокола + * 4. Ищет первую точку после доменного имени + * 5. Извлекает подстроку между протоколом и точкой + * + * Примеры использования в шаблонах: + * + * + * + * {{ link.url | linkTransform }} + * + * + * + *
    + * {{ link | linkTransform }} + * {{ link }} + *
    + * + * + * + * Ссылка на {{ url | linkTransform }} + * + * + * Примеры результатов: + * - "https://github.com/user/repo" → "github" + * - "https://www.google.com/search" → "www.google" + * - "https://api.example.com/v1" → "api.example" + * - "http://example.com" → "" (не поддерживается HTTP) + * - "https://example" → "example" (нет точки) + * - "" → "" (пустая строка) + * - null → "" (null значение) + * + * Ограничения: + * - Работает только с HTTPS URL + * - Не обрабатывает HTTP протокол + * - Не учитывает сложные структуры доменов + * - Возвращает пустую строку для некорректных URL + */ transform(value: string) { + // Проверяем наличие значения if (!value) { return ""; } const httpsPrefix = "https://"; + + // Ищем позицию протокола HTTPS в строке const startIndex = value.indexOf(httpsPrefix); + // Если HTTPS не найден, возвращаем пустую строку if (startIndex === -1) { return ""; } + // Вычисляем начало доменного имени (после протокола) const domainStartIndex = startIndex + httpsPrefix.length; + + // Ищем первую точку после доменного имени const domainEndIndex = value.indexOf(".", domainStartIndex); + // Если точка не найдена, возвращаем всё что после протокола if (domainEndIndex === -1) { return value.substring(domainStartIndex); } + // Извлекаем доменное имя между протоколом и первой точкой return value.substring(domainStartIndex, domainEndIndex); } } diff --git a/projects/core/src/lib/pipes/options-transform.pipe.ts b/projects/core/src/lib/pipes/options-transform.pipe.ts new file mode 100644 index 000000000..491c8bf63 --- /dev/null +++ b/projects/core/src/lib/pipes/options-transform.pipe.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { Pipe, PipeTransform } from "@angular/core"; + +@Pipe({ + name: "toSelectOptions", + standalone: true, +}) +export class ToSelectOptionsPipe implements PipeTransform { + transform( + values: string[] | null | undefined + ): { value: string | number; label: string; id: number }[] { + if (!values || !Array.isArray(values)) { + return []; + } + + return values.map((value, index) => ({ + value, + label: value, + id: index, + })); + } +} diff --git a/projects/core/src/lib/pipes/parse-breaks.pipe.ts b/projects/core/src/lib/pipes/parse-breaks.pipe.ts index 82772331f..addcbeaff 100644 --- a/projects/core/src/lib/pipes/parse-breaks.pipe.ts +++ b/projects/core/src/lib/pipes/parse-breaks.pipe.ts @@ -1,13 +1,67 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; +/** + * Пайп для преобразования символов новой строки в HTML теги
    + * + * Назначение: + * - Преобразует текстовые переносы строк (\n) в HTML переносы (
    ) + * - Позволяет корректно отображать многострочный текст в HTML + * - Сохраняет форматирование текста, введенного пользователем + * + * Применение: + * - Отображение пользовательского контента (комментарии, описания) + * - Сохранение форматирования в textarea + * - Преобразование текста из API для отображения в HTML + * + * Безопасность: + * - Пайп только заменяет \n на
    , не выполняет другие HTML преобразования + * - Для безопасного отображения HTML используйте с [innerHTML] + * - Рекомендуется санитизация контента перед использованием + */ @Pipe({ name: "parseBreaks", standalone: true, }) export class ParseBreaksPipe implements PipeTransform { + /** + * Преобразует символы новой строки в HTML теги
    + * @param value - Исходная строка с символами \n + * @returns Строка с замененными \n на
    + * + * Преобразование: + * - Каждый символ \n заменяется на
    + * - Множественные \n\n становятся

    + * - Исходная строка не изменяется, возвращается новая + * + * Примеры использования: + * + * В компоненте: + * userText = "Первая строка\nВторая строка\n\nТретья строка"; + * + * В шаблоне: + * + *
    + * + * Результат в HTML: + *
    + * Первая строка
    Вторая строка

    Третья строка + *
    + * + * Отображение в браузере: + * Первая строка + * Вторая строка + * + * Третья строка + * + * Альтернативы: + * - CSS white-space: pre-wrap (сохраняет \n как есть) + * - Ручная обработка в компоненте + * - Использование специальных компонентов для текста + */ transform(value: string): string { + // Глобальная замена всех вхождений \n на
    return value.replace(/\n/g, "
    "); } } diff --git a/projects/core/src/lib/pipes/parse-links.pipe.ts b/projects/core/src/lib/pipes/parse-links.pipe.ts index d289efb2a..35cd7ce48 100644 --- a/projects/core/src/lib/pipes/parse-links.pipe.ts +++ b/projects/core/src/lib/pipes/parse-links.pipe.ts @@ -1,14 +1,86 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; import linkifyStr from "linkify-string"; +/** + * Пайп для автоматического преобразования URL в кликабельные ссылки + * + * Назначение: + * - Находит URL в тексте и преобразует их в HTML ссылки + * - Поддерживает различные протоколы (http, https, ftp) + * - Автоматически определяет ссылки без протокола (example.com) + * - Добавляет атрибуты для безопасности (target="_blank", rel="noopener") + * + * Использует библиотеку linkify-string для надежного распознавания URL + * + * Применение: + * - Обработка пользовательского контента (комментарии, посты) + * - Отображение текста с автоматическими ссылками + * - Преобразование plain text в HTML с ссылками + * + * Безопасность: + * - Библиотека linkify-string экранирует HTML символы + * - Добавляет rel="noopener" для предотвращения window.opener атак + * - Рекомендуется использовать с [innerHTML] и DomSanitizer + */ @Pipe({ name: "parseLinks", standalone: true, }) export class ParseLinksPipe implements PipeTransform { + /** + * Преобразует URL в тексте в кликабельные HTML ссылки + * @param string - Исходный текст, который может содержать URL + * @returns HTML строка с преобразованными ссылками + * + * Поддерживаемые форматы URL: + * - https://example.com + * - http://example.com + * - ftp://files.example.com + * - example.com (автоматически добавляется http://) + * - www.example.com + * - subdomain.example.com + * + * Генерируемые ссылки включают: + * - target="_blank" - открытие в новой вкладке + * - rel="noopener noreferrer" - безопасность + * - Исходный URL как текст ссылки + * + * Примеры использования: + * + * В компоненте: + * userMessage = "Посетите наш сайт https://example.com или example.org"; + * + * В шаблоне: + *
    + * + * Результат в HTML: + *
    + * + * Комбинирование с другими пайпами: + *
    + * + * Важные замечания: + * - Всегда используйте с [innerHTML], не с обычной интерполяцией {{ }} + * - Рекомендуется санитизация HTML для предотвращения XSS атак + * - Библиотека автоматически экранирует специальные HTML символы + */ transform(string: string): string { + // Используем библиотеку linkify-string для преобразования URL в ссылки + // Библиотека автоматически: + // - Находит различные форматы URL + // - Добавляет необходимые атрибуты безопасности + // - Экранирует HTML символы return linkifyStr(string); } } diff --git a/projects/core/src/lib/pipes/pluralize.pipe.ts b/projects/core/src/lib/pipes/pluralize.pipe.ts index 57dfdfca1..18b37c33e 100644 --- a/projects/core/src/lib/pipes/pluralize.pipe.ts +++ b/projects/core/src/lib/pipes/pluralize.pipe.ts @@ -1,19 +1,101 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; +/** + * Пайп для склонения слов по числам в русском языке + * + * Назначение: + * - Автоматически выбирает правильную форму слова в зависимости от числа + * - Реализует правила русской грамматики для склонения + * - Поддерживает все числа (включая отрицательные) + * + * Правила склонения в русском языке: + * - 1, 21, 31, 41... → первая форма (1 проект, 21 проект) + * - 2-4, 22-24, 32-34... → вторая форма (2 проекта, 23 проекта) + * - 0, 5-20, 25-30... → третья форма (5 проектов, 10 проектов) + * + * Особые случаи: + * - Числа 11-19 всегда используют третью форму (11 проектов, 14 проектов) + * - Отрицательные числа обрабатываются по модулю + * + * Применение: + * - Отображение количества элементов + * - Динамические сообщения с числами + * - Локализация интерфейса + */ @Pipe({ name: "pluralize", standalone: true, }) export class PluralizePipe implements PipeTransform { + /** + * Возвращает правильную форму слова для указанного числа + * @param value - Число для которого нужно выбрать форму слова + * @param words - Массив из трех форм слова [единственное, 2-4, множественное] + * @returns Правильная форма слова + * + * Параметр words должен содержать три формы: + * - words[0] - для чисел заканчивающихся на 1 (кроме 11) + * - words[1] - для чисел заканчивающихся на 2, 3, 4 (кроме 12-14) + * - words[2] - для всех остальных чисел + * + * Примеры использования в шаблонах: + * + * + * {{ projectCount }} {{ projectCount | pluralize:['проект', 'проекта', 'проектов'] }} + * + * Результаты: + * - 1 проект + * - 2 проекта + * - 5 проектов + * - 21 проект + * - 22 проекта + * - 25 проектов + * + * + * {{ userCount }} {{ userCount | pluralize:['пользователь', 'пользователя', 'пользователей'] }} + * {{ commentCount }} {{ commentCount | pluralize:['комментарий', 'комментария', 'комментариев'] }} + * {{ fileCount }} {{ fileCount | pluralize:['файл', 'файла', 'файлов'] }} + * + * + * {{ -3 | pluralize:['день', 'дня', 'дней'] }} назад + * + * + * Алгоритм работы: + * 1. Берем абсолютное значение числа по модулю 100 + * 2. Если число от 11 до 19 → третья форма + * 3. Берем последнюю цифру числа + * 4. Если последняя цифра 1 → первая форма + * 5. Если последняя цифра 2, 3, 4 → вторая форма + * 6. Во всех остальных случаях → третья форма + */ transform(value: number, words: [string, string, string]): string { + // Работаем с абсолютным значением по модулю 100 + // Это позволяет корректно обрабатывать большие числа и отрицательные const calcValue = Math.abs(value) % 100; + + // Получаем последнюю цифру числа const num = calcValue % 10; - if (calcValue > 10 && calcValue < 20) return words[2]; - if (num > 1 && num < 5) return words[1]; - if (num === 1) return words[0]; - return words[2]; + // Особый случай: числа от 11 до 19 всегда используют третью форму + // Это исключение из общего правила русского языка + if (calcValue > 10 && calcValue < 20) { + return words[2]; // 11 проектов, 12 проектов, 19 проектов + } + + // Числа заканчивающиеся на 2, 3, 4 используют вторую форму + if (num > 1 && num < 5) { + return words[1]; // 2 проекта, 3 проекта, 4 проекта, 22 проекта + } + + // Числа заканчивающиеся на 1 используют первую форму + if (num === 1) { + return words[0]; // 1 проект, 21 проект, 31 проект + } + + // Все остальные числа используют третью форму + // Включает: 0, 5-9, 10, 20, 25-30 и т.д. + return words[2]; // 0 проектов, 5 проектов, 10 проектов, 20 проектов } } diff --git a/projects/core/src/lib/pipes/salary-transform.pipe.ts b/projects/core/src/lib/pipes/salary-transform.pipe.ts index 43d080b1d..2e5090370 100644 --- a/projects/core/src/lib/pipes/salary-transform.pipe.ts +++ b/projects/core/src/lib/pipes/salary-transform.pipe.ts @@ -1,17 +1,85 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; +/** + * Пайп для форматирования зарплаты с разделителями тысяч + * + * Назначение: + * - Преобразует числовые значения зарплаты в читаемый формат + * - Добавляет пробелы как разделители тысяч по российским стандартам + * - Обрабатывает как строковые, так и числовые значения + * + * Форматирование: + * - 50000 → "50 000" + * - 1500000 → "1 500 000" + * - 75000 → "75 000" + * + * Применение: + * - Отображение зарплат в вакансиях + * - Форматирование финансовых данных + * - Улучшение читаемости больших чисел + * + * Локализация: + * - Использует российский стандарт разделения (пробелы) + * - Совместимо с рублевой валютой + */ @Pipe({ name: "salaryTransform", standalone: true, }) export class SalaryTransformPipe implements PipeTransform { + /** + * Форматирует зарплату с разделителями тысяч + * @param value - Значение зарплаты (строка или число) + * @returns Отформатированная строка с разделителями тысяч + * + * Обработка входных данных: + * - Строки преобразуются в числа с помощью parseInt + * - Если преобразование не удалось (NaN), возвращается исходное значение + * - Числа форматируются с российской локализацией (ru-RU) + * + * Примеры использования в шаблонах: + * + * + *
    + * Зарплата: {{ vacancy.salary | salaryTransform }} ₽ + *
    + * + * + * + * {{ job.salaryFrom | salaryTransform }} - {{ job.salaryTo | salaryTransform }} ₽ + * + * + * + * + * + * Примеры результатов: + * - "50000" → "50 000" + * - "1500000" → "1 500 000" + * - "abc" → "abc" (некорректное значение возвращается как есть) + * - 75000 → "75 000" + * - 0 → "0" + * + * Технические детали: + * - Использует toLocaleString('ru-RU') для российского форматирования + * - parseInt с radix 10 для безопасного преобразования строк + * - Проверка на NaN для обработки некорректных значений + */ transform(value: string): string { - const numberValue = parseInt(value, 10); + // Преобразуем строку в число + const numberValue = Number.parseInt(value, 10); + + // Если преобразование не удалось, возвращаем исходное значение if (isNaN(numberValue)) { return value; } + + // Форматируем число с российской локализацией + // ru-RU использует пробелы как разделители тысяч return numberValue.toLocaleString("ru-RU"); } } diff --git a/projects/core/src/lib/pipes/years-from-birthday.pipe.ts b/projects/core/src/lib/pipes/years-from-birthday.pipe.ts index 62b3793e3..8b901cade 100644 --- a/projects/core/src/lib/pipes/years-from-birthday.pipe.ts +++ b/projects/core/src/lib/pipes/years-from-birthday.pipe.ts @@ -1,21 +1,89 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; import { PluralizePipe } from "projects/core"; import * as RelativeTime from "dayjs/plugin/relativeTime"; import * as dayjs from "dayjs"; dayjs.extend(RelativeTime); +/** + * Пайп для вычисления и отображения возраста по дате рождения + * + * Назначение: + * - Вычисляет точный возраст в годах на основе даты рождения + * - Автоматически склоняет слово "год" в соответствии с русской грамматикой + * - Возвращает читаемую строку с возрастом + * + * Особенности: + * - Использует dayjs для точного вычисления разности в годах + * - Учитывает високосные годы и точные даты + * - Интегрируется с PluralizePipe для правильного склонения + * - Округляет возраст до целых лет (Math.floor) + * + * Применение: + * - Отображение возраста пользователей в профилях + * - Показ возраста в списках участников + * - Любые места где нужно показать возраст по дате рождения + */ @Pipe({ name: "yearsFromBirthday", standalone: true, }) export class YearsFromBirthdayPipe implements PipeTransform { + /** + * Вычисляет возраст по дате рождения и возвращает отформатированную строку + * @param value - Дата рождения в любом формате, поддерживаемом dayjs + * @returns Строка с возрастом и правильно склоненным словом "год" + * + * Поддерживаемые форматы даты: + * - ISO строки: "1990-03-15T00:00:00.000Z" + * - Строки даты: "1990-03-15", "15.03.1990" + * - Date объекты + * - Timestamp числа + * + * Алгоритм: + * 1. Парсит дату рождения с помощью dayjs + * 2. Вычисляет точную разность в годах с текущей датой + * 3. Округляет до целых лет (Math.floor) + * 4. Использует PluralizePipe для склонения слова "год" + * 5. Возвращает строку вида "25 лет", "1 год", "2 года" + * + * Примеры использования в шаблонах: + * + * + *
    + * Возраст: {{ user.birthday | yearsFromBirthday }} + *
    + * + * + *
    + * {{ member.name }}, {{ member.birthday | yearsFromBirthday }} + *
    + * + * + * {{ profile.dateOfBirth | yearsFromBirthday }} + * + * Примеры результатов: + * - Дата рождения: 15.03.2000 → "24 года" (если сейчас 2024) + * - Дата рождения: 01.01.2023 → "1 год" + * - Дата рождения: 10.05.1995 → "29 лет" + * - Дата рождения: 20.12.2020 → "4 года" + * + * Точность вычислений: + * - Учитывает точную дату (день и месяц) + * - Если день рождения еще не наступил в этом году, возраст будет меньше на 1 + * - Использует Math.floor для округления вниз (не показывает неполные годы) + */ transform(value: string): string { + // Вычисляем точную разность в годах между датой рождения и текущей датой + // diff с параметром 'year' и true возвращает точное значение с дробной частью const years = Math.floor(dayjs().diff(dayjs(value), "year", true)); + // Создаем экземпляр PluralizePipe для склонения слова "год" const pluralize = new PluralizePipe(); + + // Возвращаем отформатированную строку с возрастом и склоненным словом return `${years} ${pluralize.transform(years, ["год", "года", "лет"])}`; } } diff --git a/projects/core/src/lib/providers/api-url.provide.ts b/projects/core/src/lib/providers/api-url.provide.ts index 8b19bd0dd..1d3989972 100644 --- a/projects/core/src/lib/providers/api-url.provide.ts +++ b/projects/core/src/lib/providers/api-url.provide.ts @@ -1,5 +1,28 @@ /** @format */ import { InjectionToken } from "@angular/core"; +/** + * Токен для инъекции базового URL основного API + * + * Используется в ApiService для формирования полных URL запросов + * Позволяет легко изменять базовый URL в зависимости от окружения + * + * Пример конфигурации в модуле: + * providers: [ + * { provide: API_URL, useValue: 'https://api.procollab.ru' } + * ] + */ export const API_URL = new InjectionToken("API_URL"); + +/** + * Токен для инъекции базового URL Skills API + * + * Используется в SkillsApiService для работы с отдельным API навыков + * Позволяет использовать разные API endpoints в одном приложении + * + * Пример конфигурации в модуле: + * providers: [ + * { provide: SKILLS_API_URL, useValue: 'https://skills-api.procollab.ru' } + * ] + */ export const SKILLS_API_URL = new InjectionToken("SKILLS_API_URL"); diff --git a/projects/core/src/lib/providers/production.provide.ts b/projects/core/src/lib/providers/production.provide.ts index bd0351849..933d2a87a 100644 --- a/projects/core/src/lib/providers/production.provide.ts +++ b/projects/core/src/lib/providers/production.provide.ts @@ -2,4 +2,27 @@ import { InjectionToken } from "@angular/core"; +/** + * Токен для инъекции флага production окружения + * + * Используется сервисами для изменения поведения в зависимости от окружения: + * - TokenService: настройки cookies (домен, безопасность) + * - Логирование: уровень детализации + * - API endpoints: использование production или development URL + * - Отладочная информация: показ в development, скрытие в production + * + * Пример конфигурации в модуле: + * providers: [ + * { provide: PRODUCTION, useValue: environment.production } + * ] + * + * Использование в сервисах: + * constructor(@Inject(PRODUCTION) private production: boolean) { + * if (this.production) { + * // Production логика + * } else { + * // Development логика + * } + * } + */ export const PRODUCTION = new InjectionToken("PRODUCTION"); diff --git a/projects/core/src/lib/services/api.service.ts b/projects/core/src/lib/services/api.service.ts index a725f4d6c..20b7b93fe 100644 --- a/projects/core/src/lib/services/api.service.ts +++ b/projects/core/src/lib/services/api.service.ts @@ -5,6 +5,22 @@ import { HttpClient, HttpParams } from "@angular/common/http"; import { first, Observable } from "rxjs"; import { API_URL } from "../providers"; +/** + * Базовый сервис для работы с REST API + * + * Предназначение: + * - Предоставляет унифицированный интерфейс для HTTP запросов + * - Автоматически добавляет базовый URL к всем запросам + * - Использует оператор first() для автоматического завершения Observable + * - Поддерживает типизацию TypeScript для запросов и ответов + * + * Особенности: + * - Все методы возвращают Observable, который автоматически завершается после первого значения + * - Базовый URL инжектируется через DI токен API_URL + * - Поддерживает передачу HTTP параметров и дополнительных опций + * + * Используется как базовый класс для специализированных API сервисов + */ @Injectable({ providedIn: "root", }) @@ -14,22 +30,69 @@ export class ApiService { @Inject(API_URL) private readonly apiUrl: string ) {} + /** + * Выполняет GET запрос к API + * @param path - Относительный путь к ресурсу (будет добавлен к базовому URL) + * @param params - HTTP параметры запроса (query string) + * @param options - Дополнительные опции HttpClient (headers, responseType и т.д.) + * @returns Observable с типизированным ответом, завершающийся после первого значения + * + * Пример использования: + * 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; } + /** + * Выполняет PUT запрос к API (полное обновление ресурса) + * @param path - Относительный путь к ресурсу + * @param body - Тело запроса (объект для обновления) + * @returns Observable с типизированным ответом + * + * Пример использования: + * 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; } + /** + * Выполняет PATCH запрос к API (частичное обновление ресурса) + * @param path - Относительный путь к ресурсу + * @param body - Тело запроса (объект с полями для обновления) + * @returns Observable с типизированным ответом + * + * Пример использования: + * apiService.patch('/users/1', { name: 'John' }) // обновляет только имя + */ patch(path: string, body: object): Observable { return this.http.patch(this.apiUrl + path, body).pipe(first()) as Observable; } + /** + * Выполняет POST запрос к API (создание нового ресурса) + * @param path - Относительный путь к ресурсу + * @param body - Тело запроса (данные для создания) + * @returns Observable с типизированным ответом (обычно созданный объект) + * + * Пример использования: + * 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; } + /** + * Выполняет DELETE запрос к API (удаление ресурса) + * @param path - Относительный путь к ресурсу + * @param params - HTTP параметры запроса (опционально) + * @returns Observable с типизированным ответом + * + * Пример использования: + * apiService.delete('/users/1') + * 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; } diff --git a/projects/core/src/lib/services/skillsApi.service.ts b/projects/core/src/lib/services/skillsApi.service.ts index 99990915a..00cf8e2bc 100644 --- a/projects/core/src/lib/services/skillsApi.service.ts +++ b/projects/core/src/lib/services/skillsApi.service.ts @@ -5,9 +5,50 @@ import { HttpClient } from "@angular/common/http"; import { ApiService } from "./api.service"; import { SKILLS_API_URL } from "@corelib"; -@Injectable({ providedIn: "root" }) +/** + * Специализированный API сервис для работы с Skills API + * + * Назначение: + * - Расширяет базовый ApiService для работы с отдельным Skills API + * - Использует отдельный базовый URL (SKILLS_API_URL) от основного API + * - Наследует все методы от ApiService (get, post, put, patch, delete) + * + * Архитектурное решение: + * - Позволяет работать с несколькими API endpoints в одном приложении + * - Основной API (через ApiService) - для общих операций + * - Skills API (через SkillsApiService) - для операций с навыками и подписками + * + * Использование: + * - Инжектируется в компоненты и сервисы, которым нужен доступ к Skills API + * - Автоматически использует правильный базовый URL + * - Поддерживает все HTTP методы родительского класса + */ +@Injectable({ + providedIn: "root", +}) export class SkillsApiService extends ApiService { + /** + * Конструктор сервиса + * @param http - HttpClient для выполнения HTTP запросов + * @param apiUrl - Базовый URL для Skills API (инжектируется через SKILLS_API_URL токен) + * + * Пример конфигурации в модуле: + * providers: [ + * { provide: SKILLS_API_URL, useValue: 'https://skills-api.example.com' } + * ] + */ constructor(http: HttpClient, @Inject(SKILLS_API_URL) apiUrl: string) { + // Вызываем конструктор родительского класса с Skills API URL super(http, apiUrl); } + + // Наследует все методы от ApiService: + // - get(path, params?, options?) + // - post(path, body) + // - put(path, body) + // - patch(path, body) + // - delete(path, params?) + + // Все запросы будут выполняться относительно SKILLS_API_URL + // Пример: skillsApi.get('/subscriptions') → GET https://skills-api.example.com/subscriptions } diff --git a/projects/core/src/lib/services/subscription-plans.service.ts b/projects/core/src/lib/services/subscription-plans.service.ts index fa5c035d9..632901baf 100644 --- a/projects/core/src/lib/services/subscription-plans.service.ts +++ b/projects/core/src/lib/services/subscription-plans.service.ts @@ -4,19 +4,81 @@ import { Injectable, inject } from "@angular/core"; import { SkillsApiService } from "@corelib"; import { PaymentStatus, SubscriptionPlan } from "../models"; +/** + * Сервис для управления планами подписок и платежами + * + * Функциональность: + * - Получение списка доступных планов подписок + * - Инициация процесса покупки подписки + * - Работа с платежной системой (возврат статуса платежа) + * + * Интеграция: + * - Использует SkillsApiService для взаимодействия с Skills API + * - Работает с моделями SubscriptionPlan и PaymentStatus + * - Поддерживает redirect URL для возврата после оплаты + * + * Бизнес-логика: + * - Получает актуальные планы подписок с сервера + * - Создает платежные сессии для выбранных планов + * - Обрабатывает redirect URL для возврата пользователя после оплаты + */ @Injectable({ providedIn: "root", }) export class SubscriptionPlansService { - apiService = inject(SkillsApiService); + private readonly AUTH_SUBSCRIPTION_URL = "/auth/subscription"; + private readonly SUBSCRIPTION_URL = "/subscription"; + /** Инжектируем SkillsApiService для работы с API */ + private apiService = inject(SkillsApiService); + + /** + * Получает список всех доступных планов подписок + * @returns Observable с массивом планов подписок + * + * Возвращаемые данные включают: + * - id: уникальный идентификатор плана + * - name: название плана + * - price: стоимость подписки + * - featuresList: список возможностей плана + * - active: активен ли план + * - available: доступен ли план для покупки + * + * Пример использования: + * subscriptionService.getSubscriptions().subscribe(plans => { + * console.log('Available plans:', plans); + * }); + */ getSubscriptions() { - return this.apiService.get("/auth/subscription/"); + return this.apiService.get(`${this.AUTH_SUBSCRIPTION_URL}/`); } + /** + * Инициирует процесс покупки подписки + * @param planId - ID выбранного плана подписки + * @returns Observable со статусом платежа и URL для подтверждения + * + * Процесс покупки: + * 1. Отправляет запрос на создание платежной сессии + * 2. Получает URL для перенаправления на платежную систему + * 3. Возвращает статус платежа с confirmation URL + * + * Возвращаемые данные включают: + * - id: идентификатор платежа + * - status: статус платежа + * - amount: сумма и валюта + * - confirmation.confirmationUrl: URL для подтверждения платежа + * - paid: флаг успешной оплаты + * + * Пример использования: + * subscriptionService.buySubscription(planId).subscribe(payment => { + * window.location.href = payment.confirmation.confirmationUrl; + * }); + */ buySubscription(planId: SubscriptionPlan["id"]) { - return this.apiService.post("/subscription/buy/", { + return this.apiService.post(`${this.SUBSCRIPTION_URL}/buy/`, { subscriptionId: planId, + // Автоматически определяем URL для возврата после оплаты redirectUrl: `${window.location.origin}/`, }); } diff --git a/projects/core/src/lib/services/token.service.ts b/projects/core/src/lib/services/token.service.ts index 7e8704751..0fe3ba9e5 100644 --- a/projects/core/src/lib/services/token.service.ts +++ b/projects/core/src/lib/services/token.service.ts @@ -8,33 +8,90 @@ import { Tokens } from "@auth/models/tokens.model"; import Cookies from "js-cookie"; import { ApiService, PRODUCTION } from "@corelib"; +/** + * Сервис для управления JWT токенами аутентификации + * + * Основные функции: + * - Хранение access и refresh токенов в HTTP-only cookies + * - Автоматическое обновление токенов при истечении срока действия + * - Управление настройками cookies в зависимости от окружения + * - Очистка токенов при выходе из системы + * + * Безопасность: + * - Токены хранятся в cookies (более безопасно чем localStorage) + * - В production используется domain-specific cookies + * - Автоматическое истечение cookies через 30 дней + * + * Интеграция: + * - Работает с BearerTokenInterceptor для автоматического обновления токенов + * - Использует class-transformer для типизации ответов API + */ @Injectable({ providedIn: "root", }) export class TokenService { + private readonly TOKEN_API_URL = "/api/token"; + constructor(private apiService: ApiService, @Inject(PRODUCTION) private production: boolean) {} + /** + * Обновляет access токен используя refresh токен + * @returns Observable с новой парой токенов (access + refresh) + * + * Процесс обновления: + * 1. Извлекает текущий refresh токен из cookies + * 2. Отправляет запрос на /api/token/refresh/ + * 3. Получает новую пару токенов + * 4. Преобразует ответ в типизированный объект RefreshResponse + * + * Используется автоматически в BearerTokenInterceptor при получении 401 ошибки + */ refreshTokens(): Observable { return this.apiService - .post("/api/token/refresh/", { refresh: this.getTokens()?.refresh }) + .post(`${this.TOKEN_API_URL}/refresh/`, { + refresh: this.getTokens()?.refresh, + }) .pipe(map(json => plainToInstance(RefreshResponse, json))); } + /** + * Возвращает настройки cookies в зависимости от окружения + * @returns Объект с настройками для js-cookie + * + * Production настройки: + * - domain: ".procollab.ru" - cookies доступны на всех поддоменах + * - expires: 30 дней - автоматическое истечение + * + * Development настройки: + * - Используются дефолтные настройки браузера + * - Cookies привязаны к текущему домену + */ getCookieOptions() { if (this.production) { return { - domain: ".procollab.ru", - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), + domain: ".procollab.ru", // Домен для production окружения + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 дней }; } - return {}; + return {}; // Дефолтные настройки для development } + /** + * Извлекает токены из cookies + * @returns Объект с access и refresh токенами или null если токенов нет + * + * Проверяет наличие обоих токенов: + * - Если отсутствует любой из токенов, возвращает null + * - Если оба токена присутствуют, возвращает объект Tokens + * + * Используется в BearerTokenInterceptor для добавления Authorization header + */ getTokens(): Tokens | null { const access = Cookies.get("accessToken"); const refresh = Cookies.get("refreshToken"); + // Проверяем наличие обоих токенов if (!access || !refresh) { return null; } @@ -42,13 +99,33 @@ export class TokenService { return { access, refresh }; } + /** + * Удаляет токены из cookies + * Используется при выходе пользователя из системы + * + * Удаляет оба токена с учетом настроек окружения: + * - В production удаляет с правильным доменом + * - В development удаляет с дефолтными настройками + */ clearTokens(): void { - Cookies.remove("accessToken", this.getCookieOptions()); - Cookies.remove("refreshToken", this.getCookieOptions()); + const options = this.getCookieOptions(); + Cookies.remove("accessToken", options); + Cookies.remove("refreshToken", options); } + /** + * Сохраняет токены в cookies + * @param tokens - Объект с access и refresh токенами + * + * Сохраняет оба токена с настройками окружения: + * - Использует правильный домен для production + * - Устанавливает срок истечения cookies + * + * Вызывается после успешной аутентификации или обновления токенов + */ memTokens(tokens: Tokens): void { - Cookies.set("accessToken", tokens.access, this.getCookieOptions()); - Cookies.set("refreshToken", tokens.refresh, this.getCookieOptions()); + const options = this.getCookieOptions(); + Cookies.set("accessToken", tokens.access, options); + Cookies.set("refreshToken", tokens.refresh, options); } } diff --git a/projects/core/src/lib/services/validation.service.ts b/projects/core/src/lib/services/validation.service.ts index 773305bbd..bd358a0dc 100644 --- a/projects/core/src/lib/services/validation.service.ts +++ b/projects/core/src/lib/services/validation.service.ts @@ -9,69 +9,204 @@ import * as relativeTime from "dayjs/plugin/relativeTime"; dayjs.extend(cpf); dayjs.extend(relativeTime); +/** + * Сервис для кастомной валидации форм + * + * Предоставляет набор специализированных валидаторов для: + * - Проверки совпадения полей (пароли, email подтверждения) + * - Валидации дат и возраста + * - Проверки языка ввода (русский/латиница) + * - Утилиты для работы с формами + * + * Особенности: + * - Все валидаторы возвращают ValidatorFn для использования в Angular Forms + * - Поддержка русской локализации для дат + * - Интеграция с dayjs для работы с датами + * - Автоматическое управление состоянием ошибок в контролах + */ @Injectable({ providedIn: "root", }) export class ValidationService { constructor() {} + /** + * Создает валидатор для проверки совпадения двух полей формы + * @param left - Имя первого поля для сравнения + * @param right - Имя второго поля для сравнения + * @returns ValidatorFn для использования на уровне FormGroup + * + * Применение: + * - Проверка совпадения пароля и подтверждения пароля + * - Проверка совпадения email и подтверждения email + * + * Логика работы: + * 1. Получает контролы по именам полей + * 2. Сравнивает их значения + * 3. Если не совпадают - устанавливает ошибку 'unMatch' для обоих контролов + * 4. Если совпадают - удаляет ошибку 'unMatch' + * + * Пример использования: + * this.form = this.fb.group({ + * password: ['', Validators.required], + * confirmPassword: ['', Validators.required] + * }, { + * validators: this.validationService.useMatchValidator('password', 'confirmPassword') + * }); + */ useMatchValidator(left: string, right: string): ValidatorFn { return group => { const controls = [group.get(left), group.get(right)]; + + // Проверяем существование контролов if (!controls.every(Boolean)) { throw new Error(`No control with name ${left} or ${right}`); } + const isMatching = controls[0]?.value === controls[1]?.value; + if (!isMatching) { + // Устанавливаем ошибку для обоих контролов controls.forEach(c => c?.setErrors({ ...(c.errors || {}), unMatch: true })); return { unMatch: true }; } + + // Удаляем ошибку если поля совпадают controls.forEach(c => { if (c?.errors) { delete c.errors?.["unMatch"]; + // Если других ошибок нет, очищаем errors полностью if (!Object.keys(c.errors).length) { c.setErrors(null); } } }); + return null; }; } + /** + * Валидатор для проверки формата даты DD.MM.YYYY + * @param control - Контрол формы для валидации + * @returns ValidationErrors с ошибкой 'invalidDateFormat' или null + * + * Проверки: + * - Соответствие формату DD.MM.YYYY (строгая проверка) + * - Дата не должна быть в будущем + * - Дата должна быть валидной (существующей) + * + * Пример использования: + * birthday: ['', [Validators.required, this.validationService.useDateFormatValidator]] + */ useDateFormatValidator(control: AbstractControl): ValidationErrors | null { try { + // Строгая проверка формата DD.MM.YYYY const value = dayjs(control.value, "DD.MM.YYYY", true); + + // Проверяем что дата не в будущем и валидна if (control.value && (value.fromNow().includes("in") || !value.isValid())) { return { invalidDateFormat: true }; } + return null; } catch (e) { return { invalidDateFormat: true }; } } - useAgeValidator(age = 12): ValidatorFn { + /** + * Создает валидатор для проверки минимального возраста + * @param age - Минимальный возраст в годах (по умолчанию 12) + * @returns ValidatorFn для проверки возраста + * + * Применение: + * - Проверка совершеннолетия + * - Ограничения по возрасту для регистрации + * - Валидация даты рождения + * + * Логика: + * 1. Парсит дату в формате DD.MM.YYYY + * 2. Вычисляет разность в годах с текущей датой + * 3. Сравнивает с требуемым минимальным возрастом + * + * Пример использования: + * birthday: ['', [ + * Validators.required, + * this.validationService.useDateFormatValidator, + * this.validationService.useAgeValidator(18) // минимум 18 лет + * ]] + */ + useAgeValidator(age = 14): ValidatorFn { return control => { const value = dayjs(control.value, "DD.MM.YYYY", true); + if (value.isValid()) { const difference = dayjs().diff(value, "year"); return difference >= age ? null : { tooYoung: { requiredAge: age } }; } + return null; }; } + /** + * Валидатор для проверки ввода только русских букв + * @returns ValidatorFn для проверки русского языка + * + * Применение: + * - Поля имени и фамилии + * - Поля описания на русском языке + * - Любые текстовые поля, где требуется только кириллица + * + * Проверяет соответствие регулярному выражению: ^[А-Яа-я]*$ + * - Разрешены только русские буквы (заглавные и строчные) + * - Пробелы и другие символы не разрешены + * + * Пример использования: + * firstName: ['', [ + * Validators.required, + * this.validationService.useLanguageValidator() + * ]] + */ useLanguageValidator(): ValidatorFn { return control => { return control.value.match(/^[А-Яа-я]*$/g) ? null : { invalidLanguage: true }; }; } + /** + * Утилита для валидации всей формы с отображением ошибок + * @param form - FormGroup для валидации + * @returns true если форма валидна, false если есть ошибки + * + * Функциональность: + * 1. Проверяет общую валидность формы + * 2. Если форма невалидна - помечает все контролы как 'touched' + * 3. Это приводит к отображению ошибок в UI + * + * Применение: + * - При отправке формы + * - При переходе между шагами многошаговой формы + * - При программной валидации + * + * Пример использования: + * onSubmit() { + * if (this.validationService.getFormValidation(this.form)) { + * // Форма валидна, можно отправлять + * this.submitForm(); + * } else { + * // Форма невалидна, ошибки отображены пользователю + * console.log('Form has errors'); + * } + * } + */ getFormValidation(form: FormGroup): boolean { if (form.valid) { return true; } + // Помечаем все контролы как touched для отображения ошибок Object.keys(form.controls).forEach(controlName => { const control = form.get(controlName); control && control.markAsTouched({ onlySelf: true }); diff --git a/projects/core/src/lib/services/yt-extract.service.ts b/projects/core/src/lib/services/yt-extract.service.ts index d702b009a..87a6ed8c5 100644 --- a/projects/core/src/lib/services/yt-extract.service.ts +++ b/projects/core/src/lib/services/yt-extract.service.ts @@ -2,35 +2,86 @@ import { Injectable } from "@angular/core"; +/** + * Сервис для извлечения и обработки YouTube ссылок из текста + * + * Назначение: + * - Находит YouTube ссылки в произвольном тексте + * - Извлекает video ID из различных форматов YouTube URL + * - Преобразует ссылки в embed формат для встраивания + * - Очищает исходный текст от YouTube ссылок + * + * Поддерживаемые форматы URL: + * - https://www.youtube.com/watch?v=VIDEO_ID + * - https://youtu.be/VIDEO_ID + * - http://youtube.com/watch?v=VIDEO_ID + * - youtube.com/watch?v=VIDEO_ID (без протокола) + * + * Применение: + * - Обработка пользовательского контента с YouTube ссылками + * - Автоматическое встраивание видео в посты/комментарии + * - Разделение текстового и видео контента + */ @Injectable({ providedIn: "root", }) export class YtExtractService { + /** + * Извлекает YouTube ссылку из текста и преобразует в embed формат + * @param value - Исходный текст, который может содержать YouTube ссылку + * @returns Объект с извлеченной embed ссылкой и очищенным текстом + * + * Возвращаемый объект: + * - extractedLink?: string - YouTube embed URL (если ссылка найдена) + * - newText: string - Исходный текст без YouTube ссылки + * + * Алгоритм работы: + * 1. Ищет YouTube ссылку в тексте с помощью регулярного выражения + * 2. Если ссылка не найдена - возвращает исходный текст + * 3. Если найдена - извлекает video ID + * 4. Создает embed URL: https://www.youtube.com/embed/VIDEO_ID + * 5. Удаляет исходную ссылку из текста + * + * Пример использования: + * const text = "Посмотрите это видео: https://www.youtube.com/watch?v=dQw4w9WgXcQ Очень интересно!"; + * const result = ytExtractService.transform(text); + * // result.extractedLink = "https://www.youtube.com/embed/dQw4w9WgXcQ" + * // result.newText = "Посмотрите это видео: Очень интересно!" + */ transform(value: any): { extractedLink?: string; newText: string } { + // Регулярное выражение для поиска YouTube ссылок + // Покрывает различные форматы: youtube.com, youtu.be, с протоколом и без const youtubeRegex = /(http(s)?:\/\/)?((w){3}.)?youtu(be|.be)?(\.com)?\/.+/gm; - // Find matches in the text + // Ищем первое совпадение в тексте const match = youtubeRegex.exec(value); if (!match) { - return { newText: value }; // No YouTube link found, return original text + // YouTube ссылка не найдена, возвращаем исходный текст + return { newText: value }; } - // Extracted YouTube link + // Извлеченная YouTube ссылка (полная) const extractedLink = match[0]; - // Remove the YouTube link from the original text + // Удаляем YouTube ссылку из исходного текста const newText = value.replace(extractedLink, ""); - // Generate the embedded YouTube link + // Извлекаем video ID из ссылки + // Регулярное выражение для поиска 11-символьного video ID + // Поддерживает форматы: ?v=ID, /ID, /embed/ID const videoIdRegex = /(?:v=|\/)([A-Za-z0-9_-]{11})/; const videoIdMatch = extractedLink.match(videoIdRegex); let embedLink = ""; if (videoIdMatch && videoIdMatch[1]) { const videoId = videoIdMatch[1]; + // Создаем embed URL для встраивания embedLink = `https://www.youtube.com/embed/${videoId}`; } - return { extractedLink: embedLink, newText }; + return { + extractedLink: embedLink, // Embed URL для встраивания + newText, // Текст без YouTube ссылки + }; } } diff --git a/projects/skills/README.md b/projects/skills/README.md new file mode 100644 index 000000000..c6e260df0 --- /dev/null +++ b/projects/skills/README.md @@ -0,0 +1,228 @@ + + +# 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/app/app.component.ts b/projects/skills/src/app/app.component.ts index f192c4d18..3e62573f9 100644 --- a/projects/skills/src/app/app.component.ts +++ b/projects/skills/src/app/app.component.ts @@ -1,17 +1,27 @@ /** @format */ -import { Component, inject, OnInit, signal } from "@angular/core"; -import { AsyncPipe, CommonModule } from "@angular/common"; +import { Component, inject, type OnInit, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; import { Router, RouterLink, RouterOutlet } from "@angular/router"; -import { IconComponent, ProfileInfoComponent, SidebarComponent } from "@uilib"; +import { IconComponent, SidebarComponent } from "@uilib"; import { SidebarProfileComponent } from "./shared/sidebar-profile/sidebar-profile.component"; -import { map, tap } from "rxjs"; import { ProfileService } from "./profile/services/profile.service"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { User } from "@auth/models/user.model"; -import { Profile, UserData } from "../models/profile.model"; +import type { UserData } from "../models/profile.model"; import { AuthService } from "@auth/services"; +/** + * Корневой компонент приложения, который служит основным контейнером макета + * + * Функции: + * - Основная навигационная боковая панель + * - Отображение профиля пользователя + * - Обработка мобильного меню + * - Управление состоянием аутентификации + * - Рендеринг контента на основе маршрутов + * + * Компонент инициализирует данные пользователя при запуске и обрабатывает + * перенаправления аутентификации, если пользователь не авторизован должным образом. + */ @Component({ selector: "app-root", standalone: true, @@ -27,37 +37,57 @@ import { AuthService } from "@auth/services"; styleUrl: "./app.component.scss", }) export class AppComponent implements OnInit { + // Внедренные сервисы для управления профилем и аутентификацией profileService = inject(ProfileService); authService = inject(AuthService); router = inject(Router); + // Управление состоянием UI mobileMenuOpen = false; notificationsOpen = false; + // Конфигурация приложения title = "skills"; + + /** + * Конфигурация элементов навигации + * Каждый элемент представляет основной раздел приложения + */ navItems = [ - // { name: "Навыки", icon: "lib", link: "skills" }, + // { name: "Навыки", icon: "lib", link: "skills" }, // Временно отключено { name: "Старт в карьеру", icon: "trackcar", link: "trackCar" }, { name: "Траектории", icon: "receipt", link: "subscription" }, { name: "Рейтинг", icon: "growth", link: "rating" }, - // { name: "Траектория бизнеса", icon: "trackbuss", link: "trackBuss" }, - // { name: "Вебинары", icon: "webinars", link: "webinars" }, + // { name: "Траектория бизнеса", icon: "trackbuss", link: "trackBuss" }, // Временно отключено + // { name: "Вебинары", icon: "webinars", link: "webinars" }, // Временно отключено ]; + // Реактивное состояние с использованием Angular signals userData = signal(null); logout = signal(false); + /** + * Инициализация компонента + * Получает данные пользователя и синхронизирует профиль при запуске + * Перенаправляет на страницу входа при ошибке аутентификации + */ ngOnInit(): void { + // Получение текущих данных пользователя this.profileService.getUserData().subscribe({ next: data => this.userData.set(data as UserData), error: () => { + // Перенаправление на основное приложение для входа при ошибке получения данных пользователя location.href = "https://app.procollab.ru/auth/login"; }, }); + // Синхронизация данных профиля с бэкендом this.profileService.syncProfile().subscribe({ - next: () => {}, + next: () => { + // Синхронизация профиля успешна - никаких действий не требуется + }, error: () => { + // Перенаправление на основное приложение для входа при ошибке синхронизации профиля location.href = "https://app.procollab.ru/auth/login"; }, }); diff --git a/projects/skills/src/app/app.routes.ts b/projects/skills/src/app/app.routes.ts index 1404b9606..3b06e2641 100644 --- a/projects/skills/src/app/app.routes.ts +++ b/projects/skills/src/app/app.routes.ts @@ -1,12 +1,29 @@ /** @format */ -import { Routes } from "@angular/router"; +import type { Routes } from "@angular/router"; +/** + * Основная конфигурация маршрутизации приложения + * + * Использует ленивую загрузку для всех функциональных модулей для оптимизации размера начального бандла + * Каждый маршрут загружает свой модуль только при обращении, что улучшает производительность + * + * Структура маршрутов: + * - / (корень) -> перенаправляет на профиль + * - /profile -> Управление профилем пользователя + * - /skills -> Просмотр и управление навыками + * - /rating -> Рейтинги пользователей и таблицы лидеров + * - /task -> Интерактивные обучающие задания + * - /trackBuss -> Бизнес-траектория (в настоящее время отключена) + * - /trackCar -> Карьерная траектория + * - /subscription -> Управление подписками + * - /webinars -> Система вебинаров + */ export const routes: Routes = [ { path: "", pathMatch: "full", - redirectTo: "profile", + redirectTo: "profile", // Маршрут по умолчанию перенаправляет на профиль пользователя }, { path: "profile", @@ -24,7 +41,6 @@ export const routes: Routes = [ path: "task", loadChildren: () => import("./task/task.routes").then(c => c.TASK_ROUTES), }, - { path: "trackBuss", loadChildren: () => @@ -32,7 +48,6 @@ export const routes: Routes = [ c => c.TRACK_BUSSINESS_ROUTES ), }, - { path: "trackCar", loadChildren: () => diff --git a/projects/skills/src/app/profile/home/profile-home.component.ts b/projects/skills/src/app/profile/home/profile-home.component.ts index a61b85ddb..dd71e0291 100644 --- a/projects/skills/src/app/profile/home/profile-home.component.ts +++ b/projects/skills/src/app/profile/home/profile-home.component.ts @@ -1,17 +1,29 @@ /** @format */ -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { Component, inject, type OnDestroy, type OnInit } from "@angular/core"; import { CommonModule } from "@angular/common"; import { MonthBlockComponent } from "../shared/month-block/month-block.component"; import { SkillsBlockComponent } from "../shared/skills-block/skills-block.component"; import { ProgressBlockComponent } from "../shared/progress-block/progress-block.component"; import { ActivatedRoute } from "@angular/router"; import { toSignal } from "@angular/core/rxjs-interop"; -import { map, Subscription } from "rxjs"; +import { map, type Subscription } from "rxjs"; import { mockMonthsList } from "projects/core/src/consts/list-mock-months"; import { ProfileService } from "../services/profile.service"; import { TrajectoryBlockComponent } from "../shared/trajectory-block/trajectory-block.component"; +/** + * Компонент главной страницы профиля пользователя + * + * Отображает основную информацию профиля в зависимости от статуса подписки: + * - Для пользователей без подписки: календарь месячной активности + * - Для подписчиков: блок траекторий обучения + * + * Также включает блоки навыков и общего прогресса пользователя. + * + * Компонент автоматически определяет тип отображения на основе + * статуса подписки пользователя. + */ @Component({ selector: "app-skills", standalone: true, @@ -26,17 +38,35 @@ import { TrajectoryBlockComponent } from "../shared/trajectory-block/trajectory- styleUrl: "./profile-home.component.scss", }) export class ProfileHomeComponent implements OnInit, OnDestroy { + // Моковые данные для календаря месяцев (временное решение) readonly mockMonts = mockMonthsList; + // Внедрение зависимостей private readonly route = inject(ActivatedRoute); private readonly profileService = inject(ProfileService); + /** + * Данные профиля пользователя из резолвера + * Содержат информацию о пользователе, навыках и прогрессе + */ profileData = toSignal(this.route.data.pipe(map(r => r["data"]))); + /** + * Тип отображения основного блока: + * - 'months' - календарь месячной активности (для бесплатных пользователей) + * - 'trajectory' - блок траекторий (для подписчиков) + */ type: "months" | "trajectory" = "months"; + // Массив подписок для управления жизненным циклом subscription$: Subscription[] = []; + /** + * Инициализация компонента + * + * Определяет тип отображения на основе статуса подписки пользователя. + * Подписчики видят траектории обучения, остальные - календарь активности. + */ ngOnInit(): void { const isSubscribedSub$ = this.profileService.getSubscriptionData().subscribe(r => { this.type = r.isSubscribed ? "trajectory" : "months"; @@ -45,6 +75,9 @@ export class ProfileHomeComponent implements OnInit, OnDestroy { isSubscribedSub$ && this.subscription$.push(isSubscribedSub$); } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy(): void { this.subscription$.forEach($ => $.unsubscribe()); } diff --git a/projects/skills/src/app/profile/home/profile-home.routes.ts b/projects/skills/src/app/profile/home/profile-home.routes.ts index aafcc8aba..153d4bd36 100644 --- a/projects/skills/src/app/profile/home/profile-home.routes.ts +++ b/projects/skills/src/app/profile/home/profile-home.routes.ts @@ -3,6 +3,14 @@ import { Routes } from "@angular/router"; import { ProfileHomeComponent } from "./profile-home.component"; +/** + * Конфигурация маршрутов для домашней страницы профиля + * + * Определяет маршрут для главной страницы профиля пользователя. + * Используется как дочерний маршрут в основной конфигурации профиля. + * + * @returns {Routes} Массив конфигураций маршрутов домашней страницы + */ export const PROFILE_HOME_ROUTES: Routes = [ { path: "", diff --git a/projects/skills/src/app/profile/profile.component.ts b/projects/skills/src/app/profile/profile.component.ts index d2218cffe..031905722 100644 --- a/projects/skills/src/app/profile/profile.component.ts +++ b/projects/skills/src/app/profile/profile.component.ts @@ -8,6 +8,15 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { map } from "rxjs"; import { BarComponent } from "@ui/components"; +/** + * Основной компонент профиля пользователя + * + * Служит контейнером для всех разделов профиля и предоставляет + * общие данные профиля для дочерних компонентов через router-outlet. + * + * Компонент загружает данные профиля через резолвер и делает их + * доступными для всех дочерних маршрутов профиля. + */ @Component({ selector: "app-profile", standalone: true, @@ -16,7 +25,16 @@ import { BarComponent } from "@ui/components"; styleUrl: "./profile.component.scss", }) export class ProfileComponent { + // Внедрение сервиса для работы с маршрутами route = inject(ActivatedRoute); + /** + * Данные профиля пользователя + * + * Получаются из резолвера маршрута и содержат: + * - Основную информацию о пользователе + * - Список навыков с прогрессом + * - Историю месячной активности + */ profileData = toSignal(this.route.data.pipe(map(r => r["data"]))); } diff --git a/projects/skills/src/app/profile/profile.resolver.ts b/projects/skills/src/app/profile/profile.resolver.ts index 92fb2a702..449f53d71 100644 --- a/projects/skills/src/app/profile/profile.resolver.ts +++ b/projects/skills/src/app/profile/profile.resolver.ts @@ -2,10 +2,20 @@ import { ResolveFn } from "@angular/router"; import { inject } from "@angular/core"; -import { tap } from "rxjs"; import { Profile } from "../../models/profile.model"; import { ProfileService } from "./services/profile.service"; +/** + * Резолвер для загрузки данных профиля пользователя + * + * Выполняется перед активацией маршрута профиля и предоставляет + * полную информацию о пользователе, включая навыки и прогресс. + * + * Это гарантирует, что данные профиля будут доступны + * во всех дочерних компонентах профиля сразу при загрузке. + * + * @returns Observable - полные данные профиля пользователя + */ export const profileResolver: ResolveFn = () => { const profileService = inject(ProfileService); return profileService.getProfile(); diff --git a/projects/skills/src/app/profile/profile.routes.ts b/projects/skills/src/app/profile/profile.routes.ts index 0833cc261..108cb160c 100644 --- a/projects/skills/src/app/profile/profile.routes.ts +++ b/projects/skills/src/app/profile/profile.routes.ts @@ -7,6 +7,16 @@ import { profileResolver } from "./profile.resolver"; import { ProfileStudentsComponent } from "./students/students.component"; import { studentsResolver } from "./students/students.resolver"; +/** + * Конфигурация маршрутов для модуля профиля пользователя + * + * Определяет структуру навигации в разделе профиля: + * - Главная страница профиля с дочерними маршрутами + * - Страница навыков пользователя + * - Страница студентов (для наставников) + * + * @returns {Routes} Массив конфигураций маршрутов + */ export const PROFILE_ROUTES: Routes = [ { path: "", diff --git a/projects/skills/src/app/profile/services/profile.service.ts b/projects/skills/src/app/profile/services/profile.service.ts index 17681d265..8d8fefe94 100644 --- a/projects/skills/src/app/profile/services/profile.service.ts +++ b/projects/skills/src/app/profile/services/profile.service.ts @@ -4,39 +4,95 @@ import { inject, Injectable } from "@angular/core"; import { SkillsApiService, SubscriptionData } from "@corelib"; import { Profile, UserData } from "../../../models/profile.model"; +/** + * Служба профилей + * + * Управляет всеми операциями, связанными с профилями пользователей, включая: + * - Получение и управление данными пользователей + * - Синхронизация профилей с бэкэндом + * - Управление подписками + * - Выбор и обновление навыков + * + * Эта служба выступает в качестве основного интерфейса между фронтендом + * и бэкэндом для всех функций, связанных с профилями. + */ @Injectable({ providedIn: "root", }) export class ProfileService { - apiService = inject(SkillsApiService); + private readonly PROGRESS_URL = "/progress"; + private readonly SUBSCRIPTION_URL = "/subscription"; + private apiService = inject(SkillsApiService); + + /** + * Получает информацию о профиле текущего пользователя. + * + * @returns Observable Полные данные профиля, включая прогресс и достижения. + */ getProfile() { - return this.apiService.get("/progress/profile/"); + return this.apiService.get(`${this.PROGRESS_URL}/profile/`); } + /** + * Fetches basic user data and account information + * + * @returns Observable Essential user information for display and navigation + */ getUserData() { - return this.apiService.get("/progress/user-data/"); + return this.apiService.get(`${this.PROGRESS_URL}/user-data/`); } + /** + * Получает текущий статус подписки и подробную информацию о ней. + * + * @returns Observable Информация о подписке, включая тип тарифного плана и статус. + */ getSubscriptionData() { - return this.apiService.get("/progress/subscription-data/"); + return this.apiService.get(`${this.PROGRESS_URL}/subscription-data/`); } + /** + * Обновляет настройки автоматического продления подписки пользователя. + * + * @param allowed — следует ли включить автоматическое продление. + * @returns Observable Ответ, подтверждающий обновление. + */ updateSubscriptionDate(allowed: boolean) { - return this.apiService.patch("/progress/update-auto-renewal/", { + return this.apiService.patch(`${this.PROGRESS_URL}/update-auto-renewal/`, { is_autopay_allowed: allowed, }); } + /** + * Отменяет текущую подписку и обрабатывает возврат средств, если применимо. + * + * @returns Observable Ответ, подтверждающий отмену. + */ cancelSubscription() { - return this.apiService.post("/subscription/refund", {}); + return this.apiService.post(`${this.SUBSCRIPTION_URL}/refund`, {}); } + /** + * Добавляет новые навыки в профиль обучения пользователя. + * + * @param skills — массив идентификаторов навыков, которые необходимо добавить в профиль пользователя. + * @returns Observable Ответ, подтверждающий добавление навыков. + */ addSkill(skills: number[]) { - return this.apiService.patch("/progress/add-skills/", skills); + return this.apiService.patch(`${this.PROGRESS_URL}/add-skills/`, skills); } + /** + * Синхронизирует данные локального профиля с бэкэндом. + * + * Этот метод гарантирует, что профиль пользователя будет обновлен + * с учетом последней информации из основного приложения. + * Должен вызываться при инициализации приложения и после значительных изменений. + * + * @returns Observable Ответ, подтверждающий синхронизацию. + */ syncProfile() { - return this.apiService.post("/progress/sync-profile/", {}); + return this.apiService.post(`${this.PROGRESS_URL}/sync-profile/`, {}); } } diff --git a/projects/skills/src/app/profile/shared/info-block/info-block.component.ts b/projects/skills/src/app/profile/shared/info-block/info-block.component.ts index bafda5a42..5aedefb31 100644 --- a/projects/skills/src/app/profile/shared/info-block/info-block.component.ts +++ b/projects/skills/src/app/profile/shared/info-block/info-block.component.ts @@ -11,6 +11,24 @@ import { ModalComponent } from "@ui/components/modal/modal.component"; import { ProfileService } from "../../services/profile.service"; import { Subscription } from "rxjs"; +/** + * Компонент информационного блока пользователя + * + * Отображает основную информацию о пользователе: + * - Аватар, имя, возраст, специализацию + * - Статус наставника (если применимо) + * - Количество баллов + * - Модальное окно уведомления о подписке + * + * @component InfoBlockComponent + * @selector app-info-block + * + * @input userData - Данные пользователя для отображения + * + * @property avatarSize - Размер аватара (адаптивный) + * @property openSuscriptionBought - Флаг модального окна подписки + * @property isMentor - Флаг статуса наставника + */ @Component({ selector: "app-info-block", standalone: true, @@ -63,6 +81,9 @@ export class InfoBlockComponent implements OnInit, OnDestroy { this.subscriptions.forEach(s => s.unsubscribe()); } + /** + * Обрабатывает изменение размера окна для адаптивного размера аватара + */ @HostListener("window:resize", ["$event"]) onResize(event: any) { this.avatarSize.set(event.target.innerWidth > 1200 ? 165 : 90); diff --git a/projects/skills/src/app/profile/shared/month-block/month-block.component.ts b/projects/skills/src/app/profile/shared/month-block/month-block.component.ts index 63af1f12f..204d191d6 100644 --- a/projects/skills/src/app/profile/shared/month-block/month-block.component.ts +++ b/projects/skills/src/app/profile/shared/month-block/month-block.component.ts @@ -4,6 +4,18 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { Profile } from "../../../../models/profile.model"; +/** + * Компонент для отображения блока месяцев с прогрессом + * + * Отображает список месяцев в виде стилизованных элементов, + * показывает выполненные месяцы и индикатор "Далее" + * + * @component MonthBlockComponent + * @selector app-month-block + * + * @input months - Массив месяцев из профиля пользователя + * @input hasNext - Флаг отображения индикатора "Далее" (по умолчанию true) + */ @Component({ selector: "app-month-block", standalone: true, diff --git a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.ts b/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.ts index ffaf47908..b2a00ee71 100644 --- a/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.ts +++ b/projects/skills/src/app/profile/shared/personal-rating-card/personal-rating-card.component.ts @@ -6,6 +6,19 @@ import { CircleProgressBarComponent } from "../../../shared/circle-progress-bar/ import { Skill } from "projects/skills/src/models/profile.model"; import { PluralizePipe } from "@corelib"; +/** + * Компонент карточки навыка с отображением рейтинга и прогресса + * + * Показывает информацию о навыке пользователя: + * - Название и изображение навыка + * - Текущий уровень навыка + * - Круговой индикатор прогресса + * + * @component PersonalRatingCardComponent + * @selector app-personal-rating-card + * + * @input personalRatingCardData - Данные навыка с прогрессом для отображения + */ @Component({ selector: "app-personal-rating-card", standalone: true, diff --git a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.ts b/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.ts index d01401e3a..b529ea724 100644 --- a/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.ts +++ b/projects/skills/src/app/profile/shared/personal-skill-card/personal-skill-card.component.ts @@ -17,6 +17,22 @@ import { SkillService } from "../../../skills/services/skill.service"; import { ActivatedRoute } from "@angular/router"; import { Skill as ProfileSkill } from "projects/skills/src/models/profile.model"; +/** + * Компонент карточки навыка для выбора в личном кабинете + * + * Отображает информацию о навыке с возможностью выбора/отмены выбора. + * Ограничивает выбор до 5 навыков максимум. + * Показывает состояние навыка (выполнен/не выполнен). + * + * @component PersonalSkillCardComponent + * @selector app-personal-skill-card + * + * @input personalSkillCard - Данные навыка для отображения + * @input profileIdSkills - Сигнал с массивом ID выбранных навыков + * @input isRetryPicked - Сигнал флага повторного выбора навыка + * + * @output selectedCountChange - Событие изменения количества выбранных навыков + */ @Component({ selector: "app-personal-skill-card", standalone: true, @@ -34,11 +50,18 @@ export class PersonalSkillCardComponent { route = inject(ActivatedRoute); skillService = inject(SkillService); + /** Вычисляемое свойство - выбран ли текущий навык */ isChecked = computed(() => this.profileIdSkills().includes(this.personalSkillCard.id)); + /** Вычисляемое свойство - количество выбранных навыков */ selectedCount = computed(() => this.profileIdSkills().length); ngOnInit(): void {} + /** + * Переключает состояние выбора навыка + * Проверяет ограничения на количество выбранных навыков (максимум 5) + * Устанавливает флаг повторного выбора если навык уже был выполнен + */ toggleCheck(): void { const currentId = this.personalSkillCard.id; diff --git a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.ts b/projects/skills/src/app/profile/shared/progress-block/progress-block.component.ts index e669dd6cb..13be49288 100644 --- a/projects/skills/src/app/profile/shared/progress-block/progress-block.component.ts +++ b/projects/skills/src/app/profile/shared/progress-block/progress-block.component.ts @@ -7,6 +7,21 @@ import { ActivatedRoute } from "@angular/router"; import { Skill } from "projects/skills/src/models/profile.model"; import { IconComponent } from "@uilib"; +/** + * Компонент блока прогресса навыков + * + * Отображает топ-5 навыков пользователя в виде концентрических кругов + * с индикаторами прогресса. Поддерживает интерактивность при наведении. + * + * @component ProgressBlockComponent + * @selector app-progress-block + * + * @property radius - Радиус кругов прогресса (70px) + * @property skillsList - Список навыков пользователя + * @property hoveredIndex - Индекс навыка при наведении (-1 если нет) + * @property tooltipText - Текст подсказки + * @property isHintVisible - Флаг отображения подсказки + */ @Component({ selector: "app-progress-block", standalone: true, @@ -23,19 +38,34 @@ export class ProgressBlockComponent implements OnInit { tooltipText = "В блоке «Прогресс» отображаются ваши топ-5 навыков, которые вы проходите"; isHintVisible = false; + /** + * Показывает подсказку + */ showTooltip() { this.isHintVisible = true; } + /** + * Скрывает подсказку + */ hideTooltip() { this.isHintVisible = false; } + /** + * Вычисляет смещение обводки для индикатора прогресса + * @param skillProgress - Прогресс навыка в процентах (0-100) + * @returns Значение stroke-dashoffset для SVG + */ calculateStrokeDashOffset(skillProgress: number): number { const circumference = 2 * Math.PI * this.radius; return circumference - (skillProgress / 100) * circumference; } + /** + * Вычисляет массив штрихов для окружности + * @returns Значение stroke-dasharray для SVG + */ calculateStrokeDashArray(): number { return 2 * Math.PI * this.radius; } @@ -48,6 +78,11 @@ export class ProgressBlockComponent implements OnInit { }); } + /** + * Вычисляет прозрачность элемента при наведении + * @param index - Индекс элемента + * @returns Значение прозрачности (0.3 или 1) + */ getOpacity(index: number) { if (this.hoveredIndex === -1) { return 1; diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.ts b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.ts index 279dba75c..9436d4308 100644 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.ts +++ b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.ts @@ -14,16 +14,33 @@ import { CommonModule } from "@angular/common"; import { ButtonComponent } from "@ui/components"; import { ActivatedRoute, RouterLink } from "@angular/router"; import { IconComponent } from "@uilib"; -import { PersonalRatingCardComponent } from "../personal-rating-card/personal-rating-card.component"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { SkillService } from "../../../skills/services/skill.service"; import { Skill } from "projects/skills/src/models/skill.model"; import { PersonalSkillCardComponent } from "../personal-skill-card/personal-skill-card.component"; import { ProfileService } from "../../services/profile.service"; import { Skill as ProfileSkill } from "projects/skills/src/models/profile.model"; -import { tap } from "rxjs"; import { HttpErrorResponse } from "@angular/common/http"; +/** + * Компонент выбора навыков для месячной подписки + * + * Позволяет пользователю выбрать 5 навыков из доступного списка. + * Поддерживает пагинацию, валидацию выбора и обработку ошибок подписки. + * + * @component SkillChooserComponent + * @selector app-skill-chooser + * + * @input open - Флаг отображения модального окна выбора + * @output openChange - Событие изменения состояния модального окна + * + * @property limit - Количество навыков на странице (3) + * @property offset - Смещение для пагинации + * @property currentPage - Текущая страница + * @property totalPages - Общее количество страниц (вычисляемое) + * @property skillsList - Список доступных навыков + * @property profileIdSkills - Выбранные навыки пользователя + */ @Component({ selector: "app-skill-chooser", standalone: true, @@ -74,6 +91,10 @@ export class SkillChooserComponent implements OnInit { }); } + /** + * Загружает список навыков с сервера с учетом пагинации + * Обрабатывает ошибку 403 (нет подписки) + */ private loadSkills(): void { this.skillService.getAllMarked(this.limit, this.offset).subscribe({ next: r => { @@ -97,20 +118,32 @@ export class SkillChooserComponent implements OnInit { }); } + /** + * Обрабатывает изменение состояния модального окна + */ onOpenChange(event: boolean) { this.openChange.emit(event); } + /** + * Закрывает модальное окно и сохраняет выбранные навыки + */ onCloseModal() { this.openChange.emit(false); this.profileService.addSkill(this.profileIdSkills()).subscribe(); } + /** + * Закрывает модальное окно подписки + */ onSubscriptionModalClosed() { this.nonConfirmerModalOpen.set(false); this.open = false; } + /** + * Переходит к предыдущей странице навыков + */ prevPage(): void { if (this.currentPage > 1) { this.currentPage -= 1; @@ -119,6 +152,9 @@ export class SkillChooserComponent implements OnInit { } } + /** + * Переходит к следующей странице навыков + */ nextPage(): void { if (this.currentPage < this.totalPages()) { this.currentPage += 1; @@ -127,6 +163,9 @@ export class SkillChooserComponent implements OnInit { } } + /** + * Обрабатывает изменение количества выбранных навыков + */ onSelectedCountChange(count: number): void { this.selectedSkillsCount.set(count); } diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.resolver.ts b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.resolver.ts index 9f23f6cc3..64687b169 100644 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.resolver.ts +++ b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.resolver.ts @@ -3,6 +3,14 @@ import { inject } from "@angular/core"; import { SkillService } from "../../../skills/services/skill.service"; +/** + * Резолвер для предзагрузки данных навыков + * + * Загружает все навыки пользователя перед отображением компонента выбора. + * Используется в маршрутизации для обеспечения доступности данных. + * + * @returns {Observable} Поток данных с информацией о навыках пользователя + */ export const skillChooserResolver = () => { const skillsService = inject(SkillService); return skillsService.getAll(); diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.routes.ts b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.routes.ts index c0c47ee04..29128c154 100644 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.routes.ts +++ b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.routes.ts @@ -1,6 +1,5 @@ /** @format */ -import { resolve } from "dns"; import { SkillChooserComponent } from "./skill-chooser.component"; import { skillChooserResolver } from "./skill-chooser.resolver"; diff --git a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.ts b/projects/skills/src/app/profile/shared/skills-block/skills-block.component.ts index 33298cf76..40c0644fc 100644 --- a/projects/skills/src/app/profile/shared/skills-block/skills-block.component.ts +++ b/projects/skills/src/app/profile/shared/skills-block/skills-block.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, computed, inject, OnInit, signal } from "@angular/core"; +import { Component, computed, inject, type OnInit, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { ButtonComponent } from "@ui/components"; import { PersonalRatingCardComponent } from "../personal-rating-card/personal-rating-card.component"; @@ -8,10 +8,28 @@ import { IconComponent } from "@uilib"; import { Router, RouterLink } from "@angular/router"; import { SkillChooserComponent } from "../skill-chooser/skill-chooser.component"; import { ProfileService } from "../../services/profile.service"; -import { Profile } from "projects/skills/src/models/profile.model"; +import type { Profile } from "projects/skills/src/models/profile.model"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { SkillService } from "../../../skills/services/skill.service"; +/** + * Компонент блока навыков пользователя + * + * Отображает список выбранных навыков пользователя с пагинацией. + * Поддерживает навигацию к детальной странице навыка и выбор новых навыков. + * Обрабатывает ошибки доступа для пользователей без подписки. + * + * @component SkillsBlockComponent + * @selector app-skills-block + * + * @property openSkillChoose - Флаг модального окна выбора навыков + * @property openInstruction - Флаг модального окна инструкций + * @property skillsList - Полный список навыков пользователя + * @property displayedSkills - Навыки для отображения на текущей странице + * @property limit - Количество навыков на странице (2) + * @property currentPage - Текущая страница + * @property totalPages - Общее количество страниц (вычисляемое) + */ @Component({ selector: "app-skills-block", standalone: true, @@ -47,27 +65,45 @@ export class SkillsBlockComponent implements OnInit { tooltipText = "В данном блоке отображаются ваши навыки, которые вы выбрали в текущем месяце."; + /** + * Показывает подсказку + */ showTooltip() { this.isHintVisible = true; } + /** + * Скрывает подсказку + */ hideTooltip() { this.isHintVisible = false; } + /** + * Обрабатывает изменение состояния модального окна выбора навыков + */ onOpenSkillsChange(open: boolean) { this.openSkillChoose = open; } + /** + * Обрабатывает изменение состояния модального окна инструкций + */ onOpenInstructionChange(open: boolean) { this.openSkillChoose = open; } + /** + * Переходит от инструкций к выбору навыков + */ nextStepModal() { this.openInstruction = false; this.openSkillChoose = true; } + /** + * Переходит к предыдущей странице навыков + */ prevPage(): void { this.offset -= this.limit; this.currentPage -= 1; @@ -80,6 +116,9 @@ export class SkillsBlockComponent implements OnInit { this.updateDisplayedSkills(); } + /** + * Переходит к следующей странице навыков + */ nextPage(): void { if (this.currentPage < this.totalPages()) { this.currentPage += 1; @@ -88,10 +127,17 @@ export class SkillsBlockComponent implements OnInit { } } + /** + * Обновляет список отображаемых навыков на основе текущей страницы + */ updateDisplayedSkills() { this.displayedSkills = this.skillsList.slice(this.offset, this.offset + this.limit); } + /** + * Обрабатывает клик по навыку для перехода к детальной странице + * @param skillId - ID навыка + */ onSkillClick(skillId: number) { this.skillService.setSkillId(skillId); this.router.navigate(["skills", skillId]).catch(err => { diff --git a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.ts b/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.ts index 06dfdf94c..202754b0f 100644 --- a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.ts +++ b/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.ts @@ -8,6 +8,17 @@ import { Router } from "@angular/router"; import { HttpErrorResponse } from "@angular/common/http"; import { ModalComponent } from "@ui/components/modal/modal.component"; +/** + * Компонент блока траектории обучения + * + * Отображает интерактивный блок для перехода к траектории обучения. + * Обрабатывает ошибки навигации и показывает модальные окна с уведомлениями. + * + * @component TrajectoryBlockComponent + * @selector app-trajectory-block + * + * @property isErrorTrajectoryModalOpen - Флаг отображения модального окна ошибки + */ @Component({ selector: "app-trajectory-block", standalone: true, @@ -20,10 +31,17 @@ export class TrajectoryBlockComponent { isErrorTrajectoryModalOpen = false; + /** + * Переключает состояние модального окна ошибки траектории + */ onOpenErorTrajectoryModalChange = (): void => { this.isErrorTrajectoryModalOpen = !this.isErrorTrajectoryModalOpen; }; + /** + * Выполняет навигацию к траектории обучения + * При ошибке 404 показывает модальное окно с уведомлением + */ navigateToTrajectory = (): void => { this.router.navigateByUrl("/trackCar/1").catch(err => { if (err instanceof HttpErrorResponse) { diff --git a/projects/skills/src/app/profile/skills-rating/skills-rating.component.ts b/projects/skills/src/app/profile/skills-rating/skills-rating.component.ts index 371421b96..387e2dc64 100644 --- a/projects/skills/src/app/profile/skills-rating/skills-rating.component.ts +++ b/projects/skills/src/app/profile/skills-rating/skills-rating.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, inject, OnInit } from "@angular/core"; +import { Component, inject, type OnInit } from "@angular/core"; import { CommonModule } from "@angular/common"; import { PersonalRatingCardComponent } from "../shared/personal-rating-card/personal-rating-card.component"; import { Profile } from "projects/skills/src/models/profile.model"; @@ -8,6 +8,17 @@ import { ProfileService } from "../services/profile.service"; import { SkillService } from "../../skills/services/skill.service"; import { RouterModule } from "@angular/router"; +/** + * Компонент страницы рейтинга навыков пользователя + * + * Отображает полный список навыков пользователя с их рейтингами и прогрессом. + * Загружает данные профиля при инициализации и показывает карточки навыков. + * + * @component ProfileSkillsRatingComponent + * @selector app-skills-rating + * + * @property skillsList - Список навыков пользователя с рейтингами + */ @Component({ selector: "app-skills-rating", standalone: true, diff --git a/projects/skills/src/app/profile/students/students.component.ts b/projects/skills/src/app/profile/students/students.component.ts index 22955e66f..587ea939e 100644 --- a/projects/skills/src/app/profile/students/students.component.ts +++ b/projects/skills/src/app/profile/students/students.component.ts @@ -1,7 +1,14 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { Component, HostListener, inject, OnDestroy, OnInit, signal } from "@angular/core"; +import { + Component, + HostListener, + inject, + type OnDestroy, + type OnInit, + signal, +} from "@angular/core"; import { ActivatedRoute, RouterModule } from "@angular/router"; import { PluralizePipe } from "@corelib"; import { ButtonComponent, CheckboxComponent } from "@ui/components"; @@ -12,6 +19,18 @@ import { TrajectoriesService } from "../../trajectories/trajectories.service"; import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { LoaderComponent } from "@ui/components/loader/loader.component"; +/** + * Компонент управления студентами для менторов + * + * Предоставляет интерфейс для менторов по управлению своими студентами: + * - Просмотр списка закрепленных студентов + * - Отметка о проведении начальных и финальных встреч + * - Адаптивное отображение для разных размеров экрана + * - Расширяемые карточки студентов с формой управления + * + * Компонент доступен только пользователям со статусом ментора + * и отображает студентов, назначенных конкретному ментору. + */ @Component({ selector: "app-students", standalone: true, @@ -30,39 +49,54 @@ import { LoaderComponent } from "@ui/components/loader/loader.component"; }) export class ProfileStudentsComponent implements OnInit, OnDestroy { constructor(private readonly fb: FormBuilder) { + // Инициализация формы для управления встречами this.studentForm = this.fb.group({ initialMeeting: [false, Validators.required], finalMeeting: [false, Validators.required], }); } + // Внедрение зависимостей route = inject(ActivatedRoute); trajectoryService = inject(TrajectoriesService); + // URL заглушки для аватаров без изображения placeholderUrl = "https://uch-ibadan.org.ng/wp-content/uploads/2021/10/Profile_avatar_placeholder_large.png"; + // Состояние UI expanded = false; avatarSize = signal(window.innerWidth > 1200 ? 94 : 48); expandedStudentId: number | null = null; - showLoader = signal(true); + // Данные студентов students?: Student[]; subscriptions: Subscription[] = []; + // Форма для управления встречами studentForm: FormGroup; + /** + * Переключение развернутого состояния карточки студента + * + * @param studentId - идентификатор студента + * + * Если карточка уже развернута - сворачивает её, + * иначе разворачивает и загружает данные студента в форму + */ toggleExpand(studentId: number) { if (this.expandedStudentId === studentId) { this.expandedStudentId = null; } else { this.expandedStudentId = studentId; + // Имитация загрузки данных setTimeout(() => { this.showLoader.set(false); }, 600); + // Заполнение формы данными выбранного студента const student = this.students?.find(s => s.student.id === studentId); if (student) { this.studentForm.patchValue({ @@ -73,6 +107,12 @@ export class ProfileStudentsComponent implements OnInit, OnDestroy { } } + /** + * Обработчик изменения чекбоксов встреч + * + * @param key - тип встречи ('initialMeeting' или 'finalMeeting') + * @param value - новое значение чекбокса + */ onSelect(key: string, value: boolean) { if (key === "initialMeeting") { this.studentForm.get("initialMeeting")?.setValue(value); @@ -81,6 +121,11 @@ export class ProfileStudentsComponent implements OnInit, OnDestroy { } } + /** + * Инициализация компонента + * + * Загружает список студентов из резолвера маршрута + */ ngOnInit(): void { const students = this.route.data.pipe(map(r => r["students"])).subscribe((r: Student[]) => { this.students = r; @@ -89,15 +134,27 @@ export class ProfileStudentsComponent implements OnInit, OnDestroy { this.subscriptions.push(students); } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy(): void { this.subscriptions.forEach(s => s.unsubscribe()); } + /** + * Сохранение изменений статуса встреч + * + * @param id - идентификатор встречи (meetingId) + * + * Отправляет обновленные данные о встречах на сервер + * и обновляет локальное состояние при успешном ответе + */ onSave(id: number) { if (this.studentForm.invalid) return; const { initialMeeting, finalMeeting } = this.studentForm.value; this.trajectoryService.updateMeetings(id, initialMeeting, finalMeeting).subscribe(() => { + // Обновление локальных данных const student = this.students?.find(s => s.meetingId === id); if (student) { student.initialMeeting = initialMeeting; @@ -107,6 +164,15 @@ export class ProfileStudentsComponent implements OnInit, OnDestroy { }); } + /** + * Обработчик изменения размера окна + * + * Адаптирует размер аватаров в зависимости от ширины экрана: + * - Большие экраны (>1200px): аватары 94px + * - Малые экраны (≤1200px): аватары 48px + * + * @param event - событие изменения размера окна + */ @HostListener("window:resize", ["$event"]) onResize(event: any) { this.avatarSize.set(event.target.innerWidth > 1200 ? 94 : 48); diff --git a/projects/skills/src/app/profile/students/students.resolver.ts b/projects/skills/src/app/profile/students/students.resolver.ts index 88fce0cc5..4fd11e175 100644 --- a/projects/skills/src/app/profile/students/students.resolver.ts +++ b/projects/skills/src/app/profile/students/students.resolver.ts @@ -3,6 +3,16 @@ import { inject } from "@angular/core"; import { TrajectoriesService } from "../../trajectories/trajectories.service"; +/** + * Резолвер для загрузки списка студентов ментора + * + * Выполняется перед активацией маршрута студентов и предоставляет + * список всех студентов, закрепленных за текущим ментором. + * + * Доступен только пользователям со статусом ментора. + * + * @returns Observable - массив студентов с информацией о встречах + */ export const studentsResolver = () => { const trajectoryService = inject(TrajectoriesService); return trajectoryService.getMentorStudents(); diff --git a/projects/skills/src/app/rating/general/general.component.ts b/projects/skills/src/app/rating/general/general.component.ts index e5f8327f2..5d7f312a6 100644 --- a/projects/skills/src/app/rating/general/general.component.ts +++ b/projects/skills/src/app/rating/general/general.component.ts @@ -1,18 +1,30 @@ /** @format */ -import { Component, inject, OnInit, signal } from "@angular/core"; +import { Component, inject, type OnInit, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { TopRatingCardComponent } from "../shared/top-rating-card/top-rating-card.component"; import { BasicRatingCardComponent } from "../shared/basic-rating-card/basic-rating-card.component"; import { ActivatedRoute, Router } from "@angular/router"; -import { map, Observable } from "rxjs"; -import { GeneralRating } from "../../../models/rating.model"; +import { map, type Observable } from "rxjs"; +import type { GeneralRating } from "../../../models/rating.model"; import { SelectComponent } from "@ui/components"; import { IconComponent } from "@uilib"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { FormBuilder, type FormGroup, ReactiveFormsModule } from "@angular/forms"; import { RatingService } from "../services/rating.service"; import { ratingFiltersList } from "projects/core/src/consts/rating-filters"; +/** + * Компонент общего рейтинга пользователей + * + * Отображает рейтинг всех пользователей системы с возможностью + * фильтрации по временным периодам (день, неделя, месяц, год). + * + * Функциональность: + * - Отображение топ-3 пользователей в специальном формате + * - Список остальных пользователей в стандартном формате + * - Фильтрация по временным периодам + * - Автоматическое обновление URL с параметрами фильтра + */ @Component({ selector: "app-general", standalone: true, @@ -29,32 +41,50 @@ import { ratingFiltersList } from "projects/core/src/consts/rating-filters"; }) export class RatingGeneralComponent implements OnInit { constructor() { + // Инициализация формы с фильтром по умолчанию (последний месяц) this.ratingForm = this.fb.group({ filterParam: ["last_month"], }); } + // Внедрение зависимостей private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly ratingService = inject(RatingService); - private readonly fb = inject(FormBuilder); + + // Поток данных рейтинга из резолвера private readonly rating = this.route.data.pipe(map(r => r["data"])) as Observable< GeneralRating[] >; + // Сигналы для разделения пользователей на топ-3 и остальных top3 = signal([]); rest = signal([]); + // Форма для управления фильтрами ratingForm: FormGroup; + // Константы фильтров из конфигурации readonly filterParams = ratingFiltersList; + /** + * Инициализация компонента + * + * Загружает начальные данные рейтинга и настраивает + * отслеживание изменений формы фильтрации + */ ngOnInit() { this.loadInitialRatings(); this.setupFormValueChanges(); } + /** + * Загрузка начальных данных рейтинга + * + * Получает данные из резолвера, разделяет их на топ-3 и остальных, + * и обновляет URL с текущим параметром фильтра + */ loadInitialRatings() { this.rating.subscribe(r => { this.updateRatingSignals(r); @@ -62,6 +92,12 @@ export class RatingGeneralComponent implements OnInit { }); } + /** + * Настройка отслеживания изменений формы + * + * При изменении фильтра автоматически обновляет URL + * и загружает новые данные рейтинга + */ setupFormValueChanges() { this.ratingForm.valueChanges.subscribe(value => { this.navigateWithFilterParam(value.filterParam); @@ -69,6 +105,11 @@ export class RatingGeneralComponent implements OnInit { }); } + /** + * Обновление URL с параметром фильтра + * + * @param filterParam - выбранный временной период фильтрации + */ navigateWithFilterParam(filterParam: string) { this.router.navigate([], { relativeTo: this.route, @@ -76,12 +117,25 @@ export class RatingGeneralComponent implements OnInit { }); } + /** + * Загрузка данных рейтинга с указанным фильтром + * + * @param filterParam - временной период для фильтрации рейтинга + */ loadRatings(filterParam: "last_month" | "last_year" | "last_day" | "last_week") { this.ratingService.getGeneralRating(filterParam).subscribe(r => { this.updateRatingSignals(r); }); } + /** + * Обновление сигналов с данными рейтинга + * + * Разделяет массив пользователей на топ-3 (первые 3 места) + * и остальных участников рейтинга + * + * @param r - массив пользователей рейтинга + */ updateRatingSignals(r: GeneralRating[]) { this.top3.set(r.slice(0, 3)); this.rest.set(r.slice(3)); diff --git a/projects/skills/src/app/rating/general/general.resolver.ts b/projects/skills/src/app/rating/general/general.resolver.ts index 8d629e5f1..b4eda7834 100644 --- a/projects/skills/src/app/rating/general/general.resolver.ts +++ b/projects/skills/src/app/rating/general/general.resolver.ts @@ -1,10 +1,22 @@ /** @format */ -import { ResolveFn } from "@angular/router"; +import type { ResolveFn } from "@angular/router"; import { inject } from "@angular/core"; import { RatingService } from "../services/rating.service"; -import { GeneralRating } from "../../../models/rating.model"; +import type { GeneralRating } from "../../../models/rating.model"; +/** + * Резолвер для загрузки данных общего рейтинга + * + * Выполняется перед активацией маршрута общего рейтинга + * и предоставляет начальные данные рейтинга с фильтром + * по умолчанию (последний месяц). + * + * Это обеспечивает мгновенное отображение данных + * при загрузке страницы рейтинга. + * + * @returns Observable - массив пользователей с рейтингом + */ export const generalRatingResolver: ResolveFn = () => { const ratingService = inject(RatingService); return ratingService.getGeneralRating(); diff --git a/projects/skills/src/app/rating/rating.routes.ts b/projects/skills/src/app/rating/rating.routes.ts index 6870416a9..26c4fcff4 100644 --- a/projects/skills/src/app/rating/rating.routes.ts +++ b/projects/skills/src/app/rating/rating.routes.ts @@ -4,12 +4,20 @@ import { Routes } from "@angular/router"; import { RatingGeneralComponent } from "./general/general.component"; import { generalRatingResolver } from "./general/general.resolver"; +/** + * Маршруты для модуля рейтинга + * + * Содержит: + * - Корневой маршрут ("") - отображает общий компонент рейтинга + * - Резолвер данных - предзагружает данные рейтинга перед отображением компонента + */ export const RATING_ROUTES: Routes = [ { + // Корневой маршрут модуля рейтинга path: "", - component: RatingGeneralComponent, + component: RatingGeneralComponent, // Компонент для отображения общего рейтинга resolve: { - data: generalRatingResolver, + data: generalRatingResolver, // Резолвер для предзагрузки данных рейтинга }, }, ]; diff --git a/projects/skills/src/app/rating/services/rating.service.ts b/projects/skills/src/app/rating/services/rating.service.ts index 74aabb6ab..33ab96daa 100644 --- a/projects/skills/src/app/rating/services/rating.service.ts +++ b/projects/skills/src/app/rating/services/rating.service.ts @@ -2,21 +2,55 @@ import { inject, Injectable } from "@angular/core"; import { SkillsApiService } from "@corelib"; +import { HttpParams } from "@angular/common/http"; import { GeneralRating } from "../../../models/rating.model"; import { map } from "rxjs"; import { ApiPagination } from "../../../models/api-pagination.model"; +/** + * Сервис для работы с рейтингами пользователей + * + * Предоставляет методы для получения различных типов рейтингов + * с поддержкой фильтрации по временным периодам. + * + * Взаимодействует с API для получения актуальных данных + * о достижениях и позициях пользователей в системе. + */ @Injectable({ providedIn: "root", }) export class RatingService { - apiService = inject(SkillsApiService); + private readonly PROGRESS_URL = "/progress"; + private apiService = inject(SkillsApiService); + + /** + * Получение общего рейтинга пользователей + * + * Загружает рейтинг всех пользователей системы с возможностью + * фильтрации по различным временным периодам. + * + * @param ratingParam - временной период для расчета рейтинга: + * - 'last_day' - за последний день + * - 'last_week' - за последнюю неделю + * - 'last_month' - за последний месяц (по умолчанию) + * - 'last_year' - за последний год + * + * @returns Observable - массив пользователей с их рейтинговыми данными, + * отсортированный по убыванию количества баллов + */ getGeneralRating( ratingParam: "last_year" | "last_month" | "last_day" | "last_week" = "last_month" ) { return this.apiService - .get>(`/progress/user-rating/?time_frame=${ratingParam}`) + .get>( + `${this.PROGRESS_URL}/user-rating/`, + new HttpParams({ + fromObject: { + time_frame: ratingParam, + }, + }) + ) .pipe(map(res => res.results)); } } diff --git a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.ts b/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.ts index 37e7a4411..e33050fda 100644 --- a/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.ts +++ b/projects/skills/src/app/rating/shared/basic-rating-card/basic-rating-card.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, inject, Input, OnInit, signal } from "@angular/core"; +import { Component, inject, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { AvatarComponent } from "@uilib"; import { GeneralRating } from "../../../../models/rating.model"; @@ -8,6 +8,26 @@ import { PluralizePipe } from "@corelib"; import { ActivatedRoute } from "@angular/router"; import { map, Observable } from "rxjs"; +/** + * Компонент базовой карточки рейтинга + * + * Описание: Отображает стандартную карточку участника рейтинга + * Использование: Для показа участников рейтинга вне топ-3 + * + * Входные параметры: + * @param rating - объект с данными рейтинга участника (обязательный) + * @param ratingId - идентификатор рейтинга + * + * Функциональность: + * - Получает данные рейтинга из маршрута через резолвер + * - Отображает информацию об участнике с аватаром + * - Использует pipe для плюрализации текста + * + * Зависимости: + * - AvatarComponent - для отображения аватара участника + * - PluralizePipe - для корректного склонения слов + * - GeneralRating - модель данных рейтинга + */ @Component({ selector: "app-basic-rating-card", standalone: true, @@ -16,9 +36,28 @@ import { map, Observable } from "rxjs"; styleUrl: "./basic-rating-card.component.scss", }) export class BasicRatingCardComponent { + /** + * Данные рейтинга участника + * Обязательное поле, содержит информацию об участнике и его показателях + */ @Input({ required: true }) rating!: GeneralRating; + + /** + * Идентификатор рейтинга + * Используется для идентификации конкретного рейтинга + */ @Input() ratingId!: number; + /** + * Сервис для работы с активным маршрутом + * Инжектируется для получения данных из резолвера + */ route = inject(ActivatedRoute); + + /** + * Observable с данными рейтинга + * Получает данные из резолвера маршрута и преобразует их в Observable + * Возвращает: поток данных с массивом рейтингов + */ ratingData = this.route.data.pipe(map(r => r["data"])) as Observable; } diff --git a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.ts b/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.ts index b829457dc..5325ff1b3 100644 --- a/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.ts +++ b/projects/skills/src/app/rating/shared/top-rating-card/top-rating-card.component.ts @@ -3,8 +3,22 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { AvatarComponent } from "@uilib"; -import { GeneralRating } from "../../../../models/rating.model"; +import type { GeneralRating } from "../../../../models/rating.model"; +/** + * Компонент карточки топ-рейтинга + * + * Описание: Отображает карточку участника с высоким рейтингом (топ-3) + * Использование: Для показа лидеров рейтинга с особым оформлением + * + * Входные параметры: + * @param place - позиция в рейтинге (по умолчанию 3) + * @param rating - объект с данными рейтинга участника (обязательный) + * + * Зависимости: + * - AvatarComponent - для отображения аватара участника + * - GeneralRating - модель данных рейтинга + */ @Component({ selector: "app-top-rating-card", standalone: true, @@ -13,7 +27,15 @@ import { GeneralRating } from "../../../../models/rating.model"; styleUrl: "./top-rating-card.component.scss", }) export class TopRatingCardComponent { + /** + * Позиция участника в рейтинге + * По умолчанию: 3 место + */ @Input() place = 3; + /** + * Данные рейтинга участника + * Обязательное поле, содержит информацию об участнике и его показателях + */ @Input({ required: true }) rating!: GeneralRating; } diff --git a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.ts b/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.ts index be075a60a..593765cea 100644 --- a/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.ts +++ b/projects/skills/src/app/shared/circle-progress-bar/circle-progress-bar.component.ts @@ -3,6 +3,15 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; +/** + * Компонент круглого прогресс-бара + * + * Отображает прогресс в виде круглой диаграммы с использованием SVG. + * Прогресс отображается как заполненная дуга от 0 до 100%. + * + * @example + * + */ @Component({ selector: "app-circle-progress-bar", standalone: true, @@ -11,15 +20,42 @@ import { CommonModule } from "@angular/common"; styleUrl: "./circle-progress-bar.component.scss", }) export class CircleProgressBarComponent { + /** + * Значение прогресса в процентах + * Принимает значения от 0 до 100 + * @default 0 + */ @Input() progress = 0; + + /** + * Радиус круга прогресс-бара в пикселях + * Используется для расчета окружности и отступов + * @default 70 + */ radius = 70; + /** + * Вычисляет смещение штриха для отображения прогресса + * + * Рассчитывает значение stroke-dashoffset для SVG элемента, + * которое определяет какая часть окружности будет заполнена. + * + * @returns {number} Значение смещения штриха в пикселях + */ calculateStrokeDashOffset(): number { - const circumference = 2 * Math.PI * this.radius; // 2 * π * radius + const circumference = 2 * Math.PI * this.radius; // Длина окружности: 2 * π * радиус return circumference - (this.progress / 100) * circumference; } + /** + * Вычисляет массив штрихов для SVG окружности + * + * Возвращает полную длину окружности, которая используется + * в качестве значения stroke-dasharray для создания пунктирной линии. + * + * @returns {number} Длина окружности в пикселях + */ calculateStrokeDashArray(): number { - return 2 * Math.PI * this.radius; // 2 * π * radius + return 2 * Math.PI * this.radius; // Полная длина окружности: 2 * π * радиус } } 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 index b72cc1805..21cb02ae6 100644 --- a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts +++ b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.ts @@ -1,13 +1,22 @@ /** @format */ -import { Component, EventEmitter, inject, OnInit, Output, signal } from "@angular/core"; +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 { UserData } from "projects/skills/src/models/profile.model"; +import type { UserData } from "projects/skills/src/models/profile.model"; import { ProfileService } from "../../profile/services/profile.service"; +/** + * Компонент профиля пользователя в боковой панели + * + * Отображает информацию о текущем пользователе в боковой панели приложения. + * Загружает данные пользователя при инициализации и предоставляет возможность выхода из системы. + * + * @example + * + */ @Component({ selector: "app-sidebar-profile", standalone: true, @@ -16,15 +25,37 @@ import { ProfileService } from "../../profile/services/profile.service"; styleUrl: "./sidebar-profile.component.scss", }) export class SidebarProfileComponent implements OnInit { + /** + * Событие выхода из системы + * Эмитится когда пользователь нажимает на кнопку выхода + */ @Output() logout = new EventEmitter(); + /** + * Сигнал с данными пользователя + * Содержит информацию о текущем авторизованном пользователе или null если данные не загружены + */ user = signal(null); + + /** + * Сервис для работы с профилем пользователя + * Инжектируется автоматически через DI контейнер Angular + */ profileService = inject(ProfileService); + /** + * Инициализация компонента + * + * Загружает данные пользователя при создании компонента. + * В случае ошибки перенаправляет на страницу авторизации. + * + * @returns void + */ ngOnInit(): void { this.profileService.getUserData().subscribe({ next: data => this.user.set(data as UserData), error: () => { + // Перенаправление на страницу авторизации при ошибке загрузки данных location.href = "https://app.procollab.ru/auth/login"; }, }); 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 index 944ef1a08..c952630d2 100644 --- a/projects/skills/src/app/shared/task-card/task-card.component.ts +++ b/projects/skills/src/app/shared/task-card/task-card.component.ts @@ -4,8 +4,20 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { ButtonComponent } from "@ui/components"; import { IconComponent } from "@uilib"; -import { Task, TasksResponse } from "../../../models/skill.model"; +import type { Task, TasksResponse } from "../../../models/skill.model"; +/** + * Компонент карточки задачи + * + * Отображает информацию о задаче в виде карточки с деталями задачи и её статусом. + * Используется для представления задач в списке или сетке задач. + * + * @example + * + * + */ @Component({ selector: "app-task-card", standalone: true, @@ -14,6 +26,20 @@ import { Task, TasksResponse } from "../../../models/skill.model"; 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/detail/detail.component.ts b/projects/skills/src/app/skills/detail/detail.component.ts index 030728a21..bcb002da5 100644 --- a/projects/skills/src/app/skills/detail/detail.component.ts +++ b/projects/skills/src/app/skills/detail/detail.component.ts @@ -16,6 +16,22 @@ import { CircleProgressBarComponent } from "../../shared/circle-progress-bar/cir import { TaskCardComponent } from "../../shared/task-card/task-card.component"; import { SkillDetailResolve } from "./detail.resolver"; +/** + * Компонент детальной страницы навыка + * + * Отображает подробную информацию о навыке, прогресс пользователя, + * статистику по неделям и список задач + * + * Принимает данные через резолвер маршрута: + * - Информацию о задачах и статистике пользователя + * - Детальную информацию о навыке + * + * Функциональность: + * - Отображение прогресса в виде круговой диаграммы + * - Навигация к задачам по клику + * - Адаптивная высота блоков + * - Фильтрация выполненных недель + */ @Component({ selector: "app-detail", standalone: true, diff --git a/projects/skills/src/app/skills/detail/detail.resolver.ts b/projects/skills/src/app/skills/detail/detail.resolver.ts index f40a82a2c..b7e2e9988 100644 --- a/projects/skills/src/app/skills/detail/detail.resolver.ts +++ b/projects/skills/src/app/skills/detail/detail.resolver.ts @@ -6,8 +6,18 @@ import { SkillService } from "../services/skill.service"; import { forkJoin } from "rxjs"; import { TasksResponse, Skill } from "projects/skills/src/models/skill.model"; +/** + * Резолвер для детальной страницы навыка + * + * Загружает данные перед отображением компонента: + * - Задачи и статистику пользователя по навыку + * - Детальную информацию о навыке + * + * @param route - Активный маршрут с параметром skillId + * @returns {Observable} Массив с данными задач и информацией о навыке + */ export type SkillDetailResolve = [TasksResponse, Skill]; -export const skillDetailResolver: ResolveFn = (route, state) => { +export const skillDetailResolver: ResolveFn = route => { const skillService = inject(SkillService); const skillId = route.params["skillId"]; diff --git a/projects/skills/src/app/skills/detail/detail.routes.ts b/projects/skills/src/app/skills/detail/detail.routes.ts index ba5e996cd..892ac13d1 100644 --- a/projects/skills/src/app/skills/detail/detail.routes.ts +++ b/projects/skills/src/app/skills/detail/detail.routes.ts @@ -1,9 +1,17 @@ /** @format */ -import { Routes } from "@angular/router"; +import type { Routes } from "@angular/router"; import { SkillDetailComponent } from "./detail.component"; import { skillDetailResolver } from "./detail.resolver"; +/** + * Маршруты для детальной страницы навыка + * + * Определяет единственный маршрут для отображения детальной информации о навыке + * с резолвером для предварительной загрузки данных + * + * @returns {Routes} Конфигурация маршрута для детальной страницы + */ export const DETAIL_ROUTES: Routes = [ { path: "", diff --git a/projects/skills/src/app/skills/list/list.component.ts b/projects/skills/src/app/skills/list/list.component.ts index 29f394ac5..234c2c4f5 100644 --- a/projects/skills/src/app/skills/list/list.component.ts +++ b/projects/skills/src/app/skills/list/list.component.ts @@ -2,26 +2,39 @@ import { Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { BackComponent, IconComponent } from "@uilib"; -import { - ActivatedRoute, - Router, - RouterLink, - RouterLinkActive, - RouterModule, -} from "@angular/router"; +import { IconComponent } from "@uilib"; +import { ActivatedRoute, Router, RouterLink, RouterModule } from "@angular/router"; import { BarComponent, ButtonComponent } from "@ui/components"; import { SkillCardComponent } from "../shared/skill-card/skill-card.component"; -import { map, Observable, Subscription } from "rxjs"; -import { ApiPagination } from "../../../models/api-pagination.model"; +import { map, Subscription } from "rxjs"; import { Skill } from "../../../models/skill.model"; -import { WriteTaskComponent } from "../../task/shared/write-task/write-task.component"; import { SkillService } from "../services/skill.service"; import { ProfileService } from "../../profile/services/profile.service"; import { SubscriptionData } from "@corelib"; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { ModalComponent } from "@ui/components/modal/modal.component"; +/** + * Компонент списка навыков + * + * Отображает список всех доступных навыков с возможностью поиска + * и переходом к детальной странице + * + * Функциональность: + * - Загрузка и отображение списка навыков + * - Поиск навыков по названию + * - Проверка типа подписки пользователя + * - Обработка ограничений доступа к навыкам + * - Отображение модальных окон для пользователей без доступа + * + * Принимает данные через резолвер маршрута: + * - Список навыков с пагинацией + * + * Управляет состоянием: + * - Отфильтрованный список навыков + * - Состояние модальных окон + * - Форма поиска + */ @Component({ selector: "app-list", standalone: true, diff --git a/projects/skills/src/app/skills/list/list.resolver.ts b/projects/skills/src/app/skills/list/list.resolver.ts index 4a464fcdb..a3e55e9c8 100644 --- a/projects/skills/src/app/skills/list/list.resolver.ts +++ b/projects/skills/src/app/skills/list/list.resolver.ts @@ -1,9 +1,16 @@ /** @format */ -import { ResolveFn } from "@angular/router"; +import type { ResolveFn } from "@angular/router"; import { inject } from "@angular/core"; import { SkillService } from "../services/skill.service"; +/** + * Резолвер для списка навыков + * + * Загружает список всех доступных навыков перед отображением компонента + * + * @returns {Observable} Данные со списком навыков + */ export const skillsListResolver: ResolveFn = () => { const skillService = inject(SkillService); return skillService.getAll(); diff --git a/projects/skills/src/app/skills/services/skill.service.ts b/projects/skills/src/app/skills/services/skill.service.ts index c47e9c0c0..611900ac9 100644 --- a/projects/skills/src/app/skills/services/skill.service.ts +++ b/projects/skills/src/app/skills/services/skill.service.ts @@ -6,21 +6,49 @@ import { ApiPagination } from "../../../models/api-pagination.model"; import { Skill, TasksResponse } from "../../../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>("/courses/all-skills/"); + 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>( - "/courses/choose-skills/", + `${this.COURSES_URL}/choose-skills/`, new HttpParams({ fromObject: { limit, @@ -30,19 +58,46 @@ export class SkillService { ); } + /** + * Получает подробную информацию о конкретном навыке + * + * @param skillId - Уникальный идентификатор навыка + * @returns Observable Полная информация о навыке, включая описание и требования + */ getDetail(skillId: number) { - return this.apiService.get(`/courses/skill-details/${skillId}`); + return this.apiService.get(`${this.COURSES_URL}/skill-details/${skillId}`); } + /** + * Получает все задания, связанные с конкретным навыком + * + * @param skillId - Уникальный идентификатор навыка + * @returns Observable Задания, статистика прогресса и статус завершения + */ getTasks(skillId: number) { - return this.apiService.get(`/courses/tasks-of-skill/${skillId}`); + 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/skills/shared/skill-card/skill-card.component.ts b/projects/skills/src/app/skills/shared/skill-card/skill-card.component.ts index be600aedf..902d1ee4c 100644 --- a/projects/skills/src/app/skills/shared/skill-card/skill-card.component.ts +++ b/projects/skills/src/app/skills/shared/skill-card/skill-card.component.ts @@ -6,6 +6,20 @@ import { AvatarComponent } from "@uilib"; import { Skill } from "../../../../models/skill.model"; import { PluralizePipe } from "@corelib"; +/** + * Компонент карточки навыка + * + * Отображает краткую информацию о навыке в виде карточки + * + * @Input skill - Объект с данными навыка (обязательный) + * @Input type - Тип отображения карточки: 'personal' | 'base' (по умолчанию 'base') + * + * Функциональность: + * - Отображение основной информации о навыке + * - Поддержка двух визуальных стилей + * - Индикация статуса навыка (подписка, просрочка, выполнение) + * - Плюрализация для количества уровней + */ @Component({ selector: "app-skill-card", standalone: true, diff --git a/projects/skills/src/app/skills/skills.routes.ts b/projects/skills/src/app/skills/skills.routes.ts index 86bc3a04b..ea33dcf7d 100644 --- a/projects/skills/src/app/skills/skills.routes.ts +++ b/projects/skills/src/app/skills/skills.routes.ts @@ -4,6 +4,15 @@ import { Routes } from "@angular/router"; import { SkillsListComponent } from "./list/list.component"; import { skillsListResolver } from "./list/list.resolver"; +/** + * Конфигурация маршрутов для модуля навыков + * + * Определяет основные маршруты: + * - '' - список всех навыков с резолвером для загрузки данных + * - ':skillId' - детальная страница навыка с ленивой загрузкой дочерних маршрутов + * + * @returns {Routes} Массив конфигураций маршрутов для навыков + */ export const SKILLS_ROUTES: Routes = [ { path: "", component: SkillsListComponent, resolve: { data: skillsListResolver } }, { diff --git a/projects/skills/src/app/subscription/service/subscription.service.ts b/projects/skills/src/app/subscription/service/subscription.service.ts index 8eff34c4d..8bd650c14 100644 --- a/projects/skills/src/app/subscription/service/subscription.service.ts +++ b/projects/skills/src/app/subscription/service/subscription.service.ts @@ -3,13 +3,26 @@ import { Injectable, inject } from "@angular/core"; import { SkillsApiService, SubscriptionPlan } from "@corelib"; +/** + * Сервис для работы с API подписок. Предоставляет методы для + * получения информации о доступных планах подписки. + */ @Injectable({ providedIn: "root", }) export class SubscriptionService { + private readonly SUBSCRIPTION_URL = "/subscription"; + apiService = inject(SkillsApiService); + /** + * Получение списка всех доступных планов подписки + * @returns Observable - массив планов подписки + * + * Ошибки обрабатываются на уровне компонентов + * Пример использования: this.subscriptionService.getSubscriptions(); + */ getSubscriptions() { - return this.apiService.get("/subscription/"); + return this.apiService.get(`${this.SUBSCRIPTION_URL}/`); } } diff --git a/projects/skills/src/app/subscription/subscription.component.ts b/projects/skills/src/app/subscription/subscription.component.ts index 197a2ef65..2181c1fd3 100644 --- a/projects/skills/src/app/subscription/subscription.component.ts +++ b/projects/skills/src/app/subscription/subscription.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, OnDestroy, OnInit, inject, signal } from "@angular/core"; +import { Component, type OnDestroy, type OnInit, inject, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { IconComponent } from "@uilib"; import { ButtonComponent } from "@ui/components"; @@ -8,10 +8,22 @@ import { SwitchComponent } from "@ui/components/switch/switch.component"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { ActivatedRoute } from "@angular/router"; import { toSignal } from "@angular/core/rxjs-interop"; -import { map, Subscription } from "rxjs"; +import { map, type Subscription } from "rxjs"; import { ProfileService } from "../profile/services/profile.service"; -import { SubscriptionData, SubscriptionPlan, SubscriptionPlansService } from "@corelib"; - +import { type SubscriptionData, type SubscriptionPlan, SubscriptionPlansService } from "@corelib"; + +/** + * Компонент управления подписками + * + * Предоставляет интерфейс для: + * - Просмотра доступных планов подписки + * - Покупки новой подписки + * - Управления автопродлением + * - Отмены текущей подписки + * + * Компонент обрабатывает различные модальные окна для подтверждения действий + * и взаимодействует с API для выполнения операций с подписками + */ @Component({ selector: "app-subscription", standalone: true, @@ -20,15 +32,17 @@ import { SubscriptionData, SubscriptionPlan, SubscriptionPlansService } from "@c styleUrl: "./subscription.component.scss", }) export class SubscriptionComponent implements OnInit, OnDestroy { + // Сигналы для управления состоянием модальных окон open = signal(false); autoRenewModalOpen = signal(false); + isSubscribedModalOpen = signal(false); + // Внедрение зависимостей route = inject(ActivatedRoute); profileService = inject(ProfileService); subscriptionService = inject(SubscriptionPlansService); - isSubscribedModalOpen = signal(false); - + // Сигналы для данных подписки subscriptions = signal([]); subscriptionData = toSignal( this.route.data.pipe(map(r => r["subscriptionData"])) @@ -38,8 +52,15 @@ export class SubscriptionComponent implements OnInit, OnDestroy { this.route.data.pipe(map(r => r["subscriptionData"].isSubscribed)) ); + // Массив подписок для управления жизненным циклом subscription: Subscription[] = []; + /** + * Инициализация компонента + * + * Загружает данные о планах подписки из резолвера, + * сортирует их по цене и устанавливает в локальное состояние + */ ngOnInit(): void { const subsriptionPlanSub = this.route.data .pipe(map(r => r["data"])) @@ -58,10 +79,18 @@ export class SubscriptionComponent implements OnInit, OnDestroy { this.subscription.push(subsriptionPlanSub); } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy(): void { this.subscription.forEach(sub => sub.unsubscribe()); } + /** + * Обработчик изменения состояния модальных окон + * + * @param event - новое состояние модального окна (открыто/закрыто) + */ onOpenChange(event: boolean) { if ((this.open() && !event) || (this.autoRenewModalOpen() && !event)) { this.open.set(false); @@ -72,6 +101,12 @@ export class SubscriptionComponent implements OnInit, OnDestroy { } } + /** + * Обработчик изменения настройки автопродления + * + * Если автопродление включено - отключает его, + * если выключено - открывает модальное окно подтверждения + */ onCheckedChange() { if (this.subscriptionData()?.isAutopayAllowed) { this.profileService.updateSubscriptionDate(false).subscribe(() => { @@ -83,18 +118,36 @@ export class SubscriptionComponent implements OnInit, OnDestroy { } } + /** + * Обработчик закрытия модального окна отмены подписки + * + * @param event - состояние модального окна + */ onCancelModalClose(event: boolean) { if (!event) this.open.set(false); } + /** + * Обработчик закрытия модального окна автопродления + * + * @param event - состояние модального окна + */ onAutoRenewModalClose(event: boolean) { if (!event) this.autoRenewModalOpen.set(false); } + /** + * Открытие модального окна отмены подписки + */ openCancelModal() { this.open.set(true); } + /** + * Подтверждение настройки автопродления + * + * @param event - новое значение настройки автопродления + */ onConfirmAutoPlay(event: boolean) { this.profileService.updateSubscriptionDate(event).subscribe(() => { if (this.subscriptionData()) { @@ -106,6 +159,12 @@ export class SubscriptionComponent implements OnInit, OnDestroy { }); } + /** + * Отмена текущей подписки + * + * Выполняет запрос на отмену подписки и перезагружает страницу + * для отображения обновленного состояния + */ onCancelSubscription() { this.profileService.cancelSubscription().subscribe({ next: () => { @@ -119,6 +178,14 @@ export class SubscriptionComponent implements OnInit, OnDestroy { }); } + /** + * Обработчик покупки подписки + * + * @param planId - идентификатор выбранного плана подписки + * + * Если пользователь не подписан - инициирует процесс покупки, + * если уже подписан - показывает соответствующее уведомление + */ onBuyClick(planId: SubscriptionPlan["id"]) { if (!this.isSubscribed()) { this.subscriptionService.buySubscription(planId).subscribe(status => { diff --git a/projects/skills/src/app/subscription/subscription.resolver.ts b/projects/skills/src/app/subscription/subscription.resolver.ts index 323a103f3..f756ff190 100644 --- a/projects/skills/src/app/subscription/subscription.resolver.ts +++ b/projects/skills/src/app/subscription/subscription.resolver.ts @@ -4,11 +4,27 @@ import { inject } from "@angular/core"; import { SubscriptionService } from "./service/subscription.service"; import { ProfileService } from "../profile/services/profile.service"; +/** + * Резолвер для получения списка доступных планов подписки + * + * Выполняется перед загрузкой компонента подписки и предоставляет + * данные о всех доступных тарифных планах + * + * @returns Observable с массивом планов подписки + */ export const subscriptionResolver = () => { const subscriptionService = inject(SubscriptionService); return subscriptionService.getSubscriptions(); }; +/** + * Резолвер для получения данных о текущей подписке пользователя + * + * Загружает информацию о статусе подписки, дате окончания, + * настройках автопродления и других параметрах + * + * @returns Observable с данными подписки пользователя + */ export const subscriptionDataResolver = () => { const profileService = inject(ProfileService); return profileService.getSubscriptionData(); diff --git a/projects/skills/src/app/subscription/subscription.routes.ts b/projects/skills/src/app/subscription/subscription.routes.ts index 5bf45b272..f2916d005 100644 --- a/projects/skills/src/app/subscription/subscription.routes.ts +++ b/projects/skills/src/app/subscription/subscription.routes.ts @@ -1,6 +1,6 @@ /** @format */ -import { Routes } from "@angular/router"; +import type { Routes } from "@angular/router"; import { SubscriptionComponent } from "./subscription.component"; import { subscriptionDataResolver, subscriptionResolver } from "./subscription.resolver"; @@ -9,8 +9,8 @@ export const SUBSCRIPTION_ROUTES: Routes = [ path: "", component: SubscriptionComponent, resolve: { - data: subscriptionResolver, - subscriptionData: subscriptionDataResolver, + data: subscriptionResolver, // Список всех доступных планов подписки + subscriptionData: subscriptionDataResolver, // Данные текущей подписки пользователя }, }, ]; diff --git a/projects/skills/src/app/task/complete/complete.component.ts b/projects/skills/src/app/task/complete/complete.component.ts index 592abd472..ff35c1d18 100644 --- a/projects/skills/src/app/task/complete/complete.component.ts +++ b/projects/skills/src/app/task/complete/complete.component.ts @@ -6,9 +6,19 @@ import { CircleProgressBarComponent } from "../../shared/circle-progress-bar/cir import { IconComponent } from "@uilib"; import { ButtonComponent } from "@ui/components"; import { ActivatedRoute, Router } from "@angular/router"; -import { map, Observable } from "rxjs"; -import { TaskResults } from "../../../models/skill.model"; +import { map, type Observable } from "rxjs"; +import type { TaskResults } from "../../../models/skill.model"; +/** + * Компонент завершения задачи + * Отображает результаты выполнения задачи: прогресс, статистику, баллы + * + * Функциональность: + * - Показывает круговую диаграмму прогресса + * - Отображает количество правильных ответов + * - Показывает заработанные баллы + * - Предоставляет навигацию к следующему заданию или в меню навыков + */ @Component({ selector: "app-complete", standalone: true, @@ -17,8 +27,9 @@ import { TaskResults } from "../../../models/skill.model"; styleUrl: "./complete.component.scss", }) export class TaskCompleteComponent { - route = inject(ActivatedRoute); - router = inject(Router); + route = inject(ActivatedRoute); // Сервис для работы с активным маршрутом + router = inject(Router); // Сервис для навигации + // Получаем результаты задачи из данных маршрута results = this.route.data.pipe(map(r => r["data"])) as Observable; } diff --git a/projects/skills/src/app/task/complete/complete.resolver.ts b/projects/skills/src/app/task/complete/complete.resolver.ts index 1cb23abc4..ecd08549f 100644 --- a/projects/skills/src/app/task/complete/complete.resolver.ts +++ b/projects/skills/src/app/task/complete/complete.resolver.ts @@ -1,11 +1,21 @@ /** @format */ -import { ResolveFn } from "@angular/router"; +import type { ResolveFn } from "@angular/router"; import { TaskService } from "../services/task.service"; import { inject } from "@angular/core"; -import { TaskResults } from "../../../models/skill.model"; +import type { TaskResults } from "../../../models/skill.model"; +/** + * Резолвер для получения результатов выполнения задачи + * Загружает данные о результатах перед отображением компонента завершения + * + * @param route - объект маршрута с параметрами + * @param _state - состояние маршрутизатора (не используется) + * @returns Promise - промис с результатами выполнения задачи + */ export const taskCompleteResolver: ResolveFn = (route, _state) => { const taskService = inject(TaskService); + + // Получаем ID задачи из родительского маршрута и загружаем результаты return taskService.fetchResults(route.parent?.params["taskId"]); }; diff --git a/projects/skills/src/app/task/services/task.service.ts b/projects/skills/src/app/task/services/task.service.ts index 34fd0af60..ebb477fd9 100644 --- a/projects/skills/src/app/task/services/task.service.ts +++ b/projects/skills/src/app/task/services/task.service.ts @@ -6,19 +6,59 @@ import { TaskResults, TaskStep, TaskStepsResponse } from "../../../models/skill. import { Observable, tap } from "rxjs"; import { StepType } from "../../../models/step.model"; +/** + * Сервис заданий + * + * Управляет всеми операциями, связанными с заданиями, включая: + * - Навигацию по шагам заданий и управление состоянием + * - Получение и отправку данных шагов + * - Отслеживание прогресса в рамках заданий + * - Завершение заданий и обработку результатов + * + * Этот сервис поддерживает текущее состояние прогресса задания + * и предоставляет методы для взаимодействия с отдельными шагами заданий. + */ @Injectable({ providedIn: "root", }) export class TaskService { + private readonly TASK_URL = "/questions"; + private readonly SKILLS_URL = "/courses"; + private apiService = inject(SkillsApiService); + // Реактивное управление состоянием с использованием Angular signals currentSteps = signal([]); currentTaskDone = signal(false); + /** + * Сопоставление типов шагов с соответствующими конечными точками API + * Используется для построения правильных маршрутов API для различных типов вопросов + */ + private readonly stepRouteMapping: Record = { + question_connect: "connect", + exclude_question: "exclude", + info_slide: "info-slide", + question_single_answer: "single-correct", + question_write: "write", + }; + + /** + * Получает конкретный шаг по его ID из массива текущих шагов + * + * @param stepId - Уникальный идентификатор шага + * @returns TaskStep | undefined - Данные шага или undefined, если не найден + */ getStep(stepId: number): TaskStep | undefined { return this.currentSteps().find(s => s.id === stepId); } + /** + * Находит следующий шаг в последовательности после данного ID шага + * + * @param stepId - ID текущего шага + * @returns TaskStep | undefined - Следующий шаг в последовательности или undefined, если это последний шаг + */ getNextStep(stepId: number): TaskStep | undefined { const step = this.getStep(stepId); if (!step) return; @@ -26,35 +66,52 @@ export class TaskService { return this.currentSteps().find(s => s.ordinalNumber === step.ordinalNumber + 1); } + /** + * Получает все шаги для данного задания и обновляет состояние текущих шагов + * + * @param taskId - Уникальный идентификатор задания + * @returns Observable - Полная информация о задании со всеми шагами + */ fetchSteps(taskId: number) { - return this.apiService.get(`/courses/${taskId}`).pipe( + return this.apiService.get(`${this.SKILLS_URL}/${taskId}`).pipe( tap(res => { this.currentSteps.set(res.stepData); }) ); } - private readonly stepRouteMapping: Record = { - question_connect: "connect", - exclude_question: "exclude", - info_slide: "info-slide", - question_single_answer: "single-correct", - question_write: "write", - }; - + /** + * Получает подробные данные для конкретного шага задания + * + * @param taskStepId - Уникальный идентификатор шага задания + * @param taskStepType - Тип шага (определяет, какую конечную точку использовать) + * @returns Observable - Данные, специфичные для шага, основанные на типе шага + */ fetchStep(taskStepId: TaskStep["id"], taskStepType: TaskStep["type"]): Observable { - const route = `/questions/${this.stepRouteMapping[taskStepType]}/${taskStepId}`; - + const route = `${this.TASK_URL}/${this.stepRouteMapping[taskStepType]}/${taskStepId}`; return this.apiService.get(route); } + /** + * Отправляет ответ для конкретного шага задания и проверяет ответ + * + * @param taskStepId - Уникальный идентификатор шага задания + * @param taskStepType - Тип шага (определяет логику проверки) + * @param body - Данные ответа (структура варьируется в зависимости от типа шага) + * @returns Observable - Успешный ответ или ошибка с обратной связью + */ checkStep(taskStepId: TaskStep["id"], taskStepType: TaskStep["type"], body: any) { - const route = `/questions/${this.stepRouteMapping[taskStepType]}/check/${taskStepId}`; - + const route = `${this.TASK_URL}/${this.stepRouteMapping[taskStepType]}/check/${taskStepId}`; return this.apiService.post(route, body); } + /** + * Получает финальные результаты после завершения всех шагов в задании + * + * @param taskId - Уникальный идентификатор завершенного задания + * @returns Observable - Сводка производительности, заработанных очков и следующих шагов + */ fetchResults(taskId: number) { - return this.apiService.get(`/courses/task-result/${taskId}`); + return this.apiService.get(`${this.SKILLS_URL}/task-result/${taskId}`); } } diff --git a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.ts b/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.ts index 48d952ea1..499bc451e 100644 --- a/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.ts +++ b/projects/skills/src/app/task/shared/exclude-task/exclude-task.component.ts @@ -1,11 +1,31 @@ /** @format */ -import { Component, EventEmitter, inject, Input, OnInit, Output, signal } from "@angular/core"; +import { Component, EventEmitter, inject, Input, type OnInit, Output, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { ExcludeQuestion, ExcludeQuestionResponse } from "../../../../models/step.model"; -import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import type { ExcludeQuestion, ExcludeQuestionResponse } from "../../../../models/step.model"; +import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; import { ParseBreaksPipe, YtExtractService } from "@corelib"; +/** + * Компо��ент задачи на исключение лишнего + * Позволяет пользователю выбрать несколько вариантов из предложенных (множественный выбор) + * + * Входные параметры: + * @Input data - данные вопроса типа ExcludeQuestion + * @Input hint - текст подсказки + * @Input success - флаг успешного выполнения + * @Input error - объект ошибки для сброса состояния + * + * Выходные события: + * @Output update - событие обновления с массивом ID выбранных ответов + * + * Функциональность: + * - Отображает вопрос и варианты ответов в виде тегов + * - Поддерживает множественный выбор вариантов + * - Поддерживает встроенные видео и файлы + * - Извлекает YouTube ссылки из описания + * - Визуально выделяет выбранные варианты + */ @Component({ selector: "app-exclude-task", standalone: true, @@ -14,36 +34,46 @@ import { ParseBreaksPipe, YtExtractService } from "@corelib"; styleUrl: "./exclude-task.component.scss", }) export class ExcludeTaskComponent implements OnInit { - @Input({ required: true }) data!: ExcludeQuestion; - @Input() hint!: string; - @Output() update = new EventEmitter(); + @Input({ required: true }) data!: ExcludeQuestion; // Данные вопроса + @Input() hint!: string; // Текст подсказки + @Output() update = new EventEmitter(); // Событие обновления выбранных ответов - @Input() success = false; + @Input() success = false; // Флаг успешного выполнения + + // Сеттер для обработки ошибок и сброса состояния @Input() set error(value: ExcludeQuestionResponse | null) { this._error.set(value); - value !== null && this.result.set([]); + value !== null && this.result.set([]); // Сбрасываем выбранные ответы при ошибке } get error() { return this._error(); } - result = signal([]); - _error = signal(null); + // Состояние компонента + result = signal([]); // Массив ID выбранных ответов + _error = signal(null); // Состояние ошибки - sanitizer = inject(DomSanitizer); - ytExtractService = inject(YtExtractService); + sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML + ytExtractService = inject(YtExtractService); // Сервис для извлечения YouTube ссылок - videoUrl?: SafeResourceUrl; - description: any; - sanitizedFileUrl?: SafeResourceUrl; + videoUrl?: SafeResourceUrl; // Безопасная ссылка на видео + description: any; // Обработанное описание + sanitizedFileUrl?: SafeResourceUrl; // Безопасная ссылка на файл + /** + * Обработчик выбора/отмены выбора варианта ответа + * Добавляет или удаляет ID из массива выбранных ответов + * @param id - ID варианта ответа + */ onSelect(id: number) { if (this.result().includes(id)) { + // Если вариант уже выбран, убираем его из списка this.result.set(this.result().filter(i => i !== id)); } else { + // Если вариант не выбран, добавляем его в список this.result.set([...this.result(), id]); } @@ -51,15 +81,18 @@ export class ExcludeTaskComponent implements OnInit { } ngOnInit(): void { + // Извлекаем YouTube ссылку из описания const res = this.ytExtractService.transform(this.data.description); if (res.extractedLink) this.videoUrl = this.sanitizer.bypassSecurityTrustResourceUrl(res.extractedLink); + // Обрабатываем файлы, если они есть if (this.data.files.length) { this.sanitizedFileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.data.files[0]); } + // Безопасно обрабатываем HTML в описании this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); } } diff --git a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.ts b/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.ts index 7cef0f910..6bb223dcb 100644 --- a/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.ts +++ b/projects/skills/src/app/task/shared/radio-select-task/radio-select-task.component.ts @@ -2,10 +2,29 @@ import { Component, EventEmitter, inject, Input, Output, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { SingleQuestion, SingleQuestionError } from "../../../../models/step.model"; -import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import type { SingleQuestion, SingleQuestionError } from "../../../../models/step.model"; +import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; import { ParseBreaksPipe, YtExtractService } from "@corelib"; +/** + * Компонент задачи с выбором одного варианта (радио-кнопки) + * Отображает вопрос с несколькими вариантами ответа, из которых можно выбрать только один + * + * Входные параметры: + * @Input data - данные вопроса типа SingleQuestion + * @Input success - флаг успешного выполнения + * @Input hint - текст подсказки + * @Input error - объект ошибки для сброса состояния + * + * Выходные события: + * @Output update - событие обновления с ID выбранного ответа + * + * Функциональность: + * - Отображает вопрос и варианты ответов + * - Поддерживает встроенные видео и файлы + * - Позволяет выбрать только один вариант ответа + * - Извлекает YouTube ссылки из описания + */ @Component({ selector: "app-radio-select-task", standalone: true, @@ -14,15 +33,17 @@ import { ParseBreaksPipe, YtExtractService } from "@corelib"; styleUrl: "./radio-select-task.component.scss", }) export class RadioSelectTaskComponent { - @Input({ required: true }) data!: SingleQuestion; - @Input() success = false; - @Input() hint!: string; + @Input({ required: true }) data!: SingleQuestion; // Данные вопроса + @Input() success = false; // Флаг успешного выполнения + @Input() hint!: string; // Текст подсказки + + // Сеттер для обработки ошибок и сброса состояния @Input() set error(value: SingleQuestionError | null) { this._error.set(value); if (value !== null) { - this.result.set({ answerId: null }); + this.result.set({ answerId: null }); // Сбрасываем выбранный ответ при ошибке } } @@ -30,34 +51,41 @@ export class RadioSelectTaskComponent { return this._error(); } - @Output() update = new EventEmitter<{ answerId: number }>(); + @Output() update = new EventEmitter<{ answerId: number }>(); // Событие обновления выбора - result = signal<{ answerId: number | null }>({ answerId: null }); - _error = signal(null); + // Состояние компонента + result = signal<{ answerId: number | null }>({ answerId: null }); // Выбранный ответ + _error = signal(null); // Состояние ошибки - sanitizer = inject(DomSanitizer); - ytExtractService = inject(YtExtractService); + sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML + ytExtractService = inject(YtExtractService); // Сервис для извлечения YouTube ссылок - videoUrl?: SafeResourceUrl; - description: any; - sanitizedFileUrl?: SafeResourceUrl; + videoUrl?: SafeResourceUrl; // Безопасная ссылка на видео + description: any; // Обработанное описание + sanitizedFileUrl?: SafeResourceUrl; // Безопасная ссылка на файл ngOnInit(): void { + // Извлекаем YouTube ссылку из описания const res = this.ytExtractService.transform(this.data.description); if (res.extractedLink) this.videoUrl = this.sanitizer.bypassSecurityTrustResourceUrl(res.extractedLink); + // Обрабатываем файлы, если они есть if (this.data.files.length) { this.sanitizedFileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.data.files[0]); } + // Безопасно обрабатываем HTML в описании this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); } + /** + * Обработчик выбора варианта ответа + * @param id - ID выбранного ответа + */ onSelect(id: number) { this.result.set({ answerId: id }); - this.update.emit({ answerId: id }); } } diff --git a/projects/skills/src/app/task/shared/relations-task/relations-task.component.ts b/projects/skills/src/app/task/shared/relations-task/relations-task.component.ts index 3df91fc7f..6d4cd1251 100644 --- a/projects/skills/src/app/task/shared/relations-task/relations-task.component.ts +++ b/projects/skills/src/app/task/shared/relations-task/relations-task.component.ts @@ -2,13 +2,13 @@ import { Component, - OnInit, - AfterViewInit, - OnDestroy, + type OnInit, + type AfterViewInit, + type OnDestroy, ElementRef, ViewChild, ViewChildren, - QueryList, + type QueryList, Input, Output, EventEmitter, @@ -18,15 +18,34 @@ import { } from "@angular/core"; import { CommonModule } from "@angular/common"; import { DomSanitizer } from "@angular/platform-browser"; -import { fromEvent, Subscription } from "rxjs"; +import { fromEvent, type Subscription } from "rxjs"; import { debounceTime } from "rxjs/operators"; -import { +import type { ConnectQuestion, ConnectQuestionRequest, ConnectQuestionResponse, } from "../../../../models/step.model"; import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +/** + * Компонент задачи на установление связей + * Позволяет пользователю соединять элементы из левого столбца с элементами правого столбца + * + * Входные параметры: + * @Input data - данные вопроса типа ConnectQuestion + * @Input hint - текст подсказки + * @Input success - флаг успешного выполнения + * @Input error - объект ошибки для сброса состояния + * + * Выходные события: + * @Output update - событие обновления с массивом связей + * + * Функциональность: + * - Отображает два столбца элементов для соединения + * - Рисует SVG линии между связанными элементами + * - Поддерживает текстовые и графические элементы + * - Автоматически перерисовывает линии при изменении размера окна + */ @Component({ selector: "app-relations-task", standalone: true, @@ -35,17 +54,18 @@ import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; styleUrls: ["./relations-task.component.scss"], }) export class RelationsTaskComponent implements OnInit, AfterViewInit, OnDestroy { - @Input({ required: true }) data!: ConnectQuestion; - @Input() hint!: string; - @Input() success = false; + @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); + this.result.set([]); // Сбрасываем результат при ошибке + this.selectedLeftId.set(null); // Сбрасываем выбранный элемент } } @@ -57,34 +77,41 @@ export class RelationsTaskComponent implements OnInit, AfterViewInit, OnDestroy _error = signal(null); - @Output() update = new EventEmitter(); + @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; + private resizeSub!: Subscription; // Подписка на изменение размера окна - result = signal([]); - resultLeft = computed(() => this.result().map(r => r.leftId)); - resultRight = computed(() => this.result().map(r => r.rightId)); - selectedLeftId = signal(null); + // Состояние компонента + 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; + 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()); } @@ -92,14 +119,20 @@ export class RelationsTaskComponent implements OnInit, AfterViewInit, OnDestroy 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)); @@ -110,12 +143,19 @@ export class RelationsTaskComponent implements OnInit, AfterViewInit, OnDestroy 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); @@ -125,6 +165,9 @@ export class RelationsTaskComponent implements OnInit, AfterViewInit, OnDestroy this.update.emit(this.result()); } + /** + * Удаляет все SVG линии + */ removeLines() { const svgEl = this.svgOverlay.nativeElement; while (svgEl.firstChild) { @@ -132,35 +175,44 @@ export class RelationsTaskComponent implements OnInit, AfterViewInit, OnDestroy } } + /** + * Рисует 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()); diff --git a/projects/skills/src/app/task/shared/video-task/info-task.component.ts b/projects/skills/src/app/task/shared/video-task/info-task.component.ts index 60434f90f..a3f361683 100644 --- a/projects/skills/src/app/task/shared/video-task/info-task.component.ts +++ b/projects/skills/src/app/task/shared/video-task/info-task.component.ts @@ -2,10 +2,24 @@ import { Component, inject, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { InfoSlide } from "../../../../models/step.model"; -import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import type { InfoSlide } from "../../../../models/step.model"; +import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; import { ParseBreaksPipe, ParseLinksPipe, YtExtractService } from "@corelib"; +/** + * Компонент информационного слайда с видео/изображением + * Отображает информационный контент с поддержкой различных медиа-форматов + * + * Входные параметры: + * @Input data - данные информационного слайда типа InfoSlide + * + * Функциональность: + * - Отображает текст и описание слайда + * - Поддерживает видео (mp4), GIF и изображения (webp, jpg, png) + * - Извлекает YouTube ссылки из текста + * - Автоматически определяет тип контента по расширению файла + * - Адаптивная компоновка для разных типов медиа + */ @Component({ selector: "app-info-task", standalone: true, @@ -14,18 +28,21 @@ import { ParseBreaksPipe, ParseLinksPipe, YtExtractService } from "@corelib"; styleUrl: "./info-task.component.scss", }) export class InfoTaskComponent { - @Input({ required: true }) data!: InfoSlide; + @Input({ required: true }) data!: InfoSlide; // Данные информационного слайда - sanitizer = inject(DomSanitizer); - ytExtractService = inject(YtExtractService); + sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML + ytExtractService = inject(YtExtractService); // Сервис для извлечения YouTube ссылок - videoUrl?: SafeResourceUrl; - description: any; - sanitizedFileUrl?: SafeResourceUrl; - contentType: "gif" | "webp" | "mp4" | string = ""; + videoUrl?: SafeResourceUrl; // Безопасная ссылка на видео + description: any; // Обработанное описание + sanitizedFileUrl?: SafeResourceUrl; // Безопасная ссылка на файл + contentType: "gif" | "webp" | "mp4" | string = ""; // Тип медиа-контента ngOnInit(): void { + // Извлекаем YouTube ссылку из текста const res = this.ytExtractService.transform(this.data.text); + + // Определяем тип контента по расширению файла if (this.data.files.length) { this.contentType = this.data.files[0].slice(-3).toLocaleLowerCase(); } @@ -33,10 +50,12 @@ export class InfoTaskComponent { if (res.extractedLink) this.videoUrl = this.sanitizer.bypassSecurityTrustResourceUrl(res.extractedLink); + // Обрабатываем файлы, если они есть if (this.data.files.length) { this.sanitizedFileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.data.files[0]); } + // Безопасно обрабатываем HTML в описании this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); } } diff --git a/projects/skills/src/app/task/shared/write-task/write-task.component.ts b/projects/skills/src/app/task/shared/write-task/write-task.component.ts index ddd292942..d923a498e 100644 --- a/projects/skills/src/app/task/shared/write-task/write-task.component.ts +++ b/projects/skills/src/app/task/shared/write-task/write-task.component.ts @@ -1,11 +1,28 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output, Sanitizer, inject } from "@angular/core"; +import { Component, EventEmitter, Input, type OnInit, Output, inject } from "@angular/core"; import { CommonModule, JsonPipe } from "@angular/common"; -import { WriteQuestion } from "../../../../models/step.model"; +import type { WriteQuestion } from "../../../../models/step.model"; import { ParseBreaksPipe, YtExtractService } from "@corelib"; -import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; +/** + * Компонент задачи с текстовым вводом + * Позволяет пользователю вводить текстовый ответ в textarea + * + * Входные параметры: + * @Input data - данные вопроса типа WriteQuestion + * @Input success - флаг успешного выполнения задания + * + * Выходные события: + * @Output update - событие обновления ответа с текстом + * + * Функциональность: + * - Отображает текст задания и описание + * - Поддерживает встроенные видео и файлы + * - Автоматически изменяет высоту textarea при вводе + * - Извлекает YouTube ссылки из описания + */ @Component({ selector: "app-write-task", standalone: true, @@ -14,39 +31,47 @@ import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; styleUrl: "./write-task.component.scss", }) export class WriteTaskComponent implements OnInit { - @Input({ required: true }) data!: WriteQuestion; - @Output() update = new EventEmitter<{ text: string }>(); + @Input({ required: true }) data!: WriteQuestion; // Данные вопроса + @Output() update = new EventEmitter<{ text: string }>(); // Событие обновления ответа - @Input() success = false; + @Input() success = false; // Флаг успешного выполнения - sanitizer = inject(DomSanitizer); - ytExtractService = inject(YtExtractService); + sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML + ytExtractService = inject(YtExtractService); // Сервис для извлечения YouTube ссылок - videoUrl?: SafeResourceUrl; - description: any; - sanitizedFileUrl?: SafeResourceUrl; + videoUrl?: SafeResourceUrl; // Безопасная ссылка на видео + description: any; // Обработанное описание + sanitizedFileUrl?: SafeResourceUrl; // Безопасная ссылка на файл ngOnInit(): void { + // Извлекаем YouTube ссылку из описания const res = this.ytExtractService.transform(this.data.description); if (res.extractedLink) this.videoUrl = this.sanitizer.bypassSecurityTrustResourceUrl(res.extractedLink); + // Обрабатываем файлы, если они есть if (this.data.files.length) { this.sanitizedFileUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.data.files[0]); } + // Безопасно обрабатываем HTML в описании this.description = this.sanitizer.bypassSecurityTrustHtml(this.data.description); } - // result = signal<{ text: string } | null>(null); - + /** + * Обработчик ввода текста в textarea + * Автоматически изменяет высоту поля и отправляет событие обновления + * @param event - событие клавиатуры + */ onKeyUp(event: KeyboardEvent) { const target = event.target as HTMLTextAreaElement; + // Автоматическое изменение высоты textarea target.style.height = "0px"; target.style.height = target.scrollHeight + "px"; + // Отправляем событие с введенным текстом this.update.emit({ text: target.value }); } } diff --git a/projects/skills/src/app/task/subtask/subtask.component.ts b/projects/skills/src/app/task/subtask/subtask.component.ts index e2769bbd1..317775ed5 100644 --- a/projects/skills/src/app/task/subtask/subtask.component.ts +++ b/projects/skills/src/app/task/subtask/subtask.component.ts @@ -1,6 +1,13 @@ /** @format */ -import { ChangeDetectorRef, Component, inject, OnInit, signal, ViewChild } from "@angular/core"; +import { + ChangeDetectorRef, + Component, + inject, + type OnInit, + signal, + ViewChild, +} from "@angular/core"; import { CommonModule, NgOptimizedImage } from "@angular/common"; import { InfoTaskComponent } from "../shared/video-task/info-task.component"; import { RadioSelectTaskComponent } from "../shared/radio-select-task/radio-select-task.component"; @@ -11,9 +18,9 @@ import { ActivatedRoute, NavigationStart, Router, RouterLink } from "@angular/ro import { concatMap, map, tap } from "rxjs"; import { LoaderComponent } from "@ui/components/loader/loader.component"; import { TaskService } from "../services/task.service"; -import { TaskStep } from "../../../models/skill.model"; +import type { TaskStep } from "../../../models/skill.model"; import { toSignal } from "@angular/core/rxjs-interop"; -import { +import type { ConnectQuestion, ConnectQuestionResponse, ExcludeQuestion, @@ -30,6 +37,29 @@ import { IconComponent } from "@uilib"; import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; import { SkillService } from "../../skills/services/skill.service"; +/** + * Компонент подзадания + * + * Обрабатывает выполнение отдельных шагов задания и взаимодействие пользователя. + * Этот компонент динамически отображает различные типы вопросов и заданий + * на основе типа шага, управляет ответами пользователя и обрабатывает навигацию + * между шагами. + * + * Поддерживаемые типы шагов: + * - info_slide: Отображение информации без взаимодействия + * - question_single_answer: Вопросы с единственным выбором + * - question_connect: Задания на соединение/сопоставление + * - exclude_question: Задания на исключение + * - question_write: Задания с письменным ответом + * + * Функции: + * - Динамическое отображение компонентов на основе типа шага + * - Проверка ответов в реальном времени и обратная связь + * - Обработка ошибок с подсказками и исправлениями + * - Автоматическое продвижение к следующим шагам + * - Модальные всплывающие окна для дополнительной информации + * - Состояния загрузки + */ @Component({ selector: "app-subtask", standalone: true, @@ -53,15 +83,18 @@ import { SkillService } from "../../skills/services/skill.service"; styleUrl: "./subtask.component.scss", }) export class SubtaskComponent implements OnInit { + // Внедренные сервисы router = inject(Router); route = inject(ActivatedRoute); cdref = inject(ChangeDetectorRef); taskService = inject(TaskService); skillService = inject(SkillService); + // Состояние UI loading = signal(false); hint = signal(""); + // Получение ID подзадания из параметров маршрута subTaskId = toSignal( this.route.params.pipe( map(r => r["subTaskId"]), @@ -69,21 +102,24 @@ export class SubtaskComponent implements OnInit { ) ); + // Ссылка на компонент отношений для управления линиями соединения @ViewChild(RelationsTaskComponent) relationsTask: RelationsTaskComponent | null = null; - // stepData = signal(null); + // Сигналы состояния для различных типов шагов infoSlide = signal(null); singleQuestion = signal(null); connectQuestion = signal(null); excludeQuestion = signal(null); writeQuestion = signal(null); + // Сигналы ошибок для различных типов вопросов connectQuestionError = signal(null); singleQuestionError = signal(null); excludeQuestionError = signal(null); anyError = signal(false); success = signal(false); + // Текущий открытый тип вопроса для модального окна openQuestion = signal< | "exclude_question" | "question_single_answer" @@ -94,6 +130,7 @@ export class SubtaskComponent implements OnInit { >(null); constructor() { + // Обработка навигации назад через браузер this.router.events.subscribe(event => { if (event instanceof NavigationStart) { if (event.navigationTrigger === "popstate" && event.restoredState) { @@ -104,6 +141,7 @@ export class SubtaskComponent implements OnInit { } ngOnInit() { + // Загрузка данных шага при изменении параметров маршрута this.route.params .pipe( map(p => p["subTaskId"]), @@ -126,6 +164,10 @@ export class SubtaskComponent implements OnInit { }); } + /** + * Устанавливает данные шага на основе типа + * Очищает предыдущие данные и устанавливает новые в соответствующий сигнал + */ setStepData(step: StepType) { const type = this.route.snapshot.queryParams["type"] as TaskStep["type"]; @@ -144,8 +186,13 @@ export class SubtaskComponent implements OnInit { } } + // Тело запроса для отправки ответов body = signal({}); + /** + * Очищает все данные шагов + * Сбрасывает все сигналы состояния к null + */ clearData() { [ this.singleQuestion, @@ -158,6 +205,9 @@ export class SubtaskComponent implements OnInit { ].forEach(s => s.set(null)); } + /** + * Обработчик изменения состояния модального окна + */ onOpenChange(event: any) { if (!event) { this.openQuestion.set(null); @@ -166,6 +216,10 @@ export class SubtaskComponent implements OnInit { } } + /** + * Обработчик закрытия модального окна + * Переходит к следующему шагу или к результатам + */ onCloseModal() { const id = this.subTaskId(); if (!id) return; @@ -179,7 +233,7 @@ export class SubtaskComponent implements OnInit { if (!nextStep) { this.router.navigate(["/task", taskId, "results"]).then(() => { - console.debug("Route changed from SubtaskComponent"); + console.debug("Маршрут изменен из SubtaskComponent"); location.reload(); }); this.taskService.currentTaskDone.set(true); @@ -191,22 +245,28 @@ export class SubtaskComponent implements OnInit { queryParams: { type: nextStep.type }, }) .then(() => { - console.debug("Route changed from SubtaskComponent"); + console.debug("Маршрут изменен из SubtaskComponent"); location.reload(); }); }, 1000); } + /** + * Обработчик перехода к следующему шагу + * Отправляет ответ пользователя и обрабатывает результат + */ onNext() { const id = this.subTaskId(); if (!id) return; const type = this.route.snapshot.queryParams["type"] as TaskStep["type"]; + // Отправка ответа на сервер для проверки this.taskService.checkStep(id, type, this.body()).subscribe({ next: _res => { this.success.set(true); + // Проверка наличия всплывающих окон для отображения if ( (type === "info_slide" && !this.infoSlide()?.popups.length) || (type === "exclude_question" && !this.excludeQuestion()?.popups.length) || @@ -214,6 +274,7 @@ export class SubtaskComponent implements OnInit { (type === "question_single_answer" && !this.singleQuestion()?.popups.length) || (type === "question_write" && !this.writeQuestion()?.popups.length) ) { + // Автоматический переход к следующему шагу, если нет всплывающих окон setTimeout(() => { this.success.set(false); @@ -223,7 +284,7 @@ export class SubtaskComponent implements OnInit { if (!nextStep) { this.router.navigate(["/task", taskId, "results"]).then(() => { - console.debug("Route changed from SubtaskComponent"); + console.debug("Маршрут изменен из SubtaskComponent"); location.reload(); }); this.taskService.currentTaskDone.set(true); @@ -235,13 +296,14 @@ export class SubtaskComponent implements OnInit { queryParams: { type: nextStep.type }, }) .then(() => { - console.debug("Route changed from SubtaskComponent"); + console.debug("Маршрут изменен из SubtaskComponent"); location.reload(); }); }, 1000); } }, error: err => { + // Обработка ошибок и отображение подсказок this.anyError.set(true); if (err.error.hint) { this.hint.set(err.error.hint); @@ -252,6 +314,7 @@ export class SubtaskComponent implements OnInit { this.anyError.set(false); }, 2000); + // Установка специфичных ошибок для разных типов вопросов if (type === "question_connect") { this.connectQuestionError.set(err.error); this.relationsTask?.removeLines(); diff --git a/projects/skills/src/app/task/task.resolver.ts b/projects/skills/src/app/task/task.resolver.ts index f74444f23..89155558f 100644 --- a/projects/skills/src/app/task/task.resolver.ts +++ b/projects/skills/src/app/task/task.resolver.ts @@ -1,12 +1,21 @@ /** @format */ -import { ResolveFn } from "@angular/router"; +import type { ResolveFn } from "@angular/router"; import { inject } from "@angular/core"; import { TaskService } from "./services/task.service"; -import { TaskStepsResponse } from "../../models/skill.model"; +import type { TaskStepsResponse } from "../../models/skill.model"; +/** + * Резолвер для получения данных задачи + * Используется для предварительной загрузки данных о шагах задачи перед отображением компонента + * + * @param route - объект маршрута, содержащий параметры URL (включая taskId) + * @param _state - состояние маршрутизатора (не используется) + * @returns Promise - промис с данными о шагах задачи + */ export const taskDetailResolver: ResolveFn = (route, _state) => { const taskService = inject(TaskService); + // Получаем ID задачи из параметров маршрута и загружаем шаги задачи return taskService.fetchSteps(route.params["taskId"]); }; diff --git a/projects/skills/src/app/task/task.routes.ts b/projects/skills/src/app/task/task.routes.ts index 117e9352d..7d6c477d0 100644 --- a/projects/skills/src/app/task/task.routes.ts +++ b/projects/skills/src/app/task/task.routes.ts @@ -1,28 +1,38 @@ /** @format */ -import { Routes } from "@angular/router"; + +import type { Routes } from "@angular/router"; import { TaskComponent } from "./task/task.component"; import { SubtaskComponent } from "./subtask/subtask.component"; import { TaskCompleteComponent } from "./complete/complete.component"; import { taskDetailResolver } from "./task.resolver"; import { taskCompleteResolver } from "./complete/complete.resolver"; +/** + * Конфигурация маршрутов для модуля задач + * Определяет структуру навигации и связывает компоненты с URL-путями + * + * Структура маршрутов: + * - /:taskId - основной компонент задачи + * - /results - компонент результатов выполнения задачи + * - /:subTaskId - компонент подзадачи + */ export const TASK_ROUTES: Routes = [ { - path: ":taskId", - component: TaskComponent, + path: ":taskId", // Маршрут с параметром ID задачи + component: TaskComponent, // Основной компонент задачи resolve: { - data: taskDetailResolver, + data: taskDetailResolver, // Предварительная загрузка данных задачи }, children: [ { - path: "results", + path: "results", // Маршрут для отображения результатов component: TaskCompleteComponent, resolve: { - data: taskCompleteResolver, + data: taskCompleteResolver, // Предварительная загрузка результатов }, }, { - path: ":subTaskId", + path: ":subTaskId", // Маршрут для подзадач component: SubtaskComponent, }, ], diff --git a/projects/skills/src/app/task/task/task.component.ts b/projects/skills/src/app/task/task/task.component.ts index cf6b0d6cd..b2271a6c4 100644 --- a/projects/skills/src/app/task/task/task.component.ts +++ b/projects/skills/src/app/task/task/task.component.ts @@ -4,9 +4,9 @@ import { Component, computed, effect, - ElementRef, + type ElementRef, inject, - OnInit, + type OnInit, signal, ViewChild, ViewChildren, @@ -14,10 +14,25 @@ import { import { CommonModule } from "@angular/common"; import { ActivatedRoute, Router, RouterLink, RouterOutlet } from "@angular/router"; import { map } from "rxjs"; -import { TaskStepsResponse } from "../../../models/skill.model"; +import type { TaskStepsResponse } from "../../../models/skill.model"; import { ButtonComponent } from "@ui/components"; import { TaskService } from "../services/task.service"; +/** + * Компонент задания + * + * Основной контейнерный компонент для выполнения заданий и навигации. + * Управляет общим потоком задания, визуализацией прогресса и навигацией по шагам. + * + * Функции: + * - Визуальная полоса прогресса, показывающая статус завершения + * - Пошаговая навигация через компоненты задания + * - Автоматическая маршрутизация к следующим шагам при завершении + * - Отслеживание прогресса и управление состоянием + * + * Компонент использует Angular signals для реактивного управления состоянием + * и эффекты для побочных эффектов, таких как манипуляции с DOM и маршрутизация. + */ @Component({ selector: "app-task", standalone: true, @@ -26,17 +41,24 @@ import { TaskService } from "../services/task.service"; styleUrl: "./task.component.scss", }) export class TaskComponent implements OnInit { + // ViewChild ссылки для манипуляций с DOM @ViewChildren("pointEls") pointEls?: ElementRef[]; @ViewChild("progressBarEl") progressBarEl?: ElementRef; @ViewChild("progressDone") progressDone?: ElementRef; + // Внедренные сервисы route = inject(ActivatedRoute); router = inject(Router); taskService = inject(TaskService); + // Реактивное состояние skillStepsResponse = signal(null); constructor() { + /** + * Эффект для обновления визуальной позиции полосы прогресса + * Вычисляет позицию индикатора прогресса на основе текущего шага + */ effect( () => { const targetEl = !this.taskService.currentTaskDone() @@ -59,29 +81,39 @@ export class TaskComponent implements OnInit { { allowSignalWrites: true } ); + /** + * Эффект для определения текущего активного шага + * Устанавливает текущий шаг на основе статуса завершения и порядка шагов + */ effect( () => { const skillsResponse = this.skillStepsResponse(); if (!skillsResponse) return; - // TODO: change `id` to `order` after backend add it + // Сортировка шагов по ID (TODO: изменить на ordinalNumber, когда бэкенд добавит это) const sortedSteps = skillsResponse.stepData.sort((prev, next) => prev.id - next.id); const doneSteps = sortedSteps.filter(step => step.isDone); if (doneSteps.length === sortedSteps.length) return; + // Найти следующий незавершенный шаг const lastDoneStep = sortedSteps[doneSteps.length]; if (lastDoneStep) { this.currentSubTaskId.set(lastDoneStep.id); return; } + // Если никакие шаги не выполнены, начать с первого шага const firstStep = sortedSteps[0]; this.currentSubTaskId.set(firstStep.id); }, { allowSignalWrites: true } ); + /** + * Эффект для автоматической навигации к текущему шагу + * Обновляет маршрут при изменении текущего шага + */ effect(() => { const subTaskId = this.currentSubTaskId(); if (!subTaskId) return; @@ -90,20 +122,23 @@ export class TaskComponent implements OnInit { .navigate(["/task", this.route.snapshot.params["taskId"], subTaskId], { queryParams: { type: this.currentSubTask()?.type ?? "" }, }) - .then(() => console.debug("Route changed from TaskComponent")); + .then(() => console.debug("Маршрут изменен из TaskComponent")); }); } ngOnInit() { + // Загрузка данных шагов задания из резолвера маршрута this.route.data.pipe(map(r => r["data"])).subscribe((res: TaskStepsResponse) => { this.skillStepsResponse.set(res); + // Проверка, завершены ли все шаги, и перенаправление к результатам if (res.stepData.filter(s => s.isDone).length === res.stepData.length) { this.taskService.currentTaskDone.set(true); this.router.navigate(["/task", this.route.snapshot.params["taskId"], "results"]); } }); + // Прослушивание изменений параметров маршрута для обновления текущего шага this.route.firstChild?.params .pipe( map(r => r["subTaskId"]), @@ -113,16 +148,16 @@ export class TaskComponent implements OnInit { this.currentSubTaskId.set(s); }); + // Отладочное логирование для изменений маршрута this.route.firstChild?.url.subscribe(console.log); } - // goToTask(id: number) { - // if (this.taskService.currentTaskDone()) return; - // this.currentSubTaskId.set(id); - // } - + // Вычисляемые свойства и сигналы progressDoneWidth = signal(0); + /** + * Вычисляемый массив всех ID шагов задания + */ taskIds = computed(() => { const stepsResponse = this.skillStepsResponse(); if (!stepsResponse) return []; @@ -132,6 +167,9 @@ export class TaskComponent implements OnInit { currentSubTaskId = signal(null); + /** + * Вычисляемые данные текущего шага + */ currentSubTask = computed(() => { const stepsResponse = this.skillStepsResponse(); const subTaskId = this.currentSubTaskId(); @@ -141,6 +179,9 @@ export class TaskComponent implements OnInit { return stepsResponse.stepData.find(step => step.id === subTaskId); }); + /** + * Вычисляемый массив завершенных ID заданий для визуализации прогресса + */ doneTasks = computed(() => { const subTaskId = this.currentSubTaskId(); if (!subTaskId) return []; 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 new file mode 100644 index 000000000..c118c9f83 --- /dev/null +++ b/projects/skills/src/app/trajectories/track-career/detail/info/guards/trajectory-info.guard.ts @@ -0,0 +1,30 @@ +/** @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.ts b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.ts index 7245f425a..8adbd1558 100644 --- 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 @@ -1,13 +1,13 @@ /** @format */ import { - AfterViewInit, + type AfterViewInit, ChangeDetectorRef, Component, computed, - ElementRef, + type ElementRef, inject, - OnInit, + type OnInit, signal, ViewChild, } from "@angular/core"; @@ -16,21 +16,27 @@ import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; import { ButtonComponent } from "@ui/components"; import { AvatarComponent, IconComponent } from "@uilib"; import { expandElement } from "@utils/expand-element"; -import { map, Observable, Subscription } from "rxjs"; +import { map, type Observable, type Subscription } from "rxjs"; import { SkillCardComponent } from "../../../../skills/shared/skill-card/skill-card.component"; import { CommonModule } from "@angular/common"; import { MonthBlockComponent } from "projects/skills/src/app/profile/shared/month-block/month-block.component"; -import { - Trajectory, - TrajectorySkills, - UserTrajectory, -} from "projects/skills/src/models/trajectory.model"; +import type { Trajectory, UserTrajectory } from "projects/skills/src/models/trajectory.model"; import { TrajectoriesService } from "../../../trajectories.service"; -import { Month, UserData } from "projects/skills/src/models/profile.model"; +import type { Month, UserData } from "projects/skills/src/models/profile.model"; import { ProfileService } from "projects/skills/src/app/profile/services/profile.service"; import { SkillService } from "projects/skills/src/app/skills/services/skill.service"; import { BreakpointObserver } from "@angular/cdk/layout"; +/** + * Компонент детальной информации о траектории + * Отображает полную информацию о выбранной траектории пользователя: + * - Основную информацию (название, изображение, описание) + * - Временную шкалу траектории + * - Информацию о наставнике + * - Навыки (персональные, текущие, будущие, пройденные) + * + * Поддерживает навигацию к отдельным навыкам и взаимодействие с наставником + */ @Component({ selector: "app-detail", standalone: true, @@ -43,7 +49,6 @@ import { BreakpointObserver } from "@angular/cdk/layout"; SkillCardComponent, AvatarComponent, CommonModule, - MonthBlockComponent, ], templateUrl: "./info.component.html", styleUrl: "./info.component.scss", @@ -65,6 +70,7 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { userTrajectory = signal(null); profileId!: number; + // Вычисляемые свойства для состояния навыков completeAllMainSkills = computed( () => this.userTrajectory()?.availableSkills.every(skill => skill.isDone) ?? false ); @@ -84,6 +90,10 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { .observe("(min-width: 920px)") .pipe(map(result => result.matches)); + /** + * Инициализация компонента + * Загружает данные траектории, пользовательскую информацию и настраивает навыки + */ ngOnInit(): void { this.desktopMode$.subscribe(r => console.log(r)); @@ -91,10 +101,7 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { this.trajectory = r[0]; this.userTrajectory.set({ ...r[1], individualSkills: r[2] }); - if (!this.trajectory.isActiveForUser) { - this.router.navigate(["/trackCar/all"]); - } - + // Настройка доступности навыков this.userTrajectory()?.availableSkills.forEach(i => (i.freeAccess = true)); this.userTrajectory()?.completedSkills.forEach(i => { i.freeAccess = true; @@ -108,6 +115,7 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { this.profileId = r.id; }); + // Создание макета месяцев для временной шкалы this.mockMonts = Array.from({ length: this.userTrajectory()!.durationMonths }, (_, index) => { const monthNumber = index + 1; @@ -126,6 +134,9 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { readFullDescription = false; descriptionExpandable?: boolean; + /** + * Проверка возможности расширения описания после инициализации представления + */ ngAfterViewInit(): void { const descElement = this.descEl?.nativeElement; this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; @@ -133,15 +144,29 @@ export class TrajectoryInfoComponent implements OnInit, AfterViewInit { 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.skillService.setSkillId(skillId); this.router.navigate(["skills", skillId]); 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 index 3b2ce33ea..2b33ff185 100644 --- 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 @@ -1,12 +1,17 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router, RouterOutlet } from "@angular/router"; +import { Component, inject, type OnDestroy, type OnInit } from "@angular/core"; +import { ActivatedRoute, RouterOutlet } from "@angular/router"; import { BarComponent } from "@ui/components"; -import { Trajectory } from "projects/skills/src/models/trajectory.model"; -import { concatMap, map, Subscription } from "rxjs"; - +import type { Trajectory } from "projects/skills/src/models/trajectory.model"; +import { map, type Subscription } from "rxjs"; + +/** + * Компонент детального просмотра траектории + * Отображает навигационную панель и служит контейнером для дочерних компонентов + * Управляет состоянием выбранной траектории и ID траектории из URL + */ @Component({ selector: "app-trajectory-detail", standalone: true, @@ -22,6 +27,10 @@ export class TrajectoryDetailComponent implements OnInit, OnDestroy { trajectory?: Trajectory; trackId?: string; + /** + * Инициализация компонента + * Подписывается на параметры маршрута и данные траектории + */ ngOnInit(): void { const trackIdSub = this.route.params.subscribe(params => { this.trackId = params["trackId"]; @@ -35,6 +44,10 @@ export class TrajectoryDetailComponent implements OnInit, OnDestroy { trackIdSub && this.subscriptions$.push(trackIdSub); } + /** + * Очистка ресурсов при уничтожении компонента + * Отписывается от всех активных подписок + */ ngOnDestroy(): void { this.subscriptions$.forEach($ => $.unsubscribe()); } 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 index 76dc132db..bc007dc92 100644 --- 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 @@ -1,10 +1,22 @@ /** @format */ import { inject } from "@angular/core"; -import { ActivatedRoute, ActivatedRouteSnapshot } from "@angular/router"; +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"]; 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 index 67854e206..10715ba00 100644 --- 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 @@ -1,5 +1,6 @@ /** @format */ -import { resolve } from "dns"; + +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"; @@ -8,6 +9,7 @@ export const TRAJECTORY_DETAIL_ROUTES = [ { path: "", component: TrajectoryDetailComponent, + canActivate: [TrajectoryInfoRequiredGuard], children: [ { 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 index ccdc22978..07643635f 100644 --- a/projects/skills/src/app/trajectories/track-career/list/list.component.ts +++ b/projects/skills/src/app/trajectories/track-career/list/list.component.ts @@ -1,22 +1,27 @@ /** @format */ import { - AfterViewInit, + type AfterViewInit, ChangeDetectorRef, Component, inject, - OnDestroy, - OnInit, + 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 { Webinar } from "projects/skills/src/models/webinars.model"; 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, @@ -39,6 +44,10 @@ export class TrajectoriesListComponent implements OnInit, AfterViewInit, OnDestr subscriptions$ = signal([]); + /** + * Инициализация компонента + * Определяет тип списка (all/my) и загружает начальные данные + */ ngOnInit(): void { this.type.set(this.router.url.split("/").slice(-1)[0] as "all" | "my"); @@ -56,6 +65,10 @@ export class TrajectoriesListComponent implements OnInit, AfterViewInit, OnDestr this.subscriptions$().push(subscription); } + /** + * Настройка обработчика прокрутки после инициализации представления + * Подписывается на события прокрутки для реализации бесконечной загрузки + */ ngAfterViewInit(): void { const target = document.querySelector(".office__body"); if (target) { @@ -69,10 +82,18 @@ export class TrajectoriesListComponent implements OnInit, AfterViewInit, OnDestr } } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy(): void { this.subscriptions$().forEach(s => s.unsubscribe()); } + /** + * Обработчик события прокрутки + * Проверяет достижение конца списка и загружает дополнительные элементы + * @returns Observable с результатом загрузки или пустой объект + */ onScroll() { if (this.totalItemsCount() && this.trajectoriesList().length >= this.totalItemsCount()) return of({}); @@ -94,6 +115,12 @@ export class TrajectoriesListComponent implements OnInit, AfterViewInit, OnDestr return of({}); } + /** + * Загрузка дополнительных траекторий + * @param offset - смещение для пагинации + * @param limit - количество элементов для загрузки + * @returns Observable с данными траекторий + */ onFetch(offset: number, limit: number) { return this.trajectoriesService.getTrajectories(limit, offset).pipe( tap((res: any) => { 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 index bf5603625..7d3f744db 100644 --- 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 @@ -218,7 +218,7 @@

    У вас уже есть активн customTypographyClass="text-body-12" [color]="'grey'" style="width: 100%" - (click)="onCloseModalActiveTrajectory()" + (click)="onCloseModalActiveTrajectory(trajectory.isActiveForUser ? trajectory.id : 'my')" >Перейти diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts index 4529916a7..e0def68b5 100644 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts +++ b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.ts @@ -2,13 +2,13 @@ import { CommonModule } from "@angular/common"; import { - AfterViewInit, + type AfterViewInit, ChangeDetectorRef, Component, - ElementRef, + type ElementRef, inject, Input, - OnInit, + type OnInit, signal, ViewChild, } from "@angular/core"; @@ -26,6 +26,14 @@ import { HttpErrorResponse } from "@angular/common/http"; import { BreakpointObserver } from "@angular/cdk/layout"; import { map, Observable } from "rxjs"; +/** + * Компонент отображения карточки траектории + * Показывает информацию о траектории: название, описание, навыки, длительность + * Поддерживает различные модальные окна для взаимодействия с пользователем + * Обрабатывает выбор траектории и навигацию к детальной информации + * + * @Input trajectory - объект траектории для отображения + */ @Component({ selector: "app-trajectory", standalone: true, @@ -65,8 +73,8 @@ export class TrajectoryComponent implements AfterViewInit, OnInit { currentPage = 1; + // Состояния модальных окон moreModalOpen = signal(false); - confirmModalOpen = signal(false); nonConfirmerModalOpen = signal(false); instructionModalOpen = signal(false); @@ -77,10 +85,17 @@ export class TrajectoryComponent implements AfterViewInit, OnInit { placeholderUrl = "https://uch-ibadan.org.ng/wp-content/uploads/2021/10/Profile_avatar_placeholder_large.png"; + /** + * Инициализация компонента + * Определяет тип отображения (all/my) на основе текущего URL + */ ngOnInit() { this.type.set(this.router.url.split("/").slice(-1)[0] as "all" | "my"); } + /** + * Проверка возможности расширения описания после инициализации представления + */ ngAfterViewInit(): void { const descElement = this.descEl?.nativeElement; this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; @@ -88,6 +103,10 @@ export class TrajectoryComponent implements AfterViewInit, OnInit { this.cdRef.detectChanges(); } + /** + * Обработчик клика по кнопке "Выбрать" + * Активирует траекторию и обрабатывает различные сценарии ответа сервера + */ onOpenConfirmClick() { this.trajectoryService.activateTrajectory(this.trajectory.id).subscribe({ next: () => { @@ -110,17 +129,27 @@ export class TrajectoryComponent implements AfterViewInit, OnInit { }); } + /** + * Подтверждение выбора траектории + * Закрывает модальное окно подтверждения и открывает инструкции + */ onConfirmClick() { this.confirmModalOpen.set(false); this.instructionModalOpen.set(true); } + /** + * Переход к предыдущей странице инструкций + */ prevPage(): void { if (this.currentPage > 1) { this.currentPage -= 1; } } + /** + * Переход к следующей странице инструкций или к траектории + */ nextPage(): void { if (this.currentPage === 4) { this.navigateOnTrajectory(); @@ -129,6 +158,9 @@ export class TrajectoryComponent implements AfterViewInit, OnInit { } } + /** + * Навигация к детальной странице траектории + */ navigateOnTrajectory() { this.router.navigate(["/trackCar/" + this.trajectory.id]).catch(err => { if (err.status === 403) { @@ -137,11 +169,21 @@ export class TrajectoryComponent implements AfterViewInit, OnInit { }); } - onCloseModalActiveTrajectory() { + /** + * Закрытие модального окна активной траектории и переход к ней + * @param trajectoryId - ID траектории для перехода + */ + onCloseModalActiveTrajectory(trajectoryId: number | string) { this.activatedModalOpen.set(false); - this.router.navigate(["/trackCar/my"]); + this.router.navigate([`/trackCar/${trajectoryId}`]); } + /** + * Переключение развернутого/свернутого состояния описания + * @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/skills/src/app/trajectories/track-career/track-career-my.resolver.ts b/projects/skills/src/app/trajectories/track-career/track-career-my.resolver.ts index 2d44b1726..5a7557c1d 100644 --- a/projects/skills/src/app/trajectories/track-career/track-career-my.resolver.ts +++ b/projects/skills/src/app/trajectories/track-career/track-career-my.resolver.ts @@ -3,6 +3,16 @@ import { inject } from "@angular/core"; import { TrajectoriesService } from "../trajectories.service"; +/** + * Резолвер для загрузки персональной траектории пользователя + * Выполняется перед активацией маршрута "my" + * @returns Observable с данными пользовательской траектории + */ + +/** + * Функция-резолвер для получения персональной траектории пользователя + * @returns Promise/Observable с данными пользовательской траектории + */ export const TrajectoriesMyResolver = () => { const trajectoriesService = inject(TrajectoriesService); 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 index 9a5a7e41d..545a5d095 100644 --- a/projects/skills/src/app/trajectories/track-career/track-career.component.ts +++ b/projects/skills/src/app/trajectories/track-career/track-career.component.ts @@ -5,6 +5,11 @@ import { Component } from "@angular/core"; import { RouterModule } from "@angular/router"; import { BarComponent } from "@ui/components"; +/** + * Главный компонент модуля отслеживания карьерных траекторий + * Служит контейнером для дочерних компонентов и маршрутизации + * Отображает навигационную панель с вкладками "Траектории" и "Моя траектория" + */ @Component({ selector: "app-track-career", standalone: true, 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 index 81d01395d..1ef71e309 100644 --- a/projects/skills/src/app/trajectories/track-career/track-career.resolver.ts +++ b/projects/skills/src/app/trajectories/track-career/track-career.resolver.ts @@ -3,6 +3,16 @@ 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); 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 index ecf495514..674f0454b 100644 --- a/projects/skills/src/app/trajectories/track-career/track-career.routes.ts +++ b/projects/skills/src/app/trajectories/track-career/track-career.routes.ts @@ -6,6 +6,15 @@ import { TrajectoriesListComponent } from "./list/list.component"; import { TrajectoriesResolver } from "./track-career.resolver"; import { TrajectoriesMyResolver } from "./track-career-my.resolver"; +/** + * Конфигурация маршрутов для модуля карьерных траекторий + * Определяет структуру навигации: + * - "" - редирект на "all" + * - "all" - список всех доступных траекторий + * - "my" - пользовательская траектория + * - ":trackId" - детальная информация о конкретной траектории + */ + export const TRACK_CAREER_ROUTES: Routes = [ { path: "", diff --git a/projects/skills/src/app/trajectories/trajectories.service.ts b/projects/skills/src/app/trajectories/trajectories.service.ts index 061eda77e..17c1c96a7 100644 --- a/projects/skills/src/app/trajectories/trajectories.service.ts +++ b/projects/skills/src/app/trajectories/trajectories.service.ts @@ -6,60 +6,122 @@ 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("/trajectories/"); + return this.apiService.get(this.TRAJECTORY_URL); } + /** + * Получает траекторию, в которой текущий пользователь активно зарегистрирован + * + * @returns Observable - Массив, содержащий активную траекторию пользователя (если есть) + */ getMyTrajectory() { - return this.apiService.get("/trajectories/").pipe( + return this.apiService.get(this.TRAJECTORY_URL).pipe( map(track => { const choosedTrajctory = track.find(trajectory => trajectory.isActiveForUser === true); - return [choosedTrajctory]; }) ); } + /** + * Получает подробную информацию о конкретной траектории + * + * @param id - Уникальный идентификатор траектории + * @returns Observable - Полная информация о траектории + */ getOne(id: number) { - return this.apiService.get("/trajectories/" + id); + return this.apiService.get(`${this.TRAJECTORY_URL}/${id}`); } + /** + * Получает информацию о регистрации текущего пользователя в траектории + * + * Включает прогресс, назначение ментора, статус встреч и категоризацию навыков. + * + * @returns Observable - Полные данные регистрации пользователя в траектории + */ getUserTrajectoryInfo() { - return this.apiService.get("/trajectories/user-trajectory/"); + return this.apiService.get(`${this.TRAJECTORY_URL}/user-trajectory/`); } + /** + * Получает всех студентов, назначенных текущему ментору + * + * Используется менторами для просмотра и управления прогрессом назначенных им студентов. + * + * @returns Observable - Список студентов с их прогрессом по траектории + */ getMentorStudents() { - return this.apiService.get("/trajectories/mentor/students/"); + return this.apiService.get(`${this.TRAJECTORY_URL}/mentor/students/`); } + /** + * Получает индивидуальные навыки, назначенные специально текущему пользователю + * + * Индивидуальные навыки - это пользовательские назначения, которые могут не быть частью + * стандартной учебной программы траектории. + * + * @returns Observable - Список индивидуально назначенных навыков + */ getIndividualSkills() { return this.apiService - .get("/trajectories/individual-skills/") + .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 with fetch individual skills", error); - return of([]); + 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, @@ -67,11 +129,19 @@ export class TrajectoriesService { final_meeting: finalMeeting, }; - return this.apiService.post("/trajectories/meetings/update/", body); + return this.apiService.post(`${this.TRAJECTORY_URL}/meetings/update/`, body); } + /** + * Регистрирует текущего пользователя в конкретной траектории + * + * Создает новую регистрацию пользователя в траектории и назначает ментора. + * + * @param trajectoryId - ID траектории для регистрации + * @returns Observable - Ответ, подтверждающий регистрацию + */ activateTrajectory(trajectoryId: number) { - return this.apiService.post("/trajectories/user-trajectory/create/", { + return this.apiService.post(`${this.TRAJECTORY_URL}/user-trajectory/create/`, { trajectory_id: trajectoryId, }); } diff --git a/projects/skills/src/app/webinars/list/list.component.ts b/projects/skills/src/app/webinars/list/list.component.ts index 76e4093a7..d31406fb4 100644 --- a/projects/skills/src/app/webinars/list/list.component.ts +++ b/projects/skills/src/app/webinars/list/list.component.ts @@ -1,19 +1,34 @@ /** @format */ -import { AfterViewInit, ChangeDetectorRef, Component, inject, OnInit, signal } from "@angular/core"; +import { + type AfterViewInit, + ChangeDetectorRef, + Component, + inject, + type OnInit, + signal, +} from "@angular/core"; import { CommonModule } from "@angular/common"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { AvatarComponent } from "../../../../../social_platform/src/app/ui/components/avatar/avatar.component"; -import { ButtonComponent } from "@ui/components"; -import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { IconComponent } from "@uilib"; -import { concatMap, fromEvent, map, noop, of, Subscription, tap, throttleTime } from "rxjs"; +import { concatMap, fromEvent, map, noop, of, type Subscription, tap, throttleTime } from "rxjs"; import { WebinarComponent } from "../shared/webinar/webinar.component"; -import { Webinar } from "projects/skills/src/models/webinars.model"; +import type { Webinar } from "projects/skills/src/models/webinars.model"; import { WebinarService } from "../services/webinar.service"; -import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; - +import type { ApiPagination } from "projects/skills/src/models/api-pagination.model"; + +/** + * Компонент списка вебинаров + * + * Универсальный компонент для отображения как актуальных вебинаров, + * так и записей завершенных вебинаров. Поддерживает бесконечную прокрутку + * для постепенной загрузки контента. + * + * Функциональность: + * - Отображение списка вебинаров в зависимости от типа (актуальные/записи) + * - Бесконечная прокрутка для загрузки дополнительного контента + * - Автоматическое определение типа контента по URL + * - Оптимизированная загрузка данных порциями + */ @Component({ selector: "app-list", standalone: true, @@ -22,24 +37,36 @@ import { ApiPagination } from "projects/skills/src/models/api-pagination.model"; styleUrl: "./list.component.scss", }) export class WebinarsListComponent implements OnInit, AfterViewInit { + // Внедрение зависимостей router = inject(Router); route = inject(ActivatedRoute); webinarService = inject(WebinarService); cdRef = inject(ChangeDetectorRef); + // Сигналы для управления состоянием totalItemsCount = signal(0); webinarList = signal([]); recordsList = signal([]); webinarPage = signal(1); perFetchTake = signal(20); + // Тип отображаемого контента type = signal<"actual" | "records" | null>(null); + // Управление подписками subscriptions$ = signal([]); + /** + * Инициализация компонента + * + * Определяет тип контента по URL и загружает соответствующие данные + * из резолвера маршрута + */ ngOnInit(): void { + // Определение типа контента по последнему сегменту URL this.type.set(this.router.url.split("/").slice(-1)[0] as "actual" | "records"); + // Получение данных из резолвера в зависимости от типа const routeData$ = this.type() === "actual" ? this.route.data.pipe(map(r => r["data"])) @@ -57,20 +84,35 @@ export class WebinarsListComponent implements OnInit, AfterViewInit { this.subscriptions$().push(subscription); } + /** + * Настройка бесконечной прокрутки после инициализации представления + * + * Подписывается на события прокрутки контейнера и запускает + * загрузку дополнительных данных при достижении конца списка + */ ngAfterViewInit(): void { const target = document.querySelector(".office__body"); if (target) { const scrollEvents$ = fromEvent(target, "scroll") .pipe( concatMap(() => this.onScroll()), - throttleTime(500) + throttleTime(500) // Ограничение частоты запросов ) .subscribe(noop); this.subscriptions$().push(scrollEvents$); } } + /** + * Обработчик события прокрутки + * + * Проверяет, достиг ли пользователь конца списка, и если да, + * загружает следующую порцию данных + * + * @returns Observable с новыми данными или пустой Observable + */ onScroll() { + // Проверка, загружены ли все доступные элементы if (this.totalItemsCount() && this.recordsList().length >= this.totalItemsCount()) return of({}); @@ -80,8 +122,10 @@ export class WebinarsListComponent implements OnInit, AfterViewInit { 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.webinarPage() * this.perFetchTake(), this.perFetchTake()).pipe( tap((webinarChunk: Webinar[]) => { @@ -99,6 +143,16 @@ export class WebinarsListComponent implements OnInit, AfterViewInit { return of({}); } + /** + * Загрузка дополнительных данных + * + * Выполняет запрос к API для получения следующей порции вебинаров + * в зависимости от текущего типа контента + * + * @param offset - смещение для пагинации + * @param limit - количество элементов для загрузки + * @returns Observable с новыми данными + */ onFetch(offset: number, limit: number) { if (this.type() === "actual") { return this.webinarService.getActualWebinars(limit, offset).pipe( diff --git a/projects/skills/src/app/webinars/list/records.resolver.ts b/projects/skills/src/app/webinars/list/records.resolver.ts index b19093687..88d1fecc8 100644 --- a/projects/skills/src/app/webinars/list/records.resolver.ts +++ b/projects/skills/src/app/webinars/list/records.resolver.ts @@ -3,6 +3,14 @@ import { inject } from "@angular/core"; import { WebinarService } from "../services/webinar.service"; +/** + * Резолвер для загрузки записей вебинаров + * + * Предоставляет начальную порцию записей завершенных вебинаров + * для отображения в списке. Загружает первые 20 записей. + * + * @returns Observable с пагинированным списком записей вебинаров + */ export const RecordsResolver = () => { const webinarService = inject(WebinarService); diff --git a/projects/skills/src/app/webinars/services/webinar.service.ts b/projects/skills/src/app/webinars/services/webinar.service.ts index 4981415fe..9c4ce45b6 100644 --- a/projects/skills/src/app/webinars/services/webinar.service.ts +++ b/projects/skills/src/app/webinars/services/webinar.service.ts @@ -7,12 +7,33 @@ import { plainToInstance } from "class-transformer"; import { Webinar } from "projects/skills/src/models/webinars.model"; import { map, Observable } from "rxjs"; +/** + * Сервис для работы с вебинарами + * + * Предоставляет методы для получения информации о вебинарах, + * управления регистрацией и доступом к записям. + * + * Взаимодействует с API вебинаров и обрабатывает трансформацию + * данных в типизированные модели. + */ @Injectable({ providedIn: "root", }) export class WebinarService { + private readonly WEBINAR_URL = "/webinars"; + constructor(private readonly apiService: SkillsApiService) {} + /** + * Получение списка актуальных (предстоящих) вебинаров + * + * Загружает вебинары, которые еще не прошли и доступны + * для регистрации и участия + * + * @param limit - количество вебинаров для загрузки + * @param offset - смещение для пагинации + * @returns Observable - массив актуальных вебинаров + */ getActualWebinars(limit: number, offset: number): Observable { const params = new HttpParams(); @@ -20,10 +41,20 @@ export class WebinarService { params.set("offset", offset); return this.apiService - .get("/webinars/actual/", params) + .get(`${this.WEBINAR_URL}/actual/`, params) .pipe(map(webinar => plainToInstance(Webinar, webinar))); } + /** + * Получение списка записей завершенных вебинаров + * + * Загружает вебинары, которые уже прошли и доступны + * для просмотра в записи + * + * @param limit - количество записей для загрузки + * @param offset - смещение для пагинации + * @returns Observable - пагинированный список записей вебинаров + */ getRecords(limit: number, offset: number) { const params = new HttpParams(); @@ -31,15 +62,36 @@ export class WebinarService { params.set("offset", offset); return this.apiService - .get("/webinars/records/", params) + .get(`${this.WEBINAR_URL}/records/`, params) .pipe(map(webinar => plainToInstance(Webinar, webinar))); } + /** + * Получение ссылки на запись вебинара + * + * Предоставляет прямую ссылку для просмотра записи + * конкретного завершенного вебинара + * + * @param webinarId - идентификатор вебинара + * @returns Observable<{recordingLink: string}> - объект со ссылкой на запись + */ getWebinarLink(webinarId: number) { - return this.apiService.get<{ recordingLink: string }>(`/webinars/records/${webinarId}/link/`); + return this.apiService.get<{ recordingLink: string }>( + `${this.WEBINAR_URL}/records/${webinarId}/link/` + ); } + /** + * Регистрация пользователя на вебинар + * + * Выполняет регистрацию текущего пользователя на предстоящий вебинар. + * После успешной регистрации пользователь получит уведомления + * и доступ к участию в вебинаре. + * + * @param webinarId - идентификатор вебинара для регистрации + * @returns Observable - результат операции регистрации + */ registrationOnWebinar(webinarId: number) { - return this.apiService.post(`/webinars/actual/${webinarId}/`, {}); + return this.apiService.post(`${this.WEBINAR_URL}/actual/${webinarId}/`, {}); } } diff --git a/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg b/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg index c2f0bd641..7162e7b05 100644 --- a/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg +++ b/projects/skills/src/assets/icons/symbol/svg/sprite.css.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/projects/skills/src/models/api-pagination.model.ts b/projects/skills/src/models/api-pagination.model.ts index a29579def..0d26cee6f 100644 --- a/projects/skills/src/models/api-pagination.model.ts +++ b/projects/skills/src/models/api-pagination.model.ts @@ -1,8 +1,23 @@ /** @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 index 84756e2f7..0adb28f91 100644 --- a/projects/skills/src/models/profile.model.ts +++ b/projects/skills/src/models/profile.model.ts @@ -1,35 +1,99 @@ /** @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 index 68989ae85..114f37457 100644 --- a/projects/skills/src/models/rating.model.ts +++ b/projects/skills/src/models/rating.model.ts @@ -1,10 +1,31 @@ /** @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/skill.model.ts b/projects/skills/src/models/skill.model.ts index aae261904..91d736e02 100644 --- a/projects/skills/src/models/skill.model.ts +++ b/projects/skills/src/models/skill.model.ts @@ -1,71 +1,185 @@ /** @format */ +/** + * Основная модель навыка в системе обучения + * + * Представляет навык, который пользователь может изучать. + * Содержит информацию о доступности, прогрессе и метаданных навыка. + */ export interface Skill { + /** Уникальный идентификатор навыка */ id: number; + + /** Название навыка */ name: string; + + /** Общее количество уровней в навыке */ quantityOfLevels: number; + + /** Ссылка на изображение/иконку навыка */ fileLink: string; + + /** Автор/создатель навыка */ whoCreated: string; + + /** Описание навыка */ description: string; + + /** Флаг принадлежности навыка к траектории обучения */ isFromTrajectory: boolean; + + /** Требует ли навык активную подписку для доступа */ requiresSubscription: boolean; + + /** Завершен ли навык пользователем */ isDone: boolean; + + /** Доступен ли навык бесплатно */ freeAccess: boolean; + + /** Просрочен ли навык (опциональное поле) */ overdue?: boolean; } +/** + * Детальная информация о задаче навыка + * + * Содержит метаданные конкретной задачи в рамках навыка. + */ export interface TaskDetail { + /** Название навыка, к которому относится задача */ skillName: string; + + /** Описание задачи */ description: string; + + /** Уровень сложности задачи */ level: number; + + /** Путь к файлу с дополнительными материалами */ file: string; } +/** + * Модель задачи в рамках навыка + * + * Представляет отдельную задачу с информацией о её состоянии. + */ export interface Task { + /** Уникальный идентификатор задачи */ id: number; + + /** Уровень задачи в навыке */ level: number; + + /** Название задачи */ name: string; + + /** Статус выполнения задачи (true - выполнена, false - не выполнена) */ status: boolean; } +/** + * Ответ сервера со списком задач и статистикой + * + * Содержит задачи навыка и статистику выполнения по неделям. + */ export interface TasksResponse { + /** Массив задач навыка */ tasks: Task[]; + + /** Статистика выполнения по неделям */ statsOfWeeks: { + /** Выполнено ли вовремя (null если неприменимо) */ doneOnTime: boolean | null; + + /** Выполнено ли вообще */ isDone: boolean; + + /** Номер недели */ week: number; }[]; + + /** Общий прогресс выполнения навыка в процентах (0-100) */ progress: number; } +/** + * Шаг выполнения задачи + * + * Представляет отдельный шаг в рамках задачи с определенным типом взаимодействия. + */ export interface TaskStep { + /** Уникальный идентификатор шага */ id: number; + + /** Выполнен ли шаг */ isDone: boolean; + + /** Тип шага задачи */ type: - | "exclude_question" - | "question_single_answer" - | "question_connect" - | "info_slide" - | "question_write"; + | "exclude_question" // Вопрос на исключение + | "question_single_answer" // Вопрос с одним ответом + | "question_connect" // Вопрос на соединение + | "info_slide" // Информационный слайд + | "question_write"; // Вопрос с письменным ответом + + /** Порядковый номер шага в задаче */ ordinalNumber: number; } +/** + * Ответ сервера с шагами задачи и дополнительной информацией + * + * Расширяет TaskDetail дополнительными данными о прогрессе и шагах. + */ export interface TaskStepsResponse extends TaskDetail { + /** Общее количество шагов в задаче */ count: number; + + /** Текущий уровень пользователя в навыке */ currentLevel: number; + + /** Следующий уровень навыка */ nextLevel: number; + + /** Название навыка */ skillName: string; + + /** Ссылка на логотип навыка для отображения баллов */ skillPointLogo: string; + + /** Ссылка на превью навыка */ skillPreview: string; + + /** Массив шагов задачи */ stepData: TaskStep[]; } +/** + * Результаты выполнения задачи + * + * Содержит информацию о результатах прохождения задачи пользователем. + */ export interface TaskResults { + /** Количество полученных баллов за выполнение */ pointsGained: number; + + /** Количество правильно выполненных шагов */ quantityDoneCorrect: number; + + /** Общее количество шагов в задаче */ quantityAll: number; + + /** Прогресс выполнения в процентах (0-100) */ progress: number; + + /** ID следующей задачи (null если это последняя задача) */ nextTaskId: null | number; + + /** Уровень задачи */ level: number; + + /** Название навыка */ skillName: string; } diff --git a/projects/skills/src/models/step.model.ts b/projects/skills/src/models/step.model.ts index 4c8481b8f..4802ab3f4 100644 --- a/projects/skills/src/models/step.model.ts +++ b/projects/skills/src/models/step.model.ts @@ -1,77 +1,134 @@ /** @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; - ordinalNumber: number; + title: string | null; // Заголовок всплывающего окна + text: string | null; // Текстовое содержимое + fileLink: string | null; // URL к связанному файлу или ресурсу + ordinalNumber: number; // Порядковый номер для сортировки } -export interface InfoSlide { - text: string; - description: string; - files: string[]; - popups: Popup[]; +/** + * Информационный слайд + * + * Отображает образовательный контент без требования взаимодействия пользователя. + * Используется для представления концепций, объяснений или инструкций. + */ +export interface InfoSlide extends BaseStep { + text: string; // Основной текстовый контент слайда + description: string; // Дополнительное описание или контекст + files: string[]; // Массив URL файлов для отображения (изображения, документы) + popups: Popup[]; // Всплывающие окна для отображения после просмотра } -export interface ConnectQuestion { - connectLeft: { id: number; text?: string; file?: string }[]; - connectRight: { id: number; text?: string; file?: string }[]; - description: string; - files: string[]; - id: number; - isAnswered: boolean; - text: string; - popups: Popup[]; +/** + * Вопрос на соединение/сопоставление + * + * Требует от пользователей сопоставления элементов из двух колонок или соединения связанных концепций. + * Проверяет понимание отношений между различными элементами. + */ +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; + isCorrect: boolean; // Правильно ли это соединение }[]; -export interface SingleQuestion { - answers: { id: number; text: string }[]; - description: string; - files: string[]; - id: number; - isAnswered: boolean; - text: string; - popups: Popup[]; +/** + * Вопрос с единственным правильным ответом + * + * Представляет вопрос с несколькими вариантами, где только один ответ правильный. + * Наиболее распространенный тип оценочного вопроса. + */ +export interface SingleQuestion extends BaseStep { + answers: { id: number; text: string }[]; // Доступные варианты ответов + description: string; // Дополнительное описание или контекст + files: string[]; // Связанные файлы (изображения, документы) + isAnswered: boolean; // Был ли вопрос уже отвечен + text: string; // Основной текст вопроса + popups: Popup[]; // Всплывающие окна для отображения после ответа } + +/** + * Ответ об ошибке для вопросов с единственным ответом + * Возвращается, когда пользователь выбирает неправильный ответ + */ export interface SingleQuestionError { - correctAnswer: number; - isCorrect: boolean; + correctAnswer: number; // ID правильного варианта + isCorrect: boolean; // Был ли ответ правильным (всегда false для ошибок) } -export interface ExcludeQuestion { - answers: { id: number; text: string }[]; - description: string; - files: string[]; - id: number; - isAnswered: boolean; - text: string; - popups: Popup[]; +/** + * Вопрос на исключение + * + * Представляет несколько элементов, где пользователи должны определить, какой не принадлежит + * или какие элементы должны быть исключены из группы. + */ +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[]; + isCorrect: boolean; // Был ли ответ пользователя правильным + wrongAnswers: number[]; // ID неправильно выбранных элементов } -// export interface WriteQuestionResponse { -// text: "success" | "error"; -// } - -export interface WriteQuestion { - answer: string | null; - description: string; - files: string[]; - id: number; - text: string; - popups: Popup[]; +/** + * Вопрос с письменным ответом + * + * Требует от пользователей предоставления текстового ответа. + * Может использоваться для коротких ответов, эссе или отправки кода. + */ +export interface WriteQuestion extends BaseStep { + answer: string | null; // Текущий ответ пользователя (если есть) + description: string; // Инструкции или дополнительный контекст + files: string[]; // Связанные файлы для справки + text: string; // Основной текст вопроса или подсказка + popups: Popup[]; // Всплывающие окна для отображения после отправки } +/** + * Объединенный тип, представляющий все возможные типы шагов + * Используется для типобезопасной обработки различных вариаций шагов + */ export type StepType = | InfoSlide | ConnectQuestion diff --git a/projects/skills/src/models/trajectory.model.ts b/projects/skills/src/models/trajectory.model.ts index 6b44b8494..ddf1a075c 100644 --- a/projects/skills/src/models/trajectory.model.ts +++ b/projects/skills/src/models/trajectory.model.ts @@ -1,61 +1,88 @@ /** @format */ -import { UserData } from "./profile.model"; -import { Skill } from "./skill.model"; +import type { UserData } from "./profile.model"; +import type { Skill } from "./skill.model"; +/** + * Информация о навыке в контексте траектории + * Упрощенная версия интерфейса Skill для отображения траектории + */ interface SkillInfo { - fileLink: string | null; - name: string; + fileLink: string | null; // URL к ресурсам навыка + name: string; // Отображаемое название навыка } +/** + * Определение траектории обучения + * + * Траектории - это структурированные пути обучения, которые направляют пользователей через + * серию навыков и компетенций для достижения конкретных карьерных или + * бизнес-целей. + */ export interface Trajectory { id: number; - name: string; - description: string; - isActiveForUser: boolean; - avatar: string | null; - mentors: number[]; - skills: SkillInfo[]; - backgroundColor: string; - buttonColor: string; - selectButtonColor: string; - textColor: string; - company: string; - durationMonths: 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[]; + availableSkills: Skill[]; // Навыки, к которым пользователь может получить доступ в настоящее время + unavailableSkills: Skill[]; // Навыки, заблокированные до выполнения предварительных условий + completedSkills: Skill[]; // Навыки, которые пользователь успешно завершил } +/** + * Регистрация и прогресс пользователя в конкретной траектории + * + * Содержит всю информацию о путешествии пользователя через траекторию, + * включая назначение ментора, статус встреч и прогресс по навыкам. + */ export interface UserTrajectory { - trajectoryId: number; - startDate: string; - endDate: string; - isActive: boolean; - mentorFirstName: string; - mentorLastName: string; - mentorAvatar: string | null; - mentorId: number; - firstMeetingDone: boolean; - finalMeetingDone: boolean; - availableSkills: Skill[]; - unavailableSkills: Skill[]; - completedSkills: Skill[]; - individualSkills: Skill[]; - activeMonth: number; - durationMonths: number; + 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; - meetingId: number; - student: UserData; - mentorId: number; + 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 index c4fb84005..327a50b22 100644 --- a/projects/skills/src/models/webinars.model.ts +++ b/projects/skills/src/models/webinars.model.ts @@ -1,18 +1,57 @@ /** @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/social_platform/README.md b/projects/social_platform/README.md new file mode 100644 index 000000000..d4e64b412 --- /dev/null +++ b/projects/social_platform/README.md @@ -0,0 +1,175 @@ + + +# Социальная платформа + +Веб-приложение социальной платформы, построенное на Angular с использованием современных технологий и лучших практик разработки. + +## Структура проекта + +### 📁 src/app/ + +Основная папка приложения, содержащая все модули и компоненты. + +#### 🔐 auth/ + +Модуль аутентификации и авторизации пользователей. + +- **Компоненты**: Вход, регистрация, сброс пароля, подтверждение email +- **Сервисы**: AuthService, ProfileService +- **Охранники**: AuthRequiredGuard +- **Модели**: User, Tokens, HTTP модели + +#### 🏢 office/ + +Основной рабочий модуль приложения после авторизации. + +##### 📊 projects/ + +Управление проектами + +- **list/**: Списки проектов (все, мои, подписки) +- **detail/**: Детальная информация о проекте +- **edit/**: Редактирование проектов +- **shared/**: Общие компоненты проектов + +##### 💬 chat/ + +Система чатов и сообщений + +- **chat-direct/**: Личные сообщения +- **shared/**: Общие комп��ненты чатов +- **services/**: Сервисы для работы с чатами + +##### 👥 members/ + +Управление участниками платформы + +- **filters/**: Фильтры для поиска участников + +##### 📰 feed/ + +Лента новостей и активности + +- **filter/**: Фильтры ленты +- **shared/**: Компоненты для отображения различных типов контента + +##### 💼 vacancies/ + +Система вакансий + +- **list/**: Список вакансий +- **detail/**: Детальная информация о вакансии +- **shared/**: Общие компоненты вакансий + +##### 👤 profile/ + +Профили пользователей + +- **detail/**: Просмотр профиля +- **edit/**: Редактирование профиля + +##### 🎓 program/ + +Образовательные программы + +- **detail/**: Детали программы +- **list/**: Список программ +- **shared/**: Общие компоненты программ + +##### 🚀 onboarding/ + +Процесс знакомства новых пользователей с платформой + +##### 🤝 mentors/ + +Система менторства + +#### 🎨 ui/ + +Библиотека UI компонентов + +- **components/**: Переиспользуемые компоненты (кнопки, инпуты, модалы и т.д.) +- **services/**: UI сервисы (анимации, уведомления) +- **directives/**: Пользовательские директивы +- **pipes/**: Пользовательские пайпы + +#### 🔧 core/ + +Основные сервисы и утилиты + +- **services/**: Базовые сервисы (файлы, WebSocket) +- **pipes/**: Основные пайпы +- **models/**: Базовые модели + +#### ❌ error/ + +Обработка ошибок + +- **services/**: Глобальный обработчик ошибок +- **components/**: Страницы ошибок (404, 500) + +#### 🛠 utils/ + +Вспомогательные утилиты и функции + +### 📁 src/assets/ + +Статические ресурсы приложения + +#### 🖼 images/ + +Изображения, разделенные по модулям: + +- **auth/**: Изображения для страниц авторизации +- **office/**: Изображения для рабочих модулей +- **profile/**: Изображения профилей +- **projects/**: Изображения проектов +- **shared/**: Общие изображения + +#### 🎨 icons/ + +SVG иконки, организованные по категориям + +#### 🔤 font/ + +Шрифты Mont в различных начертаниях + +#### 📄 downloads/ + +Файлы для скачивания (документы, соглашения) + +### 📁 src/styles/ + +Глобальные стили приложения + +- **\_colors.scss**: Цветовая палитра +- **\_typography.scss**: Типографика +- **\_global.scss**: Глобальные стили +- **\_responsive.scss**: Адаптивность +- **components/**: Стили компонентов +- **pages/**: Стили страниц + +## Технологии + +- **Angular 17+**: Основной фреймворк +- **TypeScript**: Язык программирования +- **SCSS**: Препроцессор CSS +- **RxJS**: Реактивное программирование +- **WebSocket**: Реальное время коммуникации + +## Архитектурные принципы + +1. **Модульность**: Каждый функциональный блок выделен в отдельный модуль +2. **Переиспользование**: Общие компоненты вынесены в ui модуль +3. **Разделение ответственности**: Четкое разделение на компоненты, сервисы, модели +4. **Типизация**: Строгая типизация с TypeScript +5. **Реактивность**: Использование RxJS для управления состоянием + +## Запуск проекта + +\`\`\`bash +npm install +ng serve +\`\`\` + +Приложение будет доступно по адресу `http://localhost:4200` diff --git a/projects/social_platform/src/app/app.component.ts b/projects/social_platform/src/app/app.component.ts index 48d389619..be9cf814e 100644 --- a/projects/social_platform/src/app/app.component.ts +++ b/projects/social_platform/src/app/app.component.ts @@ -11,18 +11,24 @@ import { map, merge, noop, - Observable, - Subscription, + type Observable, + type Subscription, throttleTime, } from "rxjs"; import { MatProgressBarModule } from "@angular/material/progress-bar"; import { AsyncPipe, NgIf } from "@angular/common"; import { TokenService } from "@corelib"; +/** + * Корневой компонент приложения + * + * Основной компонент, который служит точкой входа в приложение. + * Содержит router-outlet для отображения различных страниц приложения. + */ @Component({ selector: "app-root", templateUrl: "./app.component.html", - styleUrl: "./app.component.scss", + styleUrls: ["./app.component.scss"], standalone: true, imports: [NgIf, MatProgressBarModule, RouterOutlet, AsyncPipe], }) diff --git a/projects/social_platform/src/app/app.routes.ts b/projects/social_platform/src/app/app.routes.ts index a243106b8..fc1b1aa35 100644 --- a/projects/social_platform/src/app/app.routes.ts +++ b/projects/social_platform/src/app/app.routes.ts @@ -4,6 +4,16 @@ import { Routes } from "@angular/router"; import { AppComponent } from "./app.component"; import { AuthRequiredGuard } from "@auth/guards/auth-required.guard"; +/** + * Основные маршруты приложения + * + * Определяет структуру навигации приложения: + * - /auth - модуль аутентификации + * - /office - основной рабочий модуль (требует авторизации) + * - /error - страницы ошибок + * - /** - перенаправление на страницу 404 + */ + export const APP_ROUTES: Routes = [ { path: "", diff --git a/projects/social_platform/src/app/auth/README.md b/projects/social_platform/src/app/auth/README.md new file mode 100644 index 000000000..08d6f136d --- /dev/null +++ b/projects/social_platform/src/app/auth/README.md @@ -0,0 +1,91 @@ + + +# Модуль аутентификации (Auth) + +Модуль отвечает за аутентификацию и авторизацию пользователей в системе. + +## Структура + +### Компоненты + +#### 🔐 login/ + +Компонент входа в систему + +- Форма с email и паролем +- Валидация полей +- Обработка ошибок авторизации + +#### 📝 register/ + +Компонент регистрации нового пользователя + +- Многошаговая форма регистрации +- Валидация данных +- Отправка кода подтверждения + +#### 📧 email-verification/ + +Подтверждение email адреса + +- Ввод кода подтверждения +- Повторная отправка кода + +#### 🔑 reset-password/ + +Сброс пароля + +- Запрос на сброс по email +- Форма нового пароля + +#### ✅ confirm-email/ + +Подтверждение email по ссылке + +#### 🔒 set-password/ + +Установка нового пароля + +#### ✉️ confirm-password-reset/ + +Подтверждение сброса пароля + +### Сервисы + +#### AuthService + +Основной сервис аутентификации + +- Вход/выход из системы +- Управление токенами +- Проверка статуса авторизации + +#### ProfileService + +Управление профилем пользователя + +- Получение данных профиля +- Обновление информации + +### Охранники (Guards) + +#### AuthRequiredGuard + +Защита маршрутов, требующих авторизации + +- Проверка наличия токена +- Перенаправление на страницу входа + +### Модели + +#### User + +Модель пользователя системы + +#### Tokens + +Модель токенов доступа и обновления + +#### HTTP модели + +Модели для HTTP запросов и ответов diff --git a/projects/social_platform/src/app/auth/auth.component.ts b/projects/social_platform/src/app/auth/auth.component.ts index 1f08c7eb7..e9f5ee570 100644 --- a/projects/social_platform/src/app/auth/auth.component.ts +++ b/projects/social_platform/src/app/auth/auth.component.ts @@ -3,6 +3,17 @@ import { Component, OnInit } from "@angular/core"; import { RouterOutlet } from "@angular/router"; +/** + * Основной компонент модуля аутентификации + * + * Назначение: Корневой компонент для всех страниц аутентификации (вход, регистрация, сброс пароля) + * Принимает: Не принимает входных параметров + * Возвращает: Рендерит router-outlet для дочерних компонентов аутентификации + * + * Функциональность: + * - Предоставляет общий layout для страниц аутентификации + * - Содержит router-outlet для отображения дочерних компонентов + */ @Component({ selector: "app-auth", templateUrl: "./auth.component.html", diff --git a/projects/social_platform/src/app/auth/auth.routes.ts b/projects/social_platform/src/app/auth/auth.routes.ts index e15a96ff2..ae80194fc 100644 --- a/projects/social_platform/src/app/auth/auth.routes.ts +++ b/projects/social_platform/src/app/auth/auth.routes.ts @@ -10,6 +10,18 @@ import { ResetPasswordComponent } from "@auth/reset-password/reset-password.comp 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: "", 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 index 896cae22c..2564cc288 100644 --- 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 @@ -1,10 +1,22 @@ /** @format */ import { Component, OnInit } from "@angular/core"; -import { AuthService } from "../services"; 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", 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 index d209956f8..1b2e656d0 100644 --- 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 @@ -5,6 +5,18 @@ 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", 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 index 52433d064..16a3319f3 100644 --- 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 @@ -6,6 +6,19 @@ 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", diff --git a/projects/social_platform/src/app/auth/guards/auth-required.guard.ts b/projects/social_platform/src/app/auth/guards/auth-required.guard.ts index 5859af558..74b3e87b9 100644 --- a/projects/social_platform/src/app/auth/guards/auth-required.guard.ts +++ b/projects/social_platform/src/app/auth/guards/auth-required.guard.ts @@ -6,6 +6,20 @@ import { AuthService } from "../services"; import { catchError, map } from "rxjs"; import { TokenService } from "@corelib"; +/** + * Guard для проверки аутентификации пользователя + * + * Назначение: Защищает маршруты, требующие аутентификации пользователя + * Принимает: ActivatedRouteSnapshot и RouterStateSnapshot (параметры маршрута) + * Возвращает: boolean или UrlTree для разрешения/запрета доступа к маршруту + * + * Функциональность: + * - Проверяет наличие токенов аутентификации + * - Валидирует токены через получение профиля пользователя + * - Перенаправляет на страницу входа при отсутствии аутентификации + * - Обрабатывает ошибки при проверке токенов + * - Используется в конфигурации маршрутов для защиты приватных страниц + */ export const AuthRequiredGuard: CanActivateFn = () => { const tokenService = inject(TokenService); const authService = inject(AuthService); diff --git a/projects/social_platform/src/app/auth/login/login.component.ts b/projects/social_platform/src/app/auth/login/login.component.ts index 2d4c6c54a..fa3ab6826 100644 --- a/projects/social_platform/src/app/auth/login/login.component.ts +++ b/projects/social_platform/src/app/auth/login/login.component.ts @@ -9,6 +9,23 @@ import { ActivatedRoute, Router, RouterLink } from "@angular/router"; import { ButtonComponent, IconComponent, InputComponent } from "@ui/components"; import { CommonModule } from "@angular/common"; +/** + * Компонент входа в систему + * + * Назначение: Реализует форму входа пользователя в систему + * Принимает: Email и пароль пользователя через форму, параметр redirect из URL + * Возвращает: Перенаправление в офис при успехе или отображение ошибок + * + * Функциональность: + * - Форма входа с полями email и пароля + * - Валидация email и обязательных полей + * - Показ/скрытие пароля + * - Отправка данных на сервер для аутентификации + * - Сохранение токенов при успешном входе + * - Обработка ошибок аутентификации (неверные данные) + * - Поддержка различных типов перенаправления после входа + * - Очистка токенов при инициализации + */ @Component({ selector: "app-login", templateUrl: "./login.component.html", diff --git a/projects/social_platform/src/app/auth/models/http.model.ts b/projects/social_platform/src/app/auth/models/http.model.ts index 37ea549b8..d269913d9 100644 --- a/projects/social_platform/src/app/auth/models/http.model.ts +++ b/projects/social_platform/src/app/auth/models/http.model.ts @@ -1,5 +1,20 @@ /** @format */ +/** + * Модели HTTP запросов и ответов для аутентификации + * + * Назначение: Определяет структуру данных для API запросов аутентификации + * Принимает: Не принимает параметров (классы моделей) + * Возвращает: Типизированные объекты для HTTP взаимодействия + * + * Функциональность: + * - LoginRequest: данные для входа (email, пароль) + * - LoginResponse: ответ сервера при входе (токены) + * - RefreshResponse: ответ при обновлении токенов + * - RegisterRequest: данные для регистрации (имя, фамилия, email, пароль) + * - RegisterResponse: ответ сервера при регистрации (наследует LoginResponse) + */ + export class LoginRequest { email!: string; password!: string; diff --git a/projects/social_platform/src/app/auth/models/tokens.model.ts b/projects/social_platform/src/app/auth/models/tokens.model.ts index 775e3a9b7..13750f94d 100644 --- a/projects/social_platform/src/app/auth/models/tokens.model.ts +++ b/projects/social_platform/src/app/auth/models/tokens.model.ts @@ -1,5 +1,17 @@ /** @format */ +/** + * Модель токенов аутентификации + * + * Назначение: Определяет структуру JWT токенов для аутентификации + * Принимает: Не принимает параметров (класс модели) + * Возвращает: Типизированный объект с access и refresh токенами + * + * Функциональность: + * - Содержит access токен для авторизации запросов + * - Содержит refresh токен для обновления access токена + * - Используется для хранения и передачи токенов аутентификации + */ export class Tokens { access!: string; refresh!: string; diff --git a/projects/social_platform/src/app/auth/models/user.model.ts b/projects/social_platform/src/app/auth/models/user.model.ts index 11270dda0..fdd77f4d1 100644 --- a/projects/social_platform/src/app/auth/models/user.model.ts +++ b/projects/social_platform/src/app/auth/models/user.model.ts @@ -1,8 +1,25 @@ /** @format */ + import { Project } from "@models/project.model"; import { Skill } from "@office/models/skill"; import { Program } from "@office/program/models/program.model"; +/** + * Модели данных пользователя и связанных сущностей + * + * Назначение: Определяет типы данных для пользователя, достижений, образования и опыта работы + * Принимает: Не принимает параметров (классы моделей) + * Возвращает: Типизированные объекты для работы с данными пользователя + * + * Функциональность: + * - Класс User: основная модель пользователя с профилем, навыками, проектами + * - Класс Achievement: модель достижений пользователя + * - Класс Education: модель образования пользователя + * - Класс workExperience: модель опыта работы + * - Класс userLanguages: модель языков пользователя + * - Класс UserRole: модель ролей пользователя + * - Методы для проверки завершенности профиля и создания тестовых данных + */ export class Achievement { id!: number; title!: string; diff --git a/projects/social_platform/src/app/auth/register/register.component.ts b/projects/social_platform/src/app/auth/register/register.component.ts index beab45d5b..afc94dd4d 100644 --- a/projects/social_platform/src/app/auth/register/register.component.ts +++ b/projects/social_platform/src/app/auth/register/register.component.ts @@ -15,6 +15,22 @@ import { CommonModule } from "@angular/common"; dayjs.extend(cpf); +/** + * Компонент регистрации нового пользователя + * + * Назначение: Реализует двухэтапную форму регистрации с валидацией + * Принимает: Данные пользователя через форму (email, пароль, личные данные) + * Возвращает: Перенаправление на страницу подтверждения email или отображение ошибок + * + * Функциональность: + * - Двухэтапная регистрация (учетные данные → личная информация) + * - Валидация email, пароля, имени, фамилии, даты рождения + * - Проверка совпадения паролей + * - Обработка согласий пользователя + * - Отправка данных на сервер и обработка ошибок + * - Показ/скрытие паролей + * - Модальное окно при ошибке создания аккаунта + */ @Component({ selector: "app-login", templateUrl: "./register.component.html", 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 index 1842407b9..2d8dce1d3 100644 --- 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 @@ -8,6 +8,20 @@ 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", diff --git a/projects/social_platform/src/app/auth/services/auth.service.ts b/projects/social_platform/src/app/auth/services/auth.service.ts index 8889637ae..97056c75b 100644 --- a/projects/social_platform/src/app/auth/services/auth.service.ts +++ b/projects/social_platform/src/app/auth/services/auth.service.ts @@ -11,107 +11,186 @@ import { RegisterResponse, } from "../models/http.model"; import { User, UserRole } from "../models/user.model"; -import { HttpParams } from "@angular/common/http"; -import { decode } from "js-base64"; +/** + * Сервис аутентификации и управления пользователями + * + * Назначение: Основной сервис для всех операций аутентификации и работы с профилем пользователя + * Принимает: Данные для входа, регистрации, сброса пароля, обновления профиля + * Возвращает: 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("/api/token/", { email, password }) + .post(`${this.API_TOKEN_URL}/`, { email, password }) .pipe(map(json => plainToInstance(LoginResponse, json))); } + /** + * Выход пользователя из системы + * Отправляет refresh токен на сервер для инвалидации + * @returns Observable завершения операции + */ logout(): Observable { return this.apiService - .post("/auth/logout/", { refreshToken: this.tokenService.getTokens()?.refresh }) + .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("/auth/users/", data) + .post(`${this.AUTH_USERS_URL}/`, data) .pipe(map(json => plainToInstance(RegisterResponse, json))); } - downloadCV(): Observable { - return this.apiService.get("/auth/users/download_cv/"); - } - + /** + * Отправить резюме по email + * @returns Observable завершения операции + */ sendCV(): Observable { - return this.apiService.get("/auth/users/send_mail_cv/"); + return this.apiService.get(`${this.AUTH_USERS_URL}/send_mail_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("/auth/users/current/").pipe( + 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]]>("/auth/users/types/").pipe( + 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)) ); } + /** + * Получить роли, которые может изменить текущий пользователь + * @returns Observable с массивом изменяемых ролей + */ getChangeableRoles(): Observable { - return this.apiService.get<[[number, string]]>("/auth/users/roles/").pipe( + 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(`/auth/users/${id}/`) + .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(`/auth/users/${profile.id}`, { avatar: url }) + 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(`/auth/users/${profile.id}/`, newProfile)), + 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(`/auth/users/${profile.id}/set_onboarding_stage/`, { + this.apiService.put(`${this.AUTH_USERS_URL}/${profile.id}/set_onboarding_stage/`, { onboardingStage: stage, }) ), @@ -122,17 +201,33 @@ export class AuthService { ); } + /** + * Запросить сброс пароля + * @param email Email для отправки ссылки сброса + * @returns Observable завершения операции + */ resetPassword(email: string): Observable { - return this.apiService.post("/auth/reset_password/", { email }); + 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("/auth/reset_password/confirm/", { password, token }); + 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("/auth/resend_email/", { email }) + .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 index 43ab597e1..0f5e6ddd9 100644 --- a/projects/social_platform/src/app/auth/services/index.ts +++ b/projects/social_platform/src/app/auth/services/index.ts @@ -1,3 +1,15 @@ /** @format */ +/** + * Индексный файл для экспорта сервисов модуля аутентификации + * + * Назначение: Централизованный экспорт всех сервисов модуля аутентификации + * Принимает: Не принимает параметров + * Возвращает: Экспортирует AuthService для использования в других модулях + * + * Функциональность: + * - Упрощает импорт сервисов в других частях приложения + * - Обеспечивает единую точку входа для сервисов аутентификации + */ + export * from "./auth.service"; diff --git a/projects/social_platform/src/app/auth/services/profile.service.ts b/projects/social_platform/src/app/auth/services/profile.service.ts index a66a77ea1..167276827 100644 --- a/projects/social_platform/src/app/auth/services/profile.service.ts +++ b/projects/social_platform/src/app/auth/services/profile.service.ts @@ -2,39 +2,55 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; -import { Achievement, User } from "../models/user.model"; -import { first, last, map, Observable } from "rxjs"; +import { Achievement } from "../models/user.model"; +import { map, Observable } from "rxjs"; import { plainToInstance } from "class-transformer"; import { Approve } from "@office/models/skill"; +/** + * Сервис управления профилем пользователя + * + * Назначение: Дополнительный сервис для операций с профилем пользователя + * Принимает: Данные достижений, ID пользователей и навыков + * Возвращает: Observable с результатами операций над достижениями и навыками + * + * Функциональность: + * - Добавление, редактирование и удаление достижений пользователя + * - Подтверждение и отмена подтверждения навыков пользователя + * - Работа с API для операций профиля + * - Использует class-transformer для преобразования данных + * - Дополняет функциональность AuthService + */ @Injectable({ providedIn: "root", }) export class ProfileService { + private readonly AUTH_USERS_URL = "/auth/users"; + constructor(private apiService: ApiService) {} addAchievement(achievement: Omit): Observable { return this.apiService - .post("/auth/users/achievements/", achievement) + .post(`${this.AUTH_USERS_URL}/achievements/`, achievement) .pipe(map(achievement => plainToInstance(Achievement, achievement))); } deleteAchievement(achievementId: string): Observable { - return this.apiService.delete(`/auth/users/achievements/${achievementId}/`); + return this.apiService.delete(`${this.AUTH_USERS_URL}/achievements/${achievementId}/`); } editAchievement( achievementId: string, achievement: Omit ): Observable { - return this.apiService.put(`/auth/users/achievement/${achievementId}/`, achievement); + return this.apiService.put(`${this.AUTH_USERS_URL}/achievement/${achievementId}/`, achievement); } approveSkill(userId: number, skillId: number): Observable { - return this.apiService.post(`/auth/users/${userId}/approve_skill/${skillId}/`, {}); + return this.apiService.post(`${this.AUTH_USERS_URL}/${userId}/approve_skill/${skillId}/`, {}); } unApproveSkill(userId: number, skillId: number): Observable { - return this.apiService.delete(`/auth/users/${userId}/approve_skill/${skillId}/`); + return this.apiService.delete(`${this.AUTH_USERS_URL}/${userId}/approve_skill/${skillId}/`); } } 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 index 7d0c0c2cb..fa9399438 100644 --- 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 @@ -8,6 +8,22 @@ 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", @@ -39,7 +55,13 @@ export class SetPasswordComponent implements OnInit { showPassword = false; - ngOnInit(): void {} + 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; @@ -54,6 +76,10 @@ export class SetPasswordComponent implements OnInit { 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 new file mode 100644 index 000000000..70a80480a --- /dev/null +++ b/projects/social_platform/src/app/core/README.md @@ -0,0 +1,59 @@ + + +# 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/models/http.model.ts b/projects/social_platform/src/app/core/models/http.model.ts index 3d107541f..aae03840e 100644 --- a/projects/social_platform/src/app/core/models/http.model.ts +++ b/projects/social_platform/src/app/core/models/http.model.ts @@ -1,5 +1,10 @@ /** @format */ +/** + * Интерфейс для представления ошибки API + * Используется для типизации ошибок, возвращаемых сервером + */ export interface ApiError { + /** Детальное описание ошибки */ detail: string; } diff --git a/projects/social_platform/src/app/core/pipes/formatted-file-size.pipe.ts b/projects/social_platform/src/app/core/pipes/formatted-file-size.pipe.ts index 26f7959e1..968348d2e 100644 --- a/projects/social_platform/src/app/core/pipes/formatted-file-size.pipe.ts +++ b/projects/social_platform/src/app/core/pipes/formatted-file-size.pipe.ts @@ -1,18 +1,36 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; +/** + * Пайп для форматирования размера файла в человекочитаемый вид + * Преобразует размер в байтах в строку с соответствующими единицами измерения + * + * Пример использования: {{ fileSize | formatedFileSize }} + * Результат: "1.5 MB", "256 KB", "2.0 GB" + */ @Pipe({ standalone: true, name: "formatedFileSize", }) export class FormatedFileSizePipe implements PipeTransform { + /** + * Преобразует размер файла из байтов в читаемый формат + * + * @param bytes - размер файла в байтах (число) + * @returns строка с размером файла и единицами измерения (например, "1.5 MB") + * + * Поддерживаемые единицы: Bytes, KB, MB, GB + * Использует систему 1024 (бинарную) для расчета размеров + */ transform(bytes: number): string { const sizes = ["Bytes", "KB", "MB", "GB"]; if (bytes === 0) return "0 Bytes"; + // Определяем индекс единицы измерения const i = Math.floor(Math.log(bytes) / Math.log(1024)); + // Вычисляем размер с одним знаком после запятой const size = Math.round(bytes / Math.pow(1024, i)).toFixed(1); return `${size} ${sizes[i]}`; diff --git a/projects/social_platform/src/app/core/pipes/user-links.pipe.ts b/projects/social_platform/src/app/core/pipes/user-links.pipe.ts index 6e7054ea3..96df24d35 100644 --- a/projects/social_platform/src/app/core/pipes/user-links.pipe.ts +++ b/projects/social_platform/src/app/core/pipes/user-links.pipe.ts @@ -1,31 +1,64 @@ /** @format */ -import { Pipe, PipeTransform } from "@angular/core"; +import { Pipe, type PipeTransform } from "@angular/core"; +/** + * Пайп для обработки пользовательских ссылок + * Преобразует URL в объект с иконкой и отображаемым текстом + * Поддерживает специальные домены (Telegram, VK) и файловые ссылки + * + * Пример использования: {{ userLink | userLinks }} + */ @Pipe({ name: "userLinks", standalone: true, }) export class UserLinksPipe implements PipeTransform { + /** Маппинг доменов на названия иконок */ icons: Record = { "t.me": "telegram", "vk.com": "vk", }; + /** + * Преобразует строку URL в объект с иконкой и тегом для отображения + * + * @param value - URL или строка ссылки + * @returns объект с полями: + * - iconName: название иконки для отображения + * - tag: текст для отображения пользователю + * + * Логика обработки: + * - Email адреса и специальные ссылки (procollab_media, api.selcdn.ru) → иконка "file" или "link" + * - Telegram и VK ссылки → соответствующие иконки с username + * - Остальные ссылки → иконка "link" с полным URL + */ transform(value: string): { iconName: string; tag: string } { - if (value.includes("@")) { + // Обработка email адресов и специальных файловых ссылок + if ( + value.includes("@") || + value.includes("procollab_media") || + value.includes("api.selcdn.ru/v1") + ) { const valueTrimed = value.replace(/^https?:\/\//, ""); - return { iconName: "link", tag: valueTrimed }; + return { + iconName: + value.includes("procollab_media") || value.includes("api.selcdn.ru/v1") ? "file" : "link", + tag: valueTrimed, + }; } + // Обработка обычных URL const url = new URL(value); let domain = url.hostname; - domain = domain.split(".").slice(-2).join("."); + domain = domain.split(".").slice(-2).join("."); // Получаем основной домен let tag = url.pathname; + // Если путь сложный или домен не поддерживается - показываем полный URL if (tag.split("/").filter(Boolean).length > 1 || !this.icons[domain]) { tag = value; } else { + // Для поддерживаемых доменов показываем username с @ tag = "@" + tag.replace(/\//g, ""); } 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 index e76dcb37c..6e0111b84 100644 --- a/projects/social_platform/src/app/core/pipes/user-role.pipe.ts +++ b/projects/social_platform/src/app/core/pipes/user-role.pipe.ts @@ -4,6 +4,12 @@ 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, @@ -11,6 +17,12 @@ import { map, Observable } from "rxjs"; 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 index 83667a08c..8f3bb4819 100644 --- a/projects/social_platform/src/app/core/services/file.service.ts +++ b/projects/social_platform/src/app/core/services/file.service.ts @@ -6,18 +6,34 @@ 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 => { - fetch(`${environment.apiUrl}/files/`, { + fetch(`${environment.apiUrl}${this.FILES_URL}/`, { method: "POST", headers: { Authorization: `Bearer ${this.tokenService.getTokens()?.access}`, @@ -33,8 +49,16 @@ export class FileService { }); } + /** + * Удаляет файл с сервера по 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("/files/", params); + return this.apiService.delete(`${this.FILES_URL}/`, params); } } diff --git a/projects/social_platform/src/app/core/services/websocket.service.ts b/projects/social_platform/src/app/core/services/websocket.service.ts index 83c3511d7..224f55e3c 100644 --- a/projects/social_platform/src/app/core/services/websocket.service.ts +++ b/projects/social_platform/src/app/core/services/websocket.service.ts @@ -5,14 +5,35 @@ import { environment } from "@environment"; import * as snakecaseKeys from "snakecase-keys"; import camelcaseKeys from "camelcase-keys"; +/** + * Сервис для работы с WebSocket соединениями + * Предоставляет методы для подключения, отправки сообщений и прослушивания событий + * Автоматически преобразует ключи объектов между camelCase и snake_case + * Поддерживает автоматическое переподключение при разрыве соединения + */ @Injectable({ providedIn: "root", }) export class WebsocketService { + /** Экземпляр WebSocket соединения */ private socket: WebSocket | null = null; + /** Subject для обработки входящих сообщений */ private messages$ = new Subject(); + /** Флаг состояния соединения */ public isConnected = false; + + /** + * Устанавливает WebSocket соединение + * + * @param path - путь для подключения (добавляется к базовому websocketUrl) + * @returns Observable - Observable, который эмитит при успешном подключении + * + * Особенности: + * - Автоматически переподключается при разрыве соединения + * - Количество попыток и интервал настраиваются через environment + * - Все входящие сообщения перенаправляются в messages$ Subject + */ public connect(path: string): Observable { return new Observable((observer: Observer) => { this.socket = new WebSocket(environment.websocketUrl + path); @@ -39,14 +60,39 @@ export class WebsocketService { ); } + /** + * Отправляет сообщение через WebSocket + * + * @param type - тип сообщения + * @param content - содержимое сообщения (любой объект) + * @throws Error если WebSocket не открыт + * + * Автоматически преобразует ключи content в snake_case перед отправкой + */ public send(type: string, content: any): void { if (this.socket && this.socket.readyState === WebSocket.OPEN) { - this.socket.send(JSON.stringify({ type, content: snakecaseKeys(content, { deep: true }) })); + this.socket.send( + JSON.stringify({ + type, + content: snakecaseKeys(content, { deep: true }), + }) + ); } else { throw new Error("WebSocket is not open."); } } + /** + * Подписывается на сообщения определенного типа + * + * @param type - тип сообщений для прослушивания + * @returns Observable - Observable с сообщениями указанного типа + * + * Особенности: + * - Фильтрует сообщения по типу + * - Автоматически парсит JSON + * - Преобразует ключи из snake_case в camelCase + */ public on(type: string): Observable { return this.messages$.asObservable().pipe( map(message => JSON.parse(message.data)), @@ -55,11 +101,14 @@ export class WebsocketService { ); } + /** + * Закрывает WebSocket соединение + * Очищает ссылку на socket и сбрасывает флаг подключения + */ public close(): void { if (this.socket) { this.socket.close(); this.socket = null; - this.isConnected = false; } } diff --git a/projects/social_platform/src/app/error/README.md b/projects/social_platform/src/app/error/README.md new file mode 100644 index 000000000..227c48709 --- /dev/null +++ b/projects/social_platform/src/app/error/README.md @@ -0,0 +1,66 @@ + + +# Error Модуль + +Модуль для обработки ошибок и отображения страниц ошибок. + +## Компоненты + +### 🚫 ErrorNotFound (404) + +Страница "Страница не найдена" + +- Дружелюбное сообщение пользователю +- Ссылки для навигации +- Поиск по сайту + +### ⚠️ ErrorCode + +Универсальная страница ошибок + +- Отображение различных кодов ошибок (500, 403, и т.д.) +- Настраиваемые сообщения +- Действия для восстановления + +## Сервисы + +### 🛠 GlobalErrorHandler + +Глобальный обработчик ошибок приложения + +- Перехват всех необработанных ошибок +- Логирование ошибок +- Отправка отчетов об ошибках +- Показ пользователю понятных сообщений + +### 📋 ErrorService + +Сервис для управления ошибками + +- Централизованное логирование +- Категори��ация ошибок +- Уведомления пользователей + +## Модели + +### 📝 ErrorMessage + +Модель сообщения об ошибке + +- Код ошибки +- Текст сообщения +- Дополнительная информация + +### 🔢 ErrorCode + +Перечисление кодов ошибок + +- HTTP коды ошибок +- Пользовательские коды ошибок + +## Принципы обработки ошибок + +1. **Пользователь прежде всего**: Показываем понятные сообщения +2. **Логирование**: Все ошибки записываются для анализа +3. **Восстановление**: Предлагаем пути решения проблемы +4. **Мониторинг**: Отслеживание частоты ошибок diff --git a/projects/social_platform/src/app/error/code/error-code.component.ts b/projects/social_platform/src/app/error/code/error-code.component.ts index 0486865d3..0889250e5 100644 --- a/projects/social_platform/src/app/error/code/error-code.component.ts +++ b/projects/social_platform/src/app/error/code/error-code.component.ts @@ -5,6 +5,26 @@ import { ActivatedRoute, RouterLink } from "@angular/router"; import { map } from "rxjs"; import { AsyncPipe } from "@angular/common"; +/** + * Компонент для отображения ошибок с динамическим кодом + * + * Назначение: + * - Отображает страницу ошибки с кодом, переданным через URL параметр + * - Извлекает код ошибки из route параметров и отображает его + * - Предоставляет ссылку для возврата на главную страницу + * + * Принимает: + * - :code (route parameter) - код ошибки из URL (например, 500, 403, etc.) + * + * Возвращает: + * - HTML template с кодом ошибки, сообщением и ссылкой на главную + * + * Свойства: + * - errorCode: Observable - код ошибки из URL параметров + * + * Зависимости: + * - ActivatedRoute - для получения параметров маршрута + */ @Component({ selector: "app-code", templateUrl: "./error-code.component.html", @@ -13,6 +33,7 @@ import { AsyncPipe } from "@angular/common"; imports: [RouterLink, AsyncPipe], }) export class ErrorCodeComponent implements OnInit { + // Observable с кодом ошибки, извлеченным из URL параметра 'code' errorCode = this.activatedRoute.params.pipe(map(r => r["code"])); constructor(private readonly activatedRoute: ActivatedRoute) {} diff --git a/projects/social_platform/src/app/error/error.component.ts b/projects/social_platform/src/app/error/error.component.ts index 5cb710589..f56d76217 100644 --- a/projects/social_platform/src/app/error/error.component.ts +++ b/projects/social_platform/src/app/error/error.component.ts @@ -3,6 +3,21 @@ import { Component, OnInit } from "@angular/core"; import { RouterLink, RouterOutlet } from "@angular/router"; +/** + * Главный компонент модуля обработки ошибок + * + * Назначение: + * - Служит контейнером для всех страниц ошибок + * - Предоставляет общий layout с header и router-outlet + * - Отображает логотип приложения в header + * + * Принимает: Нет входных параметров + * Возвращает: HTML template с header и router-outlet для дочерних компонентов + * + * Дочерние маршруты: + * - /error/404 - страница "не найдено" + * - /error/:code - страница с кодом ошибки + */ @Component({ selector: "app-error", templateUrl: "./error.component.html", diff --git a/projects/social_platform/src/app/error/error.routes.ts b/projects/social_platform/src/app/error/error.routes.ts index f660c050e..7d4a1c9c1 100644 --- a/projects/social_platform/src/app/error/error.routes.ts +++ b/projects/social_platform/src/app/error/error.routes.ts @@ -5,17 +5,32 @@ import { ErrorComponent } from "./error.component"; import { ErrorCodeComponent } from "./code/error-code.component"; import { ErrorNotFoundComponent } from "./not-found/error-not-found.component"; +/** + * Конфигурация маршрутов для модуля ошибок + * + * Назначение: + * - Определяет структуру маршрутов для обработки различных типов ошибок + * - Настраивает вложенные маршруты с ErrorComponent как родительским + * + * Маршруты: + * - "" (root): ErrorComponent - главный контейнер + * - "404": ErrorNotFoundComponent - страница "не найдено" + * - ":code": ErrorCodeComponent - страница с динамическим кодом ошибки + * + * Принимает: Нет параметров (экспортируемая константа) + * Возвращает: Routes[] - массив конфигурации маршрутов для Angular Router + */ export const ERROR_ROUTES: Routes = [ { path: "", - component: ErrorComponent, + component: ErrorComponent, // Родительский компонент-контейнер children: [ { - path: "404", + path: "404", // Статический маршрут для 404 ошибки component: ErrorNotFoundComponent, }, { - path: ":code", + path: ":code", // Динамический маршрут для любого кода ошибки component: ErrorCodeComponent, }, ], diff --git a/projects/social_platform/src/app/error/models/error-code.ts b/projects/social_platform/src/app/error/models/error-code.ts index 41efc629a..0c9036ebf 100644 --- a/projects/social_platform/src/app/error/models/error-code.ts +++ b/projects/social_platform/src/app/error/models/error-code.ts @@ -1,6 +1,24 @@ /** @format */ +/** + * Перечисление HTTP кодов ошибок + * + * Назначение: + * - Определяет стандартные HTTP коды ошибок, используемые в приложении + * - Обеспечивает типобезопасность при работе с кодами ошибок + * - Используется для маршрутизации на соответствующие страницы ошибок + * + * Коды: + * - NOT_FOUND: 404 - страница или ресурс не найден + * - SERVER_ERROR: 500 - внутренняя ошибка сервера + * + * Использование: + * - В ErrorService для навигации на страницы ошибок + * - В компонентах для проверки типа ошибки + * - В HTTP interceptors для обработки ошибок + */ + export enum ErrorCode { - NOT_FOUND = "404", - SERVER_ERROR = "500", + NOT_FOUND = "404", // Ресурс не найден + SERVER_ERROR = "500", // Внутренняя ошибка сервера } diff --git a/projects/social_platform/src/app/error/models/error-message.ts b/projects/social_platform/src/app/error/models/error-message.ts index 902257a7c..22d44b8f0 100644 --- a/projects/social_platform/src/app/error/models/error-message.ts +++ b/projects/social_platform/src/app/error/models/error-message.ts @@ -1,13 +1,31 @@ /** @format */ +/** + * Перечисление сообщений об ошибках для приложения + * + * Назначение: + * - Централизованное хранение всех текстовых сообщений об ошибках + * - Обеспечивает консистентность сообщений по всему приложению + * - Упрощает локализацию и изменение текстов + * + * Категории ошибок: + * - AUTH_*: ошибки аутентификации и авторизации + * - VALIDATION_*: ошибки валидации форм + * - USER_*: ошибки, связанные с пользователями + * + * Использование: + * - Импортировать enum и использовать как ErrorMessage.AUTH_EMAIL_EXIST + * - Все сообщения на русском языке + */ + export enum ErrorMessage { - // Auth messages + // Сообщения об ошибках аутентификации AUTH_EMAIL_EXIST = "Аккаунт с таким email уже зарегистрирован", AUTH_WRONG_AUTH = "Неправильный логин или пароль", AUTH_WRONG_PASSWORD = "Неправильный пароль", AUTH_EMAIL_NOT_EXIST = "Аккаунт с таким email не зарегистрирован", - // FORM + // Сообщения об ошибках валидации форм VALIDATION_TOO_LONG = "Максимальная длина:", VALIDATION_TOO_SHORT = "Минимальная длина:", VALIDATION_REQUIRED = "Обязательное поле", @@ -17,11 +35,12 @@ export enum ErrorMessage { VALIDATION_EMAIL = "Введенное значение не соответствует формату email", VALIDATION_PASSWORD_UNMATCH = "Пароли не совпадают", EMPTY_AVATAR = "*Выберите фото для профиля", + VALIDATION_PATTERN = "Введите корректную ссылку, начинающуюся с http:// или https://", - // Project invitation + // Ошибки приглашений в проект USER_NOT_EXISTING = "По данной ссылке пользователь не найден", VALIDATION_PROFILE_LINK = "Введенное значение не соответствует формату ссылки на профиль", - // Project rating + // Ошибки оценки проектов VALIDATION_UNFILLED_CRITERIA = "Не все критерии заполнены", } diff --git a/projects/social_platform/src/app/error/not-found/error-not-found.component.ts b/projects/social_platform/src/app/error/not-found/error-not-found.component.ts index 6ad59ee22..eda0d9b3f 100644 --- a/projects/social_platform/src/app/error/not-found/error-not-found.component.ts +++ b/projects/social_platform/src/app/error/not-found/error-not-found.component.ts @@ -2,6 +2,21 @@ import { Component, OnInit } from "@angular/core"; +/** + * Компонент для отображения ошибки 404 "Страница не найдена" + * + * Назначение: + * - Отображает пользователю сообщение о том, что запрашиваемая страница не найдена + * - Показывает иллюстрацию и текст ошибки на русском языке + * - Используется для статического маршрута /error/404 + * + * Принимает: Нет входных параметров + * Возвращает: HTML template с изображением и сообщением об ошибке 404 + * + * Особенности: + * - Standalone компонент (не требует NgModule) + * - Не имеет зависимостей от других сервисов + */ @Component({ selector: "app-not-found", templateUrl: "./error-not-found.component.html", diff --git a/projects/social_platform/src/app/error/services/error.service.ts b/projects/social_platform/src/app/error/services/error.service.ts index 7bb71d2f7..be5e7df7d 100644 --- a/projects/social_platform/src/app/error/services/error.service.ts +++ b/projects/social_platform/src/app/error/services/error.service.ts @@ -4,20 +4,55 @@ import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; import { ErrorCode } from "../models/error-code"; +/** + * Сервис для обработки и навигации к страницам ошибок + * + * Назначение: + * - Централизованная обработка ошибок в приложении + * - Программная навигация на соответствующие страницы ошибок + * - Логирование переходов для отладки + * + * Методы: + * - throwNotFount(): навигация на страницу 404 + * - throwServerError(): навигация на страницу 500 + * - throwError(type): приватный метод для навигации на любую страницу ошибки + * + * Принимает: + * - ErrorCode - тип ошибки для навигации + * + * Возвращает: + * - Promise - промис завершения навигации + * + * Зависимости: + * - Router - для программной навигации между страницами + */ @Injectable({ providedIn: "root", }) export class ErrorService { constructor(private readonly router: Router) {} + /** + * Навигация на страницу ошибки 404 + * @returns Promise - промис завершения навигации + */ throwNotFount(): Promise { return this.throwError(ErrorCode.NOT_FOUND); } + /** + * Навигация на страницу ошибки 500 + * @returns Promise - промис завершения навигации + */ throwServerError(): Promise { return this.throwError(ErrorCode.SERVER_ERROR); } + /** + * Приватный метод для навигации на страницу ошибки + * @param type - код ошибки из ErrorCode enum + * @returns Promise - промис завершения навигации с логированием + */ private throwError(type: ErrorCode): Promise { return this.router.navigateByUrl(`/error/${type}`).then(() => console.debug("Route Changed")); } 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 index ff72865c4..400f09050 100644 --- 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 @@ -3,12 +3,45 @@ 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: { @@ -17,6 +50,8 @@ export class GlobalErrorHandlerService implements ErrorHandler { // } // } // } + + // Логируем ошибки типа Error в консоль if (error instanceof Error) { console.error(error); } diff --git a/projects/social_platform/src/app/office/README.md b/projects/social_platform/src/app/office/README.md new file mode 100644 index 000000000..e82248de8 --- /dev/null +++ b/projects/social_platform/src/app/office/README.md @@ -0,0 +1,110 @@ + + +# Модуль Office + +Основной рабочий модуль приложения, доступный после авторизации. + +## Структура модулей + +### 📊 Projects + +Управление проектами + +- **Создание и редактирование** проектов +- **Просмотр деталей** проекта +- **Управление участниками** проекта +- **Чат проекта** для коммуникации +- **Новости проекта** и обновления +- **Отклики на вакансии** проекта + +### 💬 Chat + +Система обмена сообщениями + +- **Личные чаты** между пользователями +- **Групповые чаты** проектов +- **Отправка файлов** и медиа +- **Реальное время** через WebSocket + +### 👥 Members + +Участники платформы + +- **Поиск участников** по критериям +- **Фильтрация** по навыкам и специализации +- **Просмотр профилей** участников + +### 📰 Feed + +Лента активности + +- **Новости проектов** и участников +- **Новые вакансии** и возможности +- **Фильтрация контента** по интересам + +### 💼 Vacancies + +Система вакансий + +- **Создание вакансий** в проектах +- **Поиск вакансий** по критериям +- **Отклики на вакансии** +- **Управление откликами** + +### 👤 Profile + +Профили пользователей + +- **Просмотр профиля** (свой и чужой) +- **Редактирование** личной информации +- **Управление навыками** и опытом +- **Портфолио** и достижения + +### 🎓 Program + +Образовательные программы + +- **Участие в программах** +- **Оценка проектов** в рамках программ +- **Рейтинговая система** + +### 🚀 Onboarding + +Знакомство с платформой + +- **Пошаговое введение** для новых пользователей +- **Настройка профиля** +- **Выбор интересов** + +### 🤝 Mentors + +Система менторства + +- **Поиск менторов** +- **Заявки на менторство** + +## Общие компоненты (Shared) + +### Карточки + +- **ProjectCard**: Карточка проекта +- **MemberCard**: Карточка участника +- **VacancyCard**: Карточка вакансии +- **NewsCard**: Карточка новости + +### Формы + +- **NewsForm**: Форма создания новости +- **MessageInput**: Поле ввода сообщения +- **SkillsBasket**: Управление навыками + +### Навигация + +- **Header**: Шапка приложения +- **Nav**: Боковая навигация + +### Специализированные + +- **ChatWindow**: Окно чата +- **ProjectRating**: Рейтинг проекта +- **Carousel**: Карусель изображений diff --git a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct.routes.ts b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct.routes.ts index 477206b74..4e68005fb 100644 --- a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct.routes.ts +++ b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct.routes.ts @@ -4,12 +4,21 @@ 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"; +/** + * Конфигурация маршрутов для модуля прямого чата + * + * Определяет маршрут для отображения конкретного прямого чата + * с предварительной загрузкой данных через ChatDirectResolver + * + * @type {Routes} Массив конфигураций маршрутов Angular + */ export const CHAT_DIRECT_ROUTES: Routes = [ { + // Корневой маршрут модуля - отображает компонент прямого чата path: "", component: ChatDirectComponent, resolve: { - data: ChatDirectResolver, + data: ChatDirectResolver, // Предварительно загружает данные чата }, }, ]; 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 index 8d16fd3c2..77c8d9ad3 100644 --- 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 @@ -10,10 +10,23 @@ import { ChatDirectService } from "@office/chat/services/chat-direct.service"; import { ChatWindowComponent } from "@office/shared/chat-window/chat-window.component"; import { AuthService } from "@auth/services"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { BackComponent } from "@uilib"; import { ApiPagination } from "@models/api-pagination.model"; import { BarComponent } from "@ui/components"; +/** + * Компонент для отображения конкретного прямого чата + * + * Функциональность: + * - Отображение сообщений чата с пагинацией + * - Отправка, редактирование и удаление сообщений + * - Обработка событий 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", @@ -29,52 +42,76 @@ export class ChatDirectComponent implements OnInit, OnDestroy { 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; /** - * Amount of messages that we fetch - * each time {@link fetchMessages} runs + * Количество сообщений, загружаемых за один запрос * @private */ private readonly messagesPerFetch = 20; + /** - * Total messages count in chat - * comes from server, set first time {@link fetchMessages} runs + * Общее количество сообщений в чате (приходит с сервера) * @private */ private messagesTotalCount = 0; + /** Список пользователей, которые сейчас печатают */ typingPersons: ChatWindowComponent["typingPersons"] = []; + /** Массив подписок для очистки */ subscriptions$: Subscription[] = []; + /** Данные текущего чата */ chat?: ChatItem; + /** Массив сообщений чата */ messages: ChatMessage[] = []; + /** + * Загружает сообщения чата с сервера с поддержкой пагинации + * + * @private + * @returns {Observable>} Observable с пагинированными сообщениями + */ private fetchMessages(): Observable> { return this.chatDirectService .loadMessages( @@ -84,12 +121,17 @@ export class ChatDirectComponent implements OnInit, OnDestroy { ) .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]; @@ -98,28 +140,35 @@ export class ChatDirectComponent implements OnInit, OnDestroy { 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, - }); - - setTimeout(() => { - const personIdx = this.typingPersons.findIndex(p => p.userId === this.chat?.opponent.id); - - this.typingPersons.splice(personIdx, 1); - }, 2000); + 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); @@ -133,6 +182,10 @@ export class ChatDirectComponent implements OnInit, OnDestroy { 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); @@ -146,6 +199,10 @@ export class ChatDirectComponent implements OnInit, OnDestroy { 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); @@ -159,11 +216,17 @@ export class ChatDirectComponent implements OnInit, OnDestroy { readEvent$ && this.subscriptions$.push(readEvent$); } + /** Флаг процесса загрузки сообщений */ fetching = false; + + /** + * Обработчик запроса на загрузку дополнительных сообщений + * Загружает следующую порцию сообщений если есть еще сообщения на сервере + */ onFetchMessages(): void { if ( (this.messages.length < this.messagesTotalCount || - // because messagesTotalCount pulls from server it's 0 in start of program, in that case we also need to make fetch + // messagesTotalCount равен 0 в начале, поэтому тоже нужно загружать this.messagesTotalCount === 0) && !this.fetching ) { @@ -174,6 +237,10 @@ export class ChatDirectComponent implements OnInit, OnDestroy { } } + /** + * Обработчик отправки нового сообщения + * @param message - Объект сообщения с текстом, файлами и ответом + */ onSubmitMessage(message: any): void { this.chatService.sendMessage({ replyTo: message.replyTo, @@ -184,6 +251,10 @@ export class ChatDirectComponent implements OnInit, OnDestroy { }); } + /** + * Обработчик редактирования сообщения + * @param message - Объект сообщения с новым текстом и ID + */ onEditMessage(message: any): void { this.chatService.editMessage({ text: message.text, @@ -193,6 +264,10 @@ export class ChatDirectComponent implements OnInit, OnDestroy { }); } + /** + * Обработчик удаления сообщения + * @param messageId - ID удаляемого сообщения + */ onDeleteMessage(messageId: number): void { this.chatService.deleteMessage({ chatId: this.chat?.id ?? "", @@ -201,10 +276,18 @@ export class ChatDirectComponent implements OnInit, OnDestroy { }); } + /** + * Обработчик события печатания + * Отправляет уведомление о том, что пользователь печатает + */ onType() { this.chatService.startTyping({ chatType: "direct", chatId: this.chat?.id ?? "" }); } + /** + * Обработчик прочтения сообщения + * @param messageId - ID прочитанного сообщения + */ onReadMessage(messageId: number) { this.chatService.readMessage({ chatType: "direct", diff --git a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.resolver.ts b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.resolver.ts index 87fe4e292..41dc7d283 100644 --- a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.resolver.ts +++ b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.resolver.ts @@ -5,6 +5,19 @@ import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; import { ChatDirectService } from "@office/chat/services/chat-direct.service"; import { ChatItem } from "@office/chat/models/chat-item.model"; +/** + * Резолвер для загрузки данных конкретного прямого чата + * + * Извлекает chatId из параметров маршрута и загружает + * информацию о чате через ChatDirectService + * + * @param route - Снимок активного маршрута с параметрами + * @returns {Observable} Observable с данными чата + */ +/** + * Резолвер для получения информации о прямом чате + * Извлекает chatId из параметров маршрута и вызывает getDirect() + */ export const ChatDirectResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { const chatDirectService = inject(ChatDirectService); diff --git a/projects/social_platform/src/app/office/chat/chat-groups.resolver.ts b/projects/social_platform/src/app/office/chat/chat-groups.resolver.ts index 90064a079..c039adb12 100644 --- a/projects/social_platform/src/app/office/chat/chat-groups.resolver.ts +++ b/projects/social_platform/src/app/office/chat/chat-groups.resolver.ts @@ -5,6 +5,16 @@ import { ResolveFn } from "@angular/router"; import { ChatProjectService } from "@office/chat/services/chat-project.service"; import { ChatListItem } from "@office/chat/models/chat-item.model"; +/** + * Резолвер для загрузки групповых чатов (проектных чатов) + * Предзагружает список проектных чатов для пользователя + * + * Принимает: + * - Контекст маршрута через Angular DI + * + * Возвращает: + * - Observable - список элементов групповых чатов + */ export const ChatGroupsResolver: ResolveFn = () => { const chatProjectService = inject(ChatProjectService); diff --git a/projects/social_platform/src/app/office/chat/chat.component.ts b/projects/social_platform/src/app/office/chat/chat.component.ts index 1b49a8986..917eeb944 100644 --- a/projects/social_platform/src/app/office/chat/chat.component.ts +++ b/projects/social_platform/src/app/office/chat/chat.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { NavService } from "@services/nav.service"; -import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from "@angular/router"; +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"; @@ -11,12 +11,24 @@ import { ChatCardComponent } from "./shared/chat-card/chat-card.component"; import { AsyncPipe } from "@angular/common"; import { BarComponent } from "@ui/components"; +/** + * Компонент списка чатов - отображает все чаты пользователя + * Управляет отображением прямых и групповых чатов с сортировкой по непрочитанным + * + * Принимает: + * - Данные чатов через резолвер + * - События новых сообщений через WebSocket + * + * Возвращает: + * - Отсортированный список чатов с индикаторами непрочитанных сообщений + * - Навигацию к конкретным чатам + */ @Component({ selector: "app-chat", templateUrl: "./chat.component.html", styleUrl: "./chat.component.scss", standalone: true, - imports: [RouterLink, RouterLinkActive, ChatCardComponent, AsyncPipe, BarComponent], + imports: [ChatCardComponent, AsyncPipe, BarComponent], }) export class ChatComponent implements OnInit, OnDestroy { constructor( diff --git a/projects/social_platform/src/app/office/chat/chat.resolver.ts b/projects/social_platform/src/app/office/chat/chat.resolver.ts index af7372b18..eb20532e5 100644 --- a/projects/social_platform/src/app/office/chat/chat.resolver.ts +++ b/projects/social_platform/src/app/office/chat/chat.resolver.ts @@ -5,6 +5,16 @@ 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); diff --git a/projects/social_platform/src/app/office/chat/chat.routes.ts b/projects/social_platform/src/app/office/chat/chat.routes.ts index 11c91574b..103e278bd 100644 --- a/projects/social_platform/src/app/office/chat/chat.routes.ts +++ b/projects/social_platform/src/app/office/chat/chat.routes.ts @@ -5,6 +5,16 @@ 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: "", diff --git a/projects/social_platform/src/app/office/chat/models/chat-item.model.ts b/projects/social_platform/src/app/office/chat/models/chat-item.model.ts index e1ff3ab67..e794fde1b 100644 --- a/projects/social_platform/src/app/office/chat/models/chat-item.model.ts +++ b/projects/social_platform/src/app/office/chat/models/chat-item.model.ts @@ -3,25 +3,56 @@ import { User } from "@auth/models/user.model"; import { ChatMessage } from "@models/chat-message.model"; +/** + * Модели данных для элементов чата + * + * Содержит интерфейсы для представления чатов в списке и детальной информации + */ +/** + * Интерфейс для элемента чата в списке чатов + * Используется для отображения превью чата с последним сообщением + */ export interface ChatListItem { + /** Уникальный идентификатор чата */ id: string; + + /** Информация о последнем сообщении в чате */ lastMessage: { + /** Автор последнего сообщения */ author: User; + /** Флаг удаленного сообщения */ isDeleted: boolean; + /** Флаг отредактированного сообщения */ isEdited: boolean; + /** Флаг прочитанного сообщения */ isRead: boolean; + /** Ссылка на сообщение, на которое отвечают */ replyTo: ChatMessage | null; + /** Текст сообщения */ text: string; + /** Дата создания сообщения */ createdAt: string; }; + + /** Название чата */ name: string; + /** URL изображения чата */ imageAddress: string; + /** Собеседник (для прямых чатов) */ opponent?: User; } +/** + * Интерфейс для детальной информации о чате + * Используется при открытии конкретного чата + */ export interface ChatItem { + /** Уникальный идентификатор чата */ id: string; + /** URL изображения чата */ imageAddress: string; + /** Название чата */ name: string; + /** Информация о собеседнике */ opponent: User; } diff --git a/projects/social_platform/src/app/office/chat/services/chat-direct.service.ts b/projects/social_platform/src/app/office/chat/services/chat-direct.service.ts index 9e014bd8f..caa7f180a 100644 --- a/projects/social_platform/src/app/office/chat/services/chat-direct.service.ts +++ b/projects/social_platform/src/app/office/chat/services/chat-direct.service.ts @@ -8,20 +8,51 @@ import { HttpParams } from "@angular/common/http"; import { ApiPagination } from "@models/api-pagination.model"; import { ChatMessage } from "@models/chat-message.model"; +/** + * Сервис для работы с прямыми чатами (личными сообщениями) + * + * Предоставляет методы для: + * - Получения списка прямых чатов + * - Получения информации о конкретном чате + * - Загрузки сообщений чата с пагинацией + * + * @Injectable providedIn: 'root' - синглтон на уровне приложения + */ @Injectable({ providedIn: "root", }) export class ChatDirectService { + private readonly CHATS_URL = "/chats"; + constructor(private readonly apiService: ApiService) {} + /** + * Получает список всех прямых чатов пользователя + * + * @returns {Observable} Observable со списком прямых чатов + */ getDirects(): Observable { - return this.apiService.get("/chats/directs/"); + return this.apiService.get(`${this.CHATS_URL}/directs/`); } + /** + * Получает детальную информацию о конкретном прямом чате + * + * @param chatId - Идентификатор чата + * @returns {Observable} Observable с информацией о чате + */ getDirect(chatId: string): Observable { - return this.apiService.get(`/chats/directs/${chatId}/`); + return this.apiService.get(`${this.CHATS_URL}/directs/${chatId}/`); } + /** + * Загружает сообщения чата с поддержкой пагинации + * + * @param chatId - Идентификатор чата + * @param count - Смещение (количество уже загруженных сообщений) + * @param take - Количество сообщений для загрузки + * @returns {Observable>} Observable с пагинированным списком сообщений + */ loadMessages( chatId: string, count?: number, @@ -32,7 +63,7 @@ export class ChatDirectService { if (take !== undefined) queries = queries.set("limit", take); return this.apiService.get>( - `/chats/directs/${chatId}/messages/`, + `${this.CHATS_URL}/directs/${chatId}/messages/`, queries ); } diff --git a/projects/social_platform/src/app/office/chat/services/chat-project.service.ts b/projects/social_platform/src/app/office/chat/services/chat-project.service.ts index 244193ffb..3dce31d6d 100644 --- a/projects/social_platform/src/app/office/chat/services/chat-project.service.ts +++ b/projects/social_platform/src/app/office/chat/services/chat-project.service.ts @@ -5,13 +5,28 @@ import { ApiService } from "projects/core"; import { Observable } from "rxjs"; import { ChatListItem } from "@office/chat/models/chat-item.model"; +/** + * Сервис для работы с чатами проектов (групповыми чатами) + * + * Предоставляет методы для получения списка чатов проектов + * через API сервис + * + * @Injectable providedIn: 'root' - синглтон на уровне приложения + */ @Injectable({ providedIn: "root", }) export class ChatProjectService { + private readonly CHATS_URL = "/chats"; + constructor(private readonly apiService: ApiService) {} + /** + * Получает список чатов проектов с сервера + * + * @returns {Observable} Observable со списком чатов проектов + */ getProjects(): Observable { - return this.apiService.get("/chats/projects/"); + return this.apiService.get(`${this.CHATS_URL}/projects/`); } } 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 index 1486ddbb6..8295a0ec4 100644 --- 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 @@ -8,6 +8,20 @@ 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", @@ -18,9 +32,18 @@ import { AvatarComponent } from "@ui/components/avatar/avatar.component"; 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) ); diff --git a/projects/social_platform/src/app/office/feed/feed.component.ts b/projects/social_platform/src/app/office/feed/feed.component.ts index cd40ad9e2..30539ba2f 100644 --- a/projects/social_platform/src/app/office/feed/feed.component.ts +++ b/projects/social_platform/src/app/office/feed/feed.component.ts @@ -14,7 +14,6 @@ import { import { CommonModule } from "@angular/common"; import { OpenVacancyComponent } from "@office/feed/shared/open-vacancy/open-vacancy.component"; import { NewProjectComponent } from "@office/feed/shared/new-project/new-project.component"; -import { ClosedVacancyComponent } from "@office/feed/shared/closed-vacancy/closed-vacancy.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"; @@ -25,6 +24,25 @@ import { ProjectNewsService } from "@office/projects/detail/services/project-new import { ProfileNewsService } from "@office/profile/detail/services/profile-news.service"; import { FeedFilterComponent } from "@office/feed/filter/feed-filter.component"; +/** + * ОСНОВНОЙ КОМПОНЕНТ ЛЕНТЫ НОВОСТЕЙ + * + * Главный компонент для отображения ленты новостей, вакансий и проектов. + * Поддерживает бесконечную прокрутку, фильтрацию и отслеживание просмотров. + * + * ОСНОВНЫЕ ФУНКЦИИ: + * - Отображение элементов ленты (новости, вакансии, проекты) + * - Бесконечная прокрутка для подгрузки новых элементов + * - Фильтрация по типам контента + * - Отслеживание просмотров элементов + * - Лайки новостей + * + * ИСПОЛЬЗУЕМЫЕ СИГНАЛЫ: + * - totalItemsCount: общее количество элементов + * - feedItems: массив элементов ленты + * - feedPage: текущая страница для пагинации + * - includes: активные фильтры + */ @Component({ selector: "app-feed", standalone: true, @@ -32,7 +50,6 @@ import { FeedFilterComponent } from "@office/feed/filter/feed-filter.component"; CommonModule, OpenVacancyComponent, NewProjectComponent, - ClosedVacancyComponent, NewsCardComponent, FeedFilterComponent, ], @@ -46,13 +63,23 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { 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); + // Настраиваем отслеживание просмотров элементов setTimeout(() => { const observer = new IntersectionObserver(this.onFeedItemView.bind(this), { root: document.querySelector(".office__body"), @@ -67,13 +94,14 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { }); this.subscriptions$().push(routeData$); + // Отслеживаем изменения параметров фильтрации const queryParams$ = this.route.queryParams .pipe( map(params => params["includes"]), tap(includes => { this.includes.set(includes); }), - skip(1), + skip(1), // Пропускаем первое значение (уже загружено резолвером) concatMap(includes => { this.totalItemsCount.set(0); this.feedPage.set(0); @@ -84,6 +112,7 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { .subscribe(feed => { this.feedItems.set(feed); + // Плавная прокрутка к началу ленты после фильтрации setTimeout(() => { this.feedRoot?.nativeElement.children[0].scrollIntoView({ behavior: "smooth" }); }); @@ -91,13 +120,20 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { this.subscriptions$().push(queryParams$); } + /** + * НАСТРОЙКА БЕСКОНЕЧНОЙ ПРОКРУТКИ + * + * ЧТО ДЕЛАЕТ: + * - Подписывается на события прокрутки + * - Загружает новые элементы при достижении конца списка + */ ngAfterViewInit() { const target = document.querySelector(".office__body"); if (target) { const scrollEvents$ = fromEvent(target, "scroll") .pipe( concatMap(() => this.onScroll()), - throttleTime(500) + throttleTime(500) // Ограничиваем частоту запросов ) .subscribe(noop); @@ -111,21 +147,35 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild("feedRoot") feedRoot?: ElementRef; - totalItemsCount = signal(0); - feedItems = signal([]); - feedPage = signal(1); - perFetchTake = signal(20); - includes = signal([]); + // Сигналы состояния компонента + totalItemsCount = signal(0); // Общее количество элементов + feedItems = signal([]); // Массив элементов ленты + feedPage = signal(1); // Текущая страница + perFetchTake = signal(20); // Количество элементов за запрос + includes = signal([]); // Активные фильтры subscriptions$ = signal([]); + /** + * ОБРАБОТКА ЛАЙКОВ НОВОСТЕЙ + * + * ЧТО ПРИНИМАЕТ: + * @param newsId - ID новости для лайка/дизлайка + * + * ЧТО ДЕЛАЕТ: + * - Переключает состояние лайка + * - Обновляет счетчик лайков + * - Различает новости проектов и профилей + */ onLike(newsId: number) { const itemIdx = this.feedItems().findIndex(n => n.content.id === newsId); const item = this.feedItems()[itemIdx]; if (!item || item.typeModel !== "news") return; + // Определяем тип новости по структуре contentObject if ("email" in item.content.contentObject) { + // Новость профиля this.profileNewsService .toggleLike( item.content.contentObject.id as unknown as string, @@ -141,11 +191,11 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { 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, @@ -161,13 +211,22 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { this.feedItems.update(items => { const newItems = [...items]; newItems.splice(itemIdx, 1, item); - return newItems; }); }); } } + /** + * ОТСЛЕЖИВАНИЕ ПРОСМОТРОВ ЭЛЕМЕНТОВ + * + * ЧТО ПРИНИМАЕТ: + * @param entries - массив элементов, попавших в область видимости + * + * ЧТО ДЕЛАЕТ: + * - Отмечает новости как прочитанные при попадании в область видимости + * - Различает новости проектов и профилей + */ onFeedItemView(entries: IntersectionObserverEntry[]): void { const items = entries .map(e => { @@ -183,6 +242,7 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { item => item.typeModel === "news" && "email" in item.content.contentObject ); + // Отмечаем новости проектов как прочитанные projectNews.forEach(news => { if (news.typeModel !== "news") return; this.projectNewsService @@ -190,6 +250,7 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { .subscribe(noop); }); + // Отмечаем новости профилей как прочитанные profileNews.forEach(news => { if (news.typeModel !== "news") return; this.profileNewsService @@ -198,12 +259,24 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * ОБРАБОТКА ПРОКРУТКИ ДЛЯ БЕСКОНЕЧНОЙ ЗАГРУЗКИ + * + * ЧТО ВОЗВРАЩАЕТ: + * @returns Observable с новыми элементами или пустой объект + * + * ЧТО ДЕЛАЕТ: + * - Проверяет, достигнут ли конец списка + * - Загружает следующую порцию элементов при необходимости + */ 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 + @@ -225,6 +298,17 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { return of({}); } + /** + * ЗАГРУЗКА ЭЛЕМЕНТОВ ЛЕНТЫ + * + * ЧТО ПРИНИМАЕТ: + * @param offset - смещение для пагинации + * @param limit - количество элементов для загрузки + * @param includes - типы элементов для включения в результат + * + * ЧТО ВОЗВРАЩАЕТ: + * @returns Observable - массив элементов ленты + */ onFetch( offset: number, limit: number, diff --git a/projects/social_platform/src/app/office/feed/feed.resolver.ts b/projects/social_platform/src/app/office/feed/feed.resolver.ts index 8ef6dfc62..8f4529270 100644 --- a/projects/social_platform/src/app/office/feed/feed.resolver.ts +++ b/projects/social_platform/src/app/office/feed/feed.resolver.ts @@ -6,9 +6,26 @@ 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, diff --git a/projects/social_platform/src/app/office/feed/feed.routes.ts b/projects/social_platform/src/app/office/feed/feed.routes.ts index 81237190b..c7047e725 100644 --- a/projects/social_platform/src/app/office/feed/feed.routes.ts +++ b/projects/social_platform/src/app/office/feed/feed.routes.ts @@ -4,12 +4,35 @@ import { Routes } from "@angular/router"; import { FeedComponent } from "@office/feed/feed.component"; import { FeedResolver } from "@office/feed/feed.resolver"; +/** + * МАРШРУТЫ ДЛЯ МОДУЛЯ ЛЕНТЫ НОВОСТЕЙ + * + * Определяет конфигурацию маршрутизации для функциональности ленты новостей. + * Настраивает связь между URL путями и компонентами, а также предварительную загрузку данных. + * + * КОНФИГУРАЦИЯ МАРШРУТА: + * - Путь: "" (корневой путь модуля) + * - Компонент: FeedComponent (основной компонент ленты) + * - Резолвер: FeedResolver (предварительная загрузка данных) + * + * ПРИНЦИП РАБОТЫ: + * 1. При навигации на маршрут ленты активируется FeedResolver + * 2. Резолвер загружает начальные данные ленты с сервера + * 3. Данные передаются в FeedComponent через свойство 'data' + * 4. Компонент отображается с предзагруженными данными + * + * ПРЕИМУЩЕСТВА ИСПОЛЬЗОВАНИЯ РЕЗОЛВЕРА: + * - Данные загружаются до отображения компонента + * - Пользователь не видит пустую страницу во время загрузки + * - Улучшенный пользовательский опыт + * - Централизованная логика загрузки данных + */ export const FEED_ROUTES: Routes = [ { - path: "", - component: FeedComponent, + path: "", // Корневой путь модуля ленты + component: FeedComponent, // Основной компонент для отображения resolve: { - data: FeedResolver, + data: FeedResolver, // Предварительная загрузка данных через резолвер }, }, ]; 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 index 99ee1165f..55e891d2a 100644 --- 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 @@ -18,6 +18,23 @@ import { User } from "@auth/models/user.model"; import { AuthService } from "@auth/services"; import { Subscription } from "rxjs"; +/** + * КОМПОНЕНТ ФИЛЬТРАЦИИ ЛЕНТЫ + * + * Предоставляет интерфейс для фильтрации элементов ленты по типам контента. + * Позволяет пользователю выбирать, какие типы элементов отображать в ленте. + * + * ОСНОВНЫЕ ФУНКЦИИ: + * - Отображение выпадающего меню с опциями фильтрации + * - Управление состоянием активных фильтров + * - Синхронизация фильтров с URL параметрами + * - Применение и сброс фильтров + * + * ДОСТУПНЫЕ ФИЛЬТРЫ: + * - Новости (news) + * - Вакансии (vacancy) + * - Новости проектов (project) + */ @Component({ selector: "app-feed-filter", standalone: true, @@ -51,11 +68,20 @@ export class FeedFilterComponent implements OnInit, OnDestroy { profile = signal(null); subscriptions: Subscription[] = []; + /** + * ИНИЦИАЛИЗАЦИЯ КОМПОНЕНТА + * + * ЧТО ДЕЛАЕТ: + * - Подписывается на изменения профиля пользователя + * - Читает текущие фильтры из URL параметров + * - Инициализирует состояние фильтров + */ ngOnInit() { const profileSubscription = this.authService.profile.subscribe(profile => { this.profile.set(profile); }); + // Читаем активные фильтры из URL this.route.queryParams.subscribe(params => { params["includes"] && this.includedFilters.set(params["includes"].split(this.feedService.FILTER_SPLIT_SYMBOL)); @@ -68,16 +94,33 @@ export class FeedFilterComponent implements OnInit, OnDestroy { this.subscriptions.forEach($ => $.unsubscribe()); } + // Состояние выпадающего меню фильтров filterOpen = signal(false); + /** + * ОПЦИИ ФИЛЬТРАЦИИ + * + * Массив доступных опций для фильтрации ленты: + * - label: отображаемое название на русском языке + * - value: значение для API запроса + */ filterOptions = [ { label: "Новости", value: "news" }, { label: "Вакансии", value: "vacancy" }, { label: "Новости проектов", value: "project" }, ]; + // Массив активных фильтров includedFilters = signal([]); + /** + * ПРИМЕНЕНИЕ ФИЛЬТРОВ + * + * ЧТО ДЕЛАЕТ: + * - Обновляет URL параметры с выбранными фильтрами + * - Инициирует перезагрузку ленты с новыми фильтрами + * - Сохраняет другие параметры запроса + */ applyFilter(): void { this.router .navigate([], { @@ -90,12 +133,25 @@ export class FeedFilterComponent implements OnInit, OnDestroy { .then(() => console.debug("Query change from FeedFilterComponent")); } + /** + * ПЕРЕКЛЮЧЕНИЕ ФИЛЬТРА + * + * ЧТО ПРИНИМАЕТ: + * @param keyword - значение фильтра для переключения + * + * ЧТО ДЕЛАЕТ: + * - Добавляет фильтр, если он не активен + * - Удаляет фильтр, если он уже активен + * - Обновляет состояние активных фильтров + */ setFilter(keyword: string): void { this.includedFilters.update(included => { if (included.indexOf(keyword) !== -1) { + // Удаляем фильтр, если он уже активен const idx = included.indexOf(keyword); included.splice(idx, 1); } else { + // Добавляем новый фильтр included.push(keyword); } @@ -103,12 +159,26 @@ export class FeedFilterComponent implements OnInit, OnDestroy { }); } + /** + * СБРОС ВСЕХ ФИЛЬТРОВ + * + * ЧТО ДЕЛАЕТ: + * - Очищает все активные фильтры + * - Применяет пустой набор фильтров + * - Возвращает ленту к состоянию по умолчанию + */ resetFilter(): void { this.includedFilters.set([]); - this.applyFilter(); } + /** + * ЗАКРЫТИЕ ВЫПАДАЮЩЕГО МЕНЮ + * + * ЧТО ДЕЛАЕТ: + * - Закрывает выпадающее меню при клике вне его области + * - Используется директивой ClickOutside + */ onClickOutside(): void { this.filterOpen.set(false); } diff --git a/projects/social_platform/src/app/office/feed/models/feed-item.model.ts b/projects/social_platform/src/app/office/feed/models/feed-item.model.ts index 117e251a3..0c1af9bc8 100644 --- a/projects/social_platform/src/app/office/feed/models/feed-item.model.ts +++ b/projects/social_platform/src/app/office/feed/models/feed-item.model.ts @@ -1,7 +1,33 @@ /** @format */ + import { FeedNews } from "@office/projects/models/project-news.model"; import { Vacancy } from "@models/vacancy.model"; +/** + * МОДЕЛИ ДАННЫХ ДЛЯ ЭЛЕМЕНТОВ ЛЕНТЫ + * + * Этот файл содержит TypeScript интерфейсы и типы для элементов ленты новостей. + * Определяет структуру данных для проектов, вакансий и новостей. + * + * ОСНОВНЫЕ ТИПЫ: + * - FeedProject: данные проекта в ленте + * - FeedItemType: возможные типы элементов ленты + * - FeedItem: объединенный тип для всех элементов ленты + */ +/** + * ИНТЕРФЕЙС ПРОЕКТА В ЛЕНТЕ + * + * Описывает структуру данных проекта, отображаемого в ленте новостей + * + * ПОЛЯ: + * @property id - уникальный идентификатор проекта + * @property name - название проекта + * @property shortDescription - краткое описание проекта + * @property industry - ID отрасли проекта + * @property imageAddress - URL изображения проекта + * @property viewsCount - количество просмотров проекта + * @property leader - ID руководителя проекта + */ export interface FeedProject { id: number; name: string; @@ -12,8 +38,27 @@ export interface FeedProject { leader: number; } +/** + * ТИП ЭЛЕМЕНТА ЛЕНТЫ + * + * Определяет возможные типы контента в ленте: + * - "vacancy": вакансия + * - "news": новость + * - "project": проект + */ export type FeedItemType = "vacancy" | "news" | "project"; +/** + * ОБЪЕДИНЕННЫЙ ТИП ЭЛЕМЕНТА ЛЕНТЫ + * + * Дискриминированное объединение типов для всех возможных элементов ленты. + * Каждый элемент имеет поле typeModel для определения типа и соответствующий content. + * + * ВАРИАНТЫ: + * - Проект: typeModel = "project", content = FeedProject + * - Вакансия: typeModel = "vacancy", content = Vacancy + * - Новость: typeModel = "news", content = FeedNews с дополнительным contentObject + */ export type FeedItem = | ({ typeModel: FeedItemType } & { typeModel: "project"; 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 index db66ad57e..066060bbc 100644 --- a/projects/social_platform/src/app/office/feed/services/feed.service.ts +++ b/projects/social_platform/src/app/office/feed/services/feed.service.ts @@ -7,14 +7,59 @@ 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, @@ -22,16 +67,21 @@ export class FeedService { ): Observable> { let reqType: string; + // Обработка различных форматов параметра type if (type.length === 0) { + // Если фильтры не выбраны, загружаем все типы по умолчанию reqType = ["vacancy", "news", "project"].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( - "/feed/", + `${this.FEED_URL}/`, new HttpParams({ fromObject: { limit, 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 index f67e0edca..cdc5aafc6 100644 --- 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 @@ -5,15 +5,47 @@ import { CommonModule } from "@angular/common"; import { ButtonComponent } from "@ui/components"; import { DayjsPipe } from "projects/core"; import { Router, RouterLink } from "@angular/router"; -import { TagComponent } from "@ui/components/tag/tag.component"; +/** + * КОМПОНЕНТ ЗАКРЫТОЙ ВАКАНСИИ + * + * Отображает карточку закрытой (неактивной) вакансии в ленте новостей. + * Предоставляет ограниченную информацию о вакансии и указывает на её статус. + * + * ОСНОВНЫЕ ФУНКЦИИ: + * - Отображение основной информации о закрытой вакансии + * - Показ статуса "закрыто" или "неактивно" + * - Навигация к детальной странице вакансии (если доступна) + * - Форматирование дат с помощью DayjsPipe + * + * ИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ: + * - ButtonComponent: кнопки действий + * - TagComponent: теги и метки + * - DayjsPipe: форматирование дат + * - RouterLink: навигация между страницами + * + * ОТЛИЧИЯ ОТ ОТКРЫТОЙ ВАКАНСИИ: + * - Ограниченный функционал + * - Визуальные индикаторы закрытого статуса + * - Отсутствие кнопок подачи заявки + */ @Component({ selector: "app-closed-vacancy", standalone: true, - imports: [CommonModule, ButtonComponent, DayjsPipe, RouterLink, TagComponent], + 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/feed/shared/new-project/new-project.component.ts b/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.ts index f0f155159..5d5103ec6 100644 --- a/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.ts +++ b/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.ts @@ -7,6 +7,31 @@ import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { Router, RouterLink } from "@angular/router"; import { FeedProject } from "@office/feed/models/feed-item.model"; +/** + * КОМПОНЕНТ НОВОГО ПРОЕКТА + * + * Отображает карточку нового проекта в ленте новостей. + * Предоставляет краткую информацию о проекте и возможность перехода к детальной странице. + * + * ОСНОВНЫЕ ФУНКЦИИ: + * - Отображение основной информации о проекте + * - Показ изображения проекта + * - Отображение краткого описания + * - Показ количества просмотров + * - Навигация к детальной странице проекта + * + * ОТОБРАЖАЕМАЯ ИНФОРМАЦИЯ: + * - Название проекта + * - Краткое описание + * - Изображение проекта + * - Количество просмотров + * - Информация о руководителе (через AvatarComponent) + * + * ИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ: + * - ButtonComponent: кнопки действий + * - AvatarComponent: аватар руководителя проекта + * - RouterLink: навигация к детальной странице + */ @Component({ selector: "app-new-project", standalone: true, @@ -15,7 +40,31 @@ import { FeedProject } from "@office/feed/models/feed-item.model"; styleUrl: "./new-project.component.scss", }) export class NewProjectComponent { + /** + * ВХОДНЫЕ ДАННЫЕ + * + * @Input feedItem - объект проекта для отображения + * + * СОДЕРЖИТ: + * - id: уникальный идентификатор проекта + * - name: название проекта + * - shortDescription: краткое описание проекта + * - industry: ID отрасли проекта + * - imageAddress: URL изображения проекта + * - viewsCount: количество просмотров проекта + * - leader: ID руководителя проекта + */ @Input() feedItem!: FeedProject; + /** + * КОНСТРУКТОР + * + * ЧТО ПРИНИМАЕТ: + * @param router - сервис маршрутизации Angular для программной навигации + * + * НАЗНАЧЕНИЕ: + * Инициализирует компонент с доступом к сервису маршрутизации + * для возможной навигации к детальной странице проекта + */ constructor(public readonly router: Router) {} } diff --git a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.ts b/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.ts index a970a8d2d..fef15eab3 100644 --- a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.ts +++ b/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.ts @@ -5,7 +5,6 @@ import { ChangeDetectorRef, Component, ElementRef, - inject, Input, ViewChild, } from "@angular/core"; @@ -17,6 +16,29 @@ import { TagComponent } from "@ui/components/tag/tag.component"; import { Vacancy } from "@models/vacancy.model"; import { expandElement } from "@utils/expand-element"; +/** + * КОМПОНЕНТ ОТКРЫТОЙ ВАКАНСИИ + * + * Отображает карточку активной вакансии в ленте новостей с полным функционалом. + * Поддерживает развертывание/свертывание длинного контента и интерактивные элементы. + * + * ОСНОВНЫЕ ФУНКЦИИ: + * - Отображение полной информации о вакансии + * - Развертывание/свертывание описания и списка навыков + * - Навигация к детальной странице вакансии + * - Форматирование текста с поддержкой ссылок и переносов строк + * - Отображение тегов и навыков + * + * ИНТЕРАКТИВНЫЕ ЭЛЕМЕНТЫ: + * - Кнопки "Показать полностью" / "Свернуть" + * - Теги навыков и требований + * - Ссылки на детальную страницу + * + * ИСПОЛЬЗУЕМЫЕ ПАЙПЫ: + * - DayjsPipe: форматирование дат + * - ParseLinksPipe: преобразование ссылок в кликабельные элементы + * - ParseBreaksPipe: обработка переносов строк + */ @Component({ selector: "app-open-vacancy", standalone: true, @@ -33,33 +55,88 @@ import { expandElement } from "@utils/expand-element"; styleUrl: "./open-vacancy.component.scss", }) export class OpenVacancyComponent implements AfterViewInit { + /** + * ВХОДНЫЕ ДАННЫЕ + * + * @Input feedItem - объект вакансии для отображения + * Содержит всю информацию о вакансии: название, описание, требования, навыки и т.д. + */ @Input() feedItem!: Vacancy; + + /** + * ССЫЛКИ НА DOM ЭЛЕМЕНТЫ + * + * @ViewChild skillsEl - ссылка на элемент со списком навыков + * @ViewChild descEl - ссылка на элемент с описанием вакансии + * + * Используются для определения необходимости показа кнопок развертывания + */ @ViewChild("skillsEl") skillsEl?: ElementRef; @ViewChild("descEl") descEl?: ElementRef; constructor(public readonly router: Router, private readonly cdRef: ChangeDetectorRef) {} + /** + * ИНИЦИАЛИЗАЦИЯ ПОСЛЕ ОТРИСОВКИ + * + * ЧТО ДЕЛАЕТ: + * - Проверяет, нужны ли кнопки развертывания для описания и навыков + * - Сравнивает высоту контента с высотой контейнера + * - Устанавливает флаги для показа кнопок "Показать полностью" + * - Запускает обнаружение изменений для обновления UI + */ ngAfterViewInit(): void { + // Проверяем, превышает ли описание доступную высоту const descElement = this.descEl?.nativeElement; this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; + // Проверяем, превышает ли список навыков доступную высоту const skillsElement = this.skillsEl?.nativeElement; this.skillsExpandable = skillsElement?.clientHeight < skillsElement?.scrollHeight; + // Обновляем UI после изменения флагов this.cdRef.detectChanges(); } - descriptionExpandable!: boolean; - skillsExpandable!: boolean; + // Флаги для определения возможности развертывания контента + descriptionExpandable!: boolean; // Можно ли развернуть описание + skillsExpandable!: boolean; // Можно ли развернуть список навыков - readFullDescription = false; - readFullSkills = false; + // Состояние развертывания контента + readFullDescription = false; // Развернуто ли описание + readFullSkills = false; // Развернут ли список навыков + /** + * РАЗВЕРТЫВАНИЕ/СВЕРТЫВАНИЕ ОПИСАНИЯ + * + * ЧТО ПРИНИМАЕТ: + * @param elem - DOM элемент для анимации + * @param expandedClass - CSS класс для развернутого состояния + * @param isExpanded - текущее состояние (развернуто/свернуто) + * + * ЧТО ДЕЛАЕТ: + * - Переключает визуальное состояние описания + * - Применяет анимацию развертывания/свертывания + * - Обновляет флаг состояния + */ onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { expandElement(elem, expandedClass, isExpanded); this.readFullDescription = !isExpanded; } + /** + * РАЗВЕРТЫВАНИЕ/СВЕРТЫВАНИЕ СПИСКА НАВЫКОВ + * + * ЧТО ПРИНИМАЕТ: + * @param elem - DOM элемент для анимации + * @param expandedClass - CSS класс для развернутого состояния + * @param isExpanded - текущее состояние (развернуто/свернуто) + * + * ЧТО ДЕЛАЕТ: + * - Переключает визуальное состояние списка навыков + * - Применяет анимацию развертывания/свертывания + * - Обновляет флаг состояния + */ onExpandSkills(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { expandElement(elem, expandedClass, isExpanded); this.readFullSkills = !isExpanded; diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.ts b/projects/social_platform/src/app/office/members/filters/members-filters.component.ts index f6ff67ed2..11a96157d 100644 --- a/projects/social_platform/src/app/office/members/filters/members-filters.component.ts +++ b/projects/social_platform/src/app/office/members/filters/members-filters.component.ts @@ -6,11 +6,9 @@ import { Component, EventEmitter, Input, - OnInit, Output, signal, } from "@angular/core"; -import { IconComponent } from "@ui/components"; import { RangeInputComponent } from "@ui/components/range-input/range-input.component"; import { MembersComponent } from "@office/members/members.component"; import { ReactiveFormsModule } from "@angular/forms"; @@ -22,13 +20,25 @@ import { Skill } from "@office/models/skill"; import { ActivatedRoute, Router } from "@angular/router"; import { CheckboxComponent } from "../../../ui/components/checkbox/checkbox.component"; +/** + * Компонент фильтров для списка участников + * + * Предоставляет интерфейс для фильтрации участников по следующим критериям: + * - Ключевой навык (с автодополнением) + * - Специальность (с автодополнением) + * - Возрастной диапазон (слайдер) + * - Принадлежность к МосПолитеху (чекбокс) + * + * Все изменения фильтров синхронизируются с URL параметрами + * + * @component MembersFiltersComponent + */ @Component({ selector: "app-members-filters", standalone: true, imports: [ CommonModule, RangeInputComponent, - IconComponent, ReactiveFormsModule, AutoCompleteInputComponent, CheckboxComponent, @@ -38,14 +48,38 @@ import { CheckboxComponent } from "../../../ui/components/checkbox/checkbox.comp changeDetection: ChangeDetectionStrategy.OnPush, }) export class MembersFiltersComponent { + /** + * Событие, генерируемое при изменении фильтров + * (В данный момент не используется, но может быть полезно для будущих расширений) + */ @Output() filtersChanged = new EventEmitter(); + /** + * Форма фильтрации, передаваемая из родительского компонента + * Содержит поля: keySkill, speciality, age, isMosPolytechStudent + */ @Input({ required: true }) filterForm!: MembersComponent["filterForm"]; + /** + * Сигнал с опциями специальностей для автодополнения + * Обновляется при поиске специальностей + */ specsOptions = signal([]); + /** + * Сигнал с опциями навыков для автодополнения + * Обновляется при поиске навыков + */ skillsOptions = signal([]); + /** + * Конструктор компонента + * + * @param route - Сервис для работы с активным маршрутом + * @param router - Сервис для навигации и управления URL параметрами + * @param specsService - Сервис для получения списка специальностей + * @param skillsService - Сервис для получения списка навыков + */ constructor( private readonly route: ActivatedRoute, private readonly router: Router, @@ -53,40 +87,74 @@ export class MembersFiltersComponent { private readonly skillsService: SkillsService ) {} + /** + * Обработчик выбора специальности из списка автодополнения + * + * @param speciality - Выбранная специальность + */ onSelectSpec(speciality: Specialization): void { this.filterForm.patchValue({ speciality: speciality.name }); } + /** + * Очищает поле специальности + */ onClearSpecField(): void { this.filterForm.patchValue({ speciality: "" }); } + /** + * Выполняет поиск специальностей по запросу для автодополнения + * + * @param query - Поисковый запрос + */ onSearchSpec(query: string): void { this.specsService.getSpecializationsInline(query, 1000, 0).subscribe(({ results }) => { this.specsOptions.set(results); }); } + /** + * Обработчик выбора навыка из списка автодополнения + * + * @param skill - Выбранный навык + */ onSelectSkill(skill: Skill): void { this.filterForm.patchValue({ keySkill: skill.name }); } + /** + * Очищает поле навыка + */ onClearSkillField(): void { this.filterForm.patchValue({ keySkill: "" }); } + /** + * Выполняет поиск навыков по запросу для автодополнения + * + * @param query - Поисковый запрос + */ onSearchSkill(query: string): void { this.skillsService.getSkillsInline(query, 1000, 0).subscribe(({ results }) => { this.skillsOptions.set(results); }); } + /** + * Переключает состояние чекбокса "Студент МосПолитеха" + */ onToggleStudentMosPolitech(): void { this.filterForm.patchValue({ isMosPolytechStudent: !this.filterForm.get("isMosPolytechStudent")?.value, }); } + /** + * Очищает все фильтры + * + * Удаляет все параметры фильтрации из URL и сбрасывает форму к начальному состоянию + */ clearFilters(): void { this.router .navigate([], { diff --git a/projects/social_platform/src/app/office/members/members.component.ts b/projects/social_platform/src/app/office/members/members.component.ts index 59ceca46b..511c8c21c 100644 --- a/projects/social_platform/src/app/office/members/members.component.ts +++ b/projects/social_platform/src/app/office/members/members.component.ts @@ -8,6 +8,7 @@ import { ElementRef, OnDestroy, OnInit, + Renderer2, ViewChild, } from "@angular/core"; import { ActivatedRoute, Router, RouterLink } from "@angular/router"; @@ -44,6 +45,18 @@ import { SearchComponent } from "@ui/components/search/search.component"; import { MembersFiltersComponent } from "./filters/members-filters.component"; import { ApiPagination } from "@models/api-pagination.model"; +/** + * Компонент для отображения списка участников с возможностью поиска и фильтрации + * + * Основные функции: + * - Отображение списка участников в виде карточек + * - Поиск участников по имени + * - Фильтрация по навыкам, специальности, возрасту и принадлежности к МосПолитеху + * - Бесконечная прокрутка для подгрузки дополнительных участников + * - Синхронизация фильтров с URL параметрами + * + * @component MembersComponent + */ @Component({ selector: "app-members", templateUrl: "./members.component.html", @@ -60,30 +73,60 @@ import { ApiPagination } from "@models/api-pagination.model"; ], }) 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 cdref: ChangeDetectorRef + 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], + keySkill: ["", Validators.required], // Ключевой навык + speciality: ["", Validators.required], // Специальность + age: [[null, null]], // Диапазон возраста [от, до] + isMosPolytechStudent: [false], // Является ли студентом МосПолитеха }); } + /** + * Инициализация компонента + * + * Выполняет: + * - Очистку URL параметров + * - Установку заголовка навигации + * - Загрузку начальных данных из резолвера + * - Настройку подписок на изменения форм и URL параметров + */ ngOnInit(): void { + // Очищаем URL параметры при инициализации this.router.navigate([], { queryParams: {} }); + + // Устанавливаем заголовок страницы this.navService.setNavTitle("Участники"); + // Загружаем начальные данные участников из резолвера this.route.data .pipe( take(1), @@ -91,22 +134,24 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { ) .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), + skip(1), // Пропускаем первое значение + distinctUntilChanged(), // Игнорируем одинаковые значения + debounceTime(100), // Задержка для предотвращения частых запросов switchMap(params => { + // Формируем параметры для API запроса const fetchParams: Record = {}; if (params["fullname"]) fetchParams["fullname"] = params["fullname"]; @@ -117,6 +162,7 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { 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); @@ -126,18 +172,22 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { .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) + throttleTime(500) // Ограничиваем частоту обработки прокрутки ) .subscribe(noop); @@ -145,39 +195,53 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { } } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy(): void { this.subscriptions$.forEach($ => $?.unsubscribe()); } - containerSm = containerSm; - appWidth = window.innerWidth; + // Константы и свойства компонента + containerSm = containerSm; // Брейкпоинт для мобильных устройств + appWidth = window.innerWidth; // Ширина окна браузера + + @ViewChild("membersRoot") membersRoot?: ElementRef; // Ссылка на корневой элемент списка + @ViewChild("filterBody") filterBody!: ElementRef; // Ссылка на элемент фильтра - @ViewChild("membersRoot") membersRoot?: ElementRef; - membersTotalCount?: number; - membersPage = 1; - membersTake = 20; + membersTotalCount?: number; // Общее количество участников + membersPage = 1; // Текущая страница для пагинации + membersTake = 20; // Количество участников на странице - subscriptions$: Subscription[] = []; + subscriptions$: Subscription[] = []; // Массив подписок для очистки - members: User[] = []; + members: User[] = []; // Массив участников для отображения - searchParamsSubject$ = new BehaviorSubject>({}); + searchParamsSubject$ = new BehaviorSubject>({}); // Subject для параметров поиска - searchForm: FormGroup; - filterForm: FormGroup; + 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, @@ -186,7 +250,6 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { tap(membersChunk => { this.membersPage++; this.members = [...this.members, ...membersChunk]; - this.cdref.detectChanges(); }) ); @@ -196,9 +259,10 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { } /** - * save value of form control in query params - * @param control - * @param queryName + * Сохраняет значение элемента формы в URL параметрах + * + * @param control - Элемент управления формы + * @param queryName - Имя параметра в URL */ saveControlValue(control: AbstractControl | null, queryName: string): void { if (!control) return; @@ -216,11 +280,18 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { 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; }) ); diff --git a/projects/social_platform/src/app/office/members/members.resolver.ts b/projects/social_platform/src/app/office/members/members.resolver.ts index 3f3e43fc9..02b9c81f5 100644 --- a/projects/social_platform/src/app/office/members/members.resolver.ts +++ b/projects/social_platform/src/app/office/members/members.resolver.ts @@ -6,8 +6,24 @@ 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.ts b/projects/social_platform/src/app/office/mentors/mentors.component.ts index 85cbe7595..1a1345d68 100644 --- a/projects/social_platform/src/app/office/mentors/mentors.component.ts +++ b/projects/social_platform/src/app/office/mentors/mentors.component.ts @@ -21,6 +21,47 @@ 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", diff --git a/projects/social_platform/src/app/office/mentors/mentors.resolver.ts b/projects/social_platform/src/app/office/mentors/mentors.resolver.ts index 228957c19..a6e17aee3 100644 --- a/projects/social_platform/src/app/office/mentors/mentors.resolver.ts +++ b/projects/social_platform/src/app/office/mentors/mentors.resolver.ts @@ -6,6 +6,36 @@ 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); diff --git a/projects/social_platform/src/app/office/models/api-pagination.model.ts b/projects/social_platform/src/app/office/models/api-pagination.model.ts index a29579def..69329dde2 100644 --- a/projects/social_platform/src/app/office/models/api-pagination.model.ts +++ b/projects/social_platform/src/app/office/models/api-pagination.model.ts @@ -1,8 +1,18 @@ -/** @format */ +/** + * Интерфейс для пагинации API ответов + * Используется для разбивки больших списков данных на страницы + * + * @format + * @template T - тип элементов в результирующем массиве + */ export interface ApiPagination { + /** Общее количество элементов во всей коллекции */ count: number; + /** Массив элементов текущей страницы */ results: T[]; + /** URL для получения следующей страницы (null если это последняя страница) */ next: string; + /** URL для получения предыдущей страницы (null если это первая страница) */ previous: string; } diff --git a/projects/social_platform/src/app/office/models/article.model.ts b/projects/social_platform/src/app/office/models/article.model.ts index e7be4e92b..ae8841639 100644 --- a/projects/social_platform/src/app/office/models/article.model.ts +++ b/projects/social_platform/src/app/office/models/article.model.ts @@ -1,9 +1,19 @@ -/** @format */ +/** + * Модель новости/статьи + * Представляет новостную статью или объявление в системе + * + * @format + */ export class New { + /** Уникальный идентификатор новости */ id!: number; + /** URL обложки новости */ coverUrl!: string; + /** Заголовок новости */ title!: string; + /** Полный текст новости (опционально) */ text?: string; + /** Краткое описание новости для превью */ shortText!: string; } diff --git a/projects/social_platform/src/app/office/models/chat-message.model.ts b/projects/social_platform/src/app/office/models/chat-message.model.ts index 91c5f54ae..5d54fdccf 100644 --- a/projects/social_platform/src/app/office/models/chat-message.model.ts +++ b/projects/social_platform/src/app/office/models/chat-message.model.ts @@ -3,6 +3,18 @@ import { User } from "@auth/models/user.model"; import * as dayjs from "dayjs"; +/** + * Модели для системы чатов + * + * ChatFile - представляет файл, прикрепленный к сообщению + * ChatMessage - модель сообщения в чате + * + * Содержат: + * - Информацию об авторе и времени создания + * - Текст сообщения и прикрепленные файлы + * - Статусы прочтения, редактирования и удаления + * - Ссылки на сообщения для ответов + */ export class ChatFile { name!: string; // TODO: switch to mimetype when back will be ready diff --git a/projects/social_platform/src/app/office/models/chat.model.ts b/projects/social_platform/src/app/office/models/chat.model.ts index 697e23058..454d815cd 100644 --- a/projects/social_platform/src/app/office/models/chat.model.ts +++ b/projects/social_platform/src/app/office/models/chat.model.ts @@ -1,76 +1,154 @@ /** @format */ -import { ChatMessage } from "@models/chat-message.model"; +import type { ChatMessage } from "@models/chat-message.model"; +/** + * Класс для уведомления об изменении статуса пользователя + */ export class OnChangeStatus { + /** Идентификатор пользователя, изменившего статус */ userId!: number; } +/** + * DTO для получения нового сообщения в чате + */ export class OnChatMessageDto { + /** Идентификатор чата */ chatId!: string; + /** Объект сообщения */ message!: ChatMessage; } + +/** + * DTO для отправки сообщения в чат + */ export class SendChatMessageDto { + /** Тип чата: "direct" - личный чат, "project" - чат проекта */ chatType!: "direct" | "project"; + /** Идентификатор чата */ chatId!: string; + /** Текст сообщения */ text!: string; + /** Массив URL прикрепленных файлов */ fileUrls!: string[]; + /** ID сообщения, на которое отвечаем (null если не ответ) */ replyTo!: number | null; } +/** + * DTO для уведомления о редактировании сообщения + */ export class OnEditChatMessageDto { + /** Идентификатор чата */ chatId!: string; + /** Отредактированное сообщение */ message!: ChatMessage; } +/** + * DTO для уведомления об удалении сообщения + */ export class OnDeleteChatMessageDto { + /** Тип чата */ chatType!: "project" | "direct"; + /** Идентификатор чата */ chatId!: string; + /** Идентификатор удаленного сообщения */ messageId!: number; } + +/** + * DTO для уведомления о прочтении сообщения + */ export class OnReadChatMessageDto { + /** Тип чата */ chatType!: "project" | "direct"; + /** Идентификатор чата */ chatId!: string; + /** Идентификатор прочитанного сообщения */ messageId!: number; + /** Идентификатор пользователя, прочитавшего сообщение */ userId!: number; } + +/** + * DTO для редактирования сообщения + */ export class EditChatMessageDto { + /** Тип чата */ chatType!: "direct" | "project"; + /** Идентификатор чата */ chatId!: string; + /** Новый текст сообщения */ text!: string; + /** Идентификатор редактируемого сообщения */ messageId!: number; } +/** + * DTO для удаления сообщения + */ export class DeleteChatMessageDto { + /** Тип чата */ chatType!: "direct" | "project"; + /** Идентификатор чата */ chatId!: string; + /** Идентификатор удаляемого сообщения */ messageId!: number; } +/** + * DTO для отметки сообщения как прочитанного + */ export class ReadChatMessageDto { + /** Тип чата */ chatType!: "direct" | "project"; + /** Идентификатор чата */ chatId!: string; + /** Идентификатор сообщения для отметки */ messageId!: number; } +/** + * DTO для уведомления о том, что пользователь печатает + */ export class TypingInChatDto { + /** Тип чата */ chatType!: "direct" | "project"; + /** Идентификатор чата */ chatId!: string; } +/** + * DTO для события "пользователь печатает" + */ export class TypingInChatEventDto { + /** Тип чата */ chatType!: "direct" | "project"; + /** Идентификатор чата */ chatId!: string; + /** Идентификатор печатающего пользователя */ userId!: number; + /** Время окончания печатания (timestamp) */ endTime!: number; } +/** + * Перечисление типов событий чата + */ export enum ChatEventType { + /** Новое сообщение */ NEW_MESSAGE = "new_message", + /** Редактирование сообщения */ EDIT_MESSAGE = "edit_message", + /** Удаление сообщения */ DELETE_MESSAGE = "delete_message", + /** Прочтение сообщения */ READ_MESSAGE = "message_read", + /** Пользователь печатает */ TYPING = "user_typing", - + /** Пользователь онлайн */ SET_ONLINE = "set_online", + /** Пользователь оффлайн */ SET_OFFLINE = "set_offline", } diff --git a/projects/social_platform/src/app/office/models/collaborator.model.ts b/projects/social_platform/src/app/office/models/collaborator.model.ts index 736443a66..40b8f4525 100644 --- a/projects/social_platform/src/app/office/models/collaborator.model.ts +++ b/projects/social_platform/src/app/office/models/collaborator.model.ts @@ -1,10 +1,21 @@ -/** @format */ +/** + * Модель участника проекта (коллаборатора) + * Содержит основную информацию о пользователе, участвующем в проекте + * + * @format + */ export class Collaborator { + /** Уникальный идентификатор пользователя */ userId!: number; + /** Имя участника */ firstName!: string; + /** Фамилия участника */ lastName!: string; + /** Роль участника в проекте (например, "Разработчик", "Дизайнер") */ role!: string; + /** Массив ключевых навыков участника */ keySkills!: string[]; + /** URL аватара участника */ avatar!: string; } diff --git a/projects/social_platform/src/app/office/models/file.model.ts b/projects/social_platform/src/app/office/models/file.model.ts index 645e5e6c8..1b57c628d 100644 --- a/projects/social_platform/src/app/office/models/file.model.ts +++ b/projects/social_platform/src/app/office/models/file.model.ts @@ -1,5 +1,14 @@ /** @format */ +/** + * Модель файла в системе + * Представляет загруженный пользователем файл + * + * Содержит: + * - Метаданные файла (имя, размер, тип) + * - Ссылку для скачивания + * - Информацию о загрузке (пользователь, время) + */ export class FileModel { datetimeUploaded!: string; extension!: string; diff --git a/projects/social_platform/src/app/office/models/filter-fields.model.ts b/projects/social_platform/src/app/office/models/filter-fields.model.ts new file mode 100644 index 000000000..0085ea9d8 --- /dev/null +++ b/projects/social_platform/src/app/office/models/filter-fields.model.ts @@ -0,0 +1,41 @@ +/** @format */ + +import { Observable } from "rxjs"; + +export interface UnifiedOption { + id: string | number; + label: string; + value?: any; +} + +export interface FilterFieldConfig { + /** Название поля в query параметрах */ + queryParam: string; + /** Тип поля */ + type: "checkbox" | "radio" | "select" | "range" | "switch" | "autocomplete" | "slider"; + /** Заголовок поля */ + title: string; + /** Значение по умолчанию */ + defaultValue?: any; + /** Опции для select/radio */ + options?: Array<{ id: any; label: string; value?: any }>; + /** Источник данных (Observable) */ + dataSource?: Observable; + /** Поле для отображения из источника данных */ + displayField?: string; + /** Поле значения из источника данных */ + valueField?: string; + /** Дополнительные параметры для конкретного типа */ + config?: any; +} + +export interface FilterConfig { + /** Конфигурация полей фильтра */ + fields: FilterFieldConfig[]; + /** Параметры для сброса при clearFilters */ + clearParams?: string[]; + /** Заголовок фильтра */ + title?: string; + /** Показывать ли кнопку "Сбросить фильтры" */ + showClearButton?: boolean; +} diff --git a/projects/social_platform/src/app/office/models/industry.model.ts b/projects/social_platform/src/app/office/models/industry.model.ts index d2f6e7eae..35766c393 100644 --- a/projects/social_platform/src/app/office/models/industry.model.ts +++ b/projects/social_platform/src/app/office/models/industry.model.ts @@ -1,5 +1,13 @@ /** @format */ +/** + * Модель отрасли/индустрии + * Представляет сферу деятельности проекта + * + * Содержит: + * - Уникальный идентификатор + * - Название отрасли + */ export class Industry { id!: number; name!: string; diff --git a/projects/social_platform/src/app/office/models/invite.model.ts b/projects/social_platform/src/app/office/models/invite.model.ts index 901d6ab5b..aaba627e4 100644 --- a/projects/social_platform/src/app/office/models/invite.model.ts +++ b/projects/social_platform/src/app/office/models/invite.model.ts @@ -1,7 +1,18 @@ /** @format */ + import { User } from "@auth/models/user.model"; import { Project } from "./project.model"; +/** + * Модель приглашения в проект + * Представляет приглашение пользователя для участия в проекте + * + * Содержит: + * - Информацию о проекте и роли + * - Статус принятия приглашения + * - Мотивационное письмо и специализацию + * - Данные пользователя-отправителя + */ export class Invite { id!: number; datetimeCreated!: string; diff --git a/projects/social_platform/src/app/office/models/notification.model.ts b/projects/social_platform/src/app/office/models/notification.model.ts index f4698ac4f..71bfe3d11 100644 --- a/projects/social_platform/src/app/office/models/notification.model.ts +++ b/projects/social_platform/src/app/office/models/notification.model.ts @@ -1,5 +1,14 @@ /** @format */ +/** + * Модель уведомления пользователя + * Представляет системные уведомления и сообщения + * + * Содержит: + * - Текст уведомления + * - Время прочтения (null если не прочитано) + * - Уникальный идентификатор + */ export class Notification { id!: number; text!: string; diff --git a/projects/social_platform/src/app/office/models/partner-program-fields.model.ts b/projects/social_platform/src/app/office/models/partner-program-fields.model.ts new file mode 100644 index 000000000..3c76f10c6 --- /dev/null +++ b/projects/social_platform/src/app/office/models/partner-program-fields.model.ts @@ -0,0 +1,33 @@ +/** + * Модель поля для полей проекта-участника программы + * Содержит основную информацию о полях проекта, который учавствует в программе + * + * PartnerProgramFields содержит: + * - Основную информацию (название, описание, типы для полей) + * + * PartnerProgramFieldsValues содержит: + * - Основную информацию по значениям, которые содержатся в полях которые привязаны к программе(название и значение) + * + * @format + */ + +export class PartnerProgramFields { + id!: number; + name!: string; + label!: string; + fieldType!: "text" | "textarea" | "checkbox" | "select" | "radio" | "file"; + isRequired!: boolean; + helpText!: string; + options!: string[]; + showFilter?: boolean; +} + +export class PartnerProgramFieldsValues { + fieldName!: string; + value!: string; +} + +export class projectNewAdditionalProgramVields { + field_id!: number; + value_text!: string | boolean; +} diff --git a/projects/social_platform/src/app/office/models/project-subscriber.model.ts b/projects/social_platform/src/app/office/models/project-subscriber.model.ts index aa14cba98..b9a2186ac 100644 --- a/projects/social_platform/src/app/office/models/project-subscriber.model.ts +++ b/projects/social_platform/src/app/office/models/project-subscriber.model.ts @@ -1,9 +1,19 @@ -/** @format */ +/** + * Модель подписчика проекта + * Представляет пользователя, который подписан на обновления проекта + * + * @format + */ export class ProjectSubscriber { + /** Уникальный идентификатор подписчика */ id!: number; + /** Имя подписчика */ firstName!: string; + /** Фамилия подписчика */ lastName!: string; + /** URL аватара подписчика */ avatar!: string; + /** Статус онлайн подписчика (true - онлайн, false - оффлайн) */ isOnline!: boolean; } diff --git a/projects/social_platform/src/app/office/models/project.model.ts b/projects/social_platform/src/app/office/models/project.model.ts index d376cf51b..4da6aaa5f 100644 --- a/projects/social_platform/src/app/office/models/project.model.ts +++ b/projects/social_platform/src/app/office/models/project.model.ts @@ -1,8 +1,31 @@ /** @format */ import { Collaborator } from "./collaborator.model"; +import { PartnerProgramFields, PartnerProgramFieldsValues } from "./partner-program-fields.model"; import { Vacancy } from "./vacancy.model"; +/** + * Основная модель проекта и связанные классы + * Представляет проект со всей необходимой информацией + * + * Project содержит: + * - Основную информацию (название, описание, цели) + * - Участников и вакансии + * - Медиа-файлы и ссылки + * + * ProjectCount - счетчики проектов по категориям + * ProjectStep - этапы развития проекта + */ + +export class PartnerProgramInfo { + programLinkId!: number; + programId!: number; + isSubmitted!: boolean; + canSubmit!: boolean; + programFields!: PartnerProgramFields[]; + programFieldValues!: PartnerProgramFieldsValues[]; +} + export class Project { id!: number; name!: string; @@ -29,6 +52,8 @@ export class Project { draft!: boolean; leader!: number; partnerProgramsTags?: string[]; + partnerProgramId!: number | null; + partnerProgram!: PartnerProgramInfo | null; vacancies!: Vacancy[]; isCompany!: boolean; @@ -49,6 +74,8 @@ export class Project { industry: 0, viewsCount: 0, links: [], + partnerProgramId: null, + partnerProgram: null, cover: null, coverImageAddress: null, presentationAddress: "string", diff --git a/projects/social_platform/src/app/office/models/skill.ts b/projects/social_platform/src/app/office/models/skill.ts index b77d95b2d..5ba79f980 100644 --- a/projects/social_platform/src/app/office/models/skill.ts +++ b/projects/social_platform/src/app/office/models/skill.ts @@ -1,25 +1,49 @@ -/** @format */ +/** + * Интерфейс для подтверждения навыка + * Содержит информацию о пользователе, который подтвердил навык + * + * @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/office/models/skills-group.ts b/projects/social_platform/src/app/office/models/skills-group.ts index 13da5fe40..037c38a5c 100644 --- a/projects/social_platform/src/app/office/models/skills-group.ts +++ b/projects/social_platform/src/app/office/models/skills-group.ts @@ -1,9 +1,16 @@ /** @format */ -import { Skill } from "./skill"; +import { Skill } from "./skill"; // Assuming Skill is defined in a separate file +/** + * Интерфейс для группы навыков + * Представляет категорию навыков с вложенным списком конкретных навыков + */ export interface SkillsGroup { + /** Уникальный идентификатор группы навыков */ id: number; + /** Название группы навыков (например, "Программирование", "Дизайн") */ name: string; + /** Массив навыков, входящих в данную группу */ skills: Skill[]; } diff --git a/projects/social_platform/src/app/office/models/specialization.ts b/projects/social_platform/src/app/office/models/specialization.ts index 707444208..1fa2b0ffc 100644 --- a/projects/social_platform/src/app/office/models/specialization.ts +++ b/projects/social_platform/src/app/office/models/specialization.ts @@ -1,5 +1,13 @@ /** @format */ +/** + * Модель специализации пользователя + * Представляет профессиональную специализацию + * + * Содержит: + * - Уникальный идентификатор + * - Название специализации + */ export interface Specialization { id: number; name: string; diff --git a/projects/social_platform/src/app/office/models/specializations-group.ts b/projects/social_platform/src/app/office/models/specializations-group.ts index d2ea0fc50..55149beb9 100644 --- a/projects/social_platform/src/app/office/models/specializations-group.ts +++ b/projects/social_platform/src/app/office/models/specializations-group.ts @@ -1,7 +1,15 @@ /** @format */ -import { Specialization } from "./specialization"; +import type { Specialization } from "./specialization"; +/** + * Модель группы специализаций + * Представляет категорию специализаций с вложенным списком + * + * Содержит: + * - Название группы специализаций + * - Массив специализаций в данной группе + */ export interface SpecializationsGroup { id: number; name: string; diff --git a/projects/social_platform/src/app/office/models/vacancy-response.model.ts b/projects/social_platform/src/app/office/models/vacancy-response.model.ts index 8f9fdcddf..4350a0d4c 100644 --- a/projects/social_platform/src/app/office/models/vacancy-response.model.ts +++ b/projects/social_platform/src/app/office/models/vacancy-response.model.ts @@ -3,6 +3,15 @@ import { User } from "@auth/models/user.model"; import { FileModel } from "./file.model"; +/** + * Модель отклика на вакансию + * Представляет ответ пользователя на размещенную вакансию в проекте + * + * Содержит: + * - Информацию о пользователе, откликнувшемся на вакансию + * - Мотивационное письмо и сопроводительные файлы + * - Статус одобрения отклика + */ export class VacancyResponse { id!: number; whyMe!: string; diff --git a/projects/social_platform/src/app/office/models/vacancy.model.ts b/projects/social_platform/src/app/office/models/vacancy.model.ts index 70436c95e..bf4d49a93 100644 --- a/projects/social_platform/src/app/office/models/vacancy.model.ts +++ b/projects/social_platform/src/app/office/models/vacancy.model.ts @@ -1,7 +1,18 @@ /** @format */ + import { Project } from "@models/project.model"; import { Skill } from "./skill"; +/** + * Модель вакансии в проекте + * Представляет открытую позицию для участия в проекте + * + * Содержит: + * - Описание роли и требований + * - Необходимые навыки и опыт + * - Условия работы (формат, график, зарплата) + * - Связь с проектом и статус активности + */ export class Vacancy { id!: number; role!: string; diff --git a/projects/social_platform/src/app/office/office.component.ts b/projects/social_platform/src/app/office/office.component.ts index e4529ce2d..ededf8a03 100644 --- a/projects/social_platform/src/app/office/office.component.ts +++ b/projects/social_platform/src/app/office/office.component.ts @@ -1,8 +1,8 @@ /** @format */ -import { Component, OnDestroy, OnInit, signal, Signal } from "@angular/core"; +import { Component, OnDestroy, OnInit, Signal } from "@angular/core"; import { IndustryService } from "@services/industry.service"; -import { forkJoin, map, noop, Subscription, tap } from "rxjs"; +import { forkJoin, map, noop, Subscription } from "rxjs"; import { ActivatedRoute, Router, RouterOutlet } from "@angular/router"; import { Invite } from "@models/invite.model"; import { AuthService } from "@auth/services"; @@ -19,6 +19,18 @@ import { AsyncPipe } from "@angular/common"; import { InviteService } from "@services/invite.service"; import { toSignal } from "@angular/core/rxjs-interop"; +/** + * Главный компонент офиса - корневой компонент рабочего пространства + * Управляет общим состоянием приложения, навигацией и модальными окнами + * + * Принимает: + * - Данные о приглашениях через резолвер + * - События от сервисов (auth, chat, invite) + * + * Возвращает: + * - Рендерит основной интерфейс офиса с сайдбаром, навигацией и роутер-аутлетом + * - Управляет модальными окнами для верификации и приглашений + */ @Component({ selector: "app-office", templateUrl: "./office.component.html", diff --git a/projects/social_platform/src/app/office/office.resolver.ts b/projects/social_platform/src/app/office/office.resolver.ts index ce5d38980..2e8cd588f 100644 --- a/projects/social_platform/src/app/office/office.resolver.ts +++ b/projects/social_platform/src/app/office/office.resolver.ts @@ -5,6 +5,16 @@ 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); diff --git a/projects/social_platform/src/app/office/office.routes.ts b/projects/social_platform/src/app/office/office.routes.ts index 64a4d37b8..7119d9a73 100644 --- a/projects/social_platform/src/app/office/office.routes.ts +++ b/projects/social_platform/src/app/office/office.routes.ts @@ -10,6 +10,17 @@ import { OfficeResolver } from "./office.resolver"; import { MentorsComponent } from "./mentors/mentors.component"; import { MentorsResolver } from "./mentors/mentors.resolver"; +/** + * Конфигурация маршрутов для модуля офиса + * Определяет все доступные пути и их компоненты в рабочем пространстве + * + * Принимает: + * - URL пути от роутера Angular + * + * Возвращает: + * - Конфигурацию маршрутов с ленивой загрузкой модулей + * - Резолверы для предзагрузки данных + */ export const OFFICE_ROUTES: Routes = [ { path: "onboarding", diff --git a/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts b/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts index 348b756b7..cbe8596e6 100644 --- a/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts +++ b/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts @@ -9,6 +9,26 @@ 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: "", 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 index c1a0a4aa0..4166fe1c5 100644 --- a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.ts +++ b/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.ts @@ -5,6 +5,42 @@ 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", @@ -29,7 +65,7 @@ export class OnboardingComponent implements OnInit, OnDestroy { } if (this.router.url.includes("stage")) { - this.stage = parseInt(this.router.url.split("-")[1]); + this.stage = Number.parseInt(this.router.url.split("-")[1]); } else { this.stage = s; } @@ -55,8 +91,8 @@ export class OnboardingComponent implements OnInit, OnDestroy { subscriptions$: Subscription[] = []; updateStage(): void { - this.activeStage = parseInt(this.router.url.split("-")[1]); - this.stage = parseInt(this.router.url.split("-")[1]); + this.activeStage = Number.parseInt(this.router.url.split("-")[1]); + this.stage = Number.parseInt(this.router.url.split("-")[1]); } goToStep(stage: number): void { diff --git a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts b/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts index 848a5fee7..d2d674365 100644 --- a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts +++ b/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts @@ -5,6 +5,39 @@ import { User } from "@auth/models/user.model"; import { AuthService } from "@auth/services"; import { BehaviorSubject, take } from "rxjs"; +/** + * СЕРВИС УПРАВЛЕНИЯ СОСТОЯНИЕМ ОНБОРДИНГА + * + * Назначение: Централизованное управление данными и состоянием процесса онбординга + * + * Что делает: + * - Хранит и управляет данными формы онбординга между этапами + * - Отслеживает текущий этап онбординга пользователя + * - Синхронизирует состояние с профилем пользователя из AuthService + * - Предоставляет реактивные потоки данных для компонентов + * - Обеспечивает персистентность данных при переходах между этапами + * + * Что принимает: + * - Обновления данных формы через setFormValue(updates: Partial) + * - Изменения текущего этапа через setStep(step: number | null) + * - Начальные данные профиля из AuthService при инициализации + * + * Что возвращает: + * - formValue$: Observable> - поток данных формы + * - currentStage$: Observable - поток текущего этапа + * + * Архитектурные особенности: + * - Использует BehaviorSubject для хранения состояния + * - Singleton сервис (providedIn: 'root') + * - Автоматическая инициализация из профиля пользователя + * - Реактивное программирование с RxJS + * + * Жизненный цикл данных: + * 1. Инициализация из AuthService.profile + * 2. Накопление изменений через setFormValue + * 3. Передача данных между компонентами этапов + * 4. Финальное сохранение в профиль пользователя + */ @Injectable({ providedIn: "root", }) 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 index 674cc2ca0..203a29814 100644 --- 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 @@ -7,8 +7,7 @@ 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, InputComponent } from "@ui/components"; -import { TagComponent } from "@ui/components/tag/tag.component"; +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"; @@ -17,6 +16,35 @@ import { Specialization } from "@office/models/specialization"; import { SpecializationsService } from "@office/services/specializations.service"; import { ErrorMessage } from "@error/models/error-message"; +/** + * КОМПОНЕНТ ПЕРВОГО ЭТАПА ОНБОРДИНГА + * + * Назначение: Этап выбора специализации пользователя из предложенных вариантов + * + * Что делает: + * - Отображает форму для ввода/выбора специализации + * - Предоставляет автокомплит для поиска специализаций + * - Показывает группированные специализации из базы данных + * - Валидирует введенные данные + * - Сохраняет специализацию в профиле и переходит к следующему этапу + * - Предоставляет возможность пропустить этап + * + * Что принимает: + * - Данные специализаций через ActivatedRoute (из StageOneResolver) + * - Текущее состояние формы из OnboardingService + * - Пользовательский ввод в поле специализации + * - Поисковые запросы для автокомплита + * + * Что возвращает: + * - Интерфейс с полем ввода специализации + * - Список предложенных специализаций для выбора + * - Навигацию на следующий этап (stage-2) или финальный (stage-3) + * + * Особенности: + * - Использует сигналы Angular для реактивного состояния + * - Поддерживает поиск специализаций в реальном времени + * - Интегрирован с сервисом специализаций для получения данных + */ @Component({ selector: "app-stage-one", templateUrl: "./stage-one.component.html", @@ -24,12 +52,9 @@ import { ErrorMessage } from "@error/models/error-message"; standalone: true, imports: [ ReactiveFormsModule, - InputComponent, - TagComponent, IconComponent, ButtonComponent, ControlErrorPipe, - InputComponent, AutoCompleteInputComponent, SpecializationsGroupComponent, CommonModule, diff --git a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts index 92039aa97..4e8f05f6a 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts +++ b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts @@ -1,10 +1,33 @@ /** @format */ import { inject } from "@angular/core"; -import type { ResolveFn } from "@angular/router"; +import { ResolveFn } from "@angular/router"; import { SpecializationsService } from "@office/services/specializations.service"; import { SpecializationsGroup } from "@office/models/specializations-group"; +/** + * РЕЗОЛВЕР ПЕРВОГО ЭТАПА ОНБОРДИНГА + * + * Назначение: Предзагрузка данных специализаций перед отображением компонента stage-one + * + * Что делает: + * - Выполняется автоматически перед активацией маршрута stage-1 + * - Загружает иерархическую структуру специализаций из API + * - Обеспечивает доступность данных в компоненте через ActivatedRoute + * + * Что принимает: + * - Контекст маршрута (автоматически от Angular Router) + * - Доступ к SpecializationsService через dependency injection + * + * Что возвращает: + * - Observable - массив групп специализаций + * - Данные становятся доступны в компоненте через route.data['data'] + * + * Преимущества использования резолвера: + * - Данные загружаются до отображения компонента + * - Предотвращает показ пустого состояния + * - Централизованная обработка ошибок загрузки + */ export const StageOneResolver: ResolveFn = () => { const specializationsService = inject(SpecializationsService); 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 index 33735431c..1d8421f08 100644 --- 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 @@ -8,6 +8,31 @@ import { OnboardingService } from "@office/onboarding/services/onboarding.servic 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", 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 index 5b5404839..67f30935d 100644 --- 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 @@ -1,14 +1,7 @@ /** @format */ -import { - ChangeDetectorRef, - Component, - ComponentFactoryResolver, - OnDestroy, - OnInit, - signal, -} from "@angular/core"; -import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +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"; @@ -22,9 +15,46 @@ import { SkillsService } from "@office/services/skills.service"; import { SkillsGroup } from "@office/models/skills-group"; import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; -import { HttpResponse } from "@angular/common/http"; import { ModalComponent } from "@ui/components/modal/modal.component"; +/** + * КОМПОНЕНТ ВТОРОГО ЭТАПА ОНБОРДИНГА + * + * Назначение: Этап выбора навыков пользователя из каталога доступных навыков + * + * Что делает: + * - Отображает интерфейс для поиска и выбора навыков + * - Управляет корзиной выбранных навыков + * - Предоставляет группированный каталог навыков + * - Поддерживает поиск навыков в реальном времени + * - Валидирует выбранные навыки перед отправкой + * - Сохраняет навыки в профиле и переходит к следующему этапу + * - Обрабатывает ошибки валидации от сервера + * + * Что принимает: + * - Данные групп навыков через ActivatedRoute (из StageTwoResolver) + * - Текущее состояние формы из OnboardingService + * - Пользовательские действия (поиск, добавление/удаление навыков) + * - Результаты поиска от SkillsService + * + * Что возвращает: + * - Интерфейс с поиском навыков и автокомплитом + * - Группированный каталог навыков для выбора + * - Корзину выбранных навыков с возможностью удаления + * - Модальное окно с ошибками валидации + * - Навигацию на следующий этап (stage-3) + * + * Особенности работы с навыками: + * - Предотвращение дублирования навыков в корзине + * - Переключение состояния навыка (добавить/удалить) одним действием + * - Отправка только ID навыков на сервер (skillsIds) + * - Обработка серверных ошибок с отображением в модальном окне + * + * Состояния компонента: + * - searchedSkills: результаты поиска навыков + * - stageSubmitting: флаг процесса отправки + * - isChooseSkill: флаг отображения модального окна с ошибкой + */ @Component({ selector: "app-stage-two", templateUrl: "./stage-two.component.html", diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts index 574b516eb..38d91200e 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts +++ b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts @@ -1,10 +1,39 @@ /** @format */ import { inject } from "@angular/core"; -import type { ResolveFn } from "@angular/router"; +import { ResolveFn } from "@angular/router"; import { SkillsService } from "@office/services/skills.service"; import { SkillsGroup } from "@office/models/skills-group"; +/** + * РЕЗОЛВЕР ВТОРОГО ЭТАПА ОНБОРДИНГА + * + * Назначение: Предзагрузка данных навыков перед отображением компонента stage-two + * + * Что делает: + * - Выполняется автоматически перед активацией маршрута stage-2 + * - Загружает иерархическую структуру навыков из API + * - Группирует навыки по категориям для удобного отображения + * - Обеспечивает доступность данных в компоненте через ActivatedRoute + * + * Что принимает: + * - Контекст маршрута (автоматически от Angular Router) + * - Доступ к SkillsService через dependency injection + * + * Что возвращает: + * - Observable - массив групп навыков + * - Данные становятся доступны в компоненте через route.data['data'] + * + * Структура данных: + * - SkillsGroup: группа навыков с названием категории + * - Каждая группа содержит массив связанных навыков + * - Используется для организации навыков в UI по категориям + * + * Преимущества: + * - Быстрое отображение интерфейса с готовыми данными + * - Предотвращение состояния загрузки в компоненте + * - Централизованная обработка ошибок загрузки данных + */ export const StageTwoResolver: ResolveFn = () => { const skillsService = inject(SkillsService); 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 index 5e0b59581..d88fff928 100644 --- 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 @@ -20,6 +20,52 @@ import { transformYearStringToNumber } from "@utils/transformYear"; import { yearRangeValidators } from "@utils/yearRangeValidators"; import { ModalComponent } from "@ui/components/modal/modal.component"; +/** + * КОМПОНЕНТ НУЛЕВОГО ЭТАПА ОНБОРДИНГА + * + * Назначение: Начальный этап сбора базовой информации профиля пользователя + * + * Что делает: + * - Собирает основную информацию: фото, город, образование, опыт работы, языки, достижения + * - Управляет сложными формами с динамическими массивами (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", @@ -426,7 +472,6 @@ export class OnboardingStageZeroComponent implements OnInit, OnDestroy { this.educationItems.update(items => [...items, educationItem.value]); this.education.push(educationItem); } - [ "organizationName", "entryYear", @@ -547,7 +592,6 @@ export class OnboardingStageZeroComponent implements OnInit, OnDestroy { this.workItems.update(items => [...items, workItem.value]); this.workExperience.push(workItem); } - [ "organizationNameWork", "entryYearWork", @@ -635,7 +679,6 @@ export class OnboardingStageZeroComponent implements OnInit, OnDestroy { 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(""); diff --git a/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.ts b/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.ts index df6467af6..c6cd50763 100644 --- a/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.ts +++ b/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.ts @@ -2,6 +2,29 @@ import { Component, Input, OnInit } from "@angular/core"; +/** + * КОМПОНЕНТ КАРТОЧКИ ТИПА ПОЛЬЗОВАТЕЛЯ + * + * Назначение: Переиспользуемый UI-компонент для отображения варианта выбора роли пользователя + * + * Что делает: + * - Отображает визуальную карточку с информацией о типе пользователя + * - Поддерживает активное/неактивное состояние для визуальной обратной связи + * - Используется в stage-three для выбора между ментором и менти + * + * Что принимает: + * - @Input() isActive: boolean - флаг активного состояния карточки + * (определяет визуальное выделение выбранной опции) + * + * Что возвращает: + * - Визуальный элемент карточки с соответствующими стилями + * - Реагирует на изменение состояния isActive обновлением внешнего вида + * + * Использование: + * - Встраивается в родительский компонент stage-three + * - Родитель управляет состоянием isActive в зависимости от выбора пользователя + * - Обеспечивает консистентный UI для выбора опций + */ @Component({ selector: "app-user-type-card", templateUrl: "./user-type-card.component.html", 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 index d0c3fb9a0..757cea152 100644 --- 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 @@ -5,7 +5,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - computed, ElementRef, OnDestroy, OnInit, @@ -16,17 +15,7 @@ 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 { - BehaviorSubject, - concatMap, - map, - noop, - Observable, - of, - Subscription, - switchMap, - tap, -} from "rxjs"; +import { concatMap, map, noop, Observable, of, Subscription, switchMap } from "rxjs"; import { ProfileNewsService } from "../services/profile-news.service"; import { NewsFormComponent } from "@office/shared/news-form/news-form.component"; import { ProfileNews } from "../models/profile-news.model"; @@ -41,6 +30,28 @@ import { ModalComponent } from "@ui/components/modal/modal.component"; import { AvatarComponent } from "../../../../ui/components/avatar/avatar.component"; import { Skill } from "@office/models/skill"; +/** + * Главный компонент страницы профиля пользователя + * + * Отображает основную информацию профиля пользователя, включая: + * - Раздел "Обо мне" с описанием и навыками пользователя + * - Ленту новостей пользователя с возможностью добавления, редактирования и удаления + * - Боковую панель с информацией о проектах, образовании, работе, достижениях и контактах + * - Систему подтверждения навыков другими пользователями + * - Модальные окна для детального просмотра подтверждений навыков + * + * Функциональность: + * - Управление новостями (CRUD операции) + * - Система лайков для новостей + * - Отслеживание просмотров новостей через Intersection Observer + * - Подтверждение/отмена подтверждения навыков пользователя + * - Раскрывающиеся списки для длинных списков (проекты, достижения и т.д.) + * - Адаптивное отображение контента + * + * @implements OnInit - для инициализации и загрузки новостей + * @implements AfterViewInit - для работы с DOM элементами + * @implements OnDestroy - для очистки подписок и observers + */ @Component({ selector: "app-profile-main", templateUrl: "./main.component.html", @@ -78,6 +89,10 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { user: Observable = this.route.data.pipe(map(r => r["data"][0])); loggedUserId: Observable = this.authService.profile.pipe(map(user => user.id)); + /** + * Инициализация компонента + * Загружает новости пользователя и настраивает Intersection Observer для отслеживания просмотров + */ ngOnInit(): void { const route$ = this.route.params .pipe( @@ -102,6 +117,10 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { } @ViewChild("descEl") descEl?: ElementRef; + /** + * Инициализация после создания представления + * Проверяет необходимость отображения кнопки "Читать полностью" для описания профиля + */ ngAfterViewInit(): void { const descElement = this.descEl?.nativeElement; this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; @@ -109,6 +128,10 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.detectChanges(); } + /** + * Очистка ресурсов при уничтожении компонента + * Отписывается от всех активных подписок + */ ngOnDestroy(): void { this.subscriptions$.forEach($ => $.unsubscribe()); } @@ -130,6 +153,10 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { 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(); @@ -137,6 +164,10 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Удаление новости из профиля + * @param newsId - идентификатор удаляемой новости + */ onDeleteNews(newsId: number): void { const newsIdx = this.news().findIndex(n => n.id === newsId); this.news().splice(newsIdx, 1); @@ -144,6 +175,10 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { 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; @@ -156,6 +191,11 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Редактирование существующей новости + * @param news - обновленные данные новости + * @param newsItemId - идентификатор редактируемой новости + */ onEditNews(news: ProfileNews, newsItemId: number) { this.profileNewsService .editNews(this.route.snapshot.params["id"], newsItemId, news) @@ -166,6 +206,11 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Обработчик появления новостей в области видимости + * Отмечает новости как просмотренные при скролле + * @param entries - массив элементов, попавших в область видимости + */ onNewsInView(entries: IntersectionObserverEntry[]): void { const ids = entries.map(e => { return Number((e.target as HTMLElement).dataset["id"]); @@ -174,11 +219,23 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { 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; } + /** + * Подтверждение или отмена подтверждения навыка пользователя + * @param skillId - идентификатор навыка + * @param event - событие клика для предотвращения всплытия + * @param skill - объект навыка для обновления + */ onToggleApprove(skillId: number, event: Event, skill: Skill) { event.stopPropagation(); const userId = this.route.snapshot.params["id"]; @@ -212,10 +269,19 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { openSkills: any = {}; + /** + * Открытие модального окна с информацией о подтверждениях навыка + * @param skillId - идентификатор навыка + */ onOpenSkill(skillId: number) { this.openSkills[skillId] = !this.openSkills[skillId]; } + /** + * Обработчик изменения состояния модального окна навыка + * @param event - новое состояние модального окна + * @param skillId - идентификатор навыка + */ onOpenChange(event: boolean, skillId: number) { if (this.openSkills[skillId] && !event) { this.openSkills[skillId] = false; 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 index 30cbd8167..89a00aabe 100644 --- 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 @@ -4,6 +4,22 @@ 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); diff --git a/projects/social_platform/src/app/office/profile/detail/models/profile-news.model.ts b/projects/social_platform/src/app/office/profile/detail/models/profile-news.model.ts index 747c2fd7a..3c78c1070 100644 --- a/projects/social_platform/src/app/office/profile/detail/models/profile-news.model.ts +++ b/projects/social_platform/src/app/office/profile/detail/models/profile-news.model.ts @@ -3,6 +3,27 @@ import * as dayjs from "dayjs"; import { FileModel } from "@models/file.model"; +/** + * Модель данных для новости профиля пользователя + * + * Представляет структуру новости, которую пользователь может публиковать в своем профиле. + * Содержит всю необходимую информацию для отображения и взаимодействия с новостью. + * + * Поля модели: + * @property {number} id - уникальный идентификатор новости + * @property {string} name - заголовок/название новости + * @property {string} imageAddress - URL изображения новости + * @property {string} text - текстовое содержимое новости + * @property {string} datetimeCreated - дата и время создания новости (ISO строка) + * @property {string} datetimeUpdated - дата и время последнего обновления (ISO строка) + * @property {number} viewsCount - количество просмотров новости + * @property {number} likesCount - количество лайков новости + * @property {FileModel[]} files - массив прикрепленных файлов + * @property {boolean} isUserLiked - флаг, указывающий лайкнул ли текущий пользователь новость + * + * Методы: + * @method default() - статический метод для создания объекта с тестовыми данными + */ export class ProfileNews { id!: number; name!: string; diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.html b/projects/social_platform/src/app/office/profile/detail/profile-detail.component.html index 2e0f46c3f..7ded85773 100644 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.html +++ b/projects/social_platform/src/app/office/profile/detail/profile-detail.component.html @@ -115,7 +115,7 @@

    {{ user.firstName }} {{ user.lastName }}

    @if (user.progress !== 100) { result.matches)); + /** + * Инициализация компонента + * Настраивает заголовок навигации, проверяет статус подписки, + * определяет необходимость заполнения профиля + */ ngOnInit(): void { this.navService.setNavTitle("Профиль"); @@ -89,26 +116,10 @@ export class ProfileDetailComponent implements OnInit { }); } - downloadCV() { - // this.authService.downloadCV().subscribe({ - // next: (response: Blob) => { - // // Создаем URL для Blob - // const blob = new Blob([response], { type: "application/pdf" }); - // const link = document.createElement("a"); - // link.href = window.URL.createObjectURL(blob); - // link.download = "download.pdf"; // Имя файла для скачивания - // link.click(); - // window.URL.revokeObjectURL(link.href); - // }, - // error: err => { - // if (err.status === 400) { - // this.errorMessageModal.set(err.error.slice(23, 25)); - // this.isDelayModalOpen = true; - // } - // }, - // }); - } - + /** + * Отправка CV пользователя на email + * Проверяет ограничения по времени и отправляет CV на почту пользователя + */ sendCVEmail() { this.authService.sendCV().subscribe({ next: () => { 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 index 627cfd975..0c92cdf5c 100644 --- 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 @@ -8,6 +8,24 @@ import { SubscriptionService } from "@office/services/subscription.service"; import { forkJoin, map } from "rxjs"; import { Project } from "@office/models/project.model"; +/** + * Резолвер для загрузки данных профиля пользователя + * + * Этот резолвер выполняется перед активацией маршрута детального просмотра профиля + * и предварительно загружает необходимые данные пользователя и его подписки на проекты. + * + * Загружаемые данные: + * - Полная информация о пользователе (User) + * - Список проектов, на которые подписан пользователь (Project[]) + * + * @param route - снимок активного маршрута, содержащий параметр 'id' пользователя + * @returns Observable<[User, Project[]]> - кортеж с данными пользователя и его подписками + * + * Использует: + * - AuthService для получения информации о пользователе + * - SubscriptionService для получения подписок пользователя + * - forkJoin для параллельного выполнения запросов + */ export const ProfileDetailResolver: ResolveFn<[User, Project[]]> = ( route: ActivatedRouteSnapshot ) => { diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts b/projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts index a1a9e9a9c..9b58b587d 100644 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts +++ b/projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts @@ -8,6 +8,20 @@ import { ProfileProjectsComponent } from "./projects/projects.component"; import { ProfileMainResolver } from "./main/main.resolver"; import { ProfileNewsComponent } from "../profile-news/profile-news.component"; +/** + * Конфигурация маршрутов для детального просмотра профиля пользователя + * + * Определяет иерархическую структуру маршрутов: + * - Корневой маршрут "" - основной компонент профиля с резолвером данных + * - Дочерний маршрут "" - главная страница профиля (информация, навыки, новости) + * - Дочерний маршрут "news/:newsId" - просмотр конкретной новости профиля + * - Дочерний маршрут "projects" - список проектов пользователя + * + * Каждый маршрут использует соответствующие резолверы для предварительной загрузки данных, + * что обеспечивает плавную навигацию без задержек загрузки. + * + * @type {Routes} - массив конфигураций маршрутов Angular + */ export const PROFILE_DETAIL_ROUTES: Routes = [ { path: "", 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 index 6946f49ab..eb499837f 100644 --- 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 @@ -9,6 +9,21 @@ import { ProjectCardComponent } from "@office/shared/project-card/project-card.c import { AsyncPipe } from "@angular/common"; import { Project } from "@office/models/project.model"; +/** + * Компонент для отображения проектов пользователя + * + * Отображает два типа проектов: + * 1. Проекты, в которых пользователь является участником + * 2. Проекты, на которые пользователь подписан + * + * Функциональность: + * - Получение данных пользователя и его подписок из родительского резолвера + * - Отображение проектов в виде карточек с возможностью перехода к деталям + * - Адаптивная сетка для отображения проектов + * - Различное отображение для собственного профиля и профиля другого пользователя + * + * @implements OnInit - для инициализации компонента + */ @Component({ selector: "app-projects", templateUrl: "./projects.component.html", 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 index 32696556b..e411da09b 100644 --- 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 @@ -9,32 +9,79 @@ 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>( - `/auth/users/${userId}/news/`, + `${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(`/auth/users/${userId}/news/${newsId}`) + .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(`/auth/users/${userId}/news/`, obj) + .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) ?? []; @@ -42,32 +89,54 @@ export class ProfileNewsService { newsIds .filter(id => !readNews.includes(id)) .map(id => - this.apiService.post(`/auth/users/${userId}/news/${id}/set_viewed/`, {}).pipe( - tap(() => { - this.storageService.setItem("readNews", [...readNews, id], sessionStorage); - }) - ) + 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(`/auth/users/${userId}/news/${newsId}/`); + 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(`/auth/users/${userId}/news/${newsId}/set_liked/`, { + 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(`/auth/users/${userId}/news/${newsId}/`, newsItem) + .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 index 25067be3b..01611cc13 100644 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.html +++ b/projects/social_platform/src/app/office/profile/edit/edit.component.html @@ -302,10 +302,10 @@ @if (profileForm.get("organizationName"); as organizationName) {
    - + @if (organizationName | controlError: "required") { @@ -364,11 +364,10 @@
    } @if (profileForm.get("isMospolytechStudent"); as isMospolytechStudent) {
    - - + > @@ -673,45 +672,50 @@
    -
    -
    - @if (profileForm.get("language"); as language) { -
    - - - - +
    +
    +
    + @if (profileForm.get("language"); as language) { +
    + + + + - @if (language | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (profileForm.get("languageLevel"); as languageLevel) { -
    - - - - + @if (language | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (profileForm.get("languageLevel"); as languageLevel) { +
    + + + + - @if (languageLevel | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    + @if (languageLevel | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    } -
    - } +
    + Количество добавляемых языков не более 4-х
    { this.profileId = profile.id; @@ -274,6 +304,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { profile$ && this.subscription$.push(profile$); } + /** + * Очистка ресурсов при уничтожении компонента + * Отписывается от всех активных подписок + */ ngOnDestroy(): void { this.subscription$.forEach($ => $.unsubscribe()); } @@ -324,6 +358,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { readonly navItems = navItems; + /** + * Навигация между шагами редактирования профиля + * @param step - название шага ('main' | 'education' | 'experience' | 'achievements' | 'skills') + */ navigateStep(step: string) { this.router.navigate([], { queryParams: { editingStep: step } }); this.editingStep = step as "main" | "education" | "experience" | "achievements" | "skills"; @@ -441,6 +479,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.achievements.removeAt(i); } + /** + * Добавление записи об образовании + * Валидирует форму и добавляет новую запись в массив образования + */ addEducation() { ["organizationName", "educationStatus"].forEach(name => this.profileForm.get(name)?.clearValidators() @@ -496,7 +538,6 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.educationItems.update(items => [...items, educationItem.value]); this.education.push(educationItem); } - [ "organizationName", "entryYear", @@ -515,6 +556,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.editEducationClick = false; } + /** + * Редактирование записи об образовании + * @param index - индекс записи в массиве образования + */ editEducation(index: number) { this.editEducationClick = true; const educationItem = this.education.value[index]; @@ -557,12 +602,20 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.editIndex.set(index); } + /** + * Удаление записи об образовании + * @param i - индекс записи для удаления + */ removeEducation(i: number) { this.educationItems.update(items => items.filter((_, index) => index !== i)); this.education.removeAt(i); } + /** + * Добавление записи об опыте работы + * Валидирует форму и добавляет новую запись в массив опыта работы + */ addWork() { ["organization", "jobPosition"].forEach(name => this.profileForm.get(name)?.clearValidators()); ["organization", "jobPosition"].forEach(name => @@ -613,7 +666,6 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.workItems.update(items => [...items, workItem.value]); this.workExperience.push(workItem); } - [ "organization", "entryYearWork", @@ -674,38 +726,60 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { } addLanguage() { - ["language", "languageLevel"].forEach(name => this.profileForm.get(name)?.clearValidators()); - ["language", "languageLevel"].forEach(name => - this.profileForm.get(name)?.setValidators([Validators.required]) - ); - ["language", "languageLevel"].forEach(name => - this.profileForm.get(name)?.updateValueAndValidity() - ); - ["language", "languageLevel"].forEach(name => this.profileForm.get(name)?.markAsTouched()); + const languageValue = this.profileForm.get("language")?.value; + const languageLevelValue = this.profileForm.get("languageLevel")?.value; - const languageItem = this.fb.group({ - language: this.profileForm.get("language")?.value, - languageLevel: this.profileForm.get("languageLevel")?.value, + ["language", "languageLevel"].forEach(name => { + this.profileForm.get(name)?.clearValidators(); }); - 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; + if ((languageValue && !languageLevelValue) || (!languageValue && languageLevelValue)) { + ["language", "languageLevel"].forEach(name => { + this.profileForm.get(name)?.setValidators([Validators.required]); }); - this.editIndex.set(null); - } else { - this.languageItems.update(items => [...items, languageItem.value]); - this.userLanguages.push(languageItem); } - this.profileForm.get("language")?.reset(); - this.profileForm.get("languageLevel")?.reset(); + ["language", "languageLevel"].forEach(name => { + this.profileForm.get(name)?.updateValueAndValidity(); + this.profileForm.get(name)?.markAsTouched(); + }); - this.editLanguageClick = true; + 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.editLanguageClick = false; + } } editLanguage(index: number) { @@ -764,6 +838,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { 4: "investor", }; + /** + * Сохранение профиля пользователя + * Валидирует всю форму и отправляет данные на сервер + */ saveProfile(): void { if (!this.validationService.getFormValidation(this.profileForm) || this.profileFormSubmitting) { this.isModalErrorSkillsChoose.set(true); @@ -801,6 +879,8 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { 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 { this.isModalErrorSkillChooseText.set(error.error[0]); } @@ -808,6 +888,11 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { }); } + /** + * Изменение типа пользователя + * @param typeId - новый тип пользователя + * @returns Observable - результат операции изменения типа + */ changeUserType(typeId: number): Observable { return this.authService .saveProfile({ @@ -819,20 +904,28 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { .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); }); } - toggleSpecsGroupsModal(): void { - this.specsGroupsModalOpen.update(open => !open); - } - + /** + * Переключение навыка (добавление/удаление) + * @param toggledSkill - навык для переключения + */ onToggleSkill(toggledSkill: Skill): void { const { skills }: { skills: Skill[] } = this.profileForm.value; @@ -845,6 +938,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { } } + /** + * Добавление нового навыка + * @param newSkill - новый навык для добавления + */ onAddSkill(newSkill: Skill): void { const { skills }: { skills: Skill[] } = this.profileForm.value; @@ -855,6 +952,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.profileForm.patchValue({ skills: [newSkill, ...skills] }); } + /** + * Удаление навыка + * @param oddSkill - навык для удаления + */ onRemoveSkill(oddSkill: Skill): void { const { skills }: { skills: Skill[] } = this.profileForm.value; @@ -871,6 +972,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.skillsGroupsModalOpen.update(open => !open); } + toggleSpecsGroupsModal(): void { + this.specsGroupsModalOpen.update(open => !open); + } + onBack() { this.location.back(); } diff --git a/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.ts b/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.ts index 317c39b2a..a5e4550fc 100644 --- a/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.ts +++ b/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.ts @@ -9,6 +9,34 @@ import { ActivatedRoute, Router } from "@angular/router"; import { FeedNews } from "@office/projects/models/project-news.model"; import { NewsCardComponent } from "@office/shared/news-card/news-card.component"; +/** + * Компонент для отображения отдельной новости профиля в модальном окне + * + * Этот компонент предназначен для детального просмотра конкретной новости пользователя + * в модальном окне поверх основной страницы профиля. Обеспечивает удобную навигацию + * между новостями без полной перезагрузки страницы. + * + * Основные возможности: + * - Отображение новости в полноэкранном модальном окне + * - Получение данных новости через резолвер маршрута + * - Автоматическое закрытие модального окна при навигации + * - Возврат к основной странице профиля при закрытии + * - Адаптивное отображение для мобильных и десктопных устройств + * + * Жизненный цикл: + * - При инициализации загружает данные новости из резолвера + * - Отображает новость в модальном окне + * - При закрытии возвращает пользователя к профилю + * - При уничтожении очищает все подписки + * + * Навигация: + * - Получает userId из родительского маршрута профиля + * - Использует newsId из параметров текущего маршрута + * - При закрытии перенаправляет на /office/profile/{userId} + * + * @implements OnInit - для инициализации и загрузки данных новости + * @implements OnDestroy - для очистки подписок и предотвращения утечек памяти + */ @Component({ selector: "app-profile-news", standalone: true, @@ -17,21 +45,29 @@ import { NewsCardComponent } from "@office/shared/news-card/news-card.component" styleUrl: "./profile-news.component.scss", }) export class ProfileNewsComponent implements OnInit, OnDestroy { - private readonly profileService = inject(ProfileNewsService); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); + private readonly profileService: ProfileNewsService = inject(ProfileNewsService); + private readonly route: ActivatedRoute = inject(ActivatedRoute); + private readonly router: Router = inject(Router); + /** ID пользователя, извлеченный из родительского маршрута профиля */ userId = this.route.parent?.parent?.snapshot.params["id"]; + /** Сигнал с данными отображаемой новости */ newsItem = signal(null); + + /** Массив активных подписок для очистки при уничтожении компонента */ subscriptions$: Subscription[] = []; + /** + * Инициализация компонента + * Загружает данные новости из резолвера маршрута и устанавливает их в сигнал + */ ngOnInit(): void { const profileNewsSub$ = this.route.data.pipe(map(r => r["data"])).subscribe({ next: (r: FeedNews) => { this.newsItem.set(r); }, - error(err) { + error: err => { console.log(err); }, }); @@ -39,10 +75,21 @@ export class ProfileNewsComponent implements OnInit, OnDestroy { this.subscriptions$.push(profileNewsSub$); } + /** + * Очистка ресурсов при уничтожении компонента + * Отписывается от всех активных подписок для предотвращения утечек памяти + */ ngOnDestroy(): void { this.subscriptions$.forEach($ => $.unsubscribe()); } + /** + * Обработчик изменения состояния модального окна + * При закрытии модального окна (value = false) перенаправляет пользователя + * обратно к основной странице профиля + * + * @param value - новое состояние модального окна (true - открыто, false - закрыто) + */ onOpenChange(value: boolean): void { if (!value) { this.router 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 index 8c17d5c0c..3f8cf7d09 100644 --- a/projects/social_platform/src/app/office/program/detail/detail.routes.ts +++ b/projects/social_platform/src/app/office/program/detail/detail.routes.ts @@ -11,6 +11,19 @@ import { ProgramProjectsResolver } from "@office/program/detail/projects/project import { ProgramMembersComponent } from "@office/program/detail/members/members.component"; import { ProgramMembersResolver } from "@office/program/detail/members/members.resolver"; +/** + * Маршруты для детальной страницы программы + * + * Определяет структуру навигации внутри детальной страницы программы: + * - Основная информация (по умолчанию) + * - Список проектов программы + * - Список участников программы + * - Страница регистрации в программе + * + * Все маршруты используют резолверы для предзагрузки данных. + * + * @returns {Routes} Конфигурация маршрутов для детальной страницы программы + */ export const PROGRAM_DETAIL_ROUTES: Routes = [ { path: "", diff --git a/projects/social_platform/src/app/office/program/detail/detail/detail.component.ts b/projects/social_platform/src/app/office/program/detail/detail/detail.component.ts index f4a4bbf61..2656cbbfe 100644 --- a/projects/social_platform/src/app/office/program/detail/detail/detail.component.ts +++ b/projects/social_platform/src/app/office/program/detail/detail/detail.component.ts @@ -2,16 +2,46 @@ import { Component, OnInit } from "@angular/core"; import { NavService } from "@services/nav.service"; -import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from "@angular/router"; -import { BackComponent } from "@uilib"; +import { ActivatedRoute, RouterOutlet } from "@angular/router"; import { BarComponent } from "@ui/components"; +/** + * Основной компонент детальной страницы программы + * + * Служит контейнером для всех дочерних страниц программы: + * - Основная информация + * - Список проектов + * - Список участников + * + * Предоставляет: + * - Навигационные вкладки между разделами + * - Кнопку "Назад" для возврата к списку программ + * - Общий layout для всех дочерних компонентов + * + * Принимает: + * @param {NavService} navService - Для установки заголовка навигации + * @param {ActivatedRoute} route - Для получения параметров маршрута + * + * Состояние: + * @property {number} programId - ID текущей программы из URL + * + * Жизненный цикл: + * - OnInit: Устанавливает заголовок "Профиль программы" и сохраняет programId + * + * Навигация: + * - RouterLinkActive для подсветки активной вкладки + * - RouterLink для навигации между разделами + * - RouterOutlet для отображения дочерних компонентов + * + * Возвращает: + * HTML шаблон с навигацией и областью для дочерних компонентов + */ @Component({ selector: "app-detail", templateUrl: "./detail.component.html", styleUrl: "./detail.component.scss", standalone: true, - imports: [RouterLinkActive, RouterLink, RouterOutlet, BarComponent, BackComponent], + imports: [RouterOutlet, BarComponent], }) export class ProgramDetailComponent implements OnInit { constructor(private readonly navService: NavService, private readonly route: ActivatedRoute) {} diff --git a/projects/social_platform/src/app/office/program/detail/detail/detail.resolver.ts b/projects/social_platform/src/app/office/program/detail/detail/detail.resolver.ts index 57a79918a..e9bde86de 100644 --- a/projects/social_platform/src/app/office/program/detail/detail/detail.resolver.ts +++ b/projects/social_platform/src/app/office/program/detail/detail/detail.resolver.ts @@ -5,6 +5,36 @@ import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; import { ProgramService } from "@office/program/services/program.service"; import { Program } from "@office/program/models/program.model"; +/** + * Резолвер для получения детальной информации о программе + * + * Предзагружает полную информацию о программе перед отображением + * детальной страницы. Это обеспечивает мгновенное отображение + * данных программы во всех дочерних компонентах. + * + * Принимает: + * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами + * + * Использует: + * @param {ProgramService} programService - Инжектируемый сервис программ + * + * Логика: + * - Извлекает programId из параметров маршрута + * - Загружает детальную информацию через programService.getOne() + * + * Возвращает: + * @returns {Observable} Полная информация о программе + * + * Загружаемые данные включают: + * - Основную информацию (название, описание, даты) + * - Изображения и медиа файлы + * - Права текущего пользователя (участник, менеджер) + * - Статистику (просмотры, лайки) + * - Дополнительные материалы и ссылки + * + * Используется в: + * Родительском маршруте детальной страницы программы + */ export const ProgramDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { const programService = inject(ProgramService); 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 index 4ea490830..33f9bc140 100644 --- 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 @@ -85,45 +85,75 @@

    О программе

    }
    - @for (n of news(); track n.id) { + @if (program.isUserManager) { + + } @for (n of news(); track n.id) { }
    } - + + + } diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.scss b/projects/social_platform/src/app/office/program/detail/main/main.component.scss index fd1dd6416..5ae07bd19 100644 --- a/projects/social_platform/src/app/office/program/detail/main/main.component.scss +++ b/projects/social_platform/src/app/office/program/detail/main/main.component.scss @@ -30,6 +30,12 @@ } } + &__left { + display: flex; + flex-direction: column; + gap: 20px; + } + &__section { padding: 24px; background-color: var(--white); @@ -366,6 +372,11 @@ } .news { + &__form { + display: block; + margin-top: 20px; + } + &__item { display: block; margin-top: 20px; 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 index fd6edc851..26326672b 100644 --- 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 @@ -23,7 +23,52 @@ import { ButtonComponent, IconComponent } from "@ui/components"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { ApiPagination } from "@models/api-pagination.model"; import { TagComponent } from "@ui/components/tag/tag.component"; +import { NewsFormComponent } from "@office/shared/news-form/news-form.component"; +/** + * Главный компонент детальной страницы программы + * + * Отображает основную информацию о программе и новостную ленту: + * - Детальное описание программы с возможностью развернуть/свернуть + * - Информацию о датах и регистрации + * - Новостную ленту для участников программы + * - Форму добавления новостей + * - Взаимодействие с новостями (лайки, просмотры) + * + * Принимает: + * @param {ProgramService} programService - Сервис программ + * @param {ProgramNewsService} programNewsService - Сервис новостей программы + * @param {ActivatedRoute} route - Для получения данных программы + * @param {ChangeDetectorRef} cdRef - Для ручного обновления представления + * + * Состояние (signals): + * @property {Signal} news - Массив новостей программы + * @property {Signal} totalNewsCount - Общее количество новостей + * @property {Signal} fetchLimit - Лимит загрузки новостей (10) + * @property {Signal} fetchPage - Текущая страница новостей + * @property {Signal} subscriptions$ - Подписки для очистки + * + * Данные программы: + * @property {Program} program - Объект программы + * @property {boolean} registerDateExpired - Истек ли срок регистрации + * @property {boolean} descriptionExpandable - Можно ли развернуть описание + * @property {boolean} readFullDescription - Развернуто ли описание + * + * ViewChild: + * @ViewChild NewsFormComponent - Ссылка на компонент формы новостей + * @ViewChild descEl - Ссылка на элемент описания + * + * Методы: + * @method fetchNews(offset, limit) - Загружает новости с пагинацией + * @method onScroll() - Обработчик прокрутки для подгрузки новостей + * @method onNewsInVew(entries) - Отмечает новости как просмотренные + * @method onAddNews(news) - Добавляет новую новость + * @method onLike(newsId) - Переключает лайк новости + * @method onExpandDescription() - Разворачивает/сворачивает описание + * + * Возвращает: + * HTML шаблон с информацией о программе и новостной лентой + */ @Component({ selector: "app-main", templateUrl: "./main.component.html", @@ -39,6 +84,7 @@ import { TagComponent } from "@ui/components/tag/tag.component"; UserLinksPipe, ParseBreaksPipe, ParseLinksPipe, + NewsFormComponent, ], }) export class ProgramDetailMainComponent implements OnInit, OnDestroy { @@ -155,6 +201,7 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { ); } + @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; @ViewChild("descEl") descEl?: ElementRef; onNewsInVew(entries: IntersectionObserverEntry[]): void { @@ -167,6 +214,27 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { 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; diff --git a/projects/social_platform/src/app/office/program/detail/members/members.component.ts b/projects/social_platform/src/app/office/program/detail/members/members.component.ts index d81d4941f..afef6967a 100644 --- a/projects/social_platform/src/app/office/program/detail/members/members.component.ts +++ b/projects/social_platform/src/app/office/program/detail/members/members.component.ts @@ -18,6 +18,43 @@ import { ProgramHeadComponent } from "../../shared/program-head/program-head.com import { AsyncPipe } from "@angular/common"; import { ApiPagination } from "@models/api-pagination.model"; +/** + * Компонент списка участников программы + * + * Отображает всех участников программы с поддержкой: + * - Бесконечной прокрутки для подгрузки участников + * - Адаптивного дизайна + * - Интеграции с заголовком программы + * + * Принимает: + * @param {ActivatedRoute} route - Для получения данных из резолвера + * @param {ChangeDetectorRef} cdref - Для ручного обновления представления + * @param {ProgramService} programService - Сервис для загрузки участников + * + * Данные: + * @property {User[]} members - Массив участников программы + * @property {number} membersTotalCount - Общее количество участников + * @property {Observable} program$ - Поток данных программы + * @property {Observable} members$ - Поток участников из резолвера + * + * Пагинация: + * @property {number} membersPage - Текущая страница + * @property {number} membersTake - Количество участников на странице (20) + * + * ViewChild: + * @ViewChild membersRoot - Ссылка на DOM элемент списка участников + * + * Жизненный цикл: + * - OnInit: Загружает начальные данные из резолвера + * - AfterViewInit: Настраивает обработчик прокрутки + * + * Методы: + * @method onScroll() - Проверяет необходимость подгрузки данных + * @method onFetch() - Загружает следующую порцию участников + * + * Возвращает: + * HTML шаблон со списком карточек участников + */ @Component({ selector: "app-members", templateUrl: "./members.component.html", diff --git a/projects/social_platform/src/app/office/program/detail/members/members.resolver.ts b/projects/social_platform/src/app/office/program/detail/members/members.resolver.ts index 682a0551a..4314a4d88 100644 --- a/projects/social_platform/src/app/office/program/detail/members/members.resolver.ts +++ b/projects/social_platform/src/app/office/program/detail/members/members.resolver.ts @@ -6,6 +6,38 @@ 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 ) => { diff --git a/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.html b/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.html new file mode 100644 index 000000000..ee61df964 --- /dev/null +++ b/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.html @@ -0,0 +1,56 @@ + + +
    +
    +
    +

    Фильтр

    + Сбросить фильтры +
    + + @if (filters()?.length && filterForm.controls) { @for (field of filters(); track field.id) { @if + (filterForm.get(field.name)) { +
    + @switch (field.fieldType) { @case ("checkbox") { +

    {{ field.label }}

    +
    + + {{ field.label }} +
    + } @case ("radio") { +

    {{ field.label }}

    +
    + + Нет + + + + Да + +
    + } @case ("select") { +

    {{ field.label }}

    +
    + +
    + } } +
    + } } } +
    +
    diff --git a/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.scss b/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.scss new file mode 100644 index 000000000..dc0130cc4 --- /dev/null +++ b/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.scss @@ -0,0 +1,119 @@ +/** @format */ + +@use "styles/typography"; +@use "styles/responsive"; + +.filter { + &__toggle { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + margin-left: 15px; + color: var(--black); + cursor: pointer; + + &--active { + color: var(--white); + background-color: var(--black); + } + } + + &__arrow { + position: absolute; + bottom: 5px; + left: 50%; + color: var(--white); + transform: rotate(180deg) translateX(50%); + } + + &__body { + display: flex; + flex-direction: column; + min-width: 330px; + min-height: 50vh; + padding: 22px; + background-color: var(--white); + border-radius: var(--rounded-md); + } + + &__titles { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + } + + &__clear { + cursor: pointer; + } + + &__title { + color: var(--black); + + @include typography.heading-3; + + @include responsive.apply-desktop { + @include typography.bold-body-16; + } + } + + &__block { + margin-bottom: 14px; + } + + &__name { + margin-bottom: 6px; + color: var(--gray); + } + + &__switch { + display: flex; + margin-top: 6px; + + &--one { + display: flex; + gap: 12px; + align-items: center; + } + + app-switch { + margin: 0 20px; + } + } + + &__yes-no { + &--active { + color: var(--accent); + } + } + + &__checkbox { + display: flex; + align-items: center; + margin-bottom: 6px; + color: var(--black); + cursor: pointer; + + app-checkbox { + margin-right: 15px; + } + } + + &__select { + margin-bottom: 6px; + color: var(--black); + cursor: pointer; + + ::ng-deep { + app-select { + .field__input, + li { + @include typography.body-12; + } + } + } + } +} diff --git a/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.spec.ts b/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.spec.ts new file mode 100644 index 000000000..70eca8f89 --- /dev/null +++ b/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.spec.ts @@ -0,0 +1,28 @@ +/** @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/program/detail/projects/projects-filter/projects-filter.component.ts b/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.ts new file mode 100644 index 000000000..08eb91477 --- /dev/null +++ b/projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.ts @@ -0,0 +1,215 @@ +/** @format */ + +import { Component, EventEmitter, OnInit, Output, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { debounceTime, distinctUntilChanged, 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({}); + } + + @Output() filtersLoaded = new EventEmitter(); + + // Константы для фильтрации по типу проекта + private programId = 0; + + ngOnInit(): void { + this.programId = this.route.parent?.snapshot.params["programId"]; + + 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); + } + } + } + + /** + * Сброс всех активных фильтров + * Очищает все query параметры и возвращает к состоянию по умолчанию + */ + clearFilters(): void { + this.filterForm.reset(); + + this.router + .navigate([], { + queryParams: { + search: undefined, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => console.log("Query change from ProjectsComponent")); + } + + 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); + }); + + 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/projects/projects.component.html b/projects/social_platform/src/app/office/program/detail/projects/projects.component.html index dfde69a3c..301e0e83c 100644 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.component.html +++ b/projects/social_platform/src/app/office/program/detail/projects/projects.component.html @@ -3,12 +3,43 @@
    @if (program$ | async; as program) { + + + } -
    - @for (p of projects; track p.id) { - - - - } +
    +
    + Фильтр + +
    +
      + @for (p of searchedProjects; track p.id) { +
    • + + + +
    • + } +
    + +
    +
    +
    +
    + +
    +
    diff --git a/projects/social_platform/src/app/office/program/detail/projects/projects.component.scss b/projects/social_platform/src/app/office/program/detail/projects/projects.component.scss index 0925c83d2..fef6abb23 100644 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.component.scss +++ b/projects/social_platform/src/app/office/program/detail/projects/projects.component.scss @@ -2,14 +2,135 @@ @use "styles/typography"; .page { + &__list--wrapper { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 20px; + + @include responsive.apply-desktop { + flex-direction: row; + justify-content: space-between; + } + } + + &__filter { + display: none; + + &--open { + display: block; + } + + @include responsive.apply-desktop { + display: block; + margin-left: 16px; + } + } + &__list { display: grid; grid-template-columns: 1fr; - margin-top: 20px; + grid-gap: 20px; + width: 100%; @include responsive.apply-desktop { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: 20px 40px; + width: 70%; } } + + &__search { + width: 100%; + padding: 20px; + background-color: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 15px; + } +} + +.filter { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + + @include responsive.apply-desktop { + position: static; + min-width: 280px; + } + + &__overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: black; + opacity: 0.3; + + @include responsive.apply-desktop { + display: none; + } + } + + &__bar { + position: fixed; + display: flex; + width: 100%; + height: 25px; + touch-action: none; + + @include responsive.apply-desktop { + display: none; + } + + &::after { + display: block; + width: 85px; + height: 5px; + margin: auto; + content: ""; + background-color: var(--gray); + border-radius: var(--rounded-lg); + transition: transform 0.2s; + } + } + + &__body { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + max-height: 72vh; + overflow-y: auto; + background-color: var(--white); + border-radius: var(--rounded-lg); + transform: translateY(0%); + + @include responsive.apply-desktop { + position: static; + max-height: unset; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + } +} + +.filter-toggle { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 10px; + cursor: pointer; + background-color: var(--white); + border: 1px solid var(--medium-medium-grey-for-outline); + border-radius: 15px; + + @include responsive.apply-desktop { + display: none; + } } diff --git a/projects/social_platform/src/app/office/program/detail/projects/projects.component.ts b/projects/social_platform/src/app/office/program/detail/projects/projects.component.ts index 8fd72845c..98a04bedd 100644 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.component.ts +++ b/projects/social_platform/src/app/office/program/detail/projects/projects.component.ts @@ -1,9 +1,20 @@ /** @format */ -import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; -import { ActivatedRoute, RouterLink } from "@angular/router"; import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + OnInit, + Renderer2, + ViewChild, +} from "@angular/core"; +import { ActivatedRoute, Params, Router, RouterLink } from "@angular/router"; +import { + catchError, concatMap, + distinctUntilChanged, fromEvent, map, noop, @@ -17,48 +28,103 @@ import { Project } from "@models/project.model"; import { Program } from "@office/program/models/program.model"; import { ProjectCardComponent } from "@office/shared/project-card/project-card.component"; import { ProgramHeadComponent } from "../../shared/program-head/program-head.component"; -import { AsyncPipe, JsonPipe } from "@angular/common"; +import { AsyncPipe, CommonModule } from "@angular/common"; import { ProgramService } from "@office/program/services/program.service"; -import { ProjectRatingComponent } from "@office/shared/project-rating/project-rating.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ButtonComponent } from "@ui/components"; import { AuthService } from "@auth/services"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { SearchComponent } from "@ui/components/search/search.component"; +import Fuse from "fuse.js"; +import { ProjectsFilterComponent } from "@office/program/detail/projects/projects-filter/projects-filter.component"; +import { HttpParams } from "@angular/common/http"; +import { IconComponent } from "@uilib"; +import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; +/** + * Компонент списка проектов программы + * + * Отображает все проекты, связанные с программой, с поддержкой: + * - Бесконечной прокрутки (infinite scroll) + * - Пагинации данных + * - Модального окна для оценки проектов (для экспертов) + * + * Принимает: + * @param {ActivatedRoute} route - Для получения данных из резолвера + * @param {ProgramService} programService - Сервис для загрузки проектов + * @param {AuthService} authService - Для проверки прав пользователя + * + * Данные: + * @property {Project[]} projects - Массив проектов программы + * @property {number} projectsTotalCount - Общее количество проектов + * @property {Observable} program$ - Поток данных программы + * @property {number} page - Текущая страница пагинации + * @property {number} perPage - Количество проектов на странице + * @property {boolean} isFilterOpen - состояние панели фильтров (мобильные) + * ViewChild: + * @ViewChild projectsRoot - Ссылка на DOM элемент списка проектов + * + * Функциональность: + * - Загрузка начальных данных из резолвера + * - Автоматическая подгрузка при прокрутке + * - Отображение карточек проектов + * - Интеграция с компонентом оценки проектов + * + * Методы: + * @method onScroll() - Обработчик прокрутки для подгрузки данных + * @method onFetch() - Загрузка следующей порции проектов + * + * Возвращает: + * HTML шаблон со списком проектов и элементами управления + */ @Component({ selector: "app-projects", templateUrl: "./projects.component.html", styleUrl: "./projects.component.scss", standalone: true, imports: [ + CommonModule, + ReactiveFormsModule, ProgramHeadComponent, RouterLink, ProjectCardComponent, + IconComponent, AsyncPipe, - JsonPipe, - ProjectRatingComponent, - ModalComponent, - ButtonComponent, + SearchComponent, + ProjectsFilterComponent, ], }) export class ProgramProjectsComponent implements OnInit, AfterViewInit, OnDestroy { constructor( private readonly route: ActivatedRoute, + private readonly router: Router, + private readonly fb: FormBuilder, + private readonly cdref: ChangeDetectorRef, private readonly programService: ProgramService, - public readonly authService: AuthService - ) {} + public readonly authService: AuthService, + private readonly renderer: Renderer2 + ) { + this.searchForm = this.fb.group({ + search: [""], + }); + } @ViewChild("projectsRoot") projectsRoot?: ElementRef; + @ViewChild("filterBody") filterBody!: ElementRef; projectsTotalCount?: number; page = 1; perPage = 21; projects: Project[] = []; + searchedProjects: Project[] = []; program$?: Observable = this.route.parent?.data.pipe(map(r => r["data"])); + searchForm: FormGroup; subscriptions$: Subscription[] = []; + private previousReqQuery: Record = {}; + private availableFilters: PartnerProgramFields[] = []; + ngOnInit(): void { const routeData$ = this.route.data .pipe( @@ -68,8 +134,78 @@ export class ProgramProjectsComponent implements OnInit, AfterViewInit, OnDestro ) .subscribe(projects => { this.projects = projects; + this.searchedProjects = projects; }); + 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 querySearch$ = this.route.queryParams.pipe(map(q => q["search"])).subscribe(search => { + const fuse = new Fuse(this.projects, { + keys: ["name"], + }); + + this.searchedProjects = search ? fuse.search(search).map(el => el.item) : this.projects; + }); + + querySearch$ && this.subscriptions$.push(querySearch$); + + const observable = this.route.queryParams.pipe( + distinctUntilChanged(), + concatMap(q => { + const reqQuery = this.buildFilterQuery(q); + const programId = this.route.parent?.snapshot.params["programId"]; + + if (JSON.stringify(reqQuery) !== JSON.stringify(this.previousReqQuery)) { + this.previousReqQuery = reqQuery; + + const hasFilters = + reqQuery && reqQuery["filters"] && Object.keys(reqQuery["filters"]).length > 0; + + const params = new HttpParams({ fromObject: { partner_program: programId } }); + + if (hasFilters) { + return this.programService.createProgramFilters(programId, reqQuery["filters"]).pipe( + catchError(err => { + console.error("createFilters failed, fallback to getAllProjects()", err); + return this.programService.getAllProjects(params); + }) + ); + } + + return this.programService.getAllProjects(params).pipe( + catchError(err => { + console.error("getAllProjects failed", err); + return this.programService.getAllProjects(params); + }) + ); + } + + this.previousReqQuery = reqQuery; + return of(0); + }) + ); + + const projects$ = observable.subscribe(projects => { + if (typeof projects === "number") return; + + this.projects = projects.results; + this.searchedProjects = projects.results; + + this.cdref.detectChanges(); + }); + + projects$ && this.subscriptions$.push(projects$); + this.subscriptions$.push(routeData$); } @@ -90,6 +226,54 @@ export class ProgramProjectsComponent implements OnInit, AfterViewInit, OnDestro this.subscriptions$.forEach($ => $.unsubscribe()); } + isFilterOpen = false; + + 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; + } + + onFiltersLoaded(filters: PartnerProgramFields[]): void { + this.availableFilters = filters; + } + private onScroll() { if (this.projectsTotalCount && this.projects.length >= this.projectsTotalCount) return of({}); @@ -109,19 +293,43 @@ export class ProgramProjectsComponent implements OnInit, AfterViewInit, OnDestro } private onFetch() { + const programId = this.route.parent?.snapshot.params["programId"]; + const offset = this.page * this.perPage; + const limit = this.perPage; + return this.programService - .getAllProjects( - this.route.parent?.snapshot.params["programId"], - this.page * this.perPage, - this.perPage - ) + .getAllProjects(new HttpParams({ fromObject: { partner_program: programId, offset, limit } })) .pipe( tap(projects => { this.projectsTotalCount = projects.count; this.projects = [...this.projects, ...projects.results]; + this.searchedProjects = this.projects; this.page++; + + this.cdref.detectChanges(); }) ); } + + private buildFilterQuery(q: Params): Record { + const filters: Record = {}; + + if (this.availableFilters.length === 0) { + Object.keys(q).forEach(key => { + if (key !== "search" && q[key] !== undefined && q[key] !== "") { + filters[key] = Array.isArray(q[key]) ? q[key] : [q[key]]; + } + }); + } else { + this.availableFilters.forEach((filter: PartnerProgramFields) => { + const value = q[filter.name]; + if (value !== undefined && value !== "") { + filters[filter.name] = Array.isArray(value) ? value : [value]; + } + }); + } + + return { filters }; + } } diff --git a/projects/social_platform/src/app/office/program/detail/projects/projects.resolver.ts b/projects/social_platform/src/app/office/program/detail/projects/projects.resolver.ts index c8c9e107d..8724b79bb 100644 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.resolver.ts +++ b/projects/social_platform/src/app/office/program/detail/projects/projects.resolver.ts @@ -5,11 +5,37 @@ import { ActivatedRouteSnapshot, ResolveFn } 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"; +/** + * Резолвер для предзагрузки проектов программы + * + * Загружает первую страницу проектов программы перед отображением компонента. + * Это обеспечивает мгновенное отображение данных без состояния загрузки. + * + * Принимает: + * @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); - - return programService.getAllProjects(route.parent?.params["programId"], 0, 21); + return programService.getAllProjects( + new HttpParams({ + fromObject: { partner_program: route.parent?.params["programId"], offset: 0, limit: 21 }, + }) + ); }; diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-all.resolver.ts b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-all.resolver.ts index 580790781..0bfcb57bd 100644 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-all.resolver.ts +++ b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-all.resolver.ts @@ -6,6 +6,41 @@ import { ApiPagination } from "@office/models/api-pagination.model"; import { ProjectRate } from "@office/program/models/project-rate"; import { ProjectRatingService } from "@office/program/services/project-rating.service"; +/** + * Резолвер для предзагрузки проектов для оценки + * + * Загружает первую страницу проектов программы, которые доступны + * для оценки экспертами. Предзагрузка обеспечивает мгновенное + * отображение данных в компоненте оценки. + * + * Принимает: + * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами + * + * Использует: + * @param {ProjectRatingService} projectRatingService - Инжектируемый сервис оценки + * + * Логика: + * - Извлекает programId из родительского маршрута + * - Загружает первые 8 проектов для оценки (skip: 0, take: 8) + * - Не применяет дополнительные фильтры + * + * Возвращает: + * @returns {Observable>} Пагинированный список проектов для оценки + * + * Данные включают: + * - Массив проектов с критериями оценки (results) + * - Общее количество проектов (count) + * - Информацию о пагинации + * + * Каждый проект содержит: + * - Основную информацию проекта + * - Массив критериев для оценки + * - Статус оценки текущим экспертом + * - Презентационные материалы + * + * Используется в: + * Маршруте "all" для списка всех проектов программы + */ export const ListAllResolver: ResolveFn> = ( route: ActivatedRouteSnapshot ) => { diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.ts b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.ts index 96ae132a9..2976a1139 100644 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.ts +++ b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.ts @@ -17,6 +17,52 @@ import { RatingCardComponent } from "@office/program/shared/rating-card/rating-c import { ProjectRate } from "@office/program/models/project-rate"; import { ProjectRatingService } from "@office/program/services/project-rating.service"; +/** + * Компонент списка проектов для оценки + * + * Отображает проекты программы в формате карточек для экспертной оценки. + * Поддерживает фильтрацию, поиск и бесконечную прокрутку. + * + * Принимает: + * @param {ActivatedRoute} route - Для получения данных и query параметров + * @param {Router} router - Для определения текущего URL + * @param {ProjectRatingService} projectRatingService - Сервис оценки проектов + * + * Состояние (signals): + * @property {Signal} projects - Массив проектов для оценки + * @property {Signal} currentIndex - Индекс текущего проекта + * @property {Signal} totalProjCount - Общее количество проектов + * @property {Signal} fetchLimit - Лимит загрузки (8 проектов) + * @property {Signal} fetchPage - Текущая страница + * @property {Signal} isRatedByExpert - Фильтр по статусу оценки + * @property {Signal} searchValue - Поисковый запрос + * + * Фильтрация и поиск: + * - Реагирует на изменения query параметров + * - Поддерживает фильтр по статусу оценки экспертом + * - Поиск по названию проекта + * - Автоматическое обновление списка при изменении фильтров + * + * Пагинация: + * - Бесконечная прокрутка для подгрузки проектов + * - Throttling для предотвращения избыточных запросов + * - Отслеживание позиции прокрутки + * + * Навигация между проектами: + * @method toggleProject(type: "next" | "prev") - Переключение между проектами + * + * Методы загрузки: + * @method onScroll() - Обработчик прокрутки для подгрузки + * @method onFetch(offset, limit) - Загрузка проектов с фильтрами + * + * Жизненный цикл: + * - OnInit: Загрузка начальных данных и настройка подписок + * - AfterViewInit: Настройка обработчика прокрутки + * - OnDestroy: Очистка подписок + * + * Возвращает: + * HTML шаблон с карточками проектов для оценки + */ @Component({ selector: "app-list", templateUrl: "./list.component.html", diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-project.routes.ts b/projects/social_platform/src/app/office/program/detail/rate-projects/rate-project.routes.ts index c2674ce93..edb657929 100644 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-project.routes.ts +++ b/projects/social_platform/src/app/office/program/detail/rate-projects/rate-project.routes.ts @@ -6,6 +6,30 @@ import { ListComponent } from "./list/list.component"; import { ListAllResolver } from "./list/list-all.resolver"; // import { ListRatedResolver } from "./list/list-rated.resolver"; +/** + * Маршруты для модуля оценки проектов программы + * + * Определяет структуру навигации для экспертной оценки проектов: + * - Главная страница с поиском и фильтрами + * - Список всех проектов для оценки + * + * Структура маршрутов: + * - "" - корневой компонент RateProjectsComponent + * - "" - редирект на "all" + * - "all" - список всех проектов с резолвером данных + * + * Закомментированный маршрут: + * - "rated" - предположительно для уже оцененных проектов + * + * Резолверы: + * - ListAllResolver - предзагружает проекты для оценки + * + * Компоненты: + * - RateProjectsComponent - контейнер с поиском и навигацией + * - ListComponent - отображение списка проектов + * + * @returns {Routes} Конфигурация маршрутов для оценки проектов + */ export const RATE_PROJECTS_ROUTES: Routes = [ { path: "", diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.ts b/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.ts index ed37013e5..cb26be4b5 100644 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.ts +++ b/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.ts @@ -2,21 +2,44 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { NavService } from "@services/nav.service"; -import { - ActivatedRoute, - Router, - RouterLink, - RouterLinkActive, - RouterOutlet, -} from "@angular/router"; -import { BackComponent, IconComponent } from "@uilib"; -import { BarComponent, ButtonComponent, SelectComponent } from "@ui/components"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ActivatedRoute, Router, RouterOutlet } from "@angular/router"; +import { BarComponent } from "@ui/components"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { Subscription } from "rxjs"; -import { CheckboxComponent } from "../../../../ui/components/checkbox/checkbox.component"; -import { filterTags } from "projects/core/src/consts/filter-tags"; +/** + * Компонент страницы оценки проектов программы + * + * Основной компонент для экспертной оценки проектов в рамках программы. + * Предоставляет интерфейс поиска и фильтрации проектов для оценки. + * + * Функциональность: + * - Поиск проектов по названию + * - Навигационная панель + * - Router outlet для дочерних компонентов (список проектов) + * - Обработка URL параметров поиска + * + * Принимает: + * @param {NavService} navService - Сервис навигации для установки заголовка + * @param {Router} router - Для навигации и изменения URL параметров + * @param {ActivatedRoute} route - Для получения параметров маршрута + * @param {FormBuilder} fb - Для создания реактивных форм + * + * Формы: + * @property {FormGroup} searchForm - Форма поиска проектов + * + * Состояние: + * @property {number} programId - ID текущей программы + * @property {boolean} isOpen - Состояние открытия фильтров + * @property {Subscription[]} subscriptions$ - Массив подписок для очистки + * + * Методы: + * @method onSearchClick() - Обработчик поиска, обновляет URL параметры + * @method onClickOutside() - Закрывает выпадающие меню при клике вне + * + * Возвращает: + * HTML шаблон с поиском и router-outlet для списка проектов + */ @Component({ selector: "app-rate-projects", templateUrl: "./rate-projects.component.html", 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 index 8b48d22da..cbf3bd75e 100644 --- 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 @@ -9,8 +9,39 @@ import { ControlErrorPipe, ValidationService } from "projects/core"; import { ProgramService } from "@office/program/services/program.service"; import { BarComponent, ButtonComponent, InputComponent } from "@ui/components"; import { KeyValuePipe } from "@angular/common"; -import { BackComponent } from "@uilib"; +/** + * Компонент регистрации в программе + * + * Предоставляет форму для регистрации пользователя в программе. + * Динамически генерирует поля формы на основе схемы данных программы. + * + * Принимает: + * @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", diff --git a/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts b/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts index ecd4754f1..4eebb752a 100644 --- a/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts +++ b/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts @@ -5,6 +5,35 @@ import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; import { ProgramService } from "@office/program/services/program.service"; import { ProgramDataSchema } from "@office/program/models/program.model"; +/** + * Резолвер для получения схемы данных регистрации в программе + * + * Предзагружает схему дополнительных полей, которые нужно заполнить + * при регистрации в конкретной программе. Каждая программа может иметь + * свои уникальные поля для сбора информации о участниках. + * + * Принимает: + * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами + * + * Использует: + * @param {ProgramService} programService - Инжектируемый сервис программ + * + * Логика: + * - Извлекает programId из параметров маршрута + * - Загружает схему данных через programService.getDataSchema() + * + * Возвращает: + * @returns {Observable} Схема полей для регистрации + * + * Схема содержит: + * - Названия полей + * - Типы полей (text, email, etc.) + * - Плейсхолдеры для полей + * - Другие метаданные для генерации формы + * + * Используется в: + * Маршруте register для предзагрузки схемы формы + */ export const ProgramRegisterResolver: ResolveFn = ( route: ActivatedRouteSnapshot ) => { 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 index 50c32b4ea..461b473d0 100644 --- 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 @@ -15,6 +15,18 @@
    + @if(isOwner) { +
    +
    + +
    + @if (menuOpen) { +
      +
    • Удалить
    • +
    + } +
    + } @if (newsItem.text) {
    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 index c34519cb5..2c6329699 100644 --- 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 @@ -18,13 +18,69 @@ 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, ParseBreaksPipe, ParseLinksPipe } from "projects/core"; +import { DayjsPipe, ParseLinksPipe } from "projects/core"; import { FileItemComponent } from "@ui/components/file-item/file-item.component"; import { 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"; +/** + * Компонент карточки новости программы + * + * Отображает отдельную новость в ленте программы с полным функционалом: + * - Просмотр текста новости с возможностью развернуть/свернуть + * - Отображение прикрепленных файлов (изображения и документы) + * - Режим редактирования новости (для владельца) + * - Взаимодействие с новостью (лайки, копирование ссылки) + * - Загрузка и удаление файлов в режиме редактирования + * + * Принимает: + * @Input newsItem: FeedNews - Объект новости для отображения + * @Input isOwner: boolean - Является ли пользователь владельцем новости + * + * Генерирует события: + * @Output delete: EventEmitter - Удаление новости + * @Output like: EventEmitter - Лайк/дизлайк новости + * @Output edited: EventEmitter - Редактирование новости + * + * Зависимости: + * @param {SnackbarService} snackbarService - Для уведомлений + * @param {FileService} fileService - Для работы с файлами + * @param {ActivatedRoute} route - Для получения ID программы + * @param {ChangeDetectorRef} cdRef - Для обновления представления + * + * Состояние: + * @property {boolean} newsTextExpandable - Можно ли развернуть текст + * @property {boolean} readMore - Развернут ли текст новости + * @property {boolean} editMode - Активен ли режим редактирования + * @property {boolean[]} showLikes - Массив состояний показа лайков для изображений + * + * Файлы: + * @property {FileModel[]} imagesViewList - Изображения для просмотра + * @property {FileModel[]} filesViewList - Файлы для просмотра + * @property {Array} imagesEditList - Изображения в режиме редактирования + * @property {Array} filesEditList - Файлы в режиме редактирования + * + * Методы: + * @method onCopyLink() - Копирует ссылку на новость в буфер обмена + * @method onUploadFile(event) - Загружает новые файлы + * @method onDeletePhoto(fId) - Удаляет изображение + * @method onDeleteFile(fId) - Удаляет файл + * @method onRetryUpload(id) - Повторяет загрузку файла при ошибке + * @method onTouchImg(event, imgIdx) - Обработчик двойного тапа для лайка + * @method onExpandNewsText() - Разворачивает/сворачивает текст новости + * + * Особенности: + * - Разделение файлов на изображения и документы + * - Поддержка drag&drop для загрузки файлов + * - Обработка ошибок загрузки с возможностью повтора + * - Двойной тап на изображениях для лайка (мобильные устройства) + * - Автоматическое определение высоты текста для кнопки "Читать далее" + * + * Возвращает: + * HTML шаблон карточки новости со всем функционалом + */ @Component({ selector: "app-program-news-card", templateUrl: "./news-card.component.html", @@ -37,7 +93,6 @@ import { FeedNews } from "@office/projects/models/project-news.model"; FileItemComponent, DayjsPipe, ParseLinksPipe, - ParseBreaksPipe, ], }) export class ProgramNewsCardComponent implements OnInit, AfterViewInit { @@ -58,6 +113,16 @@ export class ProgramNewsCardComponent implements OnInit, AfterViewInit { readMore = false; editMode = false; + /** Состояние меню действий */ + menuOpen = false; + + /** + * Закрытие меню действий + */ + onCloseMenu() { + this.menuOpen = false; + } + ngOnInit(): void { this.showLikes = this.newsItem.files.map(() => false); diff --git a/projects/social_platform/src/app/office/program/list/main/main.component.ts b/projects/social_platform/src/app/office/program/list/main/main.component.ts index 2749c6306..629116860 100644 --- a/projects/social_platform/src/app/office/program/list/main/main.component.ts +++ b/projects/social_platform/src/app/office/program/list/main/main.component.ts @@ -2,19 +2,52 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, RouterLink } from "@angular/router"; -import { map, Observable, Subscription } from "rxjs"; +import { map, Subscription } from "rxjs"; import { Program } from "@office/program/models/program.model"; import { ProgramCardComponent } from "../../shared/program-card/program-card.component"; -import { AsyncPipe } from "@angular/common"; import { NavService } from "@office/services/nav.service"; import Fuse from "fuse.js"; +/** + * Главный компонент списка программ + * + * Отображает список всех доступных программ с функциональностью поиска. + * Поддерживает фильтрацию программ по названию в реальном времени. + * + * Принимает: + * @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, AsyncPipe], + imports: [RouterLink, ProgramCardComponent], }) export class ProgramMainComponent implements OnInit, OnDestroy { constructor(private readonly route: ActivatedRoute, private readonly navService: NavService) {} diff --git a/projects/social_platform/src/app/office/program/list/main/main.resolver.ts b/projects/social_platform/src/app/office/program/list/main/main.resolver.ts index 1fbf1885e..bf91ddbe9 100644 --- a/projects/social_platform/src/app/office/program/list/main/main.resolver.ts +++ b/projects/social_platform/src/app/office/program/list/main/main.resolver.ts @@ -6,6 +6,37 @@ 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); diff --git a/projects/social_platform/src/app/office/program/models/program-create.model.ts b/projects/social_platform/src/app/office/program/models/program-create.model.ts index e183d078b..420e4529c 100644 --- a/projects/social_platform/src/app/office/program/models/program-create.model.ts +++ b/projects/social_platform/src/app/office/program/models/program-create.model.ts @@ -1,5 +1,18 @@ /** @format */ +/** + * Модель для создания новой программы + * + * Содержит все необходимые поля для создания программы в системе. + * Используется при отправке POST запроса на создание программы. + * + * Свойства: + * @param {string} name - Название программы + * @param {string} imageAddress - URL изображения программы + * @param {string} datetimeRegistrationEnd - Дата и время окончания регистрации (ISO строка) + * @param {string} datetimeStarted - Дата и время начала программы (ISO строка) + * @param {string} datetimeFinished - Дата и время окончания программы (ISO строка) + */ export class ProgramCreate { name!: string; imageAddress!: string; diff --git a/projects/social_platform/src/app/office/program/models/program.model.ts b/projects/social_platform/src/app/office/program/models/program.model.ts index 01aa130c6..f9239900c 100644 --- a/projects/social_platform/src/app/office/program/models/program.model.ts +++ b/projects/social_platform/src/app/office/program/models/program.model.ts @@ -1,5 +1,37 @@ /** @format */ +/** + * Основная модель программы в системе + * + * Содержит полную информацию о программе, включая метаданные, + * даты проведения, статистику и права пользователя. + * + * Свойства: + * @param {number} id - Уникальный идентификатор программы + * @param {string} imageAddress - URL основного изображения + * @param {string} coverImageAddress - URL обложки программы + * @param {string} presentationAddress - URL презентации программы + * @param {string} advertisementImageAddress - URL рекламного изображения + * @param {string} name - Название программы + * @param {string} description - Полное описание программы + * @param {string} city - Город проведения программы + * @param {string} tag - Тег/категория программы + * @param {number} year - Год проведения программы + * @param {string[]} links - Массив полезных ссылок + * @param {Array<{title: string; url: string}>} materials - Материалы программы + * @param {string} shortDescription - Краткое описание программы + * @param {string} datetimeRegistrationEnds - Дата окончания регистрации + * @param {string} datetimeStarted - Дата начала программы + * @param {string} datetimeFinished - Дата окончания программы + * @param {number} viewsCount - Количество просмотров + * @param {number} likesCount - Количество лайков + * @param {boolean} isUserLiked - Лайкнул ли текущий пользователь + * @param {boolean} isUserManager - Является ли пользователь менеджером программы + * @param {boolean} isUserMember - Является ли пользователь участником программы + * + * Методы: + * @method static default() - Возвращает объект программы с дефолтными значениями + */ export class Program { id!: number; imageAddress!: string; @@ -12,6 +44,7 @@ export class Program { tag!: string; year!: number; links!: string[]; + materials!: { title: string; url: string }[]; shortDescription!: string; datetimeRegistrationEnds!: string; datetimeStarted!: string; @@ -19,6 +52,7 @@ export class Program { viewsCount!: number; likesCount!: number; isUserLiked!: boolean; + isUserManager?: boolean; isUserMember?: boolean; static default(): Program { @@ -30,6 +64,7 @@ export class Program { imageAddress: "", presentationAddress: "", links: [], + materials: [], coverImageAddress: "", advertisementImageAddress: "", shortDescription: "", @@ -42,10 +77,20 @@ export class Program { year: 0, isUserLiked: false, isUserMember: false, + isUserManager: false, }; } } +/** + * Схема данных программы для динамических полей + * + * Определяет структуру дополнительных полей программы, + * которые могут быть настроены администратором. + * + * @param {string} key - Ключ поля + * @param {object} value - Объект с типом, названием и плейсхолдером поля + */ export class ProgramDataSchema { [key: string]: { type: "text"; @@ -54,6 +99,15 @@ export class ProgramDataSchema { }; } +/** + * Модель тега программы + * + * Представляет категорию или тег программы для группировки и фильтрации. + * + * @param {number} id - Уникальный идентификатор тега + * @param {string} name - Отображаемое название тега + * @param {string} tag - Системное название тега + */ export class ProgramTag { id!: number; name!: string; diff --git a/projects/social_platform/src/app/office/program/models/project-rate.ts b/projects/social_platform/src/app/office/program/models/project-rate.ts index a34ccb912..0984a29ef 100644 --- a/projects/social_platform/src/app/office/program/models/project-rate.ts +++ b/projects/social_platform/src/app/office/program/models/project-rate.ts @@ -1,7 +1,26 @@ /** @format */ -import { ProjectRatingCriterion } from "./project-rating-criterion"; +import { ProjectRatingCriterion } from "./project-rating-criterion"; // Assuming this is where ProjectRatingCriterion is declared +/** + * Интерфейс проекта для оценки + * + * Представляет проект, который может быть оценен экспертами в рамках программы. + * Содержит информацию о проекте и критерии для его оценки. + * + * Свойства: + * @param {number} id - Уникальный идентификатор проекта + * @param {string} name - Название проекта + * @param {number} leader - ID руководителя проекта + * @param {string} description - Описание проекта + * @param {string} imageAddress - URL изображения проекта + * @param {string} presentationAddress - URL презентации проекта + * @param {string} region - Регион проекта + * @param {number} viewsCount - Количество просмотров + * @param {number} industry - ID отрасли проекта + * @param {ProjectRatingCriterion[]} criterias - Массив критериев для оценки + * @param {boolean} isScored - Флаг, указывающий, оценен ли проект текущим пользователем + */ export interface ProjectRate { id: number; name: string; diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts b/projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts index ef62f1fea..c4463c254 100644 --- a/projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts +++ b/projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts @@ -1,5 +1,14 @@ /** @format */ +/** + * Интерфейс для отправки оценки критерия проекта + * + * Используется при отправке оценок проекта на сервер. + * Содержит ID критерия и значение оценки в унифицированном формате. + * + * @param {number} criterionId - ID критерия оценки + * @param {unknown} value - Значение оценки (может быть string, number, boolean) + */ export interface ProjectRatingCriterionOutput { criterionId: number; value: unknown; diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts b/projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts index b13792d0d..536d432e7 100644 --- a/projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts +++ b/projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts @@ -1,3 +1,13 @@ /** @format */ +/** + * Тип критерия оценки проекта + * + * Определяет возможные типы критериев для оценки проектов: + * - "bool" - булевый тип (да/нет, true/false) + * - "int" - целочисленный тип (числовая оценка) + * - "str" - строковый тип (текстовый комментарий) + * + * Используется для определения типа поля ввода и валидации значений критерия. + */ export type ProjectRatingCriterionType = "bool" | "int" | "str"; diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts b/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts index b2c1f7e76..016a0a2c8 100644 --- a/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts +++ b/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts @@ -1,5 +1,20 @@ /** @format */ +/** + * Интерфейс критерия оценки проекта + * + * Описывает структуру критерия, по которому оценивается проект в рамках программы. + * Критерий может быть разных типов (boolean, integer, string) с различными ограничениями. + * + * Свойства: + * @param {number} id - Уникальный идентификатор критерия + * @param {string} name - Название критерия оценки + * @param {string} description - Подробное описание критерия + * @param {ProjectRatingCriterionType} type - Тип критерия ("bool" | "int" | "str") + * @param {number | null} minValue - Минимальное значение (для числовых критериев) + * @param {number | null} maxValue - Максимальное значение (для числовых критериев) + * @param {string | number} value - Текущее значение критерия + */ import { ProjectRatingCriterionType } from "./project-rating-criterion-type"; export interface ProjectRatingCriterion { diff --git a/projects/social_platform/src/app/office/program/program.component.html b/projects/social_platform/src/app/office/program/program.component.html index 024bde9c2..74d3eb085 100644 --- a/projects/social_platform/src/app/office/program/program.component.html +++ b/projects/social_platform/src/app/office/program/program.component.html @@ -4,7 +4,7 @@ link: '/office/program/my', linkText: 'Мои программы', isRouterLinkActiveOptions: true, - + }, -->
    @@ -16,7 +16,7 @@ link: '/office/program/all', linkText: 'Все программы', isRouterLinkActiveOptions: false, - + }, ]" > diff --git a/projects/social_platform/src/app/office/program/program.component.ts b/projects/social_platform/src/app/office/program/program.component.ts index ad891ec15..fcbea3c83 100644 --- a/projects/social_platform/src/app/office/program/program.component.ts +++ b/projects/social_platform/src/app/office/program/program.component.ts @@ -3,20 +3,39 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { NavService } from "@services/nav.service"; import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { map, Subscription, tap } from "rxjs"; -import { ProjectCount } from "@models/project.model"; +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 { AsyncPipe } from "@angular/common"; import { ProgramService } from "./services/program.service"; +/** + * Основной компонент модуля "Программы" + * + * Функциональность: + * - Отображает заголовок навигации "Программы" + * - Предоставляет форму поиска программ + * - Управляет состоянием активных вкладок (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, AsyncPipe, BarComponent], + imports: [ReactiveFormsModule, SearchComponent, RouterOutlet, BarComponent], }) export class ProgramComponent implements OnInit, OnDestroy { constructor( diff --git a/projects/social_platform/src/app/office/program/program.routes.ts b/projects/social_platform/src/app/office/program/program.routes.ts index 7d6b7b861..bfd04a746 100644 --- a/projects/social_platform/src/app/office/program/program.routes.ts +++ b/projects/social_platform/src/app/office/program/program.routes.ts @@ -5,6 +5,18 @@ import { ProgramComponent } from "./program.component"; import { ProgramMainComponent } from "./list/main/main.component"; import { ProgramMainResolver } from "./list/main/main.resolver"; +/** + * Конфигурация маршрутов для модуля "Программы" + * + * Описание маршрутов: + * - "" - корневой маршрут программ с дочерними маршрутами + * - "" - редирект на "/all" + * - "all" - список всех программ с резолвером данных + * - ":programId" - детальная страница программы (ленивая загрузка) + * - ":programId/projects-rating" - страница оценки проектов программы (ленивая загрузка) + * + * @returns {Routes} Массив конфигураций маршрутов для Angular Router + */ export const PROGRAM_ROUTES: Routes = [ { path: "", 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 index 384ccf8b7..9fa645ac5 100644 --- 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 @@ -2,20 +2,44 @@ import { Injectable } from "@angular/core"; import { ApiService } from "projects/core"; -import { forkJoin, Observable } from "rxjs"; +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( - `/programs/${programId}/news/`, + `${this.PROGRAMS_URL}/${programId}/news/`, new HttpParams({ fromObject: { limit, offset } }) ); } @@ -23,14 +47,24 @@ export class ProgramNewsService { readNews(projectId: string, newsIds: number[]): Observable { return forkJoin( newsIds.map(id => - this.apiService.post(`/programs/${projectId}/news/${id}/set_viewed/`, {}) + this.apiService.post(`${this.PROGRAMS_URL}/${projectId}/news/${id}/set_viewed/`, {}) ) ); } toggleLike(projectId: string, newsId: number, state: boolean): Observable { - return this.apiService.post(`/programs/${projectId}/news/${newsId}/set_liked/`, { + 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))); + } + + 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.ts b/projects/social_platform/src/app/office/program/services/program.service.ts index d84e17841..f372baa8f 100644 --- a/projects/social_platform/src/app/office/program/services/program.service.ts +++ b/projects/social_platform/src/app/office/program/services/program.service.ts @@ -9,31 +9,66 @@ import { Program, ProgramDataSchema, ProgramTag } from "@office/program/models/p 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"; +/** + * Сервис для работы с программами + * + * Предоставляет методы для взаимодействия с 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 PROJECTS_URL = "/projects"; + private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; + private readonly AUTH_USERS_CURRENT_URL = "/auth/users/current"; + constructor(private readonly apiService: ApiService) {} getAll(skip: number, take: number): Observable> { return this.apiService.get( - "/programs/", + `${this.PROGRAMS_URL}/`, new HttpParams({ fromObject: { limit: take, offset: skip } }) ); } getOne(programId: number): Observable { - return this.apiService.get(`/programs/${programId}/`); + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/`); } create(program: ProgramCreate): Observable { - return this.apiService.post("/programs/", program); + return this.apiService.post(`${this.PROGRAMS_URL}/`, program); } getDataSchema(programId: number): Observable { return this.apiService - .get<{ dataSchema: ProgramDataSchema }>(`/programs/${programId}/schema/`) + .get<{ dataSchema: ProgramDataSchema }>(`${this.PROGRAMS_URL}/${programId}/schema/`) .pipe(map(r => r["dataSchema"])); } @@ -41,30 +76,43 @@ export class ProgramService { programId: number, additionalData: Record ): Observable { - return this.apiService.post(`/programs/${programId}/register/`, additionalData); + return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/register/`, additionalData); } - getAllProjects( - programId: number, - offset: number, - limit: number - ): Observable> { - return this.apiService.get( - `/projects/`, - new HttpParams({ fromObject: { partner_program: programId, offset, limit } }) - ); + getAllProjects(params?: HttpParams): Observable> { + return this.apiService.get(`${this.PROJECTS_URL}/`, params); } getAllMembers(programId: number, skip: number, take: number): Observable> { return this.apiService.get( - "/auth/public-users/", + `${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/`); + } + + createProgramFilters( + programId: number, + filters: { filters: { string: string[] } } + ): Observable> { + return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/projects/filter/`, { + filters, + }); + } + + submitCompettetiveProject(relationId: number): Observable { + return this.apiService.post( + `${this.PROGRAMS_URL}/partner-program-projects/${relationId}/submit/`, + {} + ); + } + programTags$ = new BehaviorSubject([]); programTags(): Observable { - return this.apiService.get("/auth/users/current/programs/tags/").pipe( + return this.apiService.get(`${this.AUTH_USERS_CURRENT_URL}/programs/tags/`).pipe( tap(programs => { this.programTags$.next(programs); }) 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 index f4bfe1985..ef14c158f 100644 --- 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 @@ -9,10 +9,38 @@ 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( @@ -23,7 +51,7 @@ export class ProjectRatingService { nameContains?: string ): Observable> { return this.apiService.get( - `/rate-project/${id}`, + `${this.RATE_PROJECT_URL}/${id}`, new HttpParams({ fromObject: { ...(nameContains !== undefined && { name__contains: nameContains }), @@ -36,7 +64,7 @@ export class ProjectRatingService { } rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable { - return this.apiService.post(`/rate-project/rate/${projectId}`, scores); + return this.apiService.post(`${this.RATE_PROJECT_URL}/rate/${projectId}`, scores); } /* diff --git a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.ts b/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.ts index 350d4a3fa..b83f3797f 100644 --- a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.ts +++ b/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.ts @@ -2,17 +2,41 @@ import { Component, Input, OnInit } from "@angular/core"; import { Program } from "@office/program/models/program.model"; -import { DayjsPipe } from "projects/core"; import { IconComponent } from "@ui/components"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { DatePipe } from "@angular/common"; +/** + * Компонент карточки программы + * + * Отображает краткую информацию о программе в виде карточки для списков. + * Используется на главной странице программ и в других местах, где нужно + * показать превью программы. + * + * Принимает: + * @Input program?: Program - Объект программы для отображения + * + * Отображает: + * - Изображение программы (аватар) + * - Название программы + * - Краткое описание + * - Даты проведения (отформатированные) + * - Иконки и дополнительную информацию + * + * Использует: + * - AvatarComponent для отображения изображения + * - IconComponent для иконок + * - DatePipe как альтернативный форматтер дат + * + * Возвращает: + * HTML шаблон карточки программы с базовой информацией + */ @Component({ selector: "app-program-card", templateUrl: "./program-card.component.html", styleUrl: "./program-card.component.scss", standalone: true, - imports: [AvatarComponent, IconComponent, DayjsPipe, DatePipe], + imports: [AvatarComponent, IconComponent, DatePipe], }) export class ProgramCardComponent implements OnInit { constructor() {} diff --git a/projects/social_platform/src/app/office/program/shared/program-head/program-head.component.ts b/projects/social_platform/src/app/office/program/shared/program-head/program-head.component.ts index 3b7d3bfb6..7f37bcf17 100644 --- a/projects/social_platform/src/app/office/program/shared/program-head/program-head.component.ts +++ b/projects/social_platform/src/app/office/program/shared/program-head/program-head.component.ts @@ -8,6 +8,31 @@ import { AuthService } from "@auth/services"; import { map } from "rxjs"; import { AsyncPipe } from "@angular/common"; +/** + * Компонент заголовка программы + * + * Отображает основную информацию о программе в верхней части страницы: + * - Название и описание программы + * - Кнопки действий (в зависимости от прав пользователя) + * - Поле поиска (для списка проектов) + * - Специальные кнопки для экспертов + * + * Принимает: + * @Input program: Program - Объект программы для отображения + * + * Зависимости: + * @param {Router} router - Для навигации и определения текущего URL + * @param {ActivatedRoute} route - Для работы с параметрами маршрута + * @param {AuthService} authService - Для получения информации о текущем пользователе + * + * Свойства: + * @property {Observable} isUserExpert - Является ли пользователь экспертом + * @property {boolean} isProjectsList - Находимся ли на странице списка проектов + * @property {string} searchValue - Текущее значение поиска из URL параметров + * + * Возвращает: + * HTML шаблон с заголовком программы и элементами управления + */ @Component({ selector: "app-program-head", templateUrl: "./program-head.component.html", diff --git a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts index bb89e27d9..fb42e3d42 100644 --- a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts +++ b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts @@ -26,6 +26,42 @@ import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { ProjectRatingService } from "@office/program/services/project-rating.service"; import { RouterLink } from "@angular/router"; +/** + * Компонент карточки оценки проекта + * + * Отображает детальную информацию о проекте и форму для его оценки экспертами. + * Поддерживает навигацию между проектами и различные типы критериев оценки. + * + * Принимает: + * @Input project: ProjectRate | null - Текущий проект для оценки + * @Input projects: ProjectRate[] | null - Список всех проектов + * @Input currentIndex: number - Индекс текущего проекта в списке + * + * Генерирует: + * @Output onNext: EventEmitter - Событие перехода к следующему проекту + * @Output onPrev: EventEmitter - Событие перехода к предыдущему проекту + * + * Зависимости: + * @param {IndustryService} industryService - Сервис для работы с отраслями + * @param {ProjectRatingService} projectRatingService - Сервис оценки проектов + * @param {BreakpointObserver} breakpointObserver - Для адаптивного дизайна + * @param {ChangeDetectorRef} cdRef - Для ручного обновления представления + * + * Функциональность: + * - Отображение информации о проекте (название, описание, изображения) + * - Форма оценки с различными типами критериев + * - Возможность развернуть/свернуть описание проекта + * - Навигация между проектами + * - Отправка оценки и обработка результата + * - Адаптивный дизайн для мобильных устройств + * + * Состояния: + * @property {FormControl} form - Реактивная форма для оценки + * @property {Signal} submitLoading - Состояние загрузки при отправке + * @property {Signal} readFullDescription - Развернуто ли описание + * @property {Signal} descriptionExpandable - Можно ли развернуть описание + * @property {Signal} projectRated - Оценен ли проект + */ @Component({ selector: "app-rating-card", templateUrl: "./rating-card.component.html", 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 index 2f85c8fe4..b7c1128d1 100644 --- 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 @@ -17,6 +17,28 @@ import { IconComponent } from "@ui/components"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { ApiPagination } from "@models/api-pagination.model"; +/** + * Компонент чата проекта + * + * Функциональность: + * - Отображение чата проекта с сообщениями участников + * - Отправка, редактирование и удаление сообщений + * - Показ индикатора набора текста + * - Загрузка файлов чата + * - Пагинация сообщений при прокрутке + * - Мобильная версия с переключением между чатом и боковой панелью + * + * Принимает: + * - Данные проекта через ActivatedRoute + * - WebSocket события через ChatService + * - Профиль пользователя через AuthService + * + * Предоставляет: + * - Список сообщений чата + * - Список участников проекта + * - Файлы, загруженные в чат + * - Интерфейс для отправки сообщений + */ @Component({ selector: "app-chat", templateUrl: "./chat.component.html", @@ -43,25 +65,30 @@ export class ProjectChatComponent implements OnInit, OnDestroy { ngOnInit(): void { this.navService.setNavTitle("Чат проекта"); + // Получение ID текущего пользователя const profile$ = this.authService.profile.subscribe(profile => { this.currentUserId = profile.id; }); profile$ && this.subscriptions$.push(profile$); + // Загрузка данных проекта const projectSub$ = this.route.data.pipe(map(r => r["data"])).subscribe(project => { this.project = project; - }); // pull info about project + }); projectSub$ && this.subscriptions$.push(projectSub$); console.debug("Chat websocket connected from ProjectChatComponent"); - this.initTypingEvent(); // event for show bare whenever anybody in chat type something - this.initMessageEvent(); // Wait for messages from other member, insert into chat - this.initEditEvent(); // Wait for messages to be edited by other members - this.initDeleteEvent(); // Delete messages when following event comes from websocket + // Инициализация 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 => { @@ -74,67 +101,55 @@ export class ProjectChatComponent implements OnInit, OnDestroy { } /** - * Amount of messages that we fetch - * each time {@link fetchMessages} runs + * Количество сообщений, загружаемых за один запрос * @private */ private readonly messagesPerFetch = 20; + /** - * Total messages count in chat - * comes from server, set first time {@link fetchMessages} runs + * Общее количество сообщений в чате + * Устанавливается при первой загрузке * @private */ private messagesTotalCount = 0; - /** - * Array with all rxjs subscriptions - * unsubscribed in {@link ngOnDestroy} - */ + /** Массив всех подписок компонента */ subscriptions$: Subscription[] = []; - /** - * Project info - * populates with observable call in {@link ngOnInit} - */ + /** Данные проекта */ project?: Project; - /** - * All files listed in this chat - */ + /** Все файлы, загруженные в чат */ chatFiles?: ChatFile[]; - /** - * Get id of logged user - */ + /** ID текущего пользователя */ currentUserId?: number; - /** - * Input message component - * Write edit reply to messages etc - */ + /** Ссылка на компонент ввода сообщений */ @ViewChild(MessageInputComponent, { read: ElementRef }) messageInputComponent?: ElementRef; - /** - * All chat messages - * Renders only few of them - * See virtual scrolling {@link viewport} - */ + /** Все сообщения чата */ messages: ChatMessage[] = []; - /** - * Amount of online users in chat - * @deprecated - */ + /** Количество пользователей онлайн (устарело) */ membersOnlineCount = 3; + /** Список пользователей, которые сейчас печатают */ typingPersons: ChatWindowComponent["typingPersons"] = []; + /** Флаг отображения боковой панели на мобильных устройствах */ isAsideMobileShown = false; + /** Переключение боковой панели на мобильных устройствах */ onToggleMobileAside(): void { this.isAsideMobileShown = !this.isAsideMobileShown; } + /** + * Инициализация обработки события набора текста + * Показывает индикатор, когда другие участники печатают + * @private + */ private initTypingEvent(): void { const typingEvent$ = this.chatService .onTyping() @@ -157,9 +172,9 @@ export class ProjectChatComponent implements OnInit, OnDestroy { userId: person.userId, }); + // Автоматическое скрытие индикатора через 2 секунды setTimeout(() => { const personIdx = this.typingPersons.findIndex(p => p.userId === person.userId); - this.typingPersons.splice(personIdx, 1); }, 2000); }); @@ -167,6 +182,11 @@ export class ProjectChatComponent implements OnInit, OnDestroy { typingEvent$ && this.subscriptions$.push(typingEvent$); } + /** + * Инициализация обработки новых сообщений + * Добавляет новые сообщения в конец списка + * @private + */ private initMessageEvent(): void { const messageEvent$ = this.chatService.onMessage().subscribe(result => { this.messages = [...this.messages, result.message]; @@ -175,6 +195,11 @@ export class ProjectChatComponent implements OnInit, OnDestroy { 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); @@ -188,6 +213,11 @@ export class ProjectChatComponent implements OnInit, OnDestroy { 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); @@ -201,6 +231,11 @@ export class ProjectChatComponent implements OnInit, OnDestroy { deleteEvent$ && this.subscriptions$.push(deleteEvent$); } + /** + * Загрузка сообщений чата с сервера + * @private + * @returns Observable с пагинированными сообщениями + */ private fetchMessages(): Observable> { return this.chatService .loadMessages( @@ -216,12 +251,13 @@ export class ProjectChatComponent implements OnInit, OnDestroy { ); } + /** Флаг процесса загрузки сообщений */ fetching = false; + + /** Загрузка дополнительных сообщений при прокрутке */ onFetchMessages(): void { if ( - (this.messages.length < this.messagesTotalCount || - // because messagesTotalCount pulls from server it's 0 in start of program, in that case we also need to make fetch - this.messagesTotalCount === 0) && + (this.messages.length < this.messagesTotalCount || this.messagesTotalCount === 0) && !this.fetching ) { this.fetching = true; @@ -231,6 +267,7 @@ export class ProjectChatComponent implements OnInit, OnDestroy { } } + /** Отправка нового сообщения */ onSubmitMessage(message: any): void { this.chatService.sendMessage({ replyTo: message.replyTo, @@ -241,6 +278,7 @@ export class ProjectChatComponent implements OnInit, OnDestroy { }); } + /** Редактирование существующего сообщения */ onEditMessage(message: any): void { this.chatService.editMessage({ text: message.text, @@ -250,6 +288,7 @@ export class ProjectChatComponent implements OnInit, OnDestroy { }); } + /** Удаление сообщения */ onDeleteMessage(messageId: number): void { this.chatService.deleteMessage({ chatId: this.route.parent?.snapshot.paramMap.get("projectId") ?? "", 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 index b99184063..bcb15b127 100644 --- 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 @@ -5,6 +5,18 @@ import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; import { Project } from "@models/project.model"; import { ProjectService } from "@services/project.service"; +/** + * Резолвер для загрузки данных проекта для чата + * + * Принимает: + * - ActivatedRouteSnapshot с родительским параметром projectId + * + * Возвращает: + * - Observable - данные проекта для отображения в чате + * + * Использует: + * - ProjectService для получения данных проекта по ID + */ export const ProjectChatResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { const projectService = inject(ProjectService); const id = Number(route.parent?.paramMap.get("projectId")); diff --git a/projects/social_platform/src/app/office/projects/detail/detail.component.ts b/projects/social_platform/src/app/office/projects/detail/detail.component.ts index e1c826bbf..611f97454 100644 --- a/projects/social_platform/src/app/office/projects/detail/detail.component.ts +++ b/projects/social_platform/src/app/office/projects/detail/detail.component.ts @@ -2,24 +2,40 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { map, Subscription } from "rxjs"; -import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from "@angular/router"; +import { ActivatedRoute, RouterOutlet } from "@angular/router"; import { Project } from "@models/project.model"; import { AuthService } from "@auth/services"; -import { BackComponent } from "@uilib"; import { AsyncPipe } from "@angular/common"; import { BarComponent } from "@ui/components"; +/** + * Компонент детального просмотра проекта + * + * Функциональность: + * - Загружает данные проекта из резолвера + * - Определяет, является ли текущий пользователь участником проекта + * - Управляет подписками на Observable + * + * Принимает: + * - Данные проекта через ActivatedRoute + * - Профиль пользователя через AuthService + * + * Предоставляет: + * - project - данные текущего проекта + * - isInProject$ - Observable, показывающий участие пользователя в проекте + */ @Component({ selector: "app-detail", templateUrl: "./detail.component.html", styleUrl: "./detail.component.scss", standalone: true, - imports: [RouterLinkActive, RouterLink, RouterOutlet, AsyncPipe, BarComponent, BackComponent], + imports: [RouterOutlet, AsyncPipe, BarComponent], }) export class ProjectDetailComponent implements OnInit, OnDestroy { constructor(private readonly route: ActivatedRoute, private readonly authService: AuthService) {} ngOnInit(): void { + // Подписка на данные проекта из резолвера const projectSub$ = this.route.data.pipe(map(r => r["data"][0])).subscribe(project => { this.project = project; }); @@ -27,13 +43,17 @@ export class ProjectDetailComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + // Отписка от всех подписок для предотвращения утечек памяти this.subscriptions$.forEach($ => $.unsubscribe()); } + /** Массив всех подписок компонента */ subscriptions$: Subscription[] = []; + /** Данные текущего проекта */ project?: Project; + /** Observable, определяющий участие текущего пользователя в проекте */ isInProject$ = this.authService.profile.pipe( map(profile => this.project?.collaborators.map(person => person.userId).includes(profile.id)) ); 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 index 6d02515f9..74fe52ae6 100644 --- a/projects/social_platform/src/app/office/projects/detail/detail.resolver.ts +++ b/projects/social_platform/src/app/office/projects/detail/detail.resolver.ts @@ -8,6 +8,19 @@ import { Project } from "@models/project.model"; import { SubscriptionService } from "@office/services/subscription.service"; import { ProjectSubscriber } from "@office/models/project-subscriber.model"; +/** + * Резолвер для загрузки данных проекта и его подписчиков + * + * Принимает: + * - ActivatedRouteSnapshot с параметром projectId + * + * Возвращает: + * - Observable<[Project, ProjectSubscriber[]]> - кортеж с данными проекта и списком подписчиков + * + * Использует: + * - ProjectService для получения данных проекта + * - SubscriptionService для получения списка подписчиков + */ export const ProjectDetailResolver: ResolveFn<[Project, ProjectSubscriber[]]> = ( route: ActivatedRouteSnapshot ) => { 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 index 549835d2b..bb902698e 100644 --- a/projects/social_platform/src/app/office/projects/detail/detail.routes.ts +++ b/projects/social_platform/src/app/office/projects/detail/detail.routes.ts @@ -12,6 +12,18 @@ 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"; +/** + * Конфигурация маршрутов для детального просмотра проекта + * + * Определяет: + * - Главный маршрут с резолвером для загрузки данных проекта + * - Дочерние маршруты для разных разделов проекта: + * - "" (пустой) - информация о проекте с возможностью просмотра новостей + * - "responses" - отклики на вакансии проекта + * - "chat" - чат проекта + * + * Каждый дочерний маршрут имеет свой резолвер для предзагрузки данных + */ export const PROJECT_DETAIL_ROUTES: Routes = [ { path: "", 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 index 978ac57a6..9ad6b85bc 100644 --- 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 @@ -103,11 +103,24 @@

    {{ project.name }}

    } @if (profile.id === project.leader) { - Редактировать + + Редактировать + } @if (isInProject | async) { @@ -374,6 +387,63 @@

    Прежде чем выйти из проекта смените основ

    + + +
    + + idea +

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

    + +

    + Этот проект уже отправлен на конкурс.
    Изменения будут доступны только после окончания + конкурса. +

    + + Отмена +
    +
    + + +
    +

    Вы действительно хотите отписаться от проекта?

    + +
    + + Отписаться + + + Отменить + +
    +
    +
    } 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 index 3230a0108..122833a3f 100644 --- 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 @@ -43,8 +43,32 @@ import { withLatestFrom, } from "rxjs"; import { ProjectMemberCardComponent } from "../shared/project-member-card/project-member-card.component"; -import { HttpResponse } from "@angular/common/http"; +/** + * КОМПОНЕНТ ДЕТАЛЬНОЙ ИНФОРМАЦИИ О ПРОЕКТЕ + * + * Этот компонент отображает подробную информацию о проекте, включая: + * - Основную информацию (название, описание, обложка) + * - Команду проекта с возможностью управления + * - Новости проекта с возможностью добавления/редактирования + * - Вакансии, достижения и контакты + * - Функции подписки и поддержки проекта + * + * @param: + * - Получает данные проекта через резолвер из маршрута + * - Использует параметр projectId из URL + * + * - Отображение информации о проекте + * - Управление подпиской на проект + * - Добавление/редактирование/удаление новостей + * - Управление командой проекта (для лидера) + * - Выход из проекта + * - Передача лидерства другому участнику + * + * @returns: + * - Отображает HTML-шаблон с информацией о проекте + * - Обрабатывает пользовательские действия через методы компонента + */ @Component({ selector: "app-detail", templateUrl: "./info.component.html", @@ -71,33 +95,42 @@ import { HttpResponse } from "@angular/common/http"; }) export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - public readonly industryService: IndustryService, - public readonly authService: AuthService, - private readonly navService: NavService, - private readonly projectNewsService: ProjectNewsService, - private readonly subscriptionService: SubscriptionService, - private readonly projectService: ProjectService, - private readonly cdRef: ChangeDetectorRef + private readonly route: ActivatedRoute, // Сервис для работы с активным маршрутом + private readonly router: Router, // Сервис для навигации + public readonly industryService: IndustryService, // Сервис для работы с отраслями + public readonly authService: AuthService, // Сервис аутентификации + private readonly navService: NavService, // Сервис навигации + private readonly projectNewsService: ProjectNewsService, // Сервис новостей проекта + private readonly subscriptionService: SubscriptionService, // Сервис подписок + private readonly projectService: ProjectService, // Сервис проектов + private readonly cdRef: ChangeDetectorRef // Сервис для ручного запуска обнаружения изменений ) {} + // Observable с данными проекта из резолвера project$?: Observable = this.route.parent?.data.pipe(map(r => r["data"][0])); + // Observable с подписчиками проекта projSubscribers$?: Observable = this.route.parent?.data.pipe(map(r => r["data"][1])); - profileId!: number; + profileId!: number; // ID текущего пользователя + // Observable с вакансиями проекта vacancies$: Observable = this.route.data.pipe(map(r => r["data"])); - subscriptions$: Subscription[] = []; + subscriptions$: Subscription[] = []; // Массив подписок для очистки + /** + * Инициализация компонента + * Устанавливает заголовок навигации, загружает новости, определяет статус подписки + */ ngOnInit(): void { this.navService.setNavTitle("Профиль проекта"); + // Загрузка новостей проекта 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"), @@ -110,10 +143,12 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { }); }); + // Получение ID текущего пользователя const profileId$ = this.authService.profile.subscribe(profile => { this.profileId = profile.id; }); + // Проверка статуса подписки пользователя на проект this.projSubscribers$ ?.pipe(take(1), withLatestFrom(this.authService.profile)) .subscribe(([projSubs, profile]) => { @@ -122,27 +157,50 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { : (this.isUserSubscribed = false); }); - this.subscriptions$.push(news$, profileId$); + const projectEditSub$ = + this.project$?.subscribe(project => { + if (project.partnerProgram) { + this.isEditDisable = project.partnerProgram?.isSubmitted; + } + }) ?? Subscription.EMPTY; + + this.subscriptions$.push(news$, profileId$, projectEditSub$); } + // Ссылки на элементы DOM @ViewChild("newsEl") newsEl?: ElementRef; @ViewChild("contentEl") contentEl?: ElementRef; @ViewChild("descEl") descEl?: ElementRef; + + /** + * Хук после инициализации представления + * Перемещает новости в контентную область на десктопе, проверяет необходимость кнопки "Читать полностью" + */ ngAfterViewInit(): void { + // На десктопе перемещаем новости в основной контент if (containerSm < window.innerWidth) { this.contentEl?.nativeElement.append(this.newsEl?.nativeElement); } + // Проверяем, нужна ли кнопка "Читать полностью" для описания const descElement = this.descEl?.nativeElement; this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; this.cdRef.detectChanges(); } + /** + * Очистка подписок при уничтожении компонента + */ 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 @@ -155,32 +213,37 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { .subscribe(noop); } + // Ссылки на дочерние компоненты @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; @ViewChild(NewsCardComponent) newsCardComponent?: NewsCardComponent; - news: FeedNews[] = []; - - readFullDescription = false; - - descriptionExpandable!: boolean; - - readAllAchievements = false; - readAllVacancies = false; - readAllMembers = false; - - isCompleted = false; - - leaderLeaveModal = false; - + // Состояние компонента + news: FeedNews[] = []; // Массив новостей + readFullDescription = false; // Флаг развернутого описания + descriptionExpandable!: boolean; // Флаг необходимости кнопки "Читать полностью" + readAllAchievements = false; // Флаг показа всех достижений + readAllVacancies = false; // Флаг показа всех вакансий + readAllMembers = false; // Флаг показа всех участников + isCompleted = false; // Флаг завершенности проекта + leaderLeaveModal = false; // Флаг модального окна предупреждения лидера + + /** + * Добавление новой новости + * @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); + 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); @@ -190,6 +253,10 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { .subscribe(() => {}); } + /** + * Переключение лайка новости + * @param newsId - ID новости для лайка + */ onLike(newsId: number) { const item = this.news.find(n => n.id === newsId); if (!item) return; @@ -202,6 +269,11 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Редактирование новости + * @param news - обновленные данные новости + * @param newsItemId - ID редактируемой новости + */ onEditNews(news: FeedNews, newsItemId: number) { this.projectNewsService .editNews(this.route.snapshot.params["projectId"], newsItemId, news) @@ -212,14 +284,21 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Удаление участника из проекта + * @param id - ID удаляемого участника + */ onRemoveMember(id: Collaborator["userId"]) { this.project$ ?.pipe(concatMap(project => this.projectService.removeColloborator(project.id, id))) .subscribe(() => { - location.reload(); + location.reload(); // Перезагружаем страницу для обновления данных }); } + /** + * Выход из проекта + */ onLeave() { this.project$?.pipe(concatMap(p => this.projectService.leave(p.id))).subscribe( () => { @@ -228,11 +307,15 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { .then(() => console.debug("Route changed from ProjectInfoComponent")); }, () => { - this.leaderLeaveModal = true; + this.leaderLeaveModal = true; // Показываем предупреждение для лидера } ); } + /** + * Передача лидерства другому участнику + * @param id - ID нового лидера + */ onTransferOwnership(id: Collaborator["userId"]) { this.project$ ?.pipe(concatMap(project => this.projectService.switchLeader(project.id, id))) @@ -241,11 +324,17 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { }); } - isUserSubscribed!: boolean; - isUnsubscribeModalOpen = false; - - isLeaveProjectModalOpen = false; - + // Состояние подписки и модальных окон + isUserSubscribed!: boolean; // Флаг подписки пользователя + isUnsubscribeModalOpen = false; // Флаг модального окна отписки + isLeaveProjectModalOpen = false; // Флаг модального окна выхода + isEditDisable = false; // Флаг недоступности редактирования + isEditDisableModal = false; // Флаг недоступности редактирования для модалки + + /** + * Подписка на проект или открытие модального окна отписки + * @param projectId - ID проекта + */ onSubscribe(projectId: number): void { if (this.isUserSubscribed) { this.isUnsubscribeModalOpen = true; @@ -256,6 +345,10 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Отписка от проекта + * @param projectId - ID проекта + */ onUnsubscribe(projectId: number): void { this.subscriptionService.deleteSubscription(projectId).subscribe(() => { this.isUserSubscribed = false; @@ -263,29 +356,62 @@ export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Закрытие модального окна отписки + */ onCloseUnsubscribeModal(): void { this.isUnsubscribeModalOpen = false; } + /** + * Закрытие модального окна предупреждения лидера + */ onCloseLeaderLeaveModal(): void { this.leaderLeaveModal = false; } + /** + * Закрытие модального окна выхода из проекта + */ onCloseLeaveProjectModal(): void { this.isLeaveProjectModalOpen = false; } - openSupport = false; + /** + * Закрытие модального окна для невозможности редактировать проект + */ + onUnableEditingProject(): void { + if (this.isEditDisable) { + this.isEditDisableModal = true; + } else { + this.isEditDisableModal = false; + } + } + + openSupport = false; // Флаг модального окна поддержки + + // Проверка участия пользователя в проекте isInProject = this.project$?.pipe( concatMap(project => forkJoin([of(project), this.authService.profile.pipe(take(1))])), map(([project, profile]) => project.collaborators.map(c => c.userId).includes(profile.id)) ); + /** + * Развертывание/свертывание описания проекта + * @param elem - HTML элемент с описанием + * @param expandedClass - CSS класс для развернутого состояния + * @param isExpanded - текущее состояние (развернуто/свернуто) + */ onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { expandElement(elem, expandedClass, isExpanded); this.readFullDescription = !isExpanded; } + /** + * Получение названий навыков для вакансии + * @param vacancy - объект вакансии + * @returns строка с названиями навыков, разделенными точками + */ getSkillsNames(vacancy: Vacancy) { return vacancy.requiredSkills.map((s: any) => s.name).join(" • "); } diff --git a/projects/social_platform/src/app/office/projects/detail/info/info.resolver.ts b/projects/social_platform/src/app/office/projects/detail/info/info.resolver.ts index aa03f75fc..da40eeb4a 100644 --- a/projects/social_platform/src/app/office/projects/detail/info/info.resolver.ts +++ b/projects/social_platform/src/app/office/projects/detail/info/info.resolver.ts @@ -5,9 +5,35 @@ import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; import { VacancyService } from "@services/vacancy.service"; import { Vacancy } from "@models/vacancy.model"; +/** + * РЕЗОЛВЕР ДЛЯ ЗАГРУЗКИ ВАКАНСИЙ ПРОЕКТА + * + * Этот резолвер загружает список вакансий для конкретного проекта перед отображением компонента. + * Используется в маршрутизации для предварительной загрузки данных. + * + * НАЗНАЧЕНИЕ: + * - Загружает вакансии проекта до инициализации компонента + * - Обеспечивает доступность данных в момент создания компонента + * - Предотвращает отображение пустого состояния во время загрузки + * + * @params + * - route: ActivatedRouteSnapshot - снимок активного маршрута с параметрами + * + * ИЗВЛЕКАЕМЫЕ ДАННЫЕ: + * - projectId - ID проекта из параметров маршрута + * + * @returns: + * - Observable - массив вакансий проекта + * + * @params + * - offset: 0 - начальная позиция для пагинации + * - limit: 20 - максимальное количество вакансий + * - projectId - ID проекта для фильтрации + */ export const ProjectInfoResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { - const vacancyService = inject(VacancyService); - const projectId = Number(route.paramMap.get("projectId")); + const vacancyService = inject(VacancyService); // Инъекция сервиса вакансий + const projectId = Number(route.paramMap.get("projectId")); // Извлечение ID проекта из параметров + // Возвращаем Observable с вакансиями проекта return vacancyService.getForProject(0, 20, projectId); }; diff --git a/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.ts b/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.ts index b4a97f193..c00ce0d64 100644 --- a/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.ts +++ b/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.ts @@ -8,6 +8,30 @@ import { NewsCardComponent } from "@office/shared/news-card/news-card.component" import { AsyncPipe } from "@angular/common"; import { ModalComponent } from "@ui/components/modal/modal.component"; +/** + * КОМПОНЕНТ ДЕТАЛЬНОЙ НОВОСТИ + * + * Этот компонент отображает детальную информацию о новости в модальном окне. + * Используется для просмотра полной версии новости из ленты проекта. + * + * НАЗНАЧЕНИЕ: + * - Отображение детальной информации о новости + * - Управление модальным окном + * - Навигация обратно к списку новостей при закрытии + * + * @params + * - Получает данные новости через резолвер из маршрута + * - Использует параметры newsId и projectId из URL + * + * ОСНОВНАЯ ФУНКЦИОНАЛЬНОСТЬ: + * - Отображение новости в модальном окне + * - Автоматическое открытие модального окна при загрузке + * - Навигация к родительскому маршруту при закрытии модального окна + * + * @returns + * - Отображает HTML-шаблон с модальным окном и карточкой новости + * - Обрабатывает закрытие модального окна через навигацию + */ @Component({ selector: "app-news-detail", templateUrl: "./news-detail.component.html", @@ -16,14 +40,26 @@ import { ModalComponent } from "@ui/components/modal/modal.component"; imports: [ModalComponent, NewsCardComponent, AsyncPipe], }) export class NewsDetailComponent implements OnInit { - constructor(private readonly route: ActivatedRoute, private readonly router: Router) {} + constructor( + private readonly route: ActivatedRoute, // Сервис для работы с активным маршрутом + private readonly router: Router // Сервис для навигации + ) {} + // Observable с данными новости из резолвера newsItem: Observable = this.route.data.pipe(map(r => r["data"])); + ngOnInit(): void {} + /** + * Обработчик изменения состояния модального окна + * При закрытии модального окна (value = false) происходит навигация к родительскому маршруту + * @param value - новое состояние модального окна (открыто/закрыто) + */ onOpenChange(value: boolean) { if (!value) { + // Получаем ID проекта из родительского маршрута const projectId = this.route.parent?.snapshot.params["projectId"]; + // Навигируем обратно к странице проекта this.router .navigateByUrl(`/office/projects/${projectId}`) .then(() => console.debug("Route changed from NewsDetailComponent")); diff --git a/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.resolver.ts b/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.resolver.ts index 327ff5368..4ed78a263 100644 --- a/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.resolver.ts +++ b/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.resolver.ts @@ -5,11 +5,39 @@ import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; import { FeedNews } from "@office/projects/models/project-news.model"; +/** + * РЕЗОЛВЕР ДЛЯ ЗАГРУЗКИ ДЕТАЛЬНОЙ ИНФОРМАЦИИ О НОВОСТИ + * + * Этот резолвер загружает детальную информацию о конкретной новости проекта + * перед отображением компонента детальной новости. + * + * НАЗНАЧЕНИЕ: + * - Загружает детальные данные новости до инициализации компонента + * - Обеспечивает доступность данных в момент создания компонента + * - Предотвращает отображение пустого состояния во время загрузки + * + * @params + * - route: ActivatedRouteSnapshot - снимок активного маршрута с параметрами + * + * ИЗВЛЕКАЕМЫЕ ДАННЫЕ: + * - projectId - ID проекта из родительского маршрута + * - newsId - ID новости из параметров текущего маршрута + * + * @returns + * - Observable - объект с детальной информацией о новости + * + * ОСОБЕННОСТИ: + * - Использует иерархию маршрутов (parent/child) + * - Извлекает параметры из разных уровней маршрутизации + */ export const NewsDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { - const projectNewsService = inject(ProjectNewsService); + const projectNewsService = inject(ProjectNewsService); // Инъекция сервиса новостей проекта + // Извлекаем ID проекта из родительского маршрута const projectId = route.parent?.params["projectId"]; + // Извлекаем ID новости из текущего маршрута const newsId = route.params["newsId"]; + // Возвращаем Observable с детальной информацией о новости return projectNewsService.fetchNewsDetail(projectId, newsId); }; diff --git a/projects/social_platform/src/app/office/projects/detail/responses/responses.component.ts b/projects/social_platform/src/app/office/projects/detail/responses/responses.component.ts index 52ac3c63a..f4cd672dc 100644 --- a/projects/social_platform/src/app/office/projects/detail/responses/responses.component.ts +++ b/projects/social_platform/src/app/office/projects/detail/responses/responses.component.ts @@ -8,6 +8,22 @@ import { VacancyService } from "@services/vacancy.service"; import { NavService } from "@services/nav.service"; import { ResponseCardComponent } from "@office/shared/response-card/response-card.component"; +/** + * Компонент для отображения откликов на вакансии проекта + * + * Функциональность: + * - Отображение списка откликов на вакансии проекта + * - Принятие и отклонение откликов + * - Фильтрация откликов (показывает только неодобренные) + * + * Принимает: + * - Список откликов через резолвер + * - ID проекта из параметров маршрута + * + * Предоставляет: + * - responses - отфильтрованный список откликов + * - Методы для принятия и отклонения откликов + */ @Component({ selector: "app-responses", templateUrl: "./responses.component.html", @@ -25,6 +41,7 @@ export class ProjectResponsesComponent implements OnInit, OnDestroy { ngOnInit(): void { this.navService.setNavTitle("Профиль проекта"); + // Загрузка и фильтрация откликов (только неодобренные) this.responses$ = this.route.data .pipe(map(r => r["data"])) .subscribe((responses: VacancyResponse[]) => { @@ -36,14 +53,22 @@ export class ProjectResponsesComponent implements OnInit, OnDestroy { this.responses$?.unsubscribe(); } + /** Observable с ID проекта из параметров маршрута */ projectId: Observable = this.route.params.pipe( map(r => r["projectId"]), map(Number) ); + /** Подписка на данные откликов */ responses$?: Subscription; + + /** Список откликов для отображения */ responses: VacancyResponse[] = []; + /** + * Принятие отклика на вакансию + * @param responseId - ID отклика для принятия + */ acceptResponse(responseId: number) { this.vacancyService.acceptResponse(responseId).subscribe(() => { const index = this.responses.findIndex(el => el.id === responseId); @@ -51,6 +76,10 @@ export class ProjectResponsesComponent implements OnInit, OnDestroy { }); } + /** + * Отклонение отклика на вакансию + * @param responseId - ID отклика для отклонения + */ rejectResponse(responseId: number) { this.vacancyService.rejectResponse(responseId).subscribe(() => { const index = this.responses.findIndex(el => el.id === responseId); diff --git a/projects/social_platform/src/app/office/projects/detail/responses/responses.resolver.ts b/projects/social_platform/src/app/office/projects/detail/responses/responses.resolver.ts index a8a2b2c28..2cfa12afe 100644 --- a/projects/social_platform/src/app/office/projects/detail/responses/responses.resolver.ts +++ b/projects/social_platform/src/app/office/projects/detail/responses/responses.resolver.ts @@ -5,6 +5,18 @@ 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 ) => { 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 index 7a7781603..03319cf99 100644 --- 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 @@ -9,61 +9,138 @@ 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 { - storageService = inject(StorageService); - apiService = inject(ApiService); + 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>( - `/projects/${projectId}/news/`, - new HttpParams({ fromObject: { limit: 100 } }) + `${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(`/projects/${projectId}/news/${newsId}`) - .pipe(map(r => plainToInstance(FeedNews, r))); + .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(`/projects/${projectId}/news/`, obj) + .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)) + .filter(id => !readNews.includes(id)) // Фильтруем уже просмотренные .map(id => - this.apiService.post(`/projects/${projectId}/news/${id}/set_viewed/`, {}).pipe( - tap(() => { - this.storageService.setItem("readNews", [...readNews, id], sessionStorage); - }) - ) + 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(`/projects/${projectId}/news/${newsId}/`); + 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(`/projects/${projectId}/news/${newsId}/set_liked/`, { + 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(`/projects/${projectId}/news/${newsId}/`, newsItem) + .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.ts b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.ts index b21715138..72ddffdd6 100644 --- 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 @@ -7,6 +7,36 @@ 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, @@ -15,18 +45,18 @@ import { AvatarComponent, IconComponent } from "@uilib"; RouterLink, IconComponent, AvatarComponent, - CdkMenuTrigger, - CdkMenuItem, - CdkMenu, + 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; + @Input({ required: true }) member!: Collaborator; // Данные участника (обязательное поле) + @Input() isLeader = false; // Флаг лидера проекта + @Input() manageRights = false; // Флаг прав управления - @Output() remove = new EventEmitter(); - @Output() transferOwnership = new EventEmitter(); + @Output() remove = new EventEmitter(); // Событие удаления участника + @Output() transferOwnership = new EventEmitter(); // Событие передачи лидерства } 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 index 6aad1a5ad..168ea8126 100644 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.html +++ b/projects/social_platform/src/app/office/projects/edit/edit.component.html @@ -8,674 +8,73 @@
    - +
    @if (editingStep === "main") { - -
    -
    -
    - @if (projectForm.get("imageAddress"); as imagesAddress) { -
    - - @if ((imagesAddress | controlError: "required") && projSubmitInitiated) { -
    - {{ errorMessage.EMPTY_AVATAR }} -
    - } -
    - } -
    - -
    - @if (projectForm.get("name"); as name) { -
    - - - @if ((name | controlError: "required") && projSubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (projectForm.get("region"); as region) { -
    - - - @if ((region | controlError: "required") && projSubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    -
    - -
    - @if (projectForm.get("industryId"); as industry) { -
    - - @if (industries$ | async; as industries) { - - } @if (industry | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (projectForm.get("step"); as step) { -
    - - @if (projectSteps$ | async; as steps) { - - } @if (step | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (projectForm.get("description"); as description) { -
    - - - @if ((description | controlError: "required") && projSubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (projectForm.get("actuality"); as actuality) { -
    - - - @if ((actuality | controlError: "required")) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (projectForm.get("goal"); as goal) { -
    - - - @if ((goal | controlError: "required") && projSubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (projectForm.get("problem"); as problem) { -
    - - - @if ((problem | controlError: "required") && projSubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (projectForm.get("track"); as track) { -
    - - @if ((track | controlError: "required")) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (projectForm.get("direction"); as direction) { -
    - - @if ((direction | controlError: "required")) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    -
    - -
    - @if (programTagsOptions.length) { -
    - - -
    - } @if (projectForm.get("presentationAddress"); as presentationAddress) { -
    - - - -

    - Добавьте  - файл  презентации -

    -

    - Презентации формата .PDF или .PPTX весом до 50МБ -

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

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

    - } -
    -
    -
    - } @if (projectForm.get("coverImageAddress"); as coverImageAddress) { -
    - - - -

    - Добавьте  - обложку  проекта -

    -

    - Презентации формата .jpg, .jpeg, .png -

    -

    Размер изображения 1280 x 230

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

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

    - } -
    -
    -
    - } -
    - - } @else if (editingStep === "contacts") { @if(projectForm.get('link'); as link){ -
    - - - @if (link | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - -
    - - Добавить ещё одну ссылку - - - -
      - @if(linksItems().length || links.length){ @for (linkItem of links.value; track $index) { -
    • - -
    • - } } -
    -
    + + + } @else if (editingStep === "contacts") { + } @else if (editingStep === "achievements") { -
    -
    - @if(projectForm.get('achievementsName'); as achievementsName){ -
    - - - @if ( !!( (achievementsName | controlError: "required") && - projectForm.get('achievementsName')?.touched && projSubmitInitiated ) ) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    - @if (projectForm.get('achievementsPrize'); as achievementsPrize) { -
    - - @if ( !!( (achievementsPrize | controlError: "required") && - projectForm.get('achievementsPrize')?.touched && projSubmitInitiated ) ) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    -
    - - Добавить достижение - - - -
      - @if(achievementsItems().length || achievements.length){ @for (achievementItem of - achievements.value; track $index) { -
    • - -
    • - } } -
    -
    + } @else if (editingStep === "vacancies"){ -
    - - - @if (vacancyForm.get("role"); as role) { -
    - - @if ((role | controlError: "required") && vacancySubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (vacancyForm.get("description"); as description) { -
    - - @if ((description | controlError: "required") && vacancySubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - -
    -
    - @if (vacancyForm.get("requiredExperience"); as requiredExperience) { -
    - - @if (requiredExperience | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (vacancyForm.get("workFormat"); as workFormat) { -
    - - @if (workFormat | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    - -
    - @if (vacancyForm.get("salary"); as salary) { -
    - - @if ((salary | controlError: "required") && vacancySubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (vacancyForm.get("workSchedule"); as workSchedule) { -
    - - @if (workSchedule | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    -
    - - - - @if (vacancyForm.get("skills"); as skills) { -
    - - @if (skills | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - - - @if (vacancyForm.get("specialization"); as specialization) { -
    - - @if ((specialization | controlError: "required") && vacancySubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - -
    - -
    - - Создать вакансию - - - -
      - @for (vacancy of vacancies; track vacancy.id) { -
    • - -
    • - } -
    -
    + } @else if (editingStep === "team") { -
    -
    - -
      - @for (user of invites; track user.id) { @if (user.isAccepted === null) { -
    • - -
    • - } } -
    - - Пригласить участника - - -
    - - -
    -
    - -
    - @if (inviteForm.get("role"); as role) { -
    - - - @if ((role | controlError: "required") && inviteSubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (inviteForm.get("link"); as link) { - - } - - @if (inviteForm.get("specialization"); as specialization) { -
    - - @if ((specialization | controlError: "required") && inviteSubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    -
    - - Отправить -
    -
    -
    + + } @else if (editingStep === "additional") { + }
    + + Сохранить черновик + - Сохранить + {{ isCompetitive ? "Отправить заявку" : "Сохранить" }}
    - +

    📢 Внимание!

    @@ -690,6 +89,65 @@

    📢 Внимание!

    + +
    +
    + +

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

    +
    + +

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

    + + Понятно +
    +
    + + +
    +
    + +

    Поздравляем!

    +
    + +

    + Мы создали дубликат проекта, который вы привязали к выбранной программе + {{ assignProjectToProgramModalMessage()?.partnerProgram }}, теперь его можно отредактировать! +

    + + Понятно +
    +
    +
    @@ -728,3 +186,38 @@

    Библиотека навыков

    Сохранить
    + + +
    +
    + + end +

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

    +
    + +

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

    + +
    + Отмена + Отправить +
    +
    +
    diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.scss b/projects/social_platform/src/app/office/projects/edit/edit.component.scss index 9bf466938..f7b04193a 100644 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.scss +++ b/projects/social_platform/src/app/office/projects/edit/edit.component.scss @@ -49,51 +49,6 @@ } } - &__navigation { - width: 100%; - padding: 10px 0; - margin-bottom: 22px; - border-bottom: 1px solid var(--grey-button); - } - - &__nav { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - justify-content: center; - - @include responsive.apply-desktop { - gap: 0; - justify-content: space-between; - } - } - - &__item { - display: flex; - flex-direction: column; - align-items: center; - cursor: pointer; - } - - &__subtitle { - color: var(--grey-for-text); - - @include typography.heading-4; - - &--active { - color: var(--black); - } - } - - &__icon { - opacity: 0.5; - - &--active { - opacity: 1; - } - } - &__inner { width: 100%; margin-bottom: 25px; @@ -137,10 +92,6 @@ } } - &__tags { - margin-bottom: 10px; - } - &__image { display: block; margin-bottom: 20px; @@ -155,94 +106,6 @@ justify-content: space-between; } - &__generals { - margin-bottom: 10px; - - :first-child { - margin-bottom: 10px; - } - } - - &__additional { - fieldset { - margin-bottom: 10px; - } - } - - &__file { - min-width: 0; - } - - &__nav-item { - color: var(--gray); - cursor: pointer; - - @include typography.heading-4; - - @include responsive.apply-desktop { - @include typography.heading-3; - } - - &:not(:last-child) { - margin-right: 24px; - } - - &--active { - color: var(--black); - } - } - - &__slides-title { - max-width: 320px; - margin-top: 12px; - color: var(--black); - text-align: center; - } - - &__slides-text { - max-width: 275px; - margin-top: 12px; - color: var(--dark-grey); - text-align: center; - } - - &__slides-error { - margin-top: 12px; - color: var(--red); - } - - &__slides-open-file { - color: var(--accent); - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } - } - - &__add-link { - i { - margin-left: 10px; - } - } - - &__achievement-item { - &:not(:last-child) { - margin-bottom: 12px; - } - } - - &__team { - display: flex; - justify-content: space-between; - width: 100%; - padding: 0; - - @include responsive.apply-desktop { - padding: 60px 50px; - } - } - &__or { margin: 20px 0; color: var(--dark-grey); @@ -252,9 +115,12 @@ @include responsive.apply-desktop { position: absolute; right: 24px; - bottom: 24px; + bottom: 48px; + display: flex; + gap: 10px; + justify-content: flex-end; order: unset; - width: 255px; + width: 90%; app-button { align-self: flex-end; @@ -263,24 +129,8 @@ } } - &__invite { - display: flex; - flex-direction: column; - - app-button { - width: 100%; - - @include typography.body-12; - - @include responsive.apply-desktop { - width: 25%; - - @include typography.bold-body-16; - } - } - } - &__warning-modal { + z-index: 1000; display: flex; flex-direction: column; gap: 20px; @@ -296,106 +146,6 @@ } } -.achievement { - &__first-row { - display: flex; - - // align-items: flex-start; - margin-bottom: 12px; - - > :first-child { - flex-grow: 1; - } - } - - &__remove { - width: 155px; - margin-left: 10px; - } -} - -.invite { - &__title { - margin-bottom: 12px; - } - - &__role { - margin-bottom: 12px; - } - - &__link { - margin-bottom: 12px; - } - - &__item { - margin-bottom: 12px; - } - - &__list { - width: 557px; - width: 100%; - margin-top: 10px; - } - - &__team { - grid-template-columns: repeat(1, 270px); - margin-top: 10px; - - @include responsive.apply-desktop { - grid-template-columns: repeat(3, 350px); - } - } -} - -.vacancy { - &__item { - margin-bottom: 12px; - } - - fieldset { - margin-bottom: 10px; - } - - &__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; - } - - &__description { - ::ng-deep { - app-input { - .field__input { - padding: 40px 20px; - } - } - } - } -} - .skill { i { color: var(--red); @@ -403,29 +153,6 @@ } } -.vacancies { - display: flex; - - &__input { - flex-grow: 1; - margin-right: 6px; - } - - ::ng-deep { - app-autocomplete-input { - .field__input { - padding: 12px 20px; - - @include typography.body-16; - } - } - } - - &__button { - width: 52px; - } -} - .project-bar { padding: 9px 0; margin-bottom: 12px; @@ -508,6 +235,12 @@ } } + &__top { + display: flex; + flex-direction: column; + margin-bottom: 10px; + } + &__title { text-align: center; } @@ -515,4 +248,24 @@ &__text { text-align: center; } + + &__buttons { + display: flex; + gap: 10px; + align-items: center; + margin-top: 20px; + + ::ng-deep { + app-button { + .button { + padding-right: 52px; + padding-left: 52px; + } + } + } + } + + &__button { + margin-top: 20px; + } } 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 index 382ebc2f8..d35c17a7d 100644 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.ts +++ b/projects/social_platform/src/app/office/projects/edit/edit.component.ts @@ -1,6 +1,5 @@ /** @format */ -import { AsyncPipe, CommonModule } from "@angular/common"; import { AfterViewInit, ChangeDetectorRef, @@ -9,59 +8,76 @@ import { OnInit, signal, } from "@angular/core"; -import { - AbstractControl, - FormArray, - FormBuilder, - FormGroup, - ReactiveFormsModule, - Validators, -} from "@angular/forms"; +import { 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 { Vacancy } from "@models/vacancy.model"; import { Skill } from "@office/models/skill"; import { ProgramTag } from "@office/program/models/program.model"; import { ProgramService } from "@office/program/services/program.service"; import { SkillsService } from "@office/services/skills.service"; -import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; import { IndustryService } from "@services/industry.service"; -import { InviteService } from "@services/invite.service"; import { NavService } from "@services/nav.service"; import { ProjectService } from "@services/project.service"; -import { VacancyService } from "@services/vacancy.service"; -import { - BarComponent, - ButtonComponent, - IconComponent, - InputComponent, - SelectComponent, -} from "@ui/components"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; +import { ButtonComponent, IconComponent, SelectComponent } from "@ui/components"; import { ModalComponent } from "@ui/components/modal/modal.component"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; -import { ControlErrorPipe, ValidationService } from "projects/core"; -import { Observable, Subscription, concatMap, distinctUntilChanged, filter, map, tap } from "rxjs"; -import { InviteCardComponent } from "../../shared/invite-card/invite-card.component"; -import { VacancyCardComponent } from "../../shared/vacancy-card/vacancy-card.component"; -import { LinkCardComponent } from "@office/shared/link-card/link-card.component"; -import { navItems } from "projects/core/src/consts/navProjectItems"; -import { experienceList } from "projects/core/src/consts/list-experience"; -import { formatList } from "projects/core/src/consts/list-format"; -import { scheludeList } from "projects/core/src/consts/list-schelude"; -import { trackProjectList } from "projects/core/src/consts/list-track-project"; -import { rolesMembersList } from "projects/core/src/consts/list-roles-members"; -import { directionProjectList } from "projects/core/src/consts/list-direction-project"; -import { CheckboxComponent } from "../../../ui/components/checkbox/checkbox.component"; -import { stringToArray } from "linkifyjs"; -import { stripNullish } from "@utils/stripNull"; - +import { ValidationService } from "projects/core"; +import { + Observable, + Subscription, + concatMap, + distinctUntilChanged, + finalize, + map, + tap, +} from "rxjs"; +import { CommonModule, AsyncPipe } from "@angular/common"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProjectAssign } from "../models/project-assign.model"; +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 { ProjectContactsStepComponent } from "./shared/project-contacts-step/project-contacts-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"; + +/** + * Компонент редактирования проекта + * + * Функциональность: + * - Многошаговое редактирование проекта (основная информация, контакты, достижения, вакансии, команда) + * - Управление формами для проекта, вакансий и приглашений + * - Загрузка файлов (презентация, обложка, аватар) + * - Создание и редактирование вакансий с навыками + * - Приглашение участников в команду + * - Управление достижениями и ссылками проекта + * - Сохранение как черновик или публикация + * + * Принимает: + * - ID проекта из URL параметров + * - Данные проекта и приглашений через resolver + * - Query параметр editingStep для определения активного шага + * + * Возвращает: + * - Интерфейс редактирования с навигацией по шагам + * - Формы для ввода данных проекта + * - Модальные окна для управления навыками и приглашениями + * + * Особенности: + * - Реактивные формы с валидацией + * - Динамическое управление массивами (достижения, ссылки) + * - Интеграция с внешними сервисами (навыки, программы) + * - Поддержка автокомплита для навыков + */ @Component({ selector: "app-edit", templateUrl: "./edit.component.html", @@ -71,261 +87,147 @@ import { stripNullish } from "@utils/stripNull"; ReactiveFormsModule, CommonModule, RouterModule, - AvatarControlComponent, - InputComponent, IconComponent, ButtonComponent, - SelectComponent, - TextareaComponent, - UploadFileComponent, - InviteCardComponent, - VacancyCardComponent, - LinkCardComponent, - TagComponent, ModalComponent, AsyncPipe, - ControlErrorPipe, - AutoCompleteInputComponent, - SkillsBasketComponent, SkillsGroupComponent, - BarComponent, - TextareaComponent, - CheckboxComponent, + ProjectNavigationComponent, + ProjectMainStepComponent, + ProjectContactsStepComponent, + ProjectAchievementStepComponent, + ProjectVacancyStepComponent, + ProjectTeamStepComponent, + ProjectAdditionalStepComponent, ], }) export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { constructor( private readonly route: ActivatedRoute, private readonly router: Router, - private readonly fb: FormBuilder, private readonly industryService: IndustryService, protected readonly projectService: ProjectService, private readonly navService: NavService, private readonly validationService: ValidationService, - private readonly vacancyService: VacancyService, - private readonly inviteService: InviteService, private readonly cdRef: ChangeDetectorRef, private readonly programService: ProgramService, - private readonly skillsService: SkillsService - ) { - this.projectForm = this.fb.group({ - imageAddress: [""], - name: ["", [Validators.required]], - region: ["", [Validators.required]], - step: [null, [Validators.required]], - track: [""], - direction: [""], - links: this.fb.array([]), - link: [""], - industryId: [undefined, [Validators.required]], - description: ["", [Validators.required]], - presentationAddress: ["", [Validators.required]], - coverImageAddress: ["", Validators.required], - actuality: ["", [Validators.max(1000)]], - goal: ["", [Validators.required, Validators.max(500)]], - problem: ["", [Validators.required, Validators.max(1000)]], - partnerProgramId: [null], - achievements: this.fb.array([]), - achievementsName: [""], - achievementsPrize: [""], - draft: [null], - }); + private readonly projectStepService: ProjectStepService, + private readonly projectFormService: ProjectFormService, + private readonly projectVacancyService: ProjectVacancyService, + private readonly projectTeamService: ProjectTeamService, + private readonly projectAchievementsService: ProjectAchievementsService, + private readonly skillsService: SkillsService, + private readonly projectAdditionalService: ProjectAdditionalService + ) {} - this.vacancyForm = this.fb.group({ - role: [null], - skills: [[]], - description: [""], - requiredExperience: [null], - workFormat: [null], - salary: [""], - workSchedule: [null], - specialization: [null], - }); + // Получаем форму проекта из сервиса + get projectForm(): FormGroup { + return this.projectFormService.getForm(); + } - this.inviteForm = this.fb.group({ - role: ["", [Validators.required]], - // eslint-disable-next-line - link: [ - "", - [ - Validators.required, - Validators.pattern(/^http(s)?:\/\/.+(:[0-9]*)?\/office\/profile\/\d+$/), - ], - ], - specialization: [null], - }); + // Получаем форму вакансии из сервиса + get vacancyForm(): FormGroup { + return this.projectVacancyService.getVacancyForm(); } - projectForm: FormGroup; - vacancyForm: FormGroup; - inviteForm: FormGroup; + // Получаем форму вакансии из сервиса + get additionalForm(): FormGroup { + return this.projectAdditionalService.getAdditionalForm(); + } - ngOnInit(): void { - this.navService.setNavTitle("Создание проекта"); - const controls: (AbstractControl | null)[] = [ - this.inviteForm.get("role"), - this.inviteForm.get("link"), - this.inviteForm.get("specialization"), - this.vacancyForm.get("role"), - this.vacancyForm.get("skills"), - this.vacancyForm.get("description"), - this.vacancyForm.get("requiredExperience"), - this.vacancyForm.get("workFormat"), - this.vacancyForm.get("salary"), - this.vacancyForm.get("workSchedule"), - this.vacancyForm.get("specialization"), - ]; - - controls.filter(Boolean).forEach(control => { - this.subscriptions.push( - control?.valueChanges - .pipe( - distinctUntilChanged(), - tap(() => { - if (control === this.inviteForm.get("link") && this.inviteNotExistingError) { - this.inviteNotExistingError = null; - } - }), - filter(value => value === "") - ) - .subscribe(() => { - if (control === this.inviteForm.get("link")) { - control?.clearValidators(); - } else { - control?.removeValidators([Validators.required]); - } - control?.updateValueAndValidity(); - }) - ); - }); + // Получаем сигналы из сервиса + get achievements() { + return this.projectFormService.achievements; + } - this.projectForm - .get("presentationAddress") - ?.valueChanges.pipe( - filter(r => !r), - concatMap(() => - this.projectService.updateProject(Number(this.route.snapshot.params["projectId"]), { - presentationAddress: "", - draft: true, - }) - ) - ) - .subscribe(() => {}); - - this.projectForm - .get("coverImageAddress") - ?.valueChanges.pipe( - filter(r => !r), - concatMap(() => - this.projectService.updateProject(Number(this.route.snapshot.params["projectId"]), { - coverImageAddress: "", - draft: true, - }) - ) - ) - .subscribe(() => {}); + // Id редатируемой части проекта + get editIndex() { + return this.projectFormService.editIndex; + } - this.editingStep = this.route.snapshot.queryParams["editingStep"]; + // Id связи проекта и программы + get relationId() { + return this.projectFormService.relationId; } - ngAfterViewInit(): void { - this.programService - .programTags() - .pipe( - tap(tags => { - this.programTags = tags; - }), - map(tags => [ - { label: "Без тега", value: 0, id: 0 }, - ...tags.map(t => ({ label: t.name, value: t.id, id: t.id })), - ]), - tap(tags => { - this.programTagsOptions = tags; - }), - concatMap(() => this.route.data), - map(d => d["data"]) - ) - .subscribe(([project, invites]: [Project, Invite[]]) => { - this.projectForm.patchValue({ - imageAddress: project.imageAddress, - name: project.name, - region: project.region, - step: project.step, - industryId: project.industry, - description: project.description, - track: project.track, - direction: project.direction, - actuality: project.actuality ?? "", - goal: project.goal ?? "", - problem: project.problem ?? "", - presentationAddress: project.presentationAddress, - coverImageAddress: project.coverImageAddress, - }); + // Геттеры для доступа к данным из сервиса дополнительных полей + get partnerProgramFields() { + return this.projectAdditionalService.getPartnerProgramFields(); + } - if (project.partnerProgramsTags?.length) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const tag = this.programTags.find(p => p.tag === project.partnerProgramsTags[0]); - - this.projectForm.patchValue({ - partnerProgramId: tag?.id, - }); - } else { - this.projectForm.patchValue({ - partnerProgramId: null, - }); - } + get isAssignProjectToProgramError() { + return this.projectAdditionalService.getIsAssignProjectToProgramError()(); + } - this.achievements.clear(); - project.achievements.forEach(achievement => { - this.achievements.push( - this.fb.group({ - id: achievement.id, - title: achievement.title, - status: achievement.status, - }) - ); - }); + get errorAssignProjectToProgramModalMessage() { + return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); + } - this.links.clear(); - project.links.forEach(link => { - this.links.push(this.fb.control(link)); - }); + // Методы для управления состоянием ошибок через сервис + setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { + this.projectAdditionalService.setAssignProjectToProgramError(error); + } + + clearAssignProjectToProgramError(): void { + this.projectAdditionalService.clearAssignProjectToProgramError(); + } - this.vacancies = project.vacancies; + ngOnInit(): void { + this.navService.setNavTitle("Создание проекта"); - this.invites = invites; + // Получение текущего шага редактирования из query параметров + this.setupEditingStep(); - this.cdRef.detectChanges(); - }); + // Получение Id лидера проекта + this.setupLeaderIdSubscription(); } - programTagsOptions: SelectComponent["options"] = []; - programTags: ProgramTag[] = []; + ngAfterViewInit(): void { + // Загрузка данных программных тегов и проекта + this.loadProgramTagsAndProject(); + } ngOnDestroy(): void { this.profile$?.unsubscribe(); this.subscriptions.forEach($ => $?.unsubscribe()); } - /** - * Current step of toggle, that navigates through - * parts of project info - */ - editingStep: "main" | "contacts" | "achievements" | "vacancies" | "team" = "main"; + // Опции для программных тегов + programTagsOptions: SelectComponent["options"] = []; + programTags: ProgramTag[] = []; + + // Id Лидера проекта + leaderId = 0; + // Маркер того является ли проект привязанный к конкурсной программе + isCompetitive = false; + + // Текущий шаг редактирования + get editingStep(): EditStep { + return this.projectStepService.getCurrentStep()(); + } + + // Состояние компонента isCompleted = false; + isSendDescisionToPartnerProgramProject = false; profile$?: Subscription; - errorMessage = ErrorMessage; + // Сигналы для работы с модальными окнами с ошибкой errorModalMessage = signal<{ program_name: string; whenCanEdit: string; daysUntilResolution: string; } | null>(null); + onEditClicked = signal(false); + warningModalSeen = false; + + // Сигналы для работы с модальными окнами с текстом + assignProjectToProgramModalMessage = signal(null); + + // Observables для данных industries$ = this.industryService.industries.pipe( map(industries => industries.map(industry => ({ value: industry.id, id: industry.id, label: industry.name })) @@ -338,239 +240,54 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { subscriptions: (Subscription | undefined)[] = []; - readonly navItems = navItems; - - readonly experienceList = experienceList; - - readonly formatList = formatList; - - readonly scheludeList = scheludeList; - - readonly trackList = trackProjectList; - - readonly directionList = directionProjectList; - - readonly rolesMembersList = rolesMembersList; - profileId: number = this.route.snapshot.params["projectId"]; - vacancies: Vacancy[] = []; - + // Сигналы для управления состоянием inlineSkills = signal([]); - nestedSkills$ = this.skillsService.getSkillsNested(); - skillsGroupsModalOpen = signal(false); - isInviteModalOpen = signal(false); - - vacancySubmitInitiated = false; - vacancyIsSubmitting = false; - - selectedRequiredExperienceId = signal(undefined); - selectedWorkFormatId = signal(undefined); - selectedWorkScheduleId = signal(undefined); - selectedVacanciesSpecializationId = signal(undefined); - - onEditClicked = signal(false); - - submitVacancy(): void { - [ - "role", - "skills", - "description", - "requiredExperience", - "workFormat", - "salary", - "workSchedule", - "specialization", - ].forEach(name => this.vacancyForm.get(name)?.clearValidators()); - - ["role", "skills", "requiredExperience", "workFormat", "workSchedule"].forEach(name => - this.vacancyForm.get(name)?.setValidators([Validators.required]) - ); - - this.vacancyForm - .get("salary") - ?.setValidators([Validators.pattern("^(\\d{1,3}( \\d{3})*|\\d+)$")]); - - ["role", "skills", "requiredExperience", "workFormat", "salary", "workSchedule"].forEach(name => - this.vacancyForm.get(name)?.updateValueAndValidity() - ); - - ["role", "skills"].forEach(name => this.vacancyForm.get(name)?.markAsTouched()); - - this.vacancySubmitInitiated = true; - - if (!this.validationService.getFormValidation(this.vacancyForm)) return; - - this.vacancyIsSubmitting = true; - const vacancy = { - ...this.vacancyForm.value, - requiredSkillsIds: this.vacancyForm.value.skills.map((s: Skill) => s.id), - salary: - typeof this.vacancyForm.get("salary")?.value === "string" - ? +this.vacancyForm.get("salary")?.value - : null, - }; - this.vacancyService - .postVacancy(Number(this.route.snapshot.paramMap.get("projectId")), vacancy) - .subscribe({ - next: vacancy => { - this.vacancies.push(vacancy); - [ - "role", - "skills", - "description", - "requiredExperience", - "workFormat", - "salary", - "workSchedule", - "specialization", - ].forEach(name => { - this.vacancyForm.get(name)?.reset(); - this.vacancyForm.get(name)?.setValue(""); - this.vacancyForm.get(name)?.setValue([]); - this.vacancyForm.get(name)?.clearValidators(); - this.vacancyForm.get(name)?.markAsPristine(); - this.vacancyForm.get(name)?.updateValueAndValidity(); - this.vacancyForm.reset(); - }); - this.vacancyIsSubmitting = false; - }, - error: () => { - this.vacancyIsSubmitting = false; - }, - }); - } - - removeVacancy(vacancyId: number): void { - if (!confirm("Вы точно хотите удалить вакансию?")) return; - - this.vacancyService.deleteVacancy(vacancyId).subscribe(() => { - const index = this.vacancies.findIndex(vacancy => vacancy.id === vacancyId); - this.vacancies.splice(index, 1); - }); - } - - editVacancy(index: number): void { - const vacancyItem = this.vacancies[index]; - - this.experienceList.forEach(experience => { - if (experience.value === vacancyItem.requiredExperience) { - this.selectedRequiredExperienceId.set(experience.id); - } - }); - - this.formatList.forEach(format => { - if (format.value === vacancyItem.workFormat) { - this.selectedWorkFormatId.set(format.id); - } - }); - - this.scheludeList.forEach(schelude => { - if (schelude.value === vacancyItem.workSchedule) { - this.selectedWorkScheduleId.set(schelude.id); - } - }); - - this.rolesMembersList.forEach(specialization => { - if (specialization.value === vacancyItem.specialization) { - this.selectedVacanciesSpecializationId.set(specialization.id); - } - }); - - this.vacancyForm.patchValue({ - role: vacancyItem.role, - skills: vacancyItem.requiredSkills, - description: vacancyItem.description, - requiredExperience: vacancyItem.requiredExperience, - workFormat: vacancyItem.workFormat, - salary: vacancyItem.salary ?? null, - workSchedule: vacancyItem.workSchedule, - specialization: vacancyItem.specialization, - }); - - this.editIndex.set(index); + isAssignProjectToProgramModalOpen = signal(false); - this.onEditClicked.set(true); - } + // Состояние отправки форм + projSubmitInitiated = false; + projFormIsSubmittingAsPublished = false; + projFormIsSubmittingAsDraft = false; - navigateStep(step: string): void { - this.router.navigate([], { queryParams: { editingStep: step } }); - this.editingStep = step as "main" | "contacts" | "team" | "achievements" | "vacancies"; + /** + * Навигация между шагами редактирования + * @param step - название шага + */ + navigateStep(step: EditStep): void { + this.projectStepService.navigateToStep(step); } - inviteSubmitInitiated = false; - inviteFormIsSubmitting = false; - - inviteNotExistingError: Error | null = null; - - invites: Invite[] = []; - invitesFill = this.invites.every(invite => invite.isAccepted !== null); - - submitInvite(): void { - this.inviteSubmitInitiated = true; - - if (!this.validationService.getFormValidation(this.inviteForm)) return; - - this.inviteFormIsSubmitting = true; - const link = new URL(this.inviteForm.get("link")?.value); - const path = link.pathname.split("/"); - - this.inviteService - .sendForUser( - Number(path[path.length - 1]), + /** + * Привязка проекта к программе выбранной + * Перенаправление её на редактирование "нового" проекта + */ + assignProjectToProgram(): void { + this.projectService + .assignProjectToProgram( Number(this.route.snapshot.paramMap.get("projectId")), - this.inviteForm.get("role")?.value, - this.inviteForm.get("specialization")?.value + this.projectForm.get("partnerProgramId")?.value ) .subscribe({ - next: invite => { - this.invites.push(invite); - this.inviteForm.reset(); - this.inviteFormIsSubmitting = false; - this.isInviteModalOpen.set(false); + next: r => { + this.assignProjectToProgramModalMessage.set(r); + this.isAssignProjectToProgramModalOpen.set(true); }, - error: () => { - this.inviteFormIsSubmitting = false; - }, - }); - } - editInvitation({ - inviteId, - role, - specialization, - }: { - inviteId: number; - role: string; - specialization: string; - }): void { - this.inviteService.updateInvite(inviteId, role, specialization).subscribe({ - next: () => { - this.invites.map(invite => { - if (invite.id === inviteId) { - invite.role = role; - invite.specialization = specialization; + error: err => { + if (err instanceof HttpErrorResponse) { + if (err.status === 400) { + this.setAssignProjectToProgramError(err.error); + } } - return this.invites; - }); - }, - }); - } - - removeInvitation(invitationId: number): void { - this.inviteService.revokeInvite(invitationId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === invitationId); - this.invites.splice(index, 1); - }); + }, + }); } - projSubmitInitiated = false; - - projFormIsSubmittingAsPublished = false; - projFormIsSubmittingAsDraft = false; - + // Методы для управления состоянием отправки форм setIsSubmittingAsPublished(status: boolean): void { this.projFormIsSubmittingAsPublished = status; } @@ -581,92 +298,89 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { setProjFormIsSubmitting!: (status: boolean) => void; - achievementsItems = signal([]); - - get achievements(): FormArray { - return this.projectForm.get("achievements") as FormArray; - } - - addAchievement(): void { - const achievementItem = this.fb.group({ - id: this.achievements.length, - title: this.projectForm.get("achievementsName")?.value, - status: this.projectForm.get("achievementsPrize")?.value, - }); - if (this.editIndex() !== null) { - this.achievementsItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = achievementItem.value; - this.achievements.at(this.editIndex()!).patchValue(achievementItem.value); - return updatedItems; - }); - this.editIndex.set(null); + /** + * Выполнение сохранения проекта + * Из дочернего компонента project-main-step через emit + * + * @param event тип проекта для публикации или для черновика + */ + onSaveProject(event: { type: "draft" | "published" }): void { + if (event.type === "draft") { + this.saveProjectAsDraft(); } else { - this.achievementsItems.update(items => [...items, achievementItem.value]); - this.achievements.push(achievementItem); + this.saveProjectAsPublished(); } - this.projectForm.get("achievementsName")?.reset(); - this.projectForm.get("achievementsPrize")?.reset(); - } - - editAchievement(index: number) { - const achievementItem = - this.achievementsItems().length > 0 - ? this.achievementsItems()[index] - : this.achievements.value[index]; - - this.projectForm.patchValue({ - achievementsName: achievementItem.title, - achievementsPrize: achievementItem.status, - }); - this.editIndex.set(index); - } - - removeAchievement(i: number): void { - this.achievementsItems.update(items => items.filter((_, index) => index !== i)); - - this.achievements.removeAt(i); } + /** + * Очистка всех ошибок валидации + */ clearAllValidationErrors(): void { - Object.keys(this.projectForm.controls).forEach(ctrl => { - this.projectForm.get(ctrl)?.setErrors(null); - }); - this.clearAllAchievementsErrors(); - } - - clearAllAchievementsErrors(): void { - this.achievements.controls.forEach(achievementForm => { - Object.keys(achievementForm).forEach(ctrl => { - this.projectForm.get(ctrl)?.setErrors(null); - }); - }); + // Очистка основной формы + this.projectFormService.clearAllValidationErrors(); + this.projectAchievementsService.clearAllAchievementsErrors(this.achievements); } + /** + * Сохранение проекта как опубликованного с проверкой доп. полей + */ saveProjectAsPublished(): void { - this.projSubmitInitiated = true; this.projectForm.get("draft")?.patchValue(false); this.setProjFormIsSubmitting = this.setIsSubmittingAsPublished; - this.submitProjectForm(); + + 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: partnerProgramId }); + this.projectForm.patchValue({ partnerProgramId }); this.submitProjectForm(); } + /** + * Отправка формы проекта + */ submitProjectForm(): void { - this.achievements.controls.forEach(achievementForm => { + this.projectFormService.achievements.controls.forEach(achievementForm => { achievementForm.markAllAsTouched(); }); - const payload = stripNullish(this.projectForm.value); - if (!this.validationService.getFormValidation(this.projectForm)) { + const payload = this.projectFormService.getFormValue(); + + if ( + !this.validationService.getFormValidation(this.projectForm) || + !this.validationService.getFormValidation(this.additionalForm) + ) { return; } @@ -684,68 +398,74 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { }); } - editIndex = signal(null); - - linksItems = signal([]); - - get links(): FormArray { - return this.projectForm.get("links") as FormArray; + // Методы для работы с модальными окнами + closeWarningModal(): void { + this.warningModalSeen = true; } - addLink() { - const linkValue = this.projectForm.get("link")?.value; - - if (linkValue) { - if (this.editIndex() !== null) { - this.links.at(this.editIndex()!).setValue(linkValue); - this.linksItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = linkValue; - return updatedItems; - }); - this.editIndex.set(null); - } else { - this.links.push(this.fb.control(linkValue)); - this.linksItems.update(items => [...items, linkValue]); - } - this.projectForm.get("link")?.reset(); - } - } + closeSendingDescisionModal(): void { + this.isSendDescisionToPartnerProgramProject = false; - editLink(index: number) { - const linkItem = - this.linksItems().length > 0 ? this.linksItems()[index] : this.links.value[index]; + const projectId = Number(this.route.snapshot.params["projectId"]); + const relationId = this.relationId(); - this.projectForm.patchValue({ link: linkItem }); - this.editIndex.set(index); + this.sendAdditionalFields(projectId, relationId); } - removeLink(index: number) { - this.links.removeAt(index); - this.linksItems.update(items => items.filter((_, i) => i !== index)); + closeAssignProjectToProgramModal(): void { + this.isAssignProjectToProgramModalOpen.set(false); + this.router.navigateByUrl(`/office/projects/my`); } - warningModalSeen = false; - - closeWarningModal(): void { - this.warningModalSeen = true; - } + /** + * Валидация дополнительных полей для публикации + * Делегирует валидацию сервису + * @returns true если есть ошибки валидации + */ + private validateAdditionalFields(): boolean { + const partnerProgramFields = this.projectAdditionalService.getPartnerProgramFields(); - onToggleSkill(toggledSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.vacancyForm.value; + if (!partnerProgramFields?.length) { + return false; + } - const isPresent = skills.some(skill => skill.id === toggledSkill.id); + const hasInvalid = this.projectAdditionalService.validateRequiredFields(); - if (isPresent) { - this.onRemoveSkill(toggledSkill); - } else { - this.onAddSkill(toggledSkill); + 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 { + this.projectAdditionalService.sendAdditionalFieldsValues(projectId).subscribe({ + next: () => { + 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; @@ -753,6 +473,10 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { this.vacancyForm.patchValue({ skills: [newSkill, ...skills] }); } + /** + * Удаление навыка + * @param oddSkill - навык для удаления + */ onRemoveSkill(oddSkill: Skill): void { const { skills }: { skills: Skill[] } = this.vacancyForm.value; @@ -761,13 +485,101 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Поиск навыков + * @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; + 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.programService + .programTags() + .pipe( + tap(tags => { + this.programTags = tags; + }), + map(tags => [ + { label: "Без тега", value: 0, id: 0 }, + ...tags.map(t => ({ label: t.name, value: t.id, id: t.id })), + ]), + tap(tags => { + this.programTagsOptions = tags; + }), + concatMap(() => this.route.data), + map(d => d["data"]) + ) + .subscribe(([project, invites]: [Project, Invite[]]) => { + // Используем сервис для инициализации данных проекта + this.projectFormService.initializeProjectData(project); + this.projectTeamService.setInvites(invites); + + // Инициализируем дополнительные поля через сервис + if (project.partnerProgram) { + this.isCompetitive = project.partnerProgram.canSubmit; + + 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 index 91c4044e0..ab7722f59 100644 --- a/projects/social_platform/src/app/office/projects/edit/edit.resolver.ts +++ b/projects/social_platform/src/app/office/projects/edit/edit.resolver.ts @@ -8,6 +8,28 @@ import { Project } from "@models/project.model"; import { InviteService } from "@services/invite.service"; import { Invite } from "@models/invite.model"; +/** + * Resolver для загрузки данных редактирования проекта + * + * Функциональность: + * - Загружает данные проекта по ID из параметров маршрута + * - Получает список приглашений для проекта + * - Объединяет данные в единый массив для компонента + * + * Принимает: + * - ActivatedRouteSnapshot с параметром projectId + * + * Возвращает: + * - Observable<[Project, Invite[]]> с данными: + * - Project: полная информация о проекте + * - Invite[]: массив приглашений в проект + * + * Используется перед загрузкой ProjectEditComponent для предварительной + * загрузки всех необходимых данных для редактирования. + * + * Применяет forkJoin для параллельной загрузки данных проекта и приглашений, + * что оптимизирует время загрузки страницы. + */ export const ProjectEditResolver: ResolveFn<[Project, Invite[]]> = ( route: ActivatedRouteSnapshot ) => { diff --git a/projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts b/projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts new file mode 100644 index 000000000..6f800c121 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; +import { Observable, of } from "rxjs"; +import { catchError, map } from "rxjs/operators"; +import { ProjectService } from "@office/services/project.service"; + +export const ProjectEditRequiredGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot +): Observable => { + const router = inject(Router); + const projectService = inject(ProjectService); + + const projectId = Number(route.paramMap.get("projectId")); + if (isNaN(projectId)) { + return of(router.createUrlTree(["/office/projects/my"])); + } + + return projectService.getOne(projectId).pipe( + map(project => { + if (project.partnerProgram?.isSubmitted) { + return router.createUrlTree([`/office/projects/${projectId}`]); + } + return true; + }), + catchError(() => { + return of(router.createUrlTree([`/office/projects/${projectId}`])); + }) + ); +}; diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts new file mode 100644 index 000000000..c9c90f8d7 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts @@ -0,0 +1,163 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormGroup } from "@angular/forms"; +import { ProjectFormService } from "./project-form.service"; + +/** + * Сервис для управления достижениями проекта. + * Предоставляет методы для добавления, редактирования, удаления достижений, + * а также очистки ошибок валидации. + */ +@Injectable({ + providedIn: "root", +}) +export class ProjectAchievementsService { + /** FormBuilder для создания FormGroup элементов */ + private readonly fb = inject(FormBuilder); + /** Сервис для управления индексом редактируемого достижения */ + private readonly projectFormService = inject(ProjectFormService); + /** Сигнал для хранения списка достижений (массив объектов) */ + public readonly achievementsItems = signal([]); + private initialized = false; + + /** + * Инициализирует сигнал achievementsItems из данных FormArray + * Вызывается при первом обращении к данным + */ + private initializeAchievementsItems(achievementsFormArray: FormArray): void { + if (this.initialized) return; + + if (achievementsFormArray && achievementsFormArray.length > 0) { + // Синхронизируем сигнал с данными из FormArray + this.achievementsItems.set(achievementsFormArray.value); + } + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncAchievementsItems(achievementsFormArray: FormArray): void { + if (achievementsFormArray) { + this.achievementsItems.set(achievementsFormArray.value); + } + } + + /** + * Добавляет новое достижение или сохраняет изменения существующего. + * @param achievementsFormArray FormArray, содержащий формы достижений + * @param projectForm основная форма проекта (FormGroup) + */ + public addAchievement(achievementsFormArray: FormArray, projectForm: FormGroup): void { + // Инициализируем сигнал при первом вызове + this.initializeAchievementsItems(achievementsFormArray); + + // Считываем вводимые данные + const achievementsName = projectForm.get("achievementsName")?.value; + const achievementsPrize = projectForm.get("achievementsPrize")?.value; + + // Проверяем, что поля не пустые + if ( + !achievementsName || + !achievementsPrize || + achievementsName.trim().length === 0 || + achievementsPrize.trim().length === 0 + ) { + return; // Выходим из функции, если поля пустые + } + + // Создаем FormGroup для нового достижения + const achievementItem = this.fb.group({ + id: achievementsFormArray.length, + title: achievementsName.trim(), + status: achievementsPrize.trim(), + }); + + // Проверяем, редактируется ли существующее достижение + const editIdx = this.projectFormService.editIndex(); + if (editIdx !== null) { + // Обновляем массив сигналов и соответствующий контрол в FormArray + this.achievementsItems.update(items => { + const updated = [...items]; + updated[editIdx] = achievementItem.value; + return updated; + }); + achievementsFormArray.at(editIdx).patchValue(achievementItem.value); + // Сбрасываем индекс редактирования + this.projectFormService.editIndex.set(null); + } else { + // Добавляем новое достижение в сигнал и FormArray + this.achievementsItems.update(items => [...items, achievementItem.value]); + achievementsFormArray.push(achievementItem); + } + + // Очищаем поля ввода формы проекта + projectForm.get("achievementsName")?.reset(); + projectForm.get("achievementsName")?.setValue(""); + + projectForm.get("achievementsPrize")?.reset(); + projectForm.get("achievementsPrize")?.setValue(""); + } + + /** + * Инициализирует редактирование существующего достижения. + * @param index индекс достижения в списке + * @param achievementsFormArray FormArray достижений + * @param projectForm основная форма проекта + */ + public editAchievement( + index: number, + achievementsFormArray: FormArray, + projectForm: FormGroup + ): void { + // Инициализируем сигнал при необходимости + this.initializeAchievementsItems(achievementsFormArray); + + // Используем данные из FormArray как источник истины + const source = achievementsFormArray.value[index]; + + // Заполняем поля формы проекта для редактирования + projectForm.patchValue({ + achievementsName: source?.title || "", + achievementsPrize: source?.status || "", + }); + // Устанавливаем текущий индекс редактирования в сервисе + this.projectFormService.editIndex.set(index); + } + + /** + * Удаляет достижение по указанному индексу. + * @param index индекс удаляемого достижения + * @param achievementsFormArray FormArray достижений + */ + public removeAchievement(index: number, achievementsFormArray: FormArray): void { + // Удаляем из сигнала и из FormArray + this.achievementsItems.update(items => items.filter((_, i) => i !== index)); + achievementsFormArray.removeAt(index); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray достижений. + * @param achievements FormArray достижений + */ + public clearAllAchievementsErrors(achievements: FormArray): void { + achievements.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Сбрасывает состояние сервиса + * Полезно при смене проекта или очистке формы + */ + public reset(): void { + this.achievementsItems.set([]); + this.initialized = false; + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts new file mode 100644 index 000000000..bd5b6dbbf --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts @@ -0,0 +1,249 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +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"; +import { Observable } from "rxjs"; + +/** + * Сервис для управления дополнительными полями проекта в партнерской программе. + * Предоставляет методы для инициализации формы, валидации, переключения значений, + * подготовки к отправке и работы со статусами отправки и ошибок. + */ +@Injectable({ providedIn: "root" }) +export class ProjectAdditionalService { + private additionalForm!: FormGroup; + private partnerProgramFields: PartnerProgramFields[] = []; + private partnerProgramFieldsValues: PartnerProgramFieldsValues[] = []; + + private readonly fb = inject(FormBuilder); + private readonly projectService = inject(ProjectService); + private readonly programService = inject(ProgramService); + + private isSendingDecision = signal(false); + private isAssignProjectToProgramError = signal(false); + private errorAssignProjectToProgramModalMessage = signal<{ non_field_errors: string[] } | null>( + null + ); + + constructor() { + // Инициализируем пустую форму + this.additionalForm = this.fb.group({}); + } + + /** + * Возвращает форму дополнительных полей. + */ + public getAdditionalForm(): FormGroup { + return this.additionalForm; + } + + /** + * Возвращает массив описаний полей партнерской программы. + */ + public getPartnerProgramFields(): PartnerProgramFields[] { + return this.partnerProgramFields; + } + + /** + * Возвращает массив сохраненных значений полей. + */ + public getPartnerProgramFieldsValues(): PartnerProgramFieldsValues[] { + return this.partnerProgramFieldsValues; + } + + /** + * Возвращает сигнал, указывающий на процесс отправки. + */ + public getIsSendingDecision() { + return this.isSendingDecision; + } + + /** + * Возвращает сигнал, указывающий на ошибку при привязке к программе. + */ + public getIsAssignProjectToProgramError() { + return this.isAssignProjectToProgramError; + } + + /** + * Возвращает сообщение об ошибке привязки к программе. + */ + public getErrorAssignProjectToProgramModalMessage() { + return this.errorAssignProjectToProgramModalMessage; + } + + /** + * Инициализирует форму дополнительных полей согласно конфигурации и значениям. + * @param fields описание полей партнерской программы + * @param values сохраненные значения полей + */ + public initializeAdditionalForm( + fields: PartnerProgramFields[], + values: PartnerProgramFieldsValues[] = [] + ): void { + this.partnerProgramFields = fields; + this.partnerProgramFieldsValues = values; + + // Создаем новую пустую форму + this.additionalForm = this.fb.group({}); + + // Добавляем контролы для каждого поля + this.partnerProgramFields.forEach(field => { + this.getInitialValue(field, values); + const validators = field.isRequired ? [Validators.required] : []; + const initialValue = this.getInitialValue(field, values); + + this.additionalForm.addControl(field.name, new FormControl(initialValue, validators)); + + // Добавляем дополнительную валидацию по типу поля + this.addFieldTypeValidators(field); + }); + + // Применяем валидацию ко всей форме + this.additionalForm.updateValueAndValidity(); + } + + /** + * Переключает значение для checkbox и radio полей. + * @param fieldType тип поля (checkbox | radio и др.) + * @param fieldName имя контрола в форме + */ + public toggleAdditionalFormValues( + fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + fieldName: string + ): void { + if (fieldType === "checkbox" || fieldType === "radio") { + const control = this.additionalForm.get(fieldName); + if (control) { + control.setValue(!control.value); + } + } + } + + /** + * Проверяет обязательные поля на валидность и помечает их как touched. + * @returns true если есть невалидные обязательные поля + */ + public validateRequiredFields(): boolean { + this.additionalForm.updateValueAndValidity(); + this.partnerProgramFields + .filter(f => f.isRequired) + .forEach(f => this.additionalForm.get(f.name)?.markAsTouched()); + + return this.partnerProgramFields + .filter(f => f.isRequired) + .some(f => this.additionalForm.get(f.name)?.invalid); + } + + /** + * Убирает валидаторы с заполненных обязательных полей перед отправкой. + */ + public prepareFieldsForSubmit(): void { + this.partnerProgramFields + .filter(f => f.isRequired) + .forEach(f => { + const ctrl = this.additionalForm.get(f.name); + if (ctrl && ctrl.value) { + ctrl.clearValidators(); + ctrl.updateValueAndValidity({ emitEvent: false }); + } + }); + } + + /** + * Отправляет значения дополнительных полей на сервер. + * @param projectId идентификатор проекта + * @returns Observable результат запроса + */ + public sendAdditionalFieldsValues(projectId: number): Observable { + this.isSendingDecision.set(true); + const newFieldsFormValues: projectNewAdditionalProgramVields[] = []; + + this.partnerProgramFields.forEach((field: PartnerProgramFields) => { + const fieldValue = this.additionalForm.get(field.name)?.value; + newFieldsFormValues.push({ + field_id: field.id, + value_text: String(fieldValue), + }); + }); + + return this.projectService.sendNewProjectFieldsValues(projectId, newFieldsFormValues); + } + + /** + * Сабмитит проект привязанный к конкурсной программе + * @param relationId идентификатор связи + * @returns Observable результат запроса + */ + public submitCompettetiveProject(relationId: number): Observable { + return this.programService.submitCompettetiveProject(relationId); + } + + /** + * Сбрасывает флаг процесса отправки. + */ + public resetSendingState(): void { + this.isSendingDecision.set(false); + } + + /** + * Устанавливает сообщение и флаг ошибки при привязке проекта. + * @param error объект с массивом полей non_field_errors + */ + public setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { + this.errorAssignProjectToProgramModalMessage.set(error); + this.isAssignProjectToProgramError.set(true); + } + + /** + * Сбрасывает сообщение и флаг ошибки при привязке проекта. + */ + public clearAssignProjectToProgramError(): void { + this.errorAssignProjectToProgramModalMessage.set(null); + this.isAssignProjectToProgramError.set(false); + } + + /** + * Вычисляет начальное значение контрола по сохраненным данным или типу поля. + * @param field описание поля + * @param values массив сохраненных значений + * @returns первоначальное значение контрола + */ + private getInitialValue(field: PartnerProgramFields, values: PartnerProgramFieldsValues[]): any { + const saved = values.find(v => v.fieldName === field.name); + if (!saved) { + return field.fieldType === "checkbox" || field.fieldType === "radio" ? false : ""; + } + + const text = saved.value.trim().toLowerCase(); + if (field.fieldType === "checkbox" || field.fieldType === "radio") { + return text === "true"; + } + return saved.value; + } + + /** + * Добавляет валидаторы по типу текстового поля. + * @param field описание поля для обработки валидаторов + */ + private addFieldTypeValidators(field: PartnerProgramFields): void { + const control = this.additionalForm.get(field.name); + if (!control) return; + + switch (field.fieldType) { + case "text": + control.addValidators([Validators.maxLength(50)]); + break; + case "textarea": + control.addValidators([Validators.maxLength(100)]); + break; + } + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts new file mode 100644 index 000000000..1bd2bd018 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts @@ -0,0 +1,103 @@ +/** @format */ + +import { inject, Injectable, signal, effect } from "@angular/core"; +import { FormArray, FormBuilder, FormGroup, FormControl } from "@angular/forms"; +import { ProjectFormService } from "./project-form.service"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectContactsService { + private readonly fb = inject(FormBuilder); + private readonly projectFormService = inject(ProjectFormService); + public readonly linksItems = signal([]); + + constructor() { + effect(() => { + const formArray = this.links; + if (formArray && formArray.length > 0) { + const currentSignalValue = this.linksItems(); + const formArrayValue = formArray.value; + + if (JSON.stringify(currentSignalValue) !== JSON.stringify(formArrayValue)) { + this.linksItems.set(formArrayValue); + } + } + }); + } + + private get projectForm(): FormGroup { + return this.projectFormService.getForm(); + } + + public get links(): FormArray { + return this.projectForm.get("links") as FormArray; + } + + public get link(): FormControl { + return this.projectForm.get("link") as FormControl; + } + + /** + * Принудительная синхронизация сигнала с FormArray + * Вызывается после загрузки данных проекта + */ + public syncLinksItems(): void { + const linksFormArray = this.links; + if (linksFormArray && linksFormArray.length > 0) { + this.linksItems.set(linksFormArray.value); + } else { + this.linksItems.set([]); + } + } + + public addLink(): void { + const linkValue = this.link?.value; + + if ( + !linkValue || + !linkValue.trim() || + !linkValue.includes("https://") || + !linkValue.includes("http://") + ) { + return; + } + + const trimmedLink = linkValue.trim(); + const editIdx = this.projectFormService.editIndex(); + + if (editIdx !== null) { + // Режим редактирования + this.links.at(editIdx).setValue(trimmedLink); + this.linksItems.update(items => { + const updated = [...items]; + updated[editIdx] = trimmedLink; + return updated; + }); + this.projectFormService.editIndex.set(null); + } else { + // Добавление нового элемента + this.links.push(this.fb.control(trimmedLink)); + this.linksItems.update(items => [...items, trimmedLink]); + } + + // Очищаем поле ввода + this.link?.reset(); + this.link?.setValue(""); + } + + public editLink(index: number): void { + const value = this.links.value[index]; + this.projectForm.patchValue({ link: value }); + this.projectFormService.editIndex.set(index); + } + + public removeLink(index: number): void { + this.links.removeAt(index); + this.linksItems.update(items => items.filter((_, i) => i !== index)); + } + + public reset(): void { + this.linksItems.set([]); + } +} 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 new file mode 100644 index 000000000..2f776ea91 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts @@ -0,0 +1,362 @@ +/** @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 { 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(); + } + + /** + * Создает и настраивает основную форму проекта с набором контролов и валидаторов. + * Подписывается на изменения полей 'presentationAddress' и 'coverImageAddress' для автосохранения при очищении. + */ + private initializeForm(): void { + this.projectForm = this.fb.group({ + imageAddress: [""], + name: ["", [Validators.required]], + region: ["", [Validators.required]], + step: [null, [Validators.required]], + track: [null], + direction: [null], + links: this.fb.array([]), + link: ["", Validators.pattern(/^(https?:\/\/)/)], + industryId: [undefined, [Validators.required]], + description: ["", [Validators.required]], + presentationAddress: ["", [Validators.required]], + coverImageAddress: ["", [Validators.required]], + actuality: ["", [Validators.max(1000)]], + goal: ["", [Validators.required, Validators.max(500)]], + problem: ["", [Validators.required, Validators.max(1000)]], + partnerProgramId: [null], + achievements: this.fb.array([]), + achievementsName: [""], + achievementsPrize: [""], + 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, + step: project.step, + industryId: project.industry, + description: project.description, + track: project.track ?? null, + direction: project.direction ?? null, + actuality: project.actuality ?? "", + goal: project.goal ?? "", + problem: project.problem ?? "", + presentationAddress: project.presentationAddress, + coverImageAddress: project.coverImageAddress, + partnerProgramId: project.partnerProgramId ?? 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)); + }); + } + + /** + * Заполняет FormArray достижений данными из проекта + * @param achievements массив достижений из проекта + */ + private populateAchievementsFormArray(achievements: any[]): void { + const achievementsFormArray = this.projectForm.get("achievements") as FormArray; + + // Очищаем существующие контролы + while (achievementsFormArray.length !== 0) { + achievementsFormArray.removeAt(0); + } + + // Добавляем новые контролы + achievements.forEach((achievement, index) => { + const achievementGroup = this.fb.group({ + id: achievement.id ?? index, + title: achievement.title || "", + status: achievement.status || "", + }); + 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 { + 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 step() { + return this.projectForm.get("step"); + } + + public get description() { + return this.projectForm.get("description"); + } + + public get actuality() { + return this.projectForm.get("actuality"); + } + + public get goal() { + return this.projectForm.get("goal"); + } + + public get problem() { + return this.projectForm.get("problem"); + } + + public get track() { + return this.projectForm.get("track"); + } + + public get direction() { + return this.projectForm.get("direction"); + } + + 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(50)); + if (field.fieldType === "textarea") validators.push(Validators.maxLength(100)); + 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(); + + // Очищаем FormArray + 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 { + return this.validateForm() && this.validateAdditionalForm(); + } + + /** + * Удаляет ошибки валидации внутри массива достижений. + * @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/office/projects/edit/services/project-step.service.ts new file mode 100644 index 000000000..33df80461 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-step.service.ts @@ -0,0 +1,71 @@ +/** + * Сервис для управления шагами редактирования проекта. + * Обеспечивает хранение текущего шага, навигацию между шагами и синхронизацию + * состояния с URL-параметрами маршрута. + * + * @format + */ + +import { inject, Injectable, Signal, signal } from "@angular/core"; +import { Router } from "@angular/router"; + +/** Тип шага редактирования проекта */ +export type EditStep = "main" | "contacts" | "achievements" | "vacancies" | "team" | "additional"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectStepService { + /** Сигнал, содержащий текущий шаг редактирования */ + private 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 новый шаг редактирования + */ + public navigateToStep(step: EditStep): void { + this.currentStep.set(step); + this.router.navigate([], { + queryParams: { editingStep: step }, + queryParamsHandling: "merge", + }); + } + + /** + * Устанавливает шаг из параметра маршрута. Если передан некорректный шаг, + * по умолчанию выбирает 'main' и обновляет URL. + * @param step строка из URL или валидный EditStep + */ + public setStepFromRoute(step: string | EditStep): void { + const validSteps: EditStep[] = [ + "main", + "contacts", + "achievements", + "vacancies", + "team", + "additional", + ]; + + if (step && validSteps.includes(step as EditStep)) { + // Устанавливаем корректный шаг без изменения URL + this.currentStep.set(step as EditStep); + } else { + // Сбрасываем на основной шаг и обновляем URL + this.currentStep.set("main"); + this.router.navigate([], { + queryParams: { editingStep: "main" }, + queryParamsHandling: "merge", + }); + } + } +} 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 new file mode 100644 index 000000000..f3578d20a --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts @@ -0,0 +1,223 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, FormGroup, Validators } from "@angular/forms"; +import { ValidationService } from "@corelib"; +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 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); + } + + /** + * Возвращает текущий список приглашений. + * @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"); + } + + /** + * Проверяет, заполнены ли все приглашения (accepted === null). + * @returns boolean true если все приглашения приняты или отклонены + */ + public readonly invitesFill = computed( + () => this.invites().length > 0 && this.invites().every(inv => inv.isAccepted === null) + ); + + /** + * Открывает модальное окно для отправки приглашения. + */ + 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)); + }); + } + + /** + * Проверяет валидность формы приглашения. + * @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 new file mode 100644 index 000000000..586bba823 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts @@ -0,0 +1,295 @@ +/** @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"; +import { Vacancy } from "@office/models/vacancy.model"; +import { VacancyService } from "@office/services/vacancy.service"; +import { stripNullish } from "@utils/stripNull"; +import { experienceList } from "projects/core/src/consts/list-experience"; +import { formatList } from "projects/core/src/consts/list-format"; +import { scheludeList } from "projects/core/src/consts/list-schelude"; +import { rolesMembersList } from "projects/core/src/consts/list-roles-members"; +import { ProjectFormService } from "./project-form.service"; + +/** + * Сервис для управления вакансиями проекта. + * Обеспечивает создание, валидацию, отправку, + * редактирование и удаление вакансий, а также работу с формой вакансии + * и синхронизацию с 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 experienceList = experienceList; + public readonly formatList = formatList; + public readonly scheludeList = scheludeList; + 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: [""], + 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.experienceList.find(e => e.value === item.requiredExperience) && + this.selectedRequiredExperienceId.set( + this.experienceList.find(e => e.value === item.requiredExperience)!.id + ); + this.formatList.find(f => f.value === item.workFormat) && + this.selectedWorkFormatId.set(this.formatList.find(f => f.value === item.workFormat)!.id); + this.scheludeList.find(s => s.value === item.workSchedule) && + this.selectedWorkScheduleId.set( + this.scheludeList.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.html b/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html new file mode 100644 index 000000000..541f58d4b --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html @@ -0,0 +1,71 @@ + + +
    +
    +
    + @if(achievementsName; as achievementsName){ +
    + + + @if ( !!( (achievementsName | controlError: "required") && + projectForm.get('achievementsName')?.touched && projSubmitInitiated ) ) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + @if (achievementsPrize; as achievementsPrize) { +
    + + @if ( !!( (achievementsPrize | controlError: "required") && achievementsPrize?.touched && + projSubmitInitiated ) ) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + + Добавить достижение + + + +
      + @if(achievementsItems().length || achievements.length){ @for (achievementItem of + achievements.value; track $index) { +
    • + +
    • + } } +
    +
    +
    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/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.scss new file mode 100644 index 000000000..2226fd28b --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.scss @@ -0,0 +1,91 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 90px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 50%; + margin-bottom: 20px; + + form { + width: 280px; + + @include responsive.apply-desktop { + width: 600px; + } + } + } + + &__right { + flex-basis: 50%; + + :first-child & :not(span, fieldset, label, h4, p, i) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } + + &__achievement-item { + &:not(:last-child) { + margin-bottom: 12px; + } + } +} + +.achievement { + &__first-row { + display: flex; + + // align-items: flex-start; + margin-bottom: 12px; + + > :first-child { + flex-grow: 1; + } + } + + &__remove { + width: 155px; + margin-left: 10px; + } +} + +.invite { + &__item { + margin-bottom: 12px; + } + + &__list { + width: 557px; + width: 100%; + margin-top: 10px; + } +} + +.vacancy { + &__submit { + display: block; + } +} 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 new file mode 100644 index 000000000..720cec5c7 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.ts @@ -0,0 +1,85 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject, Input } from "@angular/core"; +import { FormArray, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { InputComponent, ButtonComponent } from "@ui/components"; +import { LinkCardComponent } from "@office/shared/link-card/link-card.component"; +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, + LinkCardComponent, + ControlErrorPipe, + ], +}) +export class ProjectAchievementStepComponent { + @Input() projSubmitInitiated = false; + + private readonly projectAchievementService = inject(ProjectAchievementsService); + private readonly projectFormService = inject(ProjectFormService); + + readonly errorMessage = ErrorMessage; + + // Получаем форму из сервиса + get projectForm(): FormGroup { + return this.projectFormService.getForm(); + } + + // Геттеры для FormArray и полей + get achievements(): FormArray { + return this.projectFormService.achievements; + } + + get achievementsName() { + return this.projectForm.get("achievementsName"); + } + + get achievementsPrize() { + return this.projectForm.get("achievementsPrize"); + } + + get achievementsItems() { + return this.projectAchievementService.achievementsItems; + } + + get editIndex() { + return this.projectFormService.editIndex; + } + + /** + * Добавление достижения + */ + addAchievement(): void { + this.projectAchievementService.addAchievement(this.achievements, this.projectForm); + } + + /** + * Редактирование достижения + * @param index - индекс достижения + */ + editAchievement(index: number): void { + 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.html b/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html new file mode 100644 index 000000000..202cfa3fc --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html @@ -0,0 +1,95 @@ + + +
    +
    +
    + + @if (partnerProgramFields.length) { @for (field of partnerProgramFields; track field.id) { +
    + @switch (field.fieldType) { @case ("text") { @if (additionalForm.get(field.name); as + control) { + + + } } @case ("textarea") { + + @if (additionalForm.get(field.name); as control) { + + } } @case ("checkbox") { +
    + + +
    + } @case ("radio") { @if (additionalForm.get(field.name); as control) { + + } + + } @case ("select") { + + + } } @if (additionalForm.get(field.name); as control) { @if (control | controlError: + "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } } +
    + } } +
    +
    +
    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/office/projects/edit/shared/project-additional-step/project-additional-step.component.scss new file mode 100644 index 000000000..c1deba1bc --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.scss @@ -0,0 +1,142 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 90px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 50%; + margin-bottom: 20px; + + form { + width: 280px; + + @include responsive.apply-desktop { + width: 600px; + } + } + } + + &__right { + flex-basis: 50%; + + :first-child & :not(span, fieldset, label, h4, p, i) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } + + &__team { + width: 90%; + + &--additional { + gap: 10px; + } + } + + &__additional { + &-wrapper { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + + ::ng-deep { + app-input { + input { + color: var(--black) !important; + + @include typography.body-10; + } + } + + app-textarea { + textarea { + color: var(--black) !important; + + @include typography.body-10; + } + } + + app-select { + .field__input { + color: var(--black) !important; + + &--placeholder { + color: var(--grey-for-text) !important; + } + + @include typography.body-10; + } + } + } + + @include responsive.apply-desktop { + width: 60%; + + ::ng-deep { + app-input { + input { + @include typography.body-14; + } + } + + app-textarea { + textarea { + @include typography.body-14; + } + } + + app-select { + .field__input { + @include typography.body-14; + } + } + } + } + } + + fieldset { + margin-bottom: 10px; + } + } +} + +.invite { + &__item { + margin-bottom: 12px; + } + + &__list { + width: 557px; + width: 100%; + margin-top: 10px; + } +} + +.vacancy { + &__submit { + display: block; + } +} 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 new file mode 100644 index 000000000..972f62b68 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.ts @@ -0,0 +1,77 @@ +/** @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 } 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"; + +@Component({ + selector: "app-project-additional-step", + templateUrl: "./project-additional-step.component.html", + styleUrl: "./project-additional-step.component.scss", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + CheckboxComponent, + SwitchComponent, + SelectComponent, + TextareaComponent, + ControlErrorPipe, + ToSelectOptionsPipe, + ], +}) +export class ProjectAdditionalStepComponent implements OnInit { + @Input() programTagsOptions: any[] = []; + + private readonly projectAdditionalService = inject(ProjectAdditionalService); + private readonly cdRef = inject(ChangeDetectorRef); + + readonly errorMessage = ErrorMessage; + + 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(); + } + + /** + * Переключение значения для 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-contacts-step/project-contacts-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.html new file mode 100644 index 000000000..be2162d5c --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.html @@ -0,0 +1,39 @@ + + +
    + @if(link; as link){ +
    + + + @if (link | controlError: "pattern") { +
    + {{ errorMessage.VALIDATION_PATTERN }} +
    + } +
    + } + +
    + + Добавить ещё одну ссылку + + + +
      + @if(linksItems().length || links.length){ @for (linkItem of links.value; track $index) { +
    • + +
    • + } } +
    +
    +
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.scss b/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.scss new file mode 100644 index 000000000..d6a14b7d5 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.scss @@ -0,0 +1,67 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 90px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 50%; + margin-bottom: 20px; + + form { + width: 280px; + + @include responsive.apply-desktop { + width: 600px; + } + } + } + + &__right { + flex-basis: 50%; + + :first-child & :not(span, fieldset, label, h4, p, i) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } + + &__add-link { + i { + margin-left: 10px; + } + } +} + +.invite { + &__item { + margin-bottom: 12px; + } +} + +.vacancy { + &__submit { + display: block; + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.ts new file mode 100644 index 000000000..0c7f42b57 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.ts @@ -0,0 +1,74 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject } from "@angular/core"; +import { FormArray, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { InputComponent, ButtonComponent } from "@ui/components"; +import { LinkCardComponent } from "@office/shared/link-card/link-card.component"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@error/models/error-message"; +import { ProjectContactsService } from "../../services/project-contacts.service"; +import { ProjectFormService } from "../../services/project-form.service"; +import { IconComponent } from "@uilib"; + +@Component({ + selector: "app-project-contacts-step", + templateUrl: "./project-contacts-step.component.html", + styleUrl: "./project-contacts-step.component.scss", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + IconComponent, + ButtonComponent, + LinkCardComponent, + ControlErrorPipe, + ], +}) +export class ProjectContactsStepComponent { + private readonly projectContactsService = inject(ProjectContactsService); + private readonly projectFormService = inject(ProjectFormService); + readonly errorMessage = ErrorMessage; + + // Получаем форму из сервиса + get projectForm(): FormGroup { + return this.projectFormService.getForm(); + } + + // Получаем поля из формы из сервиса + get linksItems() { + return this.projectContactsService.linksItems; + } + + get link() { + return this.projectContactsService.link; + } + + get links(): FormArray { + return this.projectContactsService.links; + } + + /** + * Добавление ссылки + */ + addLink(): void { + this.projectContactsService.addLink(); + } + + /** + * Редактирование ссылки + * @param index - индекс ссылки + */ + editLink(index: number): void { + this.projectContactsService.editLink(index); + } + + /** + * Удаление ссылки + * @param index - индекс ссылки + */ + removeLink(index: number): void { + this.projectContactsService.removeLink(index); + } +} 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/office/projects/edit/shared/project-main-step/project-main-step.component.html new file mode 100644 index 000000000..882e391d9 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html @@ -0,0 +1,245 @@ + + +
    +
    +
    +
    + @if (imageAddress; as imageAddress) { +
    + + @if ((imageAddress | controlError: "required") && projSubmitInitiated) { +
    + {{ errorMessage.EMPTY_AVATAR }} +
    + } +
    + } +
    + +
    + @if (name; as name) { +
    + + + @if ((name | controlError: "required") && projSubmitInitiated) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (region; as region) { +
    + + + @if ((region | controlError: "required") && projSubmitInitiated) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + +
    + @if (industry; as industry) { +
    + + @if (industries$ | async; as industries) { + + } @if (industry | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (step; as step) { +
    + + @if (projectSteps$ | async; as steps) { + + } @if (step | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (description; as description) { +
    + + + @if ((description | controlError: "required") && projSubmitInitiated) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (actuality; as actuality) { +
    + + + @if ((actuality | controlError: "required")) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (goal; as goal) { +
    + + + @if ((goal | controlError: "required") && projSubmitInitiated) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (problem; as problem) { +
    + + + @if ((problem | controlError: "required") && projSubmitInitiated) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (trackControl; as track) { +
    + + @if ((track | controlError: "required")) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (direction; as direction) { +
    + + @if ((direction | controlError: "required")) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + +
    + @if (authService.profile | async; as profile) { @if (profile.id == leaderId) { @if + (programTagsOptions.length) { +
    + + +
    + + + Привязать проект к программе + + } } } @if (presentationAddress; as presentationAddress) { +
    + + + +

    + Добавьте  + файл  презентации +

    +

    + Презентации формата .PDF или .PPTX весом до 50МБ +

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

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

    + } +
    +
    +
    + } @if (coverImageAddress; as coverImageAddress) { +
    + + + +

    + Добавьте  + обложку  проекта +

    +

    Презентации формата .jpg, .jpeg, .png

    +

    Размер изображения 1280 x 230

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

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

    + } +
    +
    +
    + } +
    +
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.scss b/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.scss new file mode 100644 index 000000000..225c80375 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.scss @@ -0,0 +1,199 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 90px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 50%; + margin-bottom: 20px; + + form { + width: 280px; + + @include responsive.apply-desktop { + width: 600px; + } + } + } + + &__form { + display: flex; + flex-direction: column; + padding: 15px; + color: var(--black); + background-color: var(--white); + border: 1px solid var(--grey-button); + border-radius: var(--rounded-md); + + @include responsive.apply-desktop { + flex-direction: column; + align-items: flex-start; + padding: 24px; + } + } + + &__info { + display: flex; + flex-direction: column; + + @include responsive.apply-desktop { + flex-direction: row; + justify-content: space-between; + } + } + + &__right { + flex-basis: 50%; + + :first-child & :not(span, fieldset, label, h4, p, i) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } + + &__generals { + margin-bottom: 10px; + + :first-child { + margin-bottom: 10px; + } + } + + &__tags { + margin-bottom: 10px; + } + + &__additional { + &-wrapper { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + + ::ng-deep { + app-input { + input { + color: var(--black) !important; + + @include typography.body-10; + } + } + + app-textarea { + textarea { + color: var(--black) !important; + + @include typography.body-10; + } + } + + app-select { + .field__input { + color: var(--black) !important; + + &--placeholder { + color: var(--grey-for-text) !important; + } + + @include typography.body-10; + } + } + } + + @include responsive.apply-desktop { + width: 60%; + + ::ng-deep { + app-input { + input { + @include typography.body-14; + } + } + + app-textarea { + textarea { + @include typography.body-14; + } + } + + app-select { + .field__input { + @include typography.body-14; + } + } + } + } + } + + fieldset { + margin-bottom: 10px; + } + } + + &__image { + display: block; + margin-bottom: 20px; + + .error { + margin-top: 15px; + } + } + + &__avatar { + margin-bottom: 20px; + } + + &__file { + min-width: 0; + } + + &__slides-title { + max-width: 320px; + margin-top: 12px; + color: var(--black); + text-align: center; + } + + &__slides-text { + max-width: 275px; + margin-top: 12px; + color: var(--dark-grey); + text-align: center; + } + + &__slides-error { + margin-top: 12px; + color: var(--red); + } + + &__slides-open-file { + color: var(--accent); + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } + } +} 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 new file mode 100644 index 000000000..764c3da95 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.ts @@ -0,0 +1,129 @@ +/** @format */ + +import { Component, Input, Output, EventEmitter, inject, OnInit, OnDestroy } from "@angular/core"; +import { FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { AuthService } from "@auth/services"; +import { ErrorMessage } from "@error/models/error-message"; +import { directionProjectList } from "projects/core/src/consts/list-direction-project"; +import { trackProjectList } from "projects/core/src/consts/list-track-project"; +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"; + +@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, + ], +}) +export class ProjectMainStepComponent implements OnInit, OnDestroy { + @Input() industries$!: Observable; + @Input() projectSteps$!: Observable; + @Input() programTagsOptions: any[] = []; + @Input() leaderId = 0; + @Input() projSubmitInitiated = false; + @Input() projectId!: number; + + @Output() assignToProgram = new EventEmitter(); + @Output() saveProject = new EventEmitter<{ type: "draft" | "published" }>(); + + private subscription = new Subscription(); + + readonly authService = inject(AuthService); + private readonly projectFormService = inject(ProjectFormService); + + readonly errorMessage = ErrorMessage; + readonly trackList = trackProjectList; + readonly directionList = directionProjectList; + + // Получаем форму из сервиса + get projectForm(): FormGroup { + return this.projectFormService.getForm(); + } + + ngOnInit(): void {} + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + onAssignToProgram(): void { + this.assignToProgram.emit(); + } + + // Геттеры для удобного доступа к контролам формы + get name() { + return this.projectFormService.name; + } + + get region() { + return this.projectFormService.region; + } + + get industry() { + return this.projectFormService.industry; + } + + get step() { + return this.projectFormService.step; + } + + get description() { + return this.projectFormService.description; + } + + get actuality() { + return this.projectFormService.actuality; + } + + get goal() { + return this.projectFormService.goal; + } + + get problem() { + return this.projectFormService.problem; + } + + get trackControl() { + return this.projectFormService.track; + } + + get direction() { + return this.projectFormService.direction; + } + + get presentationAddress() { + return this.projectFormService.presentationAddress; + } + + get coverImageAddress() { + return this.projectFormService.coverImageAddress; + } + + get imageAddress() { + return this.projectFormService.imageAddress; + } + + get partnerProgramId() { + return this.projectFormService.partnerProgramId; + } +} 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 new file mode 100644 index 000000000..40816afaf --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.html @@ -0,0 +1,19 @@ + + + diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.scss b/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.scss new file mode 100644 index 000000000..ea3f6092b --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.scss @@ -0,0 +1,69 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + &__navigation { + padding: 10px 0; + margin-bottom: 22px; + border-bottom: 1px solid var(--grey-button); + } + + &__nav { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: center; + + @include responsive.apply-desktop { + gap: 0; + justify-content: space-between; + } + } + + &__item { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + } + + &__subtitle { + color: var(--grey-for-text); + + @include typography.heading-4; + + &--active { + color: var(--black); + } + } + + &__icon { + opacity: 0.5; + + &--active { + opacity: 1; + } + } + + &__nav-item { + color: var(--gray); + cursor: pointer; + + @include typography.heading-4; + + @include responsive.apply-desktop { + @include typography.heading-3; + } + + &:not(:last-child) { + margin-right: 24px; + } + + &--active { + color: var(--black); + } + } +} 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 new file mode 100644 index 000000000..0e21ed92c --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { Component, inject, Output, EventEmitter } from "@angular/core"; +import { EditStep, ProjectStepService } from "../../services/project-step.service"; +import { navItems } from "projects/core/src/consts/navProjectItems"; + +@Component({ + selector: "app-project-navigation", + templateUrl: "./project-navigation.component.html", + styleUrl: "project-navigation.component.scss", + standalone: true, +}) +export class ProjectNavigationComponent { + @Output() stepChange = new EventEmitter(); + + readonly navItems = navItems; + 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-team-step/project-team-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html new file mode 100644 index 000000000..6ee82d631 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html @@ -0,0 +1,108 @@ + + +
    +
    + +
      + @for (user of invites; track user.id) { @if (user.isAccepted === null) { +
    • + +
    • + } } +
    + + Пригласить участника + + +
    + + +
    +
    + + +
    + @if (role; as role) { +
    + + + @if ((role | controlError: "required") && inviteSubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (link; as link) { + + } + + + @if (specialization; as specialization) { +
    + + @if ((specialization | controlError: "required") && inviteSubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + + + Отправить + +
    +
    +
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.scss b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.scss new file mode 100644 index 000000000..e599c00db --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.scss @@ -0,0 +1,106 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 90px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 50%; + margin-bottom: 20px; + + form { + width: 280px; + + @include responsive.apply-desktop { + width: 600px; + } + } + } + + &__right { + flex-basis: 50%; + + :first-child & :not(span, fieldset, label, h4, p, i) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } + + &__invite { + display: flex; + flex-direction: column; + + app-button { + width: 100%; + + @include typography.body-12; + + @include responsive.apply-desktop { + width: 25%; + + @include typography.bold-body-16; + } + } + } +} + +.invite { + &__title { + margin-bottom: 12px; + } + + &__role { + margin-bottom: 12px; + } + + &__link { + margin-bottom: 12px; + } + + &__item { + margin-bottom: 12px; + } + + &__list { + width: 557px; + width: 100%; + margin-top: 10px; + } + + &__team { + grid-template-columns: repeat(1, 270px); + grid-gap: 20px; + margin-top: 10px; + + @include responsive.apply-desktop { + grid-template-columns: repeat(3, 350px); + } + } +} + +.vacancy { + &__submit { + display: block; + } +} 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 new file mode 100644 index 000000000..82c44d127 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.ts @@ -0,0 +1,135 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject, 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 { InviteCardComponent } from "@office/shared/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/list-roles-members"; +import { ActivatedRoute } from "@angular/router"; +import { IconComponent } from "@uilib"; + +@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, + SelectComponent, + ControlErrorPipe, + InviteCardComponent, + ModalComponent, + ], +}) +export class ProjectTeamStepComponent implements OnInit { + private readonly projectTeamService = inject(ProjectTeamService); + private readonly route = inject(ActivatedRoute); + + readonly errorMessage = ErrorMessage; + + // Константы для селектов + readonly rolesMembersList = rolesMembersList; + + ngOnInit(): void { + this.projectTeamService.setInvites(this.invites); + + // Настраиваем динамическую валидацию + 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 invitesFill() { + return this.projectTeamService.invitesFill; + } + + get isInviteModalOpen() { + return this.projectTeamService.isInviteModalOpen; + } + + get inviteNotExistingError() { + return this.projectTeamService.inviteNotExistingError; + } + + get inviteSubmitInitiated() { + return this.projectTeamService.inviteSubmitInitiated; + } + + get inviteFormIsSubmitting() { + return this.projectTeamService.inviteFormIsSubmitting; + } + + /** + * Открытие модального окна приглашения + */ + openInviteModal(): void { + this.projectTeamService.openInviteModal(); + } + + /** + * Закрытие модального окна приглашения + */ + closeInviteModal(): void { + this.projectTeamService.closeInviteModal(); + } + + /** + * Отправка приглашения + */ + submitInvite(): void { + const projectId = Number(this.route.snapshot.paramMap.get("projectId")); + this.projectTeamService.submitInvite(projectId); + } + + /** + * Редактирование приглашения + */ + 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(); + } + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.html new file mode 100644 index 000000000..ff3993837 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.html @@ -0,0 +1,178 @@ + + +
    +
    + +
    + @if (role; as role) { +
    + + @if ((role | controlError: "required") && vacancySubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (description; as description) { +
    + + @if ((description | controlError: "required") && vacancySubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + +
    +
    + @if (requiredExperience; as requiredExperience) { +
    + + @if (requiredExperience | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (workFormat; as workFormat) { +
    + + @if (workFormat | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (salary; as salary) { +
    + + @if ((salary | controlError: "required") && vacancySubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (workSchedule; as workSchedule) { +
    + + @if (workSchedule | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + + + + @if (skills; as skills) { +
    + + @if (skills | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + + + @if (specialization; as specialization) { +
    + + @if ((specialization | controlError: "required") && vacancySubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + +
    + + Создать вакансию + + + +
      + @for (vacancy of vacancies; track vacancy.id) { +
    • + +
    • + } +
    +
    +
    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 new file mode 100644 index 000000000..25ad29ef2 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.scss @@ -0,0 +1,130 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 90px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 50%; + margin-bottom: 20px; + + form { + width: 280px; + + @include responsive.apply-desktop { + width: 600px; + } + } + } + + &__right { + flex-basis: 50%; + + :first-child & :not(span, fieldset, label, h4, p, i) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } +} + +.invite { + &__item { + margin-bottom: 12px; + } +} + +.vacancy { + &__item { + margin-bottom: 12px; + } + + fieldset { + margin-bottom: 10px; + } + + &__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; + } + + &__description { + ::ng-deep { + app-input { + .field__input { + padding: 40px 20px; + } + } + } + } +} + +.vacancies { + display: flex; + + &__input { + flex-grow: 1; + margin-right: 6px; + } + + ::ng-deep { + app-autocomplete-input { + .field__input { + padding: 12px 20px; + + @include typography.body-16; + } + } + + app-button { + .button { + width: 52px; + height: 52px; + } + } + } +} 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 new file mode 100644 index 000000000..bc29b85c8 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts @@ -0,0 +1,176 @@ +/** @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/shared/vacancy-card/vacancy-card.component"; +import { Skill } from "@office/models/skill"; +import { ProjectVacancyService } from "../../services/project-vacancy.service"; +import { ActivatedRoute } from "@angular/router"; +import { IconComponent } from "@uilib"; + +@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, + ], +}) +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; + + 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.experienceList; + } + + get formatList() { + return this.projectVacancyService.formatList; + } + + get scheludeList() { + return this.projectVacancyService.scheludeList; + } + + 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; + } + + /** + * Отправка формы вакансии + */ + 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/all.resolver.ts b/projects/social_platform/src/app/office/projects/list/all.resolver.ts index 773837018..99866ef48 100644 --- a/projects/social_platform/src/app/office/projects/list/all.resolver.ts +++ b/projects/social_platform/src/app/office/projects/list/all.resolver.ts @@ -7,6 +7,34 @@ import { ApiPagination } from "@models/api-pagination.model"; import { HttpParams } from "@angular/common/http"; import { ResolveFn } from "@angular/router"; +/** + * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ВСЕХ ПРОЕКТОВ + * + * - Предзагружает данные всех доступных проектов перед активацией маршрута + * - Обеспечивает наличие данных в компоненте на момент его инициализации + * - Используется в роутинге Angular для маршрута "все проекты" + * + * @param: + * - Неявно: внедряется ProjectService через inject() + * - Параметры маршрута и состояние роутера (не используются в данной реализации) + * + * @return: + * - Observable> - пагинированный список всех проектов + * - Первая страница с лимитом 16 проектов + * + * Логика работы: + * 1. Внедряет ProjectService через функцию inject() + * 2. Вызывает метод getAll() с параметрами пагинации (limit: 16) + * 3. Возвращает Observable, который будет разрешен перед активацией маршрута + * + * Использование: + * - Подключается к маршруту в конфигурации роутера + * - Результат доступен в компоненте через route.data['data'] + * + * - Использует функциональный подход (ResolveFn) вместо класса + * - Загружает только первые 16 проектов для оптимизации производительности + * - Дополнительные проекты загружаются по мере прокрутки (infinite scroll) + */ export const ProjectsAllResolver: ResolveFn> = () => { const projectService = inject(ProjectService); 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 index cbca80722..f3be08cd7 100644 --- a/projects/social_platform/src/app/office/projects/list/list.component.ts +++ b/projects/social_platform/src/app/office/projects/list/list.component.ts @@ -36,6 +36,52 @@ import { ProjectCardComponent } from "../../shared/project-card/project-card.com import { IconComponent } from "@ui/components"; import { SubscriptionService } from "@office/services/subscription.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 - флаги текущего режима просмотра + * + * Жизненный цикл: + * - OnInit: настройка подписок, инициализация данных + * - AfterViewInit: настройка обработчика прокрутки + * - OnDestroy: отписка от всех подписок + * + * - Использует RxJS для реактивного программирования + * - Реализует паттерн "бесконечная прокрутка" для оптимизации производительности + * - Поддерживает жесты свайпа для закрытия фильтров на мобильных + * - Использует Fuse.js для нечеткого поиска по названиям проектов + * - Кэширует запросы фильтрации для избежания дублирующих HTTP-запросов + */ @Component({ selector: "app-list", templateUrl: "./list.component.html", 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 index 8a7c799c4..d2547601d 100644 --- a/projects/social_platform/src/app/office/projects/list/my.resolver.ts +++ b/projects/social_platform/src/app/office/projects/list/my.resolver.ts @@ -7,6 +7,36 @@ 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); diff --git a/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts b/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts index b832ca253..0ca78a23e 100644 --- a/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts +++ b/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts @@ -1,12 +1,46 @@ /** @format */ -import type { ResolveFn } from "@angular/router"; +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"; +/** + * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ПОДПИСОК ПОЛЬЗОВАТЕЛЯ + * + * Назначение: + * - Предзагружает данные проектов, на которые подписан текущий пользователь + * - Обеспечивает наличие данных о подписках в компоненте на момент его инициализации + * - Используется в роутинге Angular для маршрута "подписки" + * + * @params + * - Неявно: внедряет AuthService и SubscriptionService через inject() + * - Параметры маршрута и состояние роутера (не используются в данной реализации) + * + * @returns: + * - Observable<{ results: Project[] }> - объект с массивом проектов-подписок + * - Структура соответствует формату API для совместимости с другими резолверами + * + * 1. Внедряет AuthService и SubscriptionService через функцию inject() + * 2. Получает профиль текущего пользователя через authService.profile + * 3. Использует switchMap для переключения на запрос подписок с ID пользователя + * 4. Вызывает subscriptionService.getSubscriptions(userId) для получения подписок + * 5. Возвращает Observable с проектами, на которые подписан пользователь + * + * - Подключается к маршруту в конфигурации роутера для страницы подписок + * - Результат доступен в компоненте через route.data['data'] + * + * Особенности: + * - Использует функциональный подход (ResolveFn) вместо класса + * - Требует авторизованного пользователя для получения подписок + * - Использует switchMap для избежания вложенных подписок + * - Возвращает данные в формате, совместимом с другими резолверами проектов + * + * - AuthService - для получения данных текущего пользователя + * - SubscriptionService - для получения списка подписок пользователя + */ export const ProjectsSubscriptionsResolver: ResolveFn<{ results: Project[] }> = () => { const authService = inject(AuthService); const subscriptionService = inject(SubscriptionService); diff --git a/projects/social_platform/src/app/office/projects/models/project-assign.model.ts b/projects/social_platform/src/app/office/projects/models/project-assign.model.ts new file mode 100644 index 000000000..d4e1a816b --- /dev/null +++ b/projects/social_platform/src/app/office/projects/models/project-assign.model.ts @@ -0,0 +1,18 @@ +/** + * Модель ответа привязки для полей привязанных к выбранной программе + * Содержит основную информацию о полях проекта, который учавствует в программе + * + * ProjectAssign содержит: + * - Основную информацию (id дубликата проекта привязанного к программе выбранной, название программы, id программы к которой привязываем проект дубликат) + * + * ProjectAssign содержит: + * - Основную информацию по значениям, которые содержатся в ответе при привязке дубликата проекта к программе выбранной + * + * @format + */ + +export class ProjectAssign { + newProjectId!: number; + programLinkId!: number; + partnerProgram!: string; +} diff --git a/projects/social_platform/src/app/office/projects/models/project-news.model.ts b/projects/social_platform/src/app/office/projects/models/project-news.model.ts index fad1056d7..12a069c57 100644 --- a/projects/social_platform/src/app/office/projects/models/project-news.model.ts +++ b/projects/social_platform/src/app/office/projects/models/project-news.model.ts @@ -1,7 +1,33 @@ /** @format */ + import * as dayjs from "dayjs"; import { FileModel } from "@models/file.model"; +/** + * Модель для новостей проекта (FeedNews) + * + * Представляет структуру новостной записи в ленте проекта. + * + * Свойства: + * - id: уникальный идентификатор новости + * - name: название/заголовок новости + * - imageAddress: URL изображения для новости + * - text: текстовое содержимое новости + * - datetimeCreated: дата и время создания + * - datetimeUpdated: дата и время последнего обновления + * - viewsCount: количество просмотров + * - likesCount: количество лайков + * - files: массив прикрепленных файлов + * - isUserLiked: флаг, лайкнул ли текущий пользователь + * - pin: флаг закрепления новости (опционально) + * + * Методы: + * - static default(): возвращает объект с тестовыми данными + * для использования в разработке и тестировании + * + * Используется для отображения новостей в ленте проекта, + * управления лайками и просмотрами. + */ export class FeedNews { id!: number; name!: string; 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 index b6a621d96..cf6c6f2b5 100644 --- 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 @@ -10,9 +10,34 @@ import { ProjectService } from "@services/project.service"; import { SwitchComponent } from "@ui/components/switch/switch.component"; import { NumSliderComponent } from "@ui/components/num-slider/num-slider.component"; import { CheckboxComponent } from "@ui/components"; -import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { filterTags } from "projects/core/src/consts/filter-tags"; +/** + * Компонент фильтрации проектов + * + * Функциональность: + * - Предоставляет интерфейс для фильтрации списка проектов + * - Управляет фильтрами по различным критериям: + * - Этап проекта (идея, разработка, тестирование и т.д.) + * - Отрасль/направление проекта + * - Количество участников в команде + * - Наличие открытых вакансий + * - Принадлежность к программе МосПолитех + * - Тип проекта (оценен экспертами или нет) + * + * Принимает: + * - Query параметры из URL для восстановления состояния фильтров + * - Данные об отраслях и этапах проектов из сервисов + * + * Возвращает: + * - Обновляет query параметры URL при изменении фильтров + * - Эмитит события для закрытия панели фильтров + * + * Особенности: + * - Синхронизирует состояние фильтров с URL + * - Поддерживает сброс всех фильтров + * - Адаптивный интерфейс для мобильных устройств + */ @Component({ selector: "app-projects-filter", templateUrl: "./projects-filter.component.html", @@ -28,18 +53,23 @@ export class ProjectsFilterComponent implements OnInit { private readonly projectService: ProjectService ) {} + // Константы для фильтрации по типу проекта readonly filterTags = filterTags; ngOnInit(): void { + // Подписка на данные об отраслях this.industries$ = this.industryService.industries.subscribe(industries => { this.industries = industries; }); + // Подписка на данные об этапах проектов this.steps$ = this.projectService.steps.subscribe(steps => { this.steps = steps; }); + // Восстановление состояния фильтров из query параметров this.queries$ = this.route.queryParams.subscribe(queries => { + console.log(queries); this.currentIndustry = parseInt(queries["industry"]); this.currentStep = parseInt(queries["step"]); this.currentMembersCount = parseInt(queries["membersCount"]); @@ -47,32 +77,43 @@ export class ProjectsFilterComponent implements OnInit { this.isMospolytech = queries["is_mospolytech"] === "true"; const tagParam = queries["is_rated_by_expert"]; - if (tagParam === undefined || isNaN(parseInt(tagParam))) { + if (tagParam === undefined || isNaN(Number.parseInt(tagParam))) { this.currentFilterTag = 2; } else { - this.currentFilterTag = parseInt(tagParam); + this.currentFilterTag = Number.parseInt(tagParam); } }); } + // Подписки для управления жизненным циклом queries$?: Subscription; + // Состояние фильтра по этапу проекта currentStep: number | null = null; steps: ProjectStep[] = []; steps$?: Subscription; + // Состояние фильтра по отрасли currentIndustry: number | null = null; industries: Industry[] = []; industries$?: Subscription; + // Состояние остальных фильтров hasVacancies = false; isMospolytech = false; + // Опции для фильтра по количеству участников membersCountOptions = [1, 2, 3, 4, 5, 6]; currentMembersCount: number | null = null; + // Текущий тип проекта (по умолчанию - все проекты) currentFilterTag = 2; + /** + * Обработчик фильтрации по этапу проекта + * @param event - событие клика + * @param stepId - ID этапа проекта (undefined для сброса) + */ onFilterByStep(event: Event, stepId?: number): void { event.stopPropagation(); @@ -83,8 +124,14 @@ export class ProjectsFilterComponent implements OnInit { queryParamsHandling: "merge", }) .then(() => console.debug("Query change from ProjectsComponent")); + console.log(stepId, this.currentStep); } + /** + * Обработчик фильтрации по отрасли + * @param event - событие клика + * @param industryId - ID отрасли (undefined для сброса) + */ onFilterByIndustry(event: Event, industryId?: number): void { event.stopPropagation(); @@ -97,6 +144,10 @@ export class ProjectsFilterComponent implements OnInit { .then(() => console.debug("Query change from ProjectsComponent")); } + /** + * Обработчик фильтрации по количеству участников + * @param count - количество участников (undefined для сброса) + */ onFilterByMembersCount(count?: number): void { this.router .navigate([], { @@ -109,6 +160,10 @@ export class ProjectsFilterComponent implements OnInit { .then(() => console.debug("Query change from ProjectsComponent")); } + /** + * Обработчик фильтрации по наличию вакансий + * @param has - наличие вакансий + */ onFilterVacancies(has: boolean): void { this.router .navigate([], { @@ -121,12 +176,16 @@ export class ProjectsFilterComponent implements OnInit { .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 программы для политеха + partner_program: 3, // TODO: заменить когда появится итоговое id программы для политеха }, relativeTo: this.route, queryParamsHandling: "merge", @@ -134,6 +193,11 @@ export class ProjectsFilterComponent implements OnInit { .then(() => console.debug("Query change from ProjectsComponent")); } + /** + * Обработчик фильтрации по типу проекта + * @param event - событие клика + * @param tagId - ID типа проекта (null для сброса) + */ onFilterProjectType(event: Event, tagId?: number | null): void { event.stopPropagation(); @@ -144,6 +208,10 @@ export class ProjectsFilterComponent implements OnInit { }); } + /** + * Сброс всех активных фильтров + * Очищает все query параметры и возвращает к состоянию по умолчанию + */ clearFilters(): void { this.currentFilterTag = 2; @@ -157,6 +225,7 @@ export class ProjectsFilterComponent implements OnInit { is_rated_by_expert: undefined, is_mospolytech: undefined, partner_program: undefined, + search: undefined, }, relativeTo: this.route, queryParamsHandling: "merge", diff --git a/projects/social_platform/src/app/office/projects/projects.component.ts b/projects/social_platform/src/app/office/projects/projects.component.ts index 2e362e3ec..37b535651 100644 --- a/projects/social_platform/src/app/office/projects/projects.component.ts +++ b/projects/social_platform/src/app/office/projects/projects.component.ts @@ -11,6 +11,18 @@ import { SearchComponent } from "@ui/components/search/search.component"; import { BarComponent, ButtonComponent, IconComponent } from "@ui/components"; import { AsyncPipe } from "@angular/common"; +/** + * Главный компонент модуля проектов + * Управляет отображением списка проектов, поиском и созданием новых проектов + * + * Принимает: + * - Счетчики проектов через резолвер + * - Параметры поиска из URL + * + * Возвращает: + * - Интерфейс управления проектами с поиском и фильтрацией + * - Навигацию между разделами "Мои", "Все", "Подписки" + */ @Component({ selector: "app-projects", templateUrl: "./projects.component.html", diff --git a/projects/social_platform/src/app/office/projects/projects.resolver.ts b/projects/social_platform/src/app/office/projects/projects.resolver.ts index d0787a341..3ccf6ad57 100644 --- a/projects/social_platform/src/app/office/projects/projects.resolver.ts +++ b/projects/social_platform/src/app/office/projects/projects.resolver.ts @@ -8,6 +8,26 @@ import { AuthService } from "@auth/services"; import { ResolveFn } from "@angular/router"; import { SubscriptionService } from "@office/services/subscription.service"; +/** + * Resolver для загрузки данных о количестве проектов + * + * Функциональность: + * - Загружает количество проектов пользователя в разных категориях + * - Получает количество подписок пользователя + * - Объединяет данные в единый объект ProjectCount + * + * Принимает: + * - Не принимает параметры (использует текущего пользователя) + * + * Возвращает: + * - Observable с данными: + * - my: количество собственных проектов + * - all: общее количество проектов в системе + * - subs: количество подписок пользователя + * + * Используется перед загрузкой ProjectsComponent для предварительной + * загрузки необходимых данных. + */ export const ProjectsResolver: ResolveFn = () => { const projectService = inject(ProjectService); const authService = inject(AuthService); @@ -16,8 +36,8 @@ export const ProjectsResolver: ResolveFn = () => { return authService.profile.pipe( switchMap(p => { return forkJoin([ - projectService.getCount(), - subscriptionService.getSubscriptions(p.id).pipe(map(resp => resp.count)), + projectService.getCount(), // Получение количества проектов + subscriptionService.getSubscriptions(p.id).pipe(map(resp => resp.count)), // Получение количества подписок ]).pipe(map(([countData, subsCount]) => ({ ...countData, subs: subsCount }))); }) ); diff --git a/projects/social_platform/src/app/office/projects/projects.routes.ts b/projects/social_platform/src/app/office/projects/projects.routes.ts index 43a08c38f..316bd3840 100644 --- a/projects/social_platform/src/app/office/projects/projects.routes.ts +++ b/projects/social_platform/src/app/office/projects/projects.routes.ts @@ -9,7 +9,31 @@ 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"; +/** + * Конфигурация маршрутов для модуля проектов + * + * Определяет структуру навигации: + * + * Основные маршруты: + * - '' (root) - ProjectsComponent с дочерними маршрутами: + * - 'my' - список собственных проектов + * - 'subscriptions' - список проектов по подписке + * - 'all' - список всех проектов + * - ':projectId/edit' - редактирование проекта + * - ':projectId' - детальная информация о проекте (lazy loading) + * + * Каждый маршрут имеет свой resolver для предварительной загрузки данных: + * - ProjectsResolver - загружает счетчики проектов + * - ProjectsMyResolver - загружает собственные проекты + * - ProjectsAllResolver - загружает все проекты + * - ProjectsSubscriptionsResolver - загружает проекты по подписке + * - ProjectEditResolver - загружает данные для редактирования + * + * Использует lazy loading для детальной информации о проекте + * для оптимизации загрузки приложения. + */ export const PROJECTS_ROUTES: Routes = [ { path: "", @@ -52,6 +76,7 @@ export const PROJECTS_ROUTES: Routes = [ resolve: { data: ProjectEditResolver, }, + canActivate: [ProjectEditRequiredGuard], }, { path: ":projectId", diff --git a/projects/social_platform/src/app/office/services/advert.service.ts b/projects/social_platform/src/app/office/services/advert.service.ts index 5a5813a89..75e7fdfe0 100644 --- a/projects/social_platform/src/app/office/services/advert.service.ts +++ b/projects/social_platform/src/app/office/services/advert.service.ts @@ -6,19 +6,44 @@ import { New } from "@models/article.model"; import { ApiService } from "projects/core"; import { plainToInstance } from "class-transformer"; +/** + * Сервис для работы с новостями и рекламными материалами + * + * Предоставляет функциональность для: + * - Получения списка всех новостей + * - Получения детальной информации о конкретной новости + * - Преобразования данных в типизированные объекты + */ @Injectable({ providedIn: "root", }) export class AdvertService { + private readonly NEWS_URL = "/news"; + constructor(private readonly apiService: ApiService) {} + /** + * Получает список всех новостей и рекламных материалов + * Преобразует полученные данные в массив типизированных объектов New + * + * @returns Observable - массив новостей с заголовками, содержимым и метаданными + */ getAll(): Observable { - return this.apiService.get("/news/").pipe(map(adverts => plainToInstance(New, adverts))); + return this.apiService + .get(`${this.NEWS_URL}/`) + .pipe(map(adverts => plainToInstance(New, adverts))); } + /** + * Получает детальную информацию о конкретной новости + * Преобразует полученные данные в типизированный объект New + * + * @param advertId - уникальный идентификатор новости + * @returns Observable - объект новости со всеми полями (заголовок, содержимое, дата, автор и т.д.) + */ getOne(advertId: number): Observable { return this.apiService - .get(`/news/${advertId}/`) + .get(`${this.NEWS_URL}/${advertId}/`) .pipe(map(advert => plainToInstance(New, advert))); } } diff --git a/projects/social_platform/src/app/office/services/chat.service.ts b/projects/social_platform/src/app/office/services/chat.service.ts index fe07f8e7a..e7d81d306 100644 --- a/projects/social_platform/src/app/office/services/chat.service.ts +++ b/projects/social_platform/src/app/office/services/chat.service.ts @@ -23,16 +23,36 @@ import { plainToInstance } from "class-transformer"; import { ApiService, TokenService } from "projects/core"; import { BehaviorSubject, map, Observable } from "rxjs"; +/** + * Сервис для управления чатом в реальном времени + * + * Предоставляет функциональность для: + * - Подключения к WebSocket для обмена сообщениями в реальном времени + * - Отправки, редактирования и удаления сообщений + * - Отслеживания статуса пользователей (онлайн/оффлайн) + * - Индикации набора текста + * - Загрузки истории сообщений и файлов + * - Отметки сообщений как прочитанных + */ @Injectable({ providedIn: "root", }) export class ChatService { + private readonly CHATS_URL = "/chats"; + constructor( private readonly websocketService: WebsocketService, private readonly apiService: ApiService, private readonly tokenService: TokenService ) {} + /** + * Устанавливает WebSocket соединение для чата + * Использует токен доступа для аутентификации + * + * @returns Observable - завершается при успешном подключении + * @throws Error если токен не найден + */ public connect(): Observable { const tokens = this.tokenService.getTokens(); if (!tokens) throw new Error("No token provided"); @@ -40,41 +60,90 @@ export class ChatService { return this.websocketService.connect(`/chat/?token=${tokens.access}`); } + /** + * Кеш статусов пользователей (онлайн/оффлайн) + * Ключ - ID пользователя, значение - статус онлайн (true/false) + */ public userOnlineStatusCache = new BehaviorSubject>({}); + + /** + * Обновляет статус пользователя в кеше + * + * @param userId - идентификатор пользователя + * @param status - статус онлайн (true - онлайн, false - оффлайн) + */ setOnlineStatus(userId: number, status: boolean) { this.userOnlineStatusCache.next({ ...this.userOnlineStatusCache, [userId]: status }); } + /** + * Подписывается на события перехода пользователей в оффлайн + * + * @returns Observable - поток событий с информацией о пользователе, ушедшем в оффлайн + */ onSetOffline(): Observable { return this.websocketService .on(ChatEventType.SET_OFFLINE) .pipe(map(status => plainToInstance(OnChangeStatus, status))); } + /** + * Подписывается на события перехода пользователей в онлайн + * + * @returns Observable - поток событий с информацией о пользователе, вошедшем в онлайн + */ onSetOnline(): Observable { return this.websocketService .on(ChatEventType.SET_ONLINE) .pipe(map(status => plainToInstance(OnChangeStatus, status))); } + /** + * Подписывается на события набора текста другими пользователями + * + * @returns Observable - поток событий с информацией о том, кто печатает + */ onTyping(): Observable { return this.websocketService .on(ChatEventType.TYPING) .pipe(map(typing => plainToInstance(TypingInChatEventDto, typing))); } + /** + * Отправляет событие о начале набора текста + * + * @param typing - объект с информацией о наборе текста (проект, пользователь) + */ startTyping(typing: TypingInChatDto): void { this.websocketService.send(ChatEventType.TYPING, typing); } + /** + * Отправляет команду на удаление сообщения + * + * @param deleteChatMessage - объект с идентификатором сообщения для удаления + */ deleteMessage(deleteChatMessage: DeleteChatMessageDto): void { this.websocketService.send(ChatEventType.DELETE_MESSAGE, deleteChatMessage); } + /** + * Отправляет команду на отметку сообщения как прочитанного + * + * @param readChatMessage - объект с идентификатором сообщения для отметки + */ readMessage(readChatMessage: ReadChatMessageDto): void { this.websocketService.send(ChatEventType.READ_MESSAGE, readChatMessage); } + /** + * Загружает историю сообщений для проекта с пагинацией + * + * @param projectId - идентификатор проекта + * @param count - смещение (количество пропускаемых сообщений) + * @param take - лимит сообщений для загрузки + * @returns Observable> - объект с массивом сообщений и метаданными пагинации + */ loadMessages( projectId: number, count?: number, @@ -85,52 +154,97 @@ export class ChatService { if (take !== undefined) queries = queries.set("limit", take); return this.apiService.get>( - `/chats/projects/${projectId}/messages/`, + `${this.CHATS_URL}/projects/${projectId}/messages/`, queries ); } + /** + * Загружает список файлов, отправленных в чате проекта + * + * @param projectId - идентификатор проекта + * @returns Observable - массив файлов с метаданными + */ loadProjectFiles(projectId: number): Observable { return this.apiService - .get(`/chats/projects/${projectId}/files`) + .get(`${this.CHATS_URL}/projects/${projectId}/files`) .pipe(map(r => plainToInstance(ChatFile, r))); } + /** + * BehaviorSubject для отслеживания наличия непрочитанных сообщений + */ unread$ = new BehaviorSubject(false); + + /** + * Проверяет наличие непрочитанных сообщений у пользователя + * + * @returns Observable - true если есть непрочитанные сообщения + */ hasUnreads(): Observable { return this.apiService - .get<{ hasUnreads: boolean }>("/chats/has-unreads") + .get<{ hasUnreads: boolean }>(`${this.CHATS_URL}/has-unreads`) .pipe(map(r => r.hasUnreads)); } + /** + * Отправляет новое сообщение в чат + * + * @param message - объект сообщения с текстом, файлами и метаданными + */ sendMessage(message: SendChatMessageDto): void { this.websocketService.send(ChatEventType.NEW_MESSAGE, message); } + /** + * Подписывается на получение новых сообщений + * + * @returns Observable - поток новых сообщений + */ onMessage(): Observable { return this.websocketService .on(ChatEventType.NEW_MESSAGE) .pipe(map(message => plainToInstance(OnChatMessageDto, message))); } + /** + * Подписывается на события редактирования сообщений + * + * @returns Observable - поток событий редактирования сообщений + */ onEditMessage(): Observable { return this.websocketService .on(ChatEventType.EDIT_MESSAGE) .pipe(map(message => plainToInstance(OnEditChatMessageDto, message))); } + /** + * Подписывается на события удаления сообщений + * + * @returns Observable - поток событий удаления сообщений + */ onDeleteMessage(): Observable { return this.websocketService .on(ChatEventType.DELETE_MESSAGE) .pipe(map(message => plainToInstance(OnDeleteChatMessageDto, message))); } + /** + * Подписывается на события прочтения сообщений + * + * @returns Observable - поток событий прочтения сообщений + */ onReadMessage(): Observable { return this.websocketService .on(ChatEventType.READ_MESSAGE) .pipe(map(message => plainToInstance(OnReadChatMessageDto, message))); } + /** + * Отправляет команду на редактирование сообщения + * + * @param message - объект с идентификатором сообщения и новым содержимым + */ editMessage(message: EditChatMessageDto): void { this.websocketService.send(ChatEventType.EDIT_MESSAGE, message); } diff --git a/projects/social_platform/src/app/office/services/industry.service.ts b/projects/social_platform/src/app/office/services/industry.service.ts index 49954e9d6..6fb9b8f1e 100644 --- a/projects/social_platform/src/app/office/services/industry.service.ts +++ b/projects/social_platform/src/app/office/services/industry.service.ts @@ -6,17 +6,44 @@ import { BehaviorSubject, catchError, map, Observable, tap, throwError } from "r 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("/industries/").pipe( + return this.apiService.get(`${this.INDUSTRIES_URL}/`).pipe( catchError(err => throwError(err)), map(industries => plainToInstance(Industry, industries)), tap(industries => { @@ -25,6 +52,14 @@ export class IndustryService { ); } + /** + * Находит конкретную отрасль в переданном массиве по идентификатору + * Вспомогательный метод для поиска отрасли без дополнительных запросов к серверу + * + * @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.ts b/projects/social_platform/src/app/office/services/invite.service.ts index 4291cf7ed..18cecee3a 100644 --- a/projects/social_platform/src/app/office/services/invite.service.ts +++ b/projects/social_platform/src/app/office/services/invite.service.ts @@ -8,12 +8,33 @@ 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, private readonly authService: AuthService) {} + /** + * Отправляет приглашение пользователю для участия в проекте + * + * @param userId - идентификатор пользователя, которому отправляется приглашение + * @param projectId - идентификатор проекта, в который приглашается пользователь + * @param role - роль пользователя в проекте (например, "developer", "designer") + * @param specialization - специализация пользователя в проекте (необязательно) + * @returns Observable - созданное приглашение со всеми полями + */ sendForUser( userId: number, projectId: number, @@ -21,32 +42,68 @@ export class InviteService { specialization?: string ): Observable { return this.apiService - .post("/invites/", { user: userId, project: projectId, role, specialization }) + .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(`/invites/${invitationId}`); + return this.apiService.delete(`${this.INVITES_URL}/${invitationId}`); } + /** + * Принимает приглашение в проект + * Используется получателем приглашения для присоединения к проекту + * + * @param inviteId - идентификатор приглашения для принятия + * @returns Observable - информация о принятом приглашении + */ acceptInvite(inviteId: number): Observable { - return this.apiService.post(`/invites/${inviteId}/accept/`, {}); + return this.apiService.post(`${this.INVITES_URL}/${inviteId}/accept/`, {}); } + /** + * Отклоняет приглашение в проект + * Используется получателем приглашения для отказа от участия + * + * @param inviteId - идентификатор приглашения для отклонения + * @returns Observable - информация об отклоненном приглашении + */ rejectInvite(inviteId: number): Observable { - return this.apiService.post(`/invites/${inviteId}/decline/`, {}); + 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(`/invites/${inviteId}`, { role, specialization }); + return this.apiService.patch(`${this.INVITES_URL}/${inviteId}`, { role, specialization }); } + /** + * Получает список приглашений для текущего пользователя + * Использует профиль текущего пользователя для фильтрации приглашений + * + * @returns Observable - массив приглашений, адресованных текущему пользователю + */ getMy(): Observable { return this.authService.profile.pipe( take(1), concatMap(profile => this.apiService.get( - "/invites/", + `${this.INVITES_URL}/`, new HttpParams({ fromObject: { user_id: profile.id } }) ) ), @@ -54,10 +111,17 @@ export class InviteService { ); } + /** + * Получает список всех приглашений для конкретного проекта + * Используется владельцами проекта для просмотра отправленных приглашений + * + * @param projectId - идентификатор проекта + * @returns Observable - массив всех приглашений, связанных с проектом + */ getByProject(projectId: number): Observable { return this.apiService .get( - "/invites/", + `${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.ts b/projects/social_platform/src/app/office/services/member.service.ts index c62e4e980..47467f72a 100644 --- a/projects/social_platform/src/app/office/services/member.service.ts +++ b/projects/social_platform/src/app/office/services/member.service.ts @@ -7,12 +7,32 @@ 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, @@ -22,12 +42,20 @@ export class MemberService { if (otherParams) { allParams = allParams.appendAll(otherParams); } - return this.apiService.get>("/auth/public-users/", allParams); + 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>( - "/auth/public-users/", + `${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/nav.service.ts b/projects/social_platform/src/app/office/services/nav.service.ts index b4c097a02..4da4e0ffd 100644 --- a/projects/social_platform/src/app/office/services/nav.service.ts +++ b/projects/social_platform/src/app/office/services/nav.service.ts @@ -3,15 +3,39 @@ import { Injectable } from "@angular/core"; import { distinctUntilChanged, ReplaySubject } from "rxjs"; +/** + * Сервис для управления навигацией и заголовками страниц + * + * Предоставляет функциональность для: + * - Установки и получения заголовка текущей страницы + * - Реактивного обновления заголовков в компонентах + * - Предотвращения дублирующих обновлений заголовков + */ @Injectable({ providedIn: "root", }) export class NavService { constructor() {} + /** + * ReplaySubject для хранения текущего заголовка навигации + * ReplaySubject(1) означает, что новые подписчики сразу получат последнее значение + */ navTitle$ = new ReplaySubject(1); + + /** + * Observable для подписки на изменения заголовка навигации + * distinctUntilChanged() предотвращает эмиссию одинаковых значений подряд + * @returns Observable - поток изменений заголовка страницы + */ navTitle = this.navTitle$.asObservable().pipe(distinctUntilChanged()); + /** + * Устанавливает новый заголовок для текущей страницы + * Уведомляет всех подписчиков об изменении заголовка + * + * @param title - новый заголовок страницы для отображения в навигации + */ setNavTitle(title: string): void { this.navTitle$.next(title); } diff --git a/projects/social_platform/src/app/office/services/notification.service.ts b/projects/social_platform/src/app/office/services/notification.service.ts index 280f81a16..f784c7b6d 100644 --- a/projects/social_platform/src/app/office/services/notification.service.ts +++ b/projects/social_platform/src/app/office/services/notification.service.ts @@ -4,13 +4,33 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject, map } from "rxjs"; import { Notification } from "@models/notification.model"; +/** + * Сервис для управления уведомлениями пользователя + * + * Предоставляет функциональность для: + * - Хранения списка уведомлений в памяти + * - Отслеживания количества непрочитанных уведомлений + * - Реактивного обновления состояния уведомлений + */ @Injectable({ providedIn: "root", }) export class NotificationService { constructor() {} + /** + * BehaviorSubject для хранения списка уведомлений + * Позволяет компонентам подписываться на изменения списка уведомлений + */ notifications = new BehaviorSubject([]); + + /** + * Observable для отслеживания количества непрочитанных уведомлений + * Автоматически пересчитывается при изменении списка уведомлений + * Фильтрует уведомления по полю readAt (если null - уведомление не прочитано) + * + * @returns Observable - количество непрочитанных уведомлений + */ hasNotifications = this.notifications .asObservable() .pipe(map(notifications => notifications.filter(notification => notification.readAt).length)); diff --git a/projects/social_platform/src/app/office/services/project.service.ts b/projects/social_platform/src/app/office/services/project.service.ts index 64e740dfb..c16f95a54 100644 --- a/projects/social_platform/src/app/office/services/project.service.ts +++ b/projects/social_platform/src/app/office/services/project.service.ts @@ -8,74 +8,210 @@ 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"; +/** + * Сервис для управления проектами + * + * Предоставляет функциональность для: + * - Получения списка проектов с пагинацией + * - Создания, обновления и удаления проектов + * - Управления этапами проектов + * - Работы с коллабораторами проектов + * - Получения статистики по проектам + */ @Injectable({ providedIn: "root", }) export class ProjectService { + private readonly PROJECTS_URL = "/projects"; + private readonly AUTH_USERS_URL = "/auth/users"; + constructor(private readonly apiService: ApiService) {} - private readonly steps$ = new BehaviorSubject([]); + /** + * BehaviorSubject для хранения этапов проектов + * Используется для кеширования и реактивного обновления данных об этапах + */ + readonly steps$ = new BehaviorSubject([]); + + /** + * Observable для подписки на изменения этапов проектов + * @returns Observable - поток данных с этапами проектов + */ steps = this.steps$.asObservable(); + /** + * Получает список всех этапов проектов с сервера + * Преобразует данные из формата [number, string][] в объекты ProjectStep + * Обновляет локальный кеш этапов + * + * @returns Observable - массив этапов проектов с полями id и name + */ getProjectSteps(): Observable { - return this.apiService.get<[number, string][]>("/projects/steps/").pipe( + return this.apiService.get<[number, string][]>(`${this.PROJECTS_URL}/steps/`).pipe( map(steps => steps.map(step => ({ id: step[0], name: step[1] }))), map(steps => plainToInstance(ProjectStep, steps)), tap(steps => this.steps$.next(steps)) ); } + /** + * Получает список всех проектов с пагинацией + * + * @param params - HttpParams с параметрами запроса (limit, offset, фильтры) + * @returns Observable> - объект с массивом проектов и метаданными пагинации + */ getAll(params?: HttpParams): Observable> { - return this.apiService.get>("/projects/", params); + return this.apiService.get>(`${this.PROJECTS_URL}/`, params); } + /** + * Получает один проект по его идентификатору + * Преобразует полученные данные в экземпляр класса Project + * + * @param id - уникальный идентификатор проекта + * @returns Observable - объект проекта со всеми полями + */ getOne(id: number): Observable { return this.apiService - .get(`/projects/${id}/`) + .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>("/auth/users/projects/", params); + return this.apiService.get>(`${this.AUTH_USERS_URL}/projects/`, params); } + /** + * 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("/projects/count/") + .get(`${this.PROJECTS_URL}/count/`) .pipe(map(count => plainToInstance(ProjectCount, count))); } + /** + * Удаляет проект по его идентификатору + * + * @param projectId - уникальный идентификатор проекта для удаления + * @returns Observable - завершается при успешном удалении + */ remove(projectId: number): Observable { - return this.apiService.delete(`/projects/${projectId}/`); + return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/`); } + /** + * Покидает проект (удаляет текущего пользователя из коллабораторов) + * + * @param projectId - идентификатор проекта, который нужно покинуть + * @returns Observable - завершается при успешном выходе из проекта + */ leave(projectId: Project["id"]): Observable { - return this.apiService.delete(`/projects/${projectId}/collaborators/leave`); + return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators/leave`); } + /** + * Создает новый пустой проект + * Преобразует полученные данные в экземпляр класса Project + * + * @returns Observable - созданный проект со всеми полями + */ create(): Observable { return this.apiService - .post("/projects/", {}) + .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(`/projects/${projectId}/`, newProject) + .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(`/projects/${projectId}/collaborators?id=${userId}`); + 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( - `/projects/${projectId}/collaborators/${userId}/switch-leader/`, + `${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 index b30f3c5dc..528320d79 100644 --- a/projects/social_platform/src/app/office/services/skills.service.ts +++ b/projects/social_platform/src/app/office/services/skills.service.ts @@ -8,19 +8,44 @@ import { Skill } from "../models/skill"; 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("/core/skills/nested"); + 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( - "/core/skills/inline", + `${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 index 1bfd81719..5d4a3ac2e 100644 --- a/projects/social_platform/src/app/office/services/specializations.service.ts +++ b/projects/social_platform/src/app/office/services/specializations.service.ts @@ -8,23 +8,48 @@ import { Specialization } from "../models/specialization"; 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("/auth/users/specializations/nested"); + 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( - "/auth/users/specializations/inline", + `${this.AUTH_USERS_SPECIALIZATIONS_URL}/inline`, new HttpParams({ fromObject: { limit, offset, name__icontains: search } }) ); } diff --git a/projects/social_platform/src/app/office/services/storage.service.ts b/projects/social_platform/src/app/office/services/storage.service.ts index 668a59934..fe2b8442d 100644 --- a/projects/social_platform/src/app/office/services/storage.service.ts +++ b/projects/social_platform/src/app/office/services/storage.service.ts @@ -2,15 +2,41 @@ import { Injectable } from "@angular/core"; +/** + * Сервис для работы с локальным хранилищем браузера + * + * Предоставляет функциональность для: + * - Сохранения данных в localStorage или sessionStorage + * - Получения данных из хранилища с автоматическим парсингом JSON + * - Работы с объектами и примитивными типами данных + * - Типизированного получения данных + */ @Injectable({ providedIn: "root", }) export class StorageService { + /** + * Сохраняет значение в указанное хранилище + * Автоматически сериализует объекты в JSON, примитивы сохраняет как есть + * + * @param key - ключ для сохранения данных + * @param value - значение для сохранения (может быть объектом или примитивом) + * @param storage - тип хранилища (по умолчанию localStorage, может быть sessionStorage) + */ setItem(key: string, value: any, storage = localStorage) { if (typeof value === "object") storage.setItem(key, JSON.stringify(value)); else storage.setItem(key, value); } + /** + * Получает значение из указанного хранилища с типизацией + * Автоматически парсит JSON и возвращает типизированный результат + * + * @template T - тип возвращаемых данных + * @param key - ключ для получения данных + * @param storage - тип хранилища (по умолчанию localStorage, может быть sessionStorage) + * @returns T | null - типизированное значение или null, если ключ не найден + */ getItem(key: string, storage = localStorage): T | null { const value = storage.getItem(key); if (!value) return null; diff --git a/projects/social_platform/src/app/office/services/subscription.service.ts b/projects/social_platform/src/app/office/services/subscription.service.ts index 931892526..920b17fda 100644 --- a/projects/social_platform/src/app/office/services/subscription.service.ts +++ b/projects/social_platform/src/app/office/services/subscription.service.ts @@ -8,25 +8,66 @@ 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(`/projects/${projectId}/subscribers/`); + return this.apiService.get( + `${this.PROJECTS_URL}/${projectId}/subscribers/` + ); } + /** + * Подписывает текущего пользователя на проект + * После подписки пользователь будет получать уведомления об обновлениях проекта + * + * @param projectId - идентификатор проекта для подписки + * @returns Observable - завершается при успешной подписке + */ addSubscription(projectId: number): Observable { - return this.apiService.post(`/projects/${projectId}/subscribe/`, {}); + 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(`/auth/users/${userId}/subscribed_projects`, params); + return this.apiService.get(`${this.AUTH_USERS_URL}/${userId}/subscribed_projects`, params); } + /** + * Отписывает текущего пользователя от проекта + * После отписки пользователь перестанет получать уведомления об обновлениях проекта + * + * @param projectId - идентификатор проекта для отписки + * @returns Observable - завершается при успешной отписке + */ deleteSubscription(projectId: number): Observable { - return this.apiService.post(`/projects/${projectId}/unsubscribe/`, {}); + return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/unsubscribe/`, {}); } } diff --git a/projects/social_platform/src/app/office/services/vacancy.service.ts b/projects/social_platform/src/app/office/services/vacancy.service.ts index d3b4a1e64..fa2fcde51 100644 --- a/projects/social_platform/src/app/office/services/vacancy.service.ts +++ b/projects/social_platform/src/app/office/services/vacancy.service.ts @@ -8,12 +8,40 @@ 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, @@ -56,10 +84,17 @@ export class VacancyService { } return this.apiService - .get("/vacancies/", params) + .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(); @@ -67,48 +102,99 @@ export class VacancyService { params.set("offset", offset); return this.apiService - .get("/vacancies/responses/self", params) + .get(`${this.VACANCIES_URL}/responses/self`, params) .pipe(map(vacancies => plainToInstance(VacancyResponse, vacancies))); } + /** + * Получает детальную информацию о конкретной вакансии + * + * @param vacancyId - идентификатор вакансии + * @returns Observable - объект вакансии со всеми полями + */ getOne(vacancyId: number) { return this.apiService - .get("/vacancies/" + vacancyId) + .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("/vacancies/", { + .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(`/vacancies/${vacancyId}`, { ...vacancy }); + return this.apiService.patch(`${this.VACANCIES_URL}/${vacancyId}`, { ...vacancy }); } + /** + * Удаляет вакансию + * + * @param vacancyId - идентификатор вакансии для удаления + * @returns Observable - завершается при успешном удалении + */ deleteVacancy(vacancyId: number): Observable { - return this.apiService.delete(`/vacancies/${vacancyId}`); + 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(`/vacancies/${vacancyId}/responses/`, body); + return this.apiService.post(`${this.VACANCIES_URL}/${vacancyId}/responses/`, body); } + /** + * Получает все отклики на вакансии конкретного проекта + * + * @param projectId - идентификатор проекта + * @returns Observable - массив откликов с информацией о кандидатах + */ responsesByProject(projectId: number): Observable { return this.apiService - .get(`/projects/${projectId}/responses/`) + .get(`${this.PROJECTS_URL}/${projectId}/responses/`) .pipe(map(response => plainToInstance(VacancyResponse, response))); } + /** + * Принимает отклик кандидата на вакансию + * + * @param responseId - идентификатор отклика + * @returns Observable - завершается при успешном принятии отклика + */ acceptResponse(responseId: number): Observable { - return this.apiService.post(`/vacancies/responses/${responseId}/accept/`, {}); + return this.apiService.post(`${this.VACANCIES_URL}/responses/${responseId}/accept/`, {}); } + /** + * Отклоняет отклик кандидата на вакансию + * + * @param responseId - идентификатор отклика + * @returns Observable - завершается при успешном отклонении отклика + */ rejectResponse(responseId: number): Observable { - return this.apiService.post(`/vacancies/responses/${responseId}/decline/`, {}); + return this.apiService.post(`${this.VACANCIES_URL}/responses/${responseId}/decline/`, {}); } } diff --git a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.ts b/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.ts index d2bf52b92..205667e02 100644 --- a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.ts +++ b/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.ts @@ -3,6 +3,22 @@ import { Component, Input, OnInit } from "@angular/core"; import { New } from "@models/article.model"; +/** + * Компонент карточки рекламного объявления + * + * Функциональность: + * - Отображает рекламное объявление в виде карточки + * - Поддерживает два варианта макета: вертикальный и горизонтальный + * - Простой компонент для отображения информации без интерактивности + * + * Входные параметры: + * @Input advert - объект рекламного объявления (обязательный) + * @Input layout - тип макета карточки: "vertical" или "horizontal" (по умолчанию "vertical") + * + * Выходные события: отсутствуют + * + * Примечание: Компонент предназначен только для отображения, не содержит бизнес-логики + */ @Component({ selector: "app-advert-card", templateUrl: "./advert-card.component.html", diff --git a/projects/social_platform/src/app/office/shared/carousel/carousel.component.ts b/projects/social_platform/src/app/office/shared/carousel/carousel.component.ts index 59f2dca6e..47e780f78 100644 --- a/projects/social_platform/src/app/office/shared/carousel/carousel.component.ts +++ b/projects/social_platform/src/app/office/shared/carousel/carousel.component.ts @@ -1,9 +1,31 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; import { FileModel } from "@office/models/file.model"; import { IconComponent } from "@uilib"; +/** + * Компонент карусели для просмотра изображений + * + * Функциональность: + * - Отображает изображения в виде карусели с навигацией + * - Поддерживает навигацию вперед/назад через кнопки + * - Обработка двойного тапа для лайка изображения + * - Анимация сердечка при лайке + * - Поддерживает как объекты FileModel, так и строковые URL + * - Отображает текущий индекс изображения + * + * Входные параметры: + * @Input images - массив изображений (FileModel или строки URL) + * + * Выходные события: + * @Output like - событие лайка изображения, передает индекс изображения + * + * Внутренние свойства: + * - currentIndex - текущий индекс отображаемого изображения + * - lastTouch - время последнего касания для определения двойного тапа + * - showLike - флаг отображения анимации лайка + */ @Component({ selector: "app-carousel", imports: [IconComponent], @@ -21,18 +43,30 @@ export class CarouselComponent implements OnInit { ngOnInit(): void {} + /** + * Переход к следующему изображению + * Использует циклическую навигацию + */ next(): void { if (this.images.length) { this.currentIndex = (this.currentIndex + 1) % this.images.length; } } + /** + * Переход к предыдущему изображению + * Использует циклическую навигацию + */ prev(): void { if (this.images.length) { this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length; } } + /** + * Обработчик касания изображения + * Определяет двойной тап и запускает лайк с анимацией + */ onTouchImg(_event: TouchEvent): void { const now = Date.now(); if (now - this.lastTouch < 300) { @@ -45,10 +79,18 @@ export class CarouselComponent implements OnInit { this.lastTouch = now; } + /** + * Получение URL изображения + * Обрабатывает как объекты FileModel, так и строки + */ getImageUrl(image: FileModel | string): string { return typeof image === "string" ? image : image.link; } + /** + * Получение имени изображения + * Обрабатывает как объекты FileModel, так и строки + */ getImageName(image: FileModel | string): string { return typeof image === "string" ? "Image" : image.name || "Image"; } diff --git a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.ts b/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.ts index de024fef4..c6a919b04 100644 --- a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.ts +++ b/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.ts @@ -26,6 +26,11 @@ import { User } from "@auth/models/user.model"; import { PluralizePipe } from "projects/core"; import { ChatMessageComponent } from "@ui/components/chat-message/chat-message.component"; +/** + * Компонент окна чата + * Отображает список сообщений с виртуальной прокруткой и поле ввода нового сообщения + * Поддерживает редактирование, ответы на сообщения, отметки о прочтении и индикацию печатания + */ @Component({ selector: "app-chat-window", templateUrl: "./chat-window.component.html", @@ -42,24 +47,35 @@ import { ChatMessageComponent } from "@ui/components/chat-message/chat-message.c ], }) export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { + /** + * Конструктор компонента + * @param fb - FormBuilder для создания формы сообщения + * @param modalService - сервис для отображения модальных окон + * @param authService - сервис аутентификации для получения данных пользователя + */ constructor( private readonly fb: FormBuilder, private readonly modalService: ModalService, private readonly authService: AuthService ) { + // Создание формы для ввода сообщения this.messageForm = this.fb.group({ - messageControl: [{ text: "", filesUrl: [] }], + messageControl: [{ text: "", filesUrl: [] }], // Контрол с текстом и файлами }); } + /** Приватное поле для хранения сообщений */ private _messages: ChatMessage[] = []; + /** - * All chat messages - * Renders only few of them - * See virtual scrolling {@link viewport} + * Сеттер для списка сообщений чата + * Обновляет список сообщений и автоматически прокручивает к низу при новых сообщениях + * Настраивает наблюдатель для отметок о прочтении + * @param value - массив сообщений чата */ @Input({ required: true }) set messages(value: ChatMessage[]) { const messagesIds = this._messages.map(m => m.id); + // Находим новые сообщения const diff = value.filter(m => { return messagesIds.indexOf(m.id) < 0; }); @@ -67,10 +83,12 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { const noMessages = !this._messages.length; this._messages = value; + // Автопрокрутка к низу для новых сообщений от текущего пользователя или при первой загрузке if ((diff.length === 1 && diff[0]?.author.id === this.profile?.id) || noMessages) { this.scrollToBottom(); } + // Настройка наблюдателя для отметок о прочтении setTimeout(() => { const elementNode = document.querySelectorAll(".chat__message"); elementNode.forEach(el => { @@ -79,46 +97,72 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Геттер для получения списка сообщений + * @returns массив сообщений чата + */ get messages(): ChatMessage[] { return this._messages; } + /** + * Список пользователей, которые сейчас печатают + */ @Input() typingPersons: { firstName: string; lastName: string; userId: number }[] = []; + // События компонента + /** Событие отправки нового сообщения */ @Output() submit = new EventEmitter(); + /** Событие редактирования сообщения */ @Output() edit = new EventEmitter(); + /** Событие удаления сообщения */ @Output() delete = new EventEmitter(); + /** Событие начала печатания */ @Output() type = new EventEmitter(); + /** Событие запроса дополнительных сообщений */ @Output() fetch = new EventEmitter(); + /** Событие прочтения сообщения */ @Output() read = new EventEmitter(); + /** + * Инициализация компонента + * Подписывается на профиль пользователя и настраивает отслеживание печатания + */ ngOnInit(): void { + // Подписка на профиль текущего пользователя const profile$ = this.authService.profile.subscribe(p => { this.profile = p; }); this.subscriptions$.push(profile$); - this.initTypingSend(); // event for send info to websocket, that current user is typing + // Инициализация отслеживания печатания + this.initTypingSend(); } + /** + * Инициализация после отрисовки представления + * Настраивает обработчик прокрутки для загрузки истории сообщений + * Создает наблюдатель для отметок о прочтении + */ ngAfterViewInit(): void { if (this.viewport) { + // Подписка на события прокрутки для загрузки истории const viewPortScroll$ = fromEvent(this.viewport?.elementRef.nativeElement, "scroll") .pipe( - skip(1), // need skip first scroll event because it's happens programmatically in ngOnInit hook - + skip(1), // Пропуск первого события прокрутки filter(() => { - const offsetTop = this.viewport?.measureScrollOffset("top"); // get amount of pixels that can be scrolled to the top of messages container - return offsetTop ? offsetTop <= 200 : false; + const offsetTop = this.viewport?.measureScrollOffset("top"); + return offsetTop ? offsetTop <= 200 : false; // Загрузка при приближении к верху }) ) .subscribe(() => { - this.fetch.emit(); + this.fetch.emit(); // Запрос дополнительных сообщений }); viewPortScroll$ && this.subscriptions$.push(viewPortScroll$); + // Создание наблюдателя пересечений для отметок о прочтении this.observer = new IntersectionObserver(this.onReadMessage.bind(this), { root: this.viewport.elementRef.nativeElement, rootMargin: "0px 0px 0px 0px", @@ -127,58 +171,57 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { } } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy(): void { this.subscriptions$.forEach($ => $.unsubscribe()); } + /** Наблюдатель пересечений для отметок о прочтении */ observer?: IntersectionObserver; - + /** Профиль текущего пользователя */ profile?: User; /** - * The placeholder value of form control - * @private + * Базовое значение контрола сообщения */ private readonly messageControlBaseValue = { text: "", filesUrl: [], }; + /** Форма для ввода сообщения */ messageForm: FormGroup; - - /** - * Message that user want to edit - * set from {@link onEditMessage} - */ + /** Сообщение, которое редактируется */ editingMessage?: ChatMessage; - /** - * Message that user want to reply - * set from {@link onReplyMessage} - */ + /** Сообщение, на которое отвечаем */ replyMessage?: ChatMessage; /** - * The element in template that hold all messages - * it from angular cdk virtual scrolling - * need to not overpopulate browser engine by big amount of messages + * Элемент виртуальной прокрутки для оптимизации отображения большого количества сообщений */ @ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport; /** - * Input message component - * Write edit reply to messages etc + * Компонент ввода сообщения */ @ViewChild(MessageInputComponent, { read: ElementRef }) messageInputComponent?: ElementRef; + /** Массив подписок для очистки */ subscriptions$: Subscription[] = []; + /** + * Инициализация отслеживания печатания + * Отправляет событие печатания при изменении текста с задержкой + */ private initTypingSend(): void { const messageControlSub$ = this.messageForm .get("messageControl") ?.valueChanges.pipe( - throttleTime(2000), + throttleTime(2000), // Ограничение частоты отправки событий tap(() => { - this.type.emit(); + this.type.emit(); // Отправка события печатания }) ) .subscribe(noop); @@ -186,8 +229,12 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { messageControlSub$ && this.subscriptions$.push(messageControlSub$); } + /** + * Прокрутка к низу списка сообщений + * Использует двойной setTimeout для корректной работы с виртуальной прокруткой + */ private scrollToBottom(): void { - // Sadly buy it's work only this way + // Sadly but it's work only this way // It seems that when first scrollTo works // It didn't render all elements so bottom 0 is not actual bottom of all comments setTimeout(() => { @@ -199,16 +246,27 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** + * Обработчик изменения размера поля ввода + * Автоматически прокручивает к низу если пользователь находится внизу чата + */ onInputResize(): void { if (this.viewport && this.viewport.measureScrollOffset("bottom") < 50) this.scrollToBottom(); } + /** + * Установка фокуса на поле ввода + */ private focusOnInput(): void { setTimeout(() => { this.messageInputComponent?.nativeElement.querySelector("textarea").focus(); }); } + /** + * Обработчик начала редактирования сообщения + * @param messageId - идентификатор редактируемого сообщения + */ onEditMessage(messageId: number): void { this.replyMessage = undefined; this.editingMessage = this.messages.find(message => message.id === messageId); @@ -216,6 +274,10 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { this.focusOnInput(); } + /** + * Обработчик ответа на сообщение + * @param messageId - идентификатор сообщения для ответа + */ onReplyMessage(messageId: number): void { this.editingMessage = undefined; this.replyMessage = this.messages.find(message => message.id === messageId); @@ -223,15 +285,23 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { this.focusOnInput(); } + /** + * Отмена редактирования или ответа + */ onCancelInput(): void { this.replyMessage = undefined; this.editingMessage = undefined; } + /** + * Обработчик отправки сообщения + * Различает между редактированием существующего сообщения и отправкой нового + */ onSubmitMessage() { if (!this.messageForm.get("messageControl")?.value.text) return; if (this.editingMessage) { + // Редактирование существующего сообщения this.edit.emit({ text: this.messageForm.get("messageControl")?.value.text, id: this.editingMessage.id, @@ -239,6 +309,7 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { this.editingMessage = undefined; } else { + // Отправка нового сообщения this.submit.emit({ replyTo: this.replyMessage?.id ?? null, text: this.messageForm.get("messageControl")?.value.text ?? "", @@ -248,24 +319,37 @@ export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { this.replyMessage = undefined; } + // Очистка формы this.messageForm.get("messageControl")?.setValue(this.messageControlBaseValue); } + /** + * Обработчик удаления сообщения + * Показывает модальное окно подтверждения перед удалением + * @param messageId - идентификатор удаляемого сообщения + */ onDeleteMessage(messageId: number): void { const deletedMessage = this.messages.find(message => message.id === messageId); this.modalService - .confirmDelete("Вы уверены что хотите удалить сообщение?", `“${deletedMessage?.text}”`) + .confirmDelete("Вы уверены что хотите удалить сообщение?", `"${deletedMessage?.text}"`) .pipe(filter(Boolean)) .subscribe(() => { this.delete.emit(messageId); }); } + /** + * Обработчик отметки сообщений как прочитанных + * Вызывается наблюдателем пересечений когда сообщение становится видимым + * @param entries - массив элементов, пересекающихся с областью видимости + */ onReadMessage(entries: IntersectionObserverEntry[]): void { entries.forEach(e => { const element = e.target as HTMLElement; - !parseInt(element.dataset["beenRead"] || "1") && this.read.emit(parseInt(element.id)); + // Отмечаем как прочитанное только непрочитанные сообщения + !Number.parseInt(element.dataset["beenRead"] || "1") && + this.read.emit(Number.parseInt(element.id)); }); } } 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 index 4c4eb400b..8f07acc2e 100644 --- a/projects/social_platform/src/app/office/shared/header/header.component.ts +++ b/projects/social_platform/src/app/office/shared/header/header.component.ts @@ -11,6 +11,32 @@ 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", @@ -37,16 +63,28 @@ export class HeaderComponent implements OnInit { 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); @@ -56,6 +94,11 @@ export class HeaderComponent implements OnInit { }); } + /** + * Обработчик принятия приглашения + * Отправляет запрос на принятие, удаляет приглашение из списка + * и перенаправляет пользователя на страницу проекта + */ onAcceptInvite(inviteId: number): void { this.inviteService.acceptInvite(inviteId).subscribe(() => { const index = this.invites.findIndex(invite => invite.id === inviteId); diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts b/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts index 3263f2352..29154dccf 100644 --- a/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts +++ b/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts @@ -3,6 +3,23 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { IconComponent } from "@ui/components"; +/** + * Компонент карточки изображения с состояниями загрузки и ошибки + * + * Функциональность: + * - Отображает изображение по переданному URL + * - Показывает состояния загрузки и ошибки + * - Предоставляет кнопки для отмены и повторной попытки загрузки + * + * Входные параметры: + * @Input src - URL изображения (по умолчанию пустая строка) + * @Input error - флаг состояния ошибки (по умолчанию false) + * @Input loading - флаг состояния загрузки (по умолчанию false) + * + * Выходные события: + * @Output cancel - событие отмены загрузки/отображения изображения + * @Output retry - событие повторной попытки загрузки изображения + */ @Component({ selector: "app-img-card", templateUrl: "./img-card.component.html", @@ -19,5 +36,6 @@ export class ImgCardComponent implements OnInit { @Output() cancel = new EventEmitter(); @Output() retry = new EventEmitter(); + ngOnInit(): void {} } diff --git a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.html b/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.html index 8b64d12bb..8e66d16ad 100644 --- a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.html +++ b/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.html @@ -40,7 +40,7 @@

    -

    Вы, действительно, хотите удалить участника из команды?

    +

    Вы, действительно, хотите удалить приглашение в команду?

    (); @Output() edit = new EventEmitter(); + /** + * Обработчик удаления элемента + * Предотвращает всплытие события и эмитит событие удаления + */ onRemove(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); @@ -27,6 +49,10 @@ export class LinkCardComponent { this.remove.emit(this.data?.id); } + /** + * Обработчик редактирования элемента + * Предотвращает всплытие события и эмитит событие редактирования + */ onEdit(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); diff --git a/projects/social_platform/src/app/office/shared/member-card/member-card.component.ts b/projects/social_platform/src/app/office/shared/member-card/member-card.component.ts index 88e2e2470..2dd4db880 100644 --- a/projects/social_platform/src/app/office/shared/member-card/member-card.component.ts +++ b/projects/social_platform/src/app/office/shared/member-card/member-card.component.ts @@ -8,6 +8,28 @@ import { TagComponent } from "@ui/components/tag/tag.component"; import { AsyncPipe } from "@angular/common"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +/** + * Компонент карточки участника команды + * + * Функциональность: + * - Отображает основную информацию об участнике (аватар, имя, возраст) + * - Показывает роль пользователя через UserRolePipe + * - Вычисляет возраст на основе даты рождения через YearsFromBirthdayPipe + * - Отображает теги с дополнительной информацией + * - Простой компонент для отображения без интерактивности + * + * Входные параметры: + * @Input user - объект пользователя (обязательный) + * + * Выходные события: отсутствуют + * + * Используемые пайпы: + * - YearsFromBirthdayPipe - вычисление возраста по дате рождения + * - UserRolePipe - форматирование роли пользователя + * - AsyncPipe - работа с асинхронными данными + * + * Примечание: Компонент предназначен только для отображения информации + */ @Component({ selector: "app-member-card", templateUrl: "./member-card.component.html", diff --git a/projects/social_platform/src/app/office/shared/message-input/message-input.component.ts b/projects/social_platform/src/app/office/shared/message-input/message-input.component.ts index f0b10daa8..44e6c650b 100644 --- a/projects/social_platform/src/app/office/shared/message-input/message-input.component.ts +++ b/projects/social_platform/src/app/office/shared/message-input/message-input.component.ts @@ -19,12 +19,24 @@ import { NgxMaskModule } from "ngx-mask"; import { IconComponent } from "@ui/components"; import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; +/** + * Компонент ввода сообщений для чата + * Предоставляет расширенный интерфейс для ввода текстовых сообщений с поддержкой: + * - Автоматического изменения размера текстового поля + * - Прикрепления файлов через выбор или drag&drop + * - Редактирования существующих сообщений + * - Ответов на сообщения + * - Масок ввода для специальных форматов + * + * Реализует ControlValueAccessor для интеграции с Angular Forms + */ @Component({ selector: "app-message-input", templateUrl: "./message-input.component.html", styleUrl: "./message-input.component.scss", providers: [ { + // Регистрация как ControlValueAccessor для работы с формами provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MessageInputComponent), multi: true, @@ -34,12 +46,25 @@ import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; imports: [IconComponent, NgxMaskModule, AutosizeModule, FileTypePipe, FormatedFileSizePipe], }) export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAccessor { + /** + * Конструктор компонента + * @param fileService - сервис для работы с файлами (загрузка, удаление) + */ constructor(private readonly fileService: FileService) {} + /** Текст placeholder для поля ввода */ @Input() placeholder = ""; + /** Маска для форматирования ввода */ @Input() mask = ""; + /** Приватное поле для хранения редактируемого сообщения */ private _editingMessage?: ChatMessage; + + /** + * Сеттер для сообщения, которое редактируется + * При установке сообщения для редактирования, его текст загружается в поле ввода + * @param message - сообщение для редактирования или undefined для отмены + */ @Input() set editingMessage(message: ChatMessage | undefined) { this._editingMessage = message; @@ -51,47 +76,83 @@ export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAcc } } + /** + * Геттер для получения редактируемого сообщения + * @returns сообщение для редактирования или undefined + */ get editingMessage(): ChatMessage | undefined { return this._editingMessage; } + /** Сообщение, на которое отвечаем */ @Input() replyMessage?: ChatMessage; + /** + * Сеттер для значения компонента + * @param value - объект с текстом и массивом URL файлов + */ @Input() set appValue(value: MessageInputComponent["value"]) { this.value = value; } + /** + * Геттер для получения значения компонента + * @returns объект с текстом и массивом URL файлов + */ get appValue(): MessageInputComponent["value"] { return this.value; } + // События компонента + /** Событие изменения значения */ @Output() appValueChange = new EventEmitter(); + /** Событие отправки сообщения */ @Output() submit = new EventEmitter(); + /** Событие изменения размера поля ввода */ @Output() resize = new EventEmitter(); + /** Событие отмены редактирования/ответа */ @Output() cancel = new EventEmitter(); + /** + * Инициализация компонента + * Настраивает обработчики drag&drop для загрузки файлов + */ ngOnInit(): void { + // Обработчик события dragover для всего документа const dragOver$ = fromEvent(document, "dragover") .pipe() .subscribe(this.handleDragOver.bind(this)); dragOver$ && this.subscriptions$.push(dragOver$); + // Обработчик события drop для всего документа const drop$ = fromEvent(document, "drop").subscribe(this.handleDrop.bind(this)); drop$ && this.subscriptions$.push(drop$); } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy(): void { this.subscriptions$.forEach($ => $.unsubscribe()); } + /** + * Обработчик события dragover + * Предотвращает стандартное поведение и показывает модальное окно для drop + * @param event - событие dragover + */ private handleDragOver(event: DragEvent): void { event.stopPropagation(); event.preventDefault(); - this.showDropModal = true; } + /** + * Обработчик события drop + * Обрабатывает перетаскиваемые файлы и скрывает модальное окно + * @param event - событие drop + */ private handleDrop(event: DragEvent): void { event.stopPropagation(); event.preventDefault(); @@ -100,59 +161,92 @@ export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAcc if (!files) return; this.addFiles(files); - this.showDropModal = false; } + /** Флаг отображения модального окна для drag&drop */ showDropModal = false; - + /** Массив подписок для очистки */ subscriptions$: Subscription[] = []; + /** Значение компонента: текст и массив URL файлов */ value: { text: string; filesUrl: string[] } = { text: "", filesUrl: [] }; + /** + * Обработчик ввода текста + * @param event - событие ввода + */ onInput(event: Event): void { const value = (event.target as HTMLInputElement).value; const newValue = { ...this.value, text: value }; this.onChange(newValue); this.appValueChange.emit(newValue); - this.value = newValue; } + /** + * Обработчик потери фокуса + */ onBlur(): void { this.onTouch(); } + // Методы ControlValueAccessor + /** + * Установка значения в компонент (ControlValueAccessor) + * @param value - значение для установки + */ writeValue(value: MessageInputComponent["value"]): void { setTimeout(() => { this.value = value; + // Очистка списка файлов если нет URL файлов if (!value.filesUrl.length) { this.attachFiles = []; } }); } + /** Функция обратного вызова для уведомления об изменениях */ // eslint-disable-next-line no-use-before-define onChange: (value: MessageInputComponent["value"]) => void = () => {}; + /** + * Регистрация функции обратного вызова для изменений (ControlValueAccessor) + * @param fn - функция для вызова при изменении значения + */ registerOnChange(fn: (v: MessageInputComponent["value"]) => void): void { this.onChange = fn; } + /** Функция обратного вызова для уведомления о касании */ onTouch: () => void = () => {}; + /** + * Регистрация функции обратного вызова для касания (ControlValueAccessor) + * @param fn - функция для вызова при касании компонента + */ registerOnTouched(fn: () => void): void { this.onTouch = fn; } + /** Флаг отключения компонента */ disabled = false; + /** + * Установка состояния отключения (ControlValueAccessor) + * @param isDisabled - флаг отключения + */ setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } + /** + * Обработчик нажатия клавиш в textarea + * Обрабатывает Tab для вставки символа табуляции + * @param event - событие клавиатуры + */ onTextareaKeydown(event: any) { if (event.key === "Tab") { event.preventDefault(); @@ -160,13 +254,14 @@ export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAcc const start = textarea.selectionStart; const end = textarea.selectionEnd; + // Вставка символа табуляции в позицию курсора textarea.value = textarea.value.substring(0, start) + "\t" + textarea.value.substring(end); - textarea.selectionStart = textarea.selectionEnd = start + 1; this.onInput(event); } } + /** Массив прикрепленных файлов с метаданными */ attachFiles: { name: string; size: string; @@ -175,6 +270,10 @@ export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAcc loading: boolean; }[] = []; + /** + * Обработчик загрузки файлов через input + * @param evt - событие выбора файлов + */ onUpload(evt: Event) { const files = (evt.currentTarget as HTMLInputElement).files; @@ -185,7 +284,13 @@ export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAcc this.addFiles(files); } + /** + * Добавление файлов для загрузки + * Создает записи в массиве attachFiles и запускает загрузку на сервер + * @param files - список файлов для загрузки + */ private addFiles(files: FileList): void { + // Создание записей для каждого файла for (let i = 0; i < files.length; i++) { this.attachFiles.push({ name: files[i].name, @@ -195,12 +300,14 @@ export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAcc }); } + // Загрузка каждого файла на сервер for (let i = 0; i < files.length; i++) { this.fileService .uploadFile(files[i]) .pipe(map(r => r.url)) .subscribe({ next: url => { + // Обновление значения компонента с новым URL файла this.value = { ...this.value, filesUrl: [...this.value.filesUrl, url], @@ -208,6 +315,7 @@ export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAcc this.onChange(this.value); + // Обновление метаданных файла setTimeout(() => { this.attachFiles[i].loading = false; this.attachFiles[i].link = url; @@ -222,18 +330,24 @@ export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAcc } } + /** + * Удаление файла + * Удаляет файл с сервера и из списка прикрепленных файлов + * @param idx - индекс файла в массиве attachFiles + */ onDeleteFile(idx: number): void { const file = this.attachFiles[idx]; if (!file || !file.link) return; this.fileService.deleteFile(file.link).subscribe(() => { + // Удаление из массива прикрепленных файлов this.attachFiles.splice(idx, 1); - + // Удаление URL из значения компонента this.value.filesUrl.splice(idx, 1); - this.onChange(this.value); }); } + /** Ссылка на модуль для совместимости */ protected readonly repl = module; } diff --git a/projects/social_platform/src/app/office/shared/nav/nav.component.ts b/projects/social_platform/src/app/office/shared/nav/nav.component.ts index e1edbf395..47742dc95 100644 --- a/projects/social_platform/src/app/office/shared/nav/nav.component.ts +++ b/projects/social_platform/src/app/office/shared/nav/nav.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, signal } from "@angular/core"; +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"; @@ -12,6 +12,35 @@ 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", @@ -37,6 +66,7 @@ export class NavComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { + // Подписка на события роутера для закрытия мобильного меню const routerEvents$ = this.router.events.subscribe(event => { if (event instanceof NavigationStart) { this.mobileMenuOpen = false; @@ -44,6 +74,7 @@ export class NavComponent implements OnInit, OnDestroy { }); routerEvents$ && this.subscriptions$.push(routerEvents$); + // Подписка на изменения заголовка страницы const title$ = this.navService.navTitle.subscribe(title => { this.title = title; this.cdref.detectChanges(); @@ -59,17 +90,22 @@ export class NavComponent implements OnInit, OnDestroy { @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); @@ -80,6 +116,11 @@ export class NavComponent implements OnInit, OnDestroy { }); } + /** + * Обработчик принятия приглашения + * Отправляет запрос на принятие, удаляет приглашение из списка + * и перенаправляет пользователя на страницу проекта + */ onAcceptInvite(inviteId: number): void { this.inviteService.acceptInvite(inviteId).subscribe(() => { const index = this.invites.findIndex(invite => invite.id === inviteId); @@ -95,6 +136,10 @@ export class NavComponent implements OnInit, OnDestroy { }); } + /** + * Переход на внешний сервис навыков + * Открывает новую вкладку с сервисом skills.procollab.ru + */ openSkills() { location.href = "https://skills.procollab.ru"; } diff --git a/projects/social_platform/src/app/office/shared/news-card/news-card.component.ts b/projects/social_platform/src/app/office/shared/news-card/news-card.component.ts index 15077fd8e..b08eba469 100644 --- a/projects/social_platform/src/app/office/shared/news-card/news-card.component.ts +++ b/projects/social_platform/src/app/office/shared/news-card/news-card.component.ts @@ -26,11 +26,13 @@ import { FileUploadItemComponent } from "@ui/components/file-upload-item/file-up import { ImgCardComponent } from "../img-card/img-card.component"; import { TextareaComponent } from "@ui/components/textarea/textarea.component"; import { ClickOutsideModule } from "ng-click-outside"; -import { JsonPipe } from "@angular/common"; import { CarouselComponent } from "../carousel/carousel.component"; -import { User } from "@auth/models/user.model"; -import { Project } from "@office/models/project.model"; +/** + * Компонент карточки новости + * Отображает новость с возможностью редактирования, лайков, просмотра файлов + * Поддерживает загрузку и удаление файлов, расширение текста, копирование ссылки + */ @Component({ selector: "app-news-card", templateUrl: "./news-card.component.html", @@ -49,11 +51,19 @@ import { Project } from "@office/models/project.model"; CarouselComponent, DayjsPipe, FormControlPipe, - JsonPipe, ParseLinksPipe, ], }) export class NewsCardComponent implements OnInit { + /** + * Конструктор компонента + * @param snackbarService - сервис для отображения уведомлений + * @param route - текущий маршрут для получения параметров + * @param fb - FormBuilder для создания формы редактирования + * @param validationService - сервис валидации форм + * @param fileService - сервис для работы с файлами + * @param cdRef - ChangeDetectorRef для ручного обновления представления + */ constructor( private readonly snackbarService: SnackbarService, private readonly route: ActivatedRoute, @@ -62,35 +72,56 @@ export class NewsCardComponent implements OnInit { private readonly fileService: FileService, private readonly cdRef: ChangeDetectorRef ) { + // Создание формы редактирования новости this.editForm = this.fb.group({ - text: ["", [Validators.required]], + text: ["", [Validators.required]], // Текст новости - обязательное поле }); } + /** Данные новости для отображения */ @Input({ required: true }) feedItem!: FeedNews; + /** Ссылка на ресурс (профиль или проект) */ @Input({ required: true }) resourceLink!: (string | number)[]; + /** ID контента (проекта или пользователя) */ @Input({ required: false }) contentId?: number; - + /** Является ли текущий пользователь владельцем новости */ @Input() isOwner?: boolean; + + // События компонента + /** Событие удаления новости */ @Output() delete = new EventEmitter(); + /** Событие лайка новости */ @Output() like = new EventEmitter(); + /** Событие редактирования новости */ @Output() edited = new EventEmitter(); + /** URL заглушки для аватара */ placeholderUrl = "https://hwchamber.co.uk/wp-content/uploads/2022/04/avatar-placeholder.gif"; + // Состояние компонента + /** Можно ли расширить текст новости */ newsTextExpandable!: boolean; + /** Показан ли полный текст */ readMore = false; + /** Режим редактирования */ editMode = false; - + /** Форма редактирования */ editForm: FormGroup; + /** + * Инициализация компонента + * Настраивает форму редактирования и обрабатывает файлы новости + */ ngOnInit(): void { + // Установка текущего текста в форму редактирования this.editForm.setValue({ text: this.feedItem.text, }); + // Обработка файлов новости const processedFiles = this.feedItem.files.map(file => { if (typeof file === "string") { + // Преобразование строки в объект FileModel return { link: file, name: "Image", @@ -104,8 +135,10 @@ export class NewsCardComponent implements OnInit { 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"; @@ -116,6 +149,7 @@ export class NewsCardComponent implements OnInit { return type !== "image" && f.mimeType !== "x-empty"; }); + // Создание списков для редактирования this.imagesEditList = this.imagesViewList.map(file => ({ src: file.link, id: nanoid(), @@ -136,53 +170,74 @@ export class NewsCardComponent implements OnInit { })); } + /** Список изображений для просмотра */ imagesViewList: FileModel[] = []; + /** Список файлов для просмотра */ filesViewList: FileModel[] = []; + /** Ссылка на элемент текста новости */ @ViewChild("newsTextEl") newsTextEl?: ElementRef; + /** + * Инициализация после отрисовки представления + * Определяет, можно ли расширить текст новости + */ ngAfterViewInit(): void { const newsTextElem = this.newsTextEl?.nativeElement; this.newsTextExpandable = newsTextElem?.clientHeight < newsTextElem?.scrollHeight; - this.cdRef.detectChanges(); } + /** + * Копирование ссылки на новость в буфер обмена + */ onCopyLink(): void { - // const projectId = this.route.snapshot.params["projectId"]; - // const userId = this.route.snapshot.params["id"]; const isProject = this.resourceLink[0].toString().includes("projects"); let fullUrl = ""; + // Формирование URL в зависимости от типа ресурса 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; + this.edited.emit({ ...this.editForm.value, files: this.imagesEditList.filter(f => f.src).map(f => f.src), }); } + /** + * Закрытие режима редактирования + */ onCloseEditMode() { this.editMode = false; } + /** Список изображений для редактирования */ imagesEditList: { id: string; src: string; @@ -191,6 +246,7 @@ export class NewsCardComponent implements OnInit { tempFile: File | null; }[] = []; + /** Список файлов для редактирования */ filesEditList: { id: string; src: string; @@ -202,15 +258,22 @@ export class NewsCardComponent implements OnInit { tempFile: File | null; }[] = []; + /** + * Загрузка файлов при редактировании + * @param event - событие выбора файлов + */ 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: "", @@ -226,6 +289,7 @@ export class NewsCardComponent implements OnInit { fileObj.src = file.url; fileObj.loading = false; + // Добавление в список для просмотра if (fileObj.tempFile) { this.imagesViewList.push({ name: fileObj.tempFile.name, @@ -243,6 +307,7 @@ export class NewsCardComponent implements OnInit { ) ); } else { + // Обработка документов const fileObj: NewsCardComponent["filesEditList"][0] = { id: nanoid(2), loading: true, @@ -261,6 +326,7 @@ export class NewsCardComponent implements OnInit { fileObj.loading = false; fileObj.src = file.url; + // Добавление в список для просмотра if (fileObj.tempFile) { this.filesViewList.push({ name: fileObj.tempFile.name, @@ -278,29 +344,14 @@ export class NewsCardComponent implements OnInit { } } + // Параллельная загрузка всех файлов forkJoin(observableArray).subscribe(noop); - // const fileObj: NewsCardComponent["imagesEditList"][0] = { - // id: nanoid(2), - // src: "", - // loading: true, - // error: false, - // tempFile: files[0], - // }; - // this.imagesEditList.push(fileObj); - // this.fileService.uploadFile(files[0]).subscribe({ - // next: file => { - // fileObj.src = file.url; - // fileObj.loading = false; - // - // fileObj.tempFile = null; - // }, - // error: () => { - // fileObj.error = true; - // fileObj.loading = false; - // }, - // }); } + /** + * Удаление изображения + * @param fId - идентификатор файла для удаления + */ onDeletePhoto(fId: string) { const fileIdx = this.imagesEditList.findIndex(f => f.id === fId); @@ -314,6 +365,10 @@ export class NewsCardComponent implements OnInit { } } + /** + * Удаление файла + * @param fId - идентификатор файла для удаления + */ onDeleteFile(fId: string) { const fileIdx = this.filesEditList.findIndex(f => f.id === fId); @@ -327,6 +382,10 @@ export class NewsCardComponent implements OnInit { } } + /** + * Повторная попытка загрузки файла + * @param id - идентификатор файла для повторной загрузки + */ onRetryUpload(id: string) { const fileObj = this.imagesEditList.find(f => f.id === id); if (!fileObj || !fileObj.tempFile) return; @@ -338,7 +397,6 @@ export class NewsCardComponent implements OnInit { next: file => { fileObj.src = file.url; fileObj.loading = false; - fileObj.tempFile = null; }, error: () => { @@ -348,15 +406,24 @@ export class NewsCardComponent implements OnInit { }); } + /** Массив для отображения лайков на изображениях */ showLikes: boolean[] = []; - + /** Время последнего касания для определения двойного тапа */ lastTouch = 0; + /** + * Обработчик касания изображения (для мобильных устройств) + * Определяет двойной тап для лайка + * @param _event - событие касания + * @param imgIdx - индекс изображения + */ 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); @@ -365,10 +432,20 @@ export class NewsCardComponent implements OnInit { this.lastTouch = Date.now(); } + /** + * Обработчик лайка изображения + * @param index - индекс изображения + */ handleLike(index: number): void { console.log("Лайк на изображении с индексом: ", index); } + /** + * Расширение/сворачивание текста новости + * @param elem - HTML элемент с текстом + * @param expandedClass - CSS класс для расширенного состояния + * @param isExpanded - текущее состояние (расширен/свернут) + */ onExpandNewsText(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { expandElement(elem, expandedClass, isExpanded); this.readMore = !isExpanded; diff --git a/projects/social_platform/src/app/office/shared/news-form/news-form.component.ts b/projects/social_platform/src/app/office/shared/news-form/news-form.component.ts index 8cdd4bddf..988d1c1c0 100644 --- a/projects/social_platform/src/app/office/shared/news-form/news-form.component.ts +++ b/projects/social_platform/src/app/office/shared/news-form/news-form.component.ts @@ -11,6 +11,25 @@ import { ImgCardComponent } from "../img-card/img-card.component"; import { IconComponent } from "@ui/components"; import { AutosizeModule } from "ngx-autosize"; +/** + * Компонент формы создания новости + * + * Функциональность: + * - Создание новой новости с текстом и прикрепленными файлами + * - Загрузка файлов через input или drag&drop, а также вставка из буфера обмена + * - Разделение файлов на изображения и документы + * - Предварительный просмотр загруженных файлов + * - Управление состояниями загрузки и ошибок для каждого файла + * - Возможность удаления и повторной загрузки файлов + * + * Выходные события: + * @Output addNews - событие добавления новости, передает объект с текстом и массивом URL файлов + * + * Внутренние свойства: + * - messageForm - форма с полем текста новости (обязательное) + * - imagesList - массив объектов изображений с состояниями загрузки + * - filesList - массив объектов файлов с состояниями загрузки + */ @Component({ selector: "app-news-form", templateUrl: "./news-form.component.html", @@ -41,6 +60,10 @@ export class NewsFormComponent implements OnInit { messageForm: FormGroup; + /** + * Обработчик отправки формы + * Валидирует форму и эмитит событие с данными новости + */ onSubmit() { if (!this.validationService.getFormValidation(this.messageForm)) { return; @@ -52,11 +75,15 @@ export class NewsFormComponent implements OnInit { }); } + /** + * Сброс формы и очистка списков файлов + */ onResetForm() { this.imagesList = []; this.messageForm.reset(); } + // Массив изображений с метаданными imagesList: { id: string; src: string; @@ -65,6 +92,7 @@ export class NewsFormComponent implements OnInit { tempFile: File | null; }[] = []; + // Массив файлов с метаданными filesList: { id: string; loading: boolean; @@ -73,6 +101,10 @@ export class NewsFormComponent implements OnInit { tempFile: File; }[] = []; + /** + * Загрузка файлов на сервер + * Разделяет файлы на изображения и документы, загружает параллельно + */ uploadFiles(files: FileList) { const observableArray: Observable[] = []; for (let i = 0; i < files.length; i++) { @@ -92,7 +124,6 @@ export class NewsFormComponent implements OnInit { tap(file => { fileObj.src = file.url; fileObj.loading = false; - fileObj.tempFile = null; }) ) @@ -120,6 +151,9 @@ export class NewsFormComponent implements OnInit { forkJoin(observableArray).subscribe(noop); } + /** + * Обработчик выбора файлов через input + */ onInputFiles(event: Event) { const files = (event.currentTarget as HTMLInputElement).files; if (!files) return; @@ -127,6 +161,9 @@ export class NewsFormComponent implements OnInit { this.uploadFiles(files); } + /** + * Обработчик вставки файлов из буфера обмена + */ onPaste(event: ClipboardEvent) { const files = event.clipboardData?.files; if (!files) return; @@ -134,6 +171,10 @@ export class NewsFormComponent implements OnInit { this.uploadFiles(files); } + /** + * Удаление изображения из списка + * Если файл уже загружен на сервер, удаляет его оттуда + */ onDeletePhoto(fId: string) { const fileIdx = this.imagesList.findIndex(f => f.id === fId); @@ -147,6 +188,10 @@ export class NewsFormComponent implements OnInit { } } + /** + * Удаление файла из списка + * Если файл уже загружен на сервер, удаляет его оттуда + */ onDeleteFile(fId: string) { const fileIdx = this.filesList.findIndex(f => f.id === fId); @@ -160,6 +205,10 @@ export class NewsFormComponent implements OnInit { } } + /** + * Повторная попытка загрузки изображения + * Используется при ошибке загрузки + */ onRetryUpload(id: string) { const fileObj = this.imagesList.find(f => f.id === id); if (!fileObj || !fileObj.tempFile) return; @@ -170,7 +219,6 @@ export class NewsFormComponent implements OnInit { next: file => { fileObj.src = file.url; fileObj.loading = false; - fileObj.tempFile = null; }, error: () => { diff --git a/projects/social_platform/src/app/office/shared/project-card/project-card.component.scss b/projects/social_platform/src/app/office/shared/project-card/project-card.component.scss index e68eb73db..751048c6f 100644 --- a/projects/social_platform/src/app/office/shared/project-card/project-card.component.scss +++ b/projects/social_platform/src/app/office/shared/project-card/project-card.component.scss @@ -7,6 +7,7 @@ flex-direction: column; &__body { + height: 200px; padding: 24px 15px; background-color: var(--white); border-radius: 15px; @@ -16,9 +17,7 @@ } &--company { - @include responsive.apply-desktop { - border: 1px solid var(--accent); - } + border: 1px solid var(--accent); } } @@ -50,6 +49,7 @@ } &__basket { + margin-left: auto; color: var(--red); } diff --git a/projects/social_platform/src/app/office/shared/project-card/project-card.component.ts b/projects/social_platform/src/app/office/shared/project-card/project-card.component.ts index d9e1a6458..c5a2c5a2d 100644 --- a/projects/social_platform/src/app/office/shared/project-card/project-card.component.ts +++ b/projects/social_platform/src/app/office/shared/project-card/project-card.component.ts @@ -7,6 +7,29 @@ import { IconComponent } from "@ui/components"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { AsyncPipe, CommonModule } from "@angular/common"; +/** + * Компонент карточки проекта + * + * Функциональность: + * - Отображает основную информацию о проекте (название, описание, участники) + * - Показывает аватары участников проекта + * - Отображает информацию об отрасли проекта через IndustryService + * - Предоставляет кнопку удаления проекта (корзина) при наличии прав + * - Поддерживает индикацию подписки на проект + * - Проверяет права доступа на основе ID профиля пользователя + * + * Входные параметры: + * @Input project - объект проекта (обязательный) + * @Input canDelete - флаг возможности удаления проекта (по умолчанию false) + * @Input isSubscribed - флаг подписки на проект (по умолчанию false) + * @Input profileId - ID профиля текущего пользователя + * + * Выходные события: + * @Output remove - событие удаления проекта, передает ID проекта + * + * Внутренние свойства: + * - industryService - сервис для работы с отраслями (публичный для использования в шаблоне) + */ @Component({ selector: "app-project-card", templateUrl: "./project-card.component.html", @@ -26,6 +49,10 @@ export class ProjectCardComponent implements OnInit { @Output() remove = new EventEmitter(); + /** + * Обработчик удаления проекта (клик по корзине) + * Предотвращает всплытие события и эмитит событие удаления + */ onBasket(event: MouseEvent) { event.stopPropagation(); event.preventDefault(); diff --git a/projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.ts b/projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.ts index 20d36a8d3..04404aabc 100644 --- a/projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.ts +++ b/projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.ts @@ -5,6 +5,34 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { IconComponent } from "@ui/components"; import { noop } from "rxjs"; +/** + * Компонент булевого критерия оценки (чекбокс) + * + * Функциональность: + * - Отображает чекбокс для булевых критериев оценки + * - Поддерживает кастомный дизайн с иконкой галочки + * - Реализует ControlValueAccessor для интеграции с Angular Forms + * - Обрабатывает клики как по чекбоксу, так и по всей области + * - Поддержка отключенного состояния + * + * Входные параметры: + * @Input disabled - флаг отключенного состояния (по умолчанию false) + * + * Внутренние свойства: + * - isChecked - текущее состояние чекбокса (true/false) + * - onChange - функция обратного вызова для уведомления об изменениях + * - onTouched - функция обратного вызова для уведомления о касании + * + * Методы ControlValueAccessor: + * - writeValue - установка значения извне + * - registerOnChange - регистрация обработчика изменений + * - registerOnTouched - регистрация обработчика касания + * - setDisabledState - установка отключенного состояния + * + * Обработчики событий: + * - onChanged - обработка изменения состояния через input[type="checkbox"] + * - onClickLog - обработка клика по всей области компонента + */ @Component({ selector: "app-boolean-criterion", templateUrl: "./boolean-criterion.component.html", @@ -42,6 +70,10 @@ export class BooleanCriterionComponent implements ControlValueAccessor { this.disabled = isDisabled; } + /** + * Обработчик изменения состояния чекбокса + * Вызывается при клике непосредственно по input[type="checkbox"] + */ onChanged(event: Event) { const target = event.target as HTMLInputElement; this.isChecked = target && target.checked; @@ -49,6 +81,10 @@ export class BooleanCriterionComponent implements ControlValueAccessor { this.onTouched(); } + /** + * Обработчик клика по всей области компонента + * Переключает состояние чекбокса при клике в любом месте компонента + */ onClickLog() { this.isChecked = !this.isChecked; } diff --git a/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.ts b/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.ts index aebaacb38..9515655c9 100644 --- a/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.ts +++ b/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.ts @@ -3,6 +3,31 @@ import { ChangeDetectorRef, Component, forwardRef, Input } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +/** + * Компонент поля ввода для числовых критериев с ограничением диапазона + * + * Функциональность: + * - Поле ввода числовых значений с ограничением максимального значения + * - Автоматическое ограничение значения при потере фокуса + * - Блокировка ввода недопустимых символов (e, E, +, -) + * - Предотвращение вставки нечисловых символов + * - Реализует ControlValueAccessor для интеграции с Angular Forms + * - Поддержка состояния ошибки для стилизации + * - Автоматическое позиционирование курсора в конец при фокусе + * + * Входные параметры: + * @Input max - максимальное допустимое значение (по умолчанию 10) + * @Input error - флаг состояния ошибки для стилизации + * + * Внутренние свойства: + * - value - текущее значение поля (number | null) + * - disabled - флаг отключенного состояния + * + * Особенности: + * - Переключение типа input с number на text для корректного позиционирования курсора + * - Валидация значения при потере фокуса с автоматической коррекцией + * - Блокировка ввода экспоненциальной записи и знаков + */ @Component({ selector: "app-range-criterion-input", templateUrl: "./range-criterion-input.component.html", @@ -18,22 +43,28 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; }) export class RangeCriterionInputComponent implements ControlValueAccessor { @Input() max = 10; - @Input() error = false; value!: number | null; constructor(private readonly cdref: ChangeDetectorRef) {} + /** + * Обработчик ввода значения + * Парсит введенное значение и уведомляет о изменении + */ onInput(event: Event): void { const target = event.currentTarget as HTMLInputElement; - - const value = target.value ? parseInt(target.value) : null; + const value = target.value ? Number.parseInt(target.value) : null; this.value = value; this.onChange(value); } + /** + * Обработчик вставки из буфера обмена + * Блокирует вставку нечисловых символов + */ onPaste(event: ClipboardEvent): void { const pasteData = event.clipboardData?.getData("text/plain"); if (pasteData && pasteData.match(/[^0-9]/)) { @@ -41,12 +72,20 @@ export class RangeCriterionInputComponent implements ControlValueAccessor { } } + /** + * Обработчик нажатия клавиш + * Блокирует ввод с��мволов экспоненциальной записи и знаков + */ onKeydown(event: KeyboardEvent): void { if (["e", "E", "+", "-"].some(char => event.key === char)) { event.preventDefault(); } } + /** + * Обработчик потери фокуса + * Ограничивает значение максимумом и уведомляет о касании + */ onBlur(): void { if (this.value) { const val = Math.min(this.value, this.max); @@ -57,10 +96,10 @@ export class RangeCriterionInputComponent implements ControlValueAccessor { this.onTouch(); } + // Методы ControlValueAccessor writeValue(value: number): void { setTimeout(() => { this.value = value; - this.cdref.detectChanges(); }); } @@ -83,6 +122,10 @@ export class RangeCriterionInputComponent implements ControlValueAccessor { this.disabled = isDisabled; } + /** + * Перемещение курсора в конец поля при фокусе + * Временно меняет тип поля для корректного позиционирования + */ moveCursorToEnd(event: FocusEvent) { const input = event.target as HTMLInputElement; input.type = "text"; diff --git a/projects/social_platform/src/app/office/shared/project-rating/project-rating.component.ts b/projects/social_platform/src/app/office/shared/project-rating/project-rating.component.ts index 80928f453..cafae8035 100644 --- a/projects/social_platform/src/app/office/shared/project-rating/project-rating.component.ts +++ b/projects/social_platform/src/app/office/shared/project-rating/project-rating.component.ts @@ -10,18 +10,17 @@ import { signal, } from "@angular/core"; import { - AbstractControl, ControlValueAccessor, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, - ValidationErrors, Validator, Validators, + ValidationErrors, + AbstractControl, } from "@angular/forms"; -import { InputComponent } from "@ui/components"; import { TextareaComponent } from "@ui/components/textarea/textarea.component"; import { ProjectRatingCriterion } from "@office/program/models/project-rating-criterion"; import { noop, Subscription } from "rxjs"; @@ -29,12 +28,22 @@ import { BooleanCriterionComponent } from "./components/boolean-criterion/boolea import { RangeCriterionInputComponent } from "./components/range-criterion-input/range-criterion-input.component"; import { ErrorMessage } from "@error/models/error-message"; +/** + * Компонент рейтинга проекта + * Предоставляет интерфейс для оценки проекта по различным критериям + * Поддерживает три типа критериев: + * - int: числовая оценка в диапазоне (например, от 1 до 10) + * - bool: булевая оценка (да/нет) + * - str: текстовый комментарий + * + * Реализует ControlValueAccessor для интеграции с Angular Forms + * и Validator для валидации введенных данных + */ @Component({ selector: "app-project-rating", standalone: true, imports: [ CommonModule, - InputComponent, TextareaComponent, RangeCriterionInputComponent, BooleanCriterionComponent, @@ -45,11 +54,13 @@ import { ErrorMessage } from "@error/models/error-message"; changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { + // Регистрация как ControlValueAccessor для работы с формами provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ProjectRatingComponent), multi: true, }, { + // Регистрация как Validator для валидации provide: NG_VALIDATORS, useExisting: forwardRef(() => ProjectRatingComponent), multi: true, @@ -57,6 +68,12 @@ import { ErrorMessage } from "@error/models/error-message"; ], }) export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, Validator { + /** + * Сеттер для критериев оценки + * При установке новых критериев создает соответствующие FormControl'ы + * и настраивает отслеживание изменений + * @param val - массив критериев для оценки проекта + */ @Input({ required: true }) set criteria(val: ProjectRatingCriterion[]) { if (!val) return; @@ -65,44 +82,79 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, this.trackFormValueChange(); } + /** + * Геттер для получения текущих критериев + * @returns массив критериев оценки + */ get criteria(): ProjectRatingCriterion[] { return this._criteria(); } + /** Сигнал для хранения критериев оценки */ _criteria = signal([]); + /** Форма для управления всеми критериями оценки */ form!: FormGroup; + /** + * Объект с функциями-создателями FormControl для разных типов критериев + * Каждый тип критерия имеет свою логику создания контрола и валидации + */ controlCreators: Record FormControl> = { + // Числовой критерий - обязательное поле int: val => new FormControl(val, [Validators.required]), + // Булевый критерий - преобразование строки в boolean bool: val => new FormControl(val ? JSON.parse((val as string).toLowerCase()) : false), + // Строковый критерий - без валидации (комментарии опциональны) str: val => new FormControl(val), }; + /** Сигнал для хранения подписок */ subscriptions$ = signal([]); + // Методы ControlValueAccessor + /** Функция обратного вызова для уведомления об изменениях */ onChange: (val: unknown) => void = noop; + /** Функция обратного вызова для уведомления о касании */ onTouched: () => void = noop; + /** + * Установка значения в компонент (ControlValueAccessor) + * @param val - значения для установки в форму + */ writeValue(val: typeof this.form.value): void { if (val) { this.form.patchValue(val); } } + /** + * Регистрация функции обратного вызова для изменений (ControlValueAccessor) + * @param fn - функция для вызова при изменении значений + */ registerOnChange(fn: (v: unknown) => void): void { this.onChange = fn; } + /** + * Регистрация функции обратного вызова для касания (ControlValueAccessor)\ + * @param fn - функция для вызова при касании компонента + */ registerOnTouched(fn: () => void): void { this.onTouched = fn; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + /** + * Валидация формы (Validator) + * Проверяет, что все обязательные критерии заполнены + * @param _ - контрол для валидации (не используется) + * @returns объект с ошибками валидации или null если валидация прошла + */ validate(_: AbstractControl): ValidationErrors | null { let output: ValidationErrors | null = null; if (this.form.invalid) { + // Проверка каждого контрола на наличие ошибок\ Object.values(this.form.controls).forEach(control => { if (control.errors !== null) { output = { required: ErrorMessage.VALIDATION_UNFILLED_CRITERIA }; @@ -112,22 +164,33 @@ export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, return output; } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); + this.subscriptions$().forEach(subscription => subscription.unsubscribe()); } + /** + * Создание FormControl'ов для каждого критерия + * Использует соответствующий создатель контрола в зависимости от типа критерия + * @param criteria - массив критериев для создания контролов + */ private createFormControls(criteria: ProjectRatingCriterion[]): void { const formGroupControls: Record = {}; criteria.forEach(criterion => { const controlCreator = this.controlCreators[criterion.type]; - formGroupControls[criterion.id] = controlCreator(criterion.value); }); this.form = new FormGroup(formGroupControls); } + /** + * Настройка отслеживания изменений в форме + * Подписывается на изменения значений и уведомляет родительский компонент + */ private trackFormValueChange(): void { const trackChanged$ = this.form.valueChanges.subscribe(val => { this.onChange(val); diff --git a/projects/social_platform/src/app/office/shared/response-card/response-card.component.ts b/projects/social_platform/src/app/office/shared/response-card/response-card.component.ts index 5177e55c1..e6bb47279 100644 --- a/projects/social_platform/src/app/office/shared/response-card/response-card.component.ts +++ b/projects/social_platform/src/app/office/shared/response-card/response-card.component.ts @@ -10,8 +10,28 @@ 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 { map, tap } from "rxjs"; +/** + * Компонент карточки отклика на вакансию + * + * Функциональность: + * - Отображает информацию об отклике на вакансию (кандидат, роль, файлы) + * - Показывает аватар и основную информацию о кандидате + * - Отображает прикрепленные файлы (резюме, портфолио) + * - Предоставляет кнопки для принятия или отклонения отклика + * - Ссылка на профиль кандидата + * - Получает ID текущего пользователя для проверки прав доступа + * + * Входные параметры: + * @Input response - объект отклика на вакансию (обязательный) + * + * Выходные события: + * @Output reject - событие отклонения отклика, передает ID отклика + * @Output accept - событие принятия отклика, передает ID отклика + * + * Внутренние свойства: + * - profileId - ID текущего пользователя для проверки прав + */ @Component({ selector: "app-response-card", templateUrl: "./response-card.component.html", @@ -44,10 +64,18 @@ export class ResponseCardComponent implements OnInit { }); } + /** + * Обработчик принятия отклика + * Эмитит событие с 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/shared/skills-basket/skills-basket.component.ts b/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts index 2fe23c45d..564c2b69b 100644 --- a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts +++ b/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts @@ -7,6 +7,14 @@ import { IconComponent } from "@ui/components"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { noop } from "rxjs"; +/** + * Компонент корзины навыков + * Отображает выбранные навыки в виде тегов с возможностью удаления + * Используется в формах выбора навыков как визуальное представление выбранных элементов + * + * Реализует ControlValueAccessor для интеграции с Angular Forms + * Поддерживает отображение состояния ошибки валидации + */ @Component({ selector: "app-skills-basket", templateUrl: "./skills-basket.component.html", @@ -15,6 +23,7 @@ import { noop } from "rxjs"; standalone: true, providers: [ { + // Регистрация как ControlValueAccessor для работы с формами provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SkillsBasketComponent), multi: true, @@ -22,27 +31,50 @@ import { noop } from "rxjs"; ], }) export class SkillsBasketComponent { + /** Флаг отображения состояния ошибки (красная рамка) */ @Input() error = false; + /** Сигнал для хранения массива выбранных навыков */ value = signal([]); + // Методы ControlValueAccessor + /** Функция обратного вызова для уведомления об изменениях */ onChange: (val: Skill[]) => void = noop; + /** Функция обратного вызова для уведомления о касании */ onTouched: () => void = noop; + /** + * Установка значения в компонент (ControlValueAccessor) + * @param val - массив навыков для отображения в корзине + */ writeValue(val: Skill[]): void { if (val) { this.value.set(val); } } + /** + * Регистрация функции обратного вызова для изменений (ControlValueAccessor) + * @param fn - функция для вызова при изменении значения + */ registerOnChange(fn: (v: unknown) => void): void { this.onChange = fn; } + /** + * Регистрация функции обратного вызова для касания (ControlValueAccessor) + * @param fn - функция для вызова при касании компонента + */ registerOnTouched(fn: () => void): void { this.onTouched = fn; } + /** + * Удаление навыка из корзины + * Фильтрует массив навыков, исключая навык с указанным ID + * Уведомляет родительский компонент об изменении через onChange + * @param id - идентификатор навыка для удаления + */ deleteSkill(id: number): void { const filtered = this.value().filter(skill => skill.id !== id); diff --git a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts b/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts index e85f414a3..a6cab1687 100644 --- a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts +++ b/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts @@ -12,6 +12,30 @@ import { import { IconComponent } from "@ui/components"; import { Skill } from "@office/models/skill"; +/** + * Компонент группы навыков с возможностью множественного выбора + * + * Функциональность: + * - Отображает заголовок группы навыков + * - Показывает/скрывает список навыков при клике на заголовок + * - Поддерживает множественный выбор навыков с чекбоксами + * - Синхронизирует состояние выбранных навыков с внешним состоянием + * - Использует Angular Signals для реактивности + * - Использует OnPush стратегию для оптимизации производительности + * + * Входные параметры: + * @Input options - массив доступных навыков (обязательный) + * @Input selected - массив выбранных навыков (обязательный) + * @Input title - заголовок группы навыков (обязательный) + * + * Выходные события: + * @Output optionToggled - событие переключения навыка, передает навык который был включен/выключен + * + * Внутренние свойства: + * - _options - сигнал с массивом навыков и их состоянием выбора + * - _selected - сигнал с массивом выбранных навыков + * - contentVisible - сигнал видимости содержимого группы + */ @Component({ selector: "app-skills-group", standalone: true, @@ -21,6 +45,10 @@ import { Skill } from "@office/models/skill"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkillsGroupComponent { + /** + * Сеттер для опций навыков + * Обновляет внутренний сигнал с массивом навыков + */ @Input({ required: true }) set options(value: Skill[]) { this._options.set(value); } @@ -29,6 +57,10 @@ export class SkillsGroupComponent { return this._options(); } + /** + * Сеттер для выбранных навыков + * Обновляет состояние выбора для каждого навыка в списке опций + */ @Input({ required: true }) set selected(value: Skill[]) { this._selected.set(value); @@ -44,15 +76,15 @@ export class SkillsGroupComponent { } @Input({ required: true }) title!: string; - @Output() optionToggled = new EventEmitter(); _options = signal<(Skill & { checked?: boolean })[]>([]); - _selected = signal([]); - contentVisible = signal(false); + /** + * Переключение видимости содержимого группы + */ toggleContentVisible(): void { this.contentVisible.update(visible => !visible); } diff --git a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts b/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts index 2a16b16a5..f16453198 100644 --- a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts +++ b/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts @@ -5,6 +5,25 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from import { IconComponent } from "@ui/components"; import { Specialization } from "@office/models/specialization"; +/** + * Компонент группы специализаций с возможностью сворачивания/разворачивания + * + * Функциональность: + * - Отображает заголовок группы специализаций + * - Показывает/скрывает список специализаций при клике на заголовок + * - Позволяет выбирать специализацию из списка + * - Использует OnPush стратегию для оптимизации производительности + * + * Входные параметры: + * @Input title - заголовок группы специализаций (обязательный) + * @Input options - массив специализаций для отображения (обязательный) + * + * Выходные события: + * @Output selectOption - событие выбора специализации, передает выбранную специализацию + * + * Внутренние свойства: + * - contentVisible - флаг видимости содержимого группы + */ @Component({ selector: "app-specializations-group", standalone: true, @@ -15,13 +34,15 @@ import { Specialization } from "@office/models/specialization"; }) export class SpecializationsGroupComponent { @Input({ required: true }) title!: string; - @Input({ required: true }) options!: Specialization[]; - @Output() selectOption = new EventEmitter(); contentVisible = false; + /** + * Обработчик выбора специализации + * Эмитит событие с выбранной специализацией + */ onSelectOption(opt: Specialization) { this.selectOption.emit(opt); } diff --git a/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.scss b/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.scss index fa072eb8c..ea0653c23 100644 --- a/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.scss +++ b/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.scss @@ -26,18 +26,15 @@ &__basket { color: var(--red); cursor: pointer; - - svg { - max-width: 24px; - } } &__edit { color: var(--dark-grey); cursor: pointer; + } - svg { - max-width: 24px; - } + i { + width: 24px; + height: 24px; } } diff --git a/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.ts b/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.ts index 0d755e361..c787fef05 100644 --- a/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.ts +++ b/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.ts @@ -1,16 +1,35 @@ /** @format */ -import { JsonPipe } from "@angular/common"; import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { Vacancy } from "@models/vacancy.model"; import { IconComponent } from "@ui/components"; +/** + * Компонент карточки вакансии + * + * Функциональность: + * - Отображает информацию о вакансии (название, описание, требуемые навыки) + * - Формирует строку навыков из массива требуемых навыков + * - Предоставляет кнопки для редактирования и удаления вакансии + * - Предотвращает всплытие событий при клике на кнопки действий + * - Отображает данные в JSON формате для отладки + * + * Входные параметры: + * @Input vacancy - объект вакансии (опциональный) + * + * Выходные события: + * @Output remove - событие удаления вакансии, передает ID вакансии + * @Output edit - событие редактирования вакансии, передает ID вакансии + * + * Внутренние свойства: + * - skillString - строка с перечислением требуемых навыков через разделитель + */ @Component({ selector: "app-vacancy-card", templateUrl: "./vacancy-card.component.html", styleUrl: "./vacancy-card.component.scss", standalone: true, - imports: [IconComponent, JsonPipe], + imports: [IconComponent], }) export class VacancyCardComponent implements OnInit { constructor() {} @@ -22,9 +41,14 @@ export class VacancyCardComponent implements OnInit { skillString = ""; ngOnInit(): void { + // Формирование строки навыков с разделителем this.skillString = this.vacancy?.requiredSkills.map(s => s.name).join(" • ") ?? ""; } + /** + * Обработчик удаления вакансии + * Предотвращает всплытие события и эмитит событие удаления + */ onRemove(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); @@ -32,6 +56,10 @@ export class VacancyCardComponent implements OnInit { this.remove.emit(this.vacancy?.id); } + /** + * Обработчик редактирования вакансии + * Предотвращает всплытие события и эмитит событие редактирования + */ onEdit(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); 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 index e3acc2a93..17f344e03 100644 --- 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 @@ -29,6 +29,40 @@ import { SalaryTransformPipe } from "projects/core/src/lib/pipes/salary-transfor import { map, Subscription } from "rxjs"; import { CapitalizePipe } from "projects/core/src/lib/pipes/capitalize.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, 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 index bb733159b..0c8a6f8d3 100644 --- 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 @@ -3,11 +3,29 @@ import { CommonModule } from "@angular/common"; import { Component, inject, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, RouterOutlet } from "@angular/router"; -import { Project } from "@office/models/project.model"; import { Vacancy } from "@office/models/vacancy.model"; import { BarComponent } from "@ui/components"; -import { concatMap, map, Subscription } from "rxjs"; +import { map, Subscription } from "rxjs"; +/** + * Компонент детального просмотра вакансии + * + * Функциональность: + * - Получает данные вакансии из резолвера через ActivatedRoute + * - Отображает навигационную панель с кнопкой "Назад" + * - Содержит router-outlet для дочерних компонентов (информация о вакансии) + * - Управляет подписками для предотвращения утечек памяти + * + * Жизненный цикл: + * - OnInit: подписывается на данные маршрута и извлекает объект вакансии + * - OnDestroy: отписывается от всех активных подписок + * + * @property {Vacancy} vacancy - объект вакансии, полученный из резолвера + * @property {Subscription[]} subscriptions$ - массив подписок для управления памятью + * + * @selector app-vacancies-detail + * @standalone true - автономный компонент + */ @Component({ selector: "app-vacancies-detail", standalone: true, diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts index c341fefb8..52c14bb37 100644 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts +++ b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts @@ -1,10 +1,24 @@ /** @format */ import { inject } from "@angular/core"; -import { ActivatedRoute, ActivatedRouteSnapshot } from "@angular/router"; +import { ActivatedRouteSnapshot } from "@angular/router"; import { VacancyService } from "@office/services/vacancy.service"; -import { map, tap } from "rxjs"; +/** + * Резолвер для загрузки детальной информации о конкретной вакансии + * + * Функциональность: + * - Извлекает ID вакансии из параметров маршрута (:vacancyId) + * - Выполняет запрос к API для получения полной информации о вакансии + * - Данные становятся доступными в компоненте до его инициализации + * + * @param {ActivatedRouteSnapshot} route - снимок активного маршрута с параметрами + * @param {VacancyService} vacancyService - сервис для работы с API вакансий + * @returns {Observable} Observable с объектом вакансии + * + * Параметры: + * - vacancyId - ID вакансии из URL параметров (например: /vacancies/123) + */ export const VacanciesDetailResolver = (route: ActivatedRouteSnapshot) => { const vacancyService = inject(VacancyService); const vacancyId = route.params["vacancyId"]; diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts index fe830babf..f440a13d9 100644 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts +++ b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts @@ -4,6 +4,21 @@ import { VacancyInfoComponent } from "./info/info.component"; import { VacanciesDetailComponent } from "./vacancies-detail.component"; import { VacanciesDetailResolver } from "./vacancies-detail.resolver"; +/** + * Конфигурация маршрутов для детального просмотра вакансии + * + * Структура маршрутов: + * - '' (корневой) - основной компонент детального просмотра + * - resolve.data - предварительная загрузка данных вакансии через VacanciesDetailResolver + * - children - дочерние маршруты: + * * '' - компонент с информацией о вакансии (VacancyInfoComponent) + * + * Использование резолвера: + * - VacanciesDetailResolver загружает данные вакансии перед отображением компонента + * - Данные доступны в компоненте через this.route.data['data'] + * + * @returns {Routes} Массив конфигурации маршрутов для детального просмотра + */ export const VACANCIES_DETAIL_ROUTES = [ { path: "", 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 index 0434e220d..d1f49f2e1 100644 --- a/projects/social_platform/src/app/office/vacancies/list/list.component.ts +++ b/projects/social_platform/src/app/office/vacancies/list/list.component.ts @@ -1,9 +1,8 @@ /** @format */ import { Component, inject, signal } from "@angular/core"; -import { AsyncPipe, CommonModule } from "@angular/common"; +import { CommonModule } from "@angular/common"; import { - combineLatest, concatMap, debounceTime, distinctUntilChanged, @@ -24,10 +23,14 @@ import { ActivatedRoute, Router } from "@angular/router"; import { VacancyFilterComponent } from "../shared/filter/vacancy-filter.component"; import { VacancyResponse } from "@office/models/vacancy-response.model"; import { ResponseCardComponent } from "@office/shared/response-card/response-card.component"; -import { InputComponent } from "@ui/components"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { SearchComponent } from "@ui/components/search/search.component"; +/** + * Компонент списка вакансий + * Отображает список всех вакансий или откликов пользователя с возможностью фильтрации и поиска + * Поддерживает бесконечную прокрутку для загрузки дополнительных данных + */ @Component({ selector: "app-vacancies-list", standalone: true, @@ -43,27 +46,43 @@ import { SearchComponent } from "@ui/components/search/search.component"; styleUrl: "./list.component.scss", }) export class VacanciesListComponent { + /** Сервис для работы с маршрутами */ route = inject(ActivatedRoute); + /** Сервис роутера для навигации */ router = inject(Router); + /** Сервис для работы с вакансиями */ vacancyService = inject(VacancyService); + /** + * Конструктор компонента + * @param fb - FormBuilder для создания формы поиска + */ constructor(private readonly fb: FormBuilder) { + // Создание формы поиска this.searchForm = this.fb.group({ - search: [""], + search: [""], // Поле для поискового запроса }); } + /** + * Инициализация компонента + * Определяет тип страницы (все вакансии или мои отклики) + * Настраивает подписки на изменения формы поиска и параметров маршрута + */ ngOnInit() { + // Определение типа страницы из URL const urlSegment = this.router.url.split("/").slice(-1)[0]; const trimmedSegment = urlSegment.split("?")[0]; this.type.set(trimmedSegment as "all" | "my"); + // Подписка на изменения поля поиска с задержкой this.searchForm .get("search") ?.valueChanges.pipe( - debounceTime(300), - distinctUntilChanged(), + debounceTime(300), // Задержка 300мс перед выполнением поиска + distinctUntilChanged(), // Выполнять только при изменении значения tap(value => { + // Обновление параметров маршрута this.router.navigate([], { relativeTo: this.route, queryParams: { role_contains: value || null }, @@ -73,11 +92,13 @@ export class VacanciesListComponent { ) .subscribe(); + // Получение данных из резолвера маршрута 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") { @@ -89,14 +110,17 @@ export class VacanciesListComponent { } ); + // Подписка на изменения параметров запроса для фильтрации const queryParams$ = this.route.queryParams .pipe( - debounceTime(200), + debounceTime(200), // Задержка для избежания частых запросов tap(params => { + // Извлечение параметров фильтрации из URL const requiredExperience = params["required_experience"] ? params["required_experience"] : undefined; + // Установка значения поиска без вызова события this.searchForm .get("search") ?.setValue(params["role_contains"] || "", { emitEvent: false }); @@ -106,13 +130,14 @@ export class VacanciesListComponent { const salaryMax = params["salary_max"] ? params["salary_max"] : undefined; const salaryMin = params["salary_min"] ? params["salary_min"] : undefined; + // Обновление сигналов фильтрации this.requiredExperience.set(requiredExperience); this.workFormat.set(workFormat); this.workSchedule.set(workSchedule); this.salaryMin.set(salaryMin); this.salaryMax.set(salaryMax); }), - switchMap(() => this.onFetch(0, 20)) + switchMap(() => this.onFetch(0, 20)) // Загрузка данных с новыми фильтрами ) .subscribe((result: any) => { this.vacancyList.set(result.results); @@ -121,9 +146,14 @@ export class VacanciesListComponent { this.subscriptions$().push(subscription, queryParams$); } + /** + * Инициализация после отрисовки представления + * Настраивает обработчик прокрутки для бесконечной загрузки + */ ngAfterViewInit() { const target = document.querySelector(".office__body"); if (target) { + // Подписка на события прокрутки с троттлингом const scrollEvents$ = fromEvent(target, "scroll") .pipe( concatMap(() => this.onScroll()), @@ -135,36 +165,60 @@ export class VacanciesListComponent { } } + /** + * Очистка ресурсов при уничтожении компонента + */ ngOnDestroy() { this.subscriptions$().forEach(($: any) => $.unsubscribe()); } + /** Форма поиска */ searchForm: FormGroup; + /** Общее количество элементов */ totalItemsCount = signal(0); + /** Список вакансий */ vacancyList = signal([]); + /** Список откликов */ responsesList = signal([]); + /** Текущая страница */ vacancyPage = signal(1); + /** Количество элементов на страницу */ perFetchTake = signal(20); + /** Тип страницы: все вакансии или мои отклики */ type = signal<"all" | "my" | null>(null); + // Сигналы для фильтрации + /** Фильтр по требуемому опыту */ requiredExperience = signal(undefined); + /** Фильтр по формату работы */ workFormat = signal(undefined); + /** Фильтр по графику работы */ workSchedule = signal(undefined); + /** Минимальная зарплата */ salaryMin = signal(undefined); + /** Максимальная зарплата */ salaryMax = signal(undefined); + /** Массив подписок для очистки */ subscriptions$ = signal([]); + /** + * Обработчик прокрутки для бесконечной загрузки + * @returns Observable с дополнительными данными или пустой объект + */ 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((vacancyChunk: Vacancy[]) => { this.vacancyPage.update(page => page + 1); @@ -176,10 +230,17 @@ export class VacanciesListComponent { return of({}); } + /** + * Обработчик изменения значения поиска + * @param event - новое значение поиска + */ onSearhValueChanged(event: string) { this.searchForm.get("search")?.setValue(event); } + /** + * Обработчик отправки формы поиска + */ onSearchSubmit() { const value = this.searchForm.get("search")?.value; this.router.navigate([], { @@ -189,6 +250,12 @@ export class VacanciesListComponent { }); } + /** + * Загрузка данных с сервера + * @param offset - смещение для пагинации + * @param limit - количество элементов для загрузки + * @returns Observable с данными вакансий + */ onFetch(offset: number, limit: number) { return this.vacancyService .getForProject( diff --git a/projects/social_platform/src/app/office/vacancies/list/list.routes.ts b/projects/social_platform/src/app/office/vacancies/list/list.routes.ts index 28a2ce35c..e3e6397c0 100644 --- a/projects/social_platform/src/app/office/vacancies/list/list.routes.ts +++ b/projects/social_platform/src/app/office/vacancies/list/list.routes.ts @@ -4,6 +4,20 @@ import { Routes } from "@angular/router"; import { VacanciesListComponent } from "./list.component"; import { VacanciesMyResolver } from "./my.resolver"; +/** + * Конфигурация маршрутов для страницы "Мои отклики" + * + * Структура: + * - '' (корневой маршрут) - отображает компонент VacanciesListComponent + * - resolve.data - предварительная загрузка откликов через VacanciesMyResolver + * + * Особенности: + * - Используется тот же компонент VacanciesListComponent, что и для всех вакансий + * - Компонент определяет тип отображения по URL и показывает соответствующий контент + * - Резолвер загружает данные откликов пользователя перед инициализацией компонента + * + * @returns {Routes} Массив конфигурации маршрутов для страницы откликов + */ export const VACANCY_LIST_ROUTES: Routes = [ { path: "", 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 index b55852665..68fc83468 100644 --- a/projects/social_platform/src/app/office/vacancies/list/my.resolver.ts +++ b/projects/social_platform/src/app/office/vacancies/list/my.resolver.ts @@ -5,6 +5,25 @@ 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); 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 index 5200b49f5..5d8c54b95 100644 --- 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 @@ -19,7 +19,19 @@ import { FeedService } from "@office/feed/services/feed.service"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { VacancyService } from "@office/services/vacancy.service"; import { map, Subscription, tap } from "rxjs"; +import { filterExperience } from "projects/core/src/consts/filter-experience"; +import { filterWorkFormat } from "projects/core/src/consts/filter-work-format"; +import { filterWorkSchedule } from "projects/core/src/consts/filter-work-schedule"; +/** + * Компонент фильтра вакансий + * Предоставляет интерфейс для фильтрации вакансий по различным критериям: + * - Опыт работы + * - Формат работы (удаленно/офис/гибрид) + * - График работы + * - Диапазон зарплаты + * Поддерживает как десктопную, так и мобильную версии интерфейса + */ @Component({ selector: "app-vacancy-filter", standalone: true, @@ -46,32 +58,57 @@ import { map, Subscription, tap } from "rxjs"; ], }) export class VacancyFilterComponent implements OnInit { + /** Сервис роутера для навигации */ router = inject(Router); + /** Сервис текущего маршрута */ route = inject(ActivatedRoute); + /** Сервис ленты новостей */ feedService = inject(FeedService); + /** Сервис для работы с вакансиями */ vacancyService = inject(VacancyService); + /** + * Конструктор компонента + * @param fb - FormBuilder для создания формы зарплатного диапазона + */ constructor(private readonly fb: FormBuilder) { + // Создание формы для фильтрации по зарплате this.salaryForm = this.fb.group({ - salaryMin: [""], - salaryMax: [""], + salaryMin: [""], // Минимальная зарплата + salaryMax: [""], // Максимальная зарплата }); } + /** Приватное поле для хранения значения поиска */ 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(); + /** + * Инициализация компонента + * Подписывается на изменения параметров запроса для синхронизации фильтров + */ 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"]); @@ -81,45 +118,49 @@ export class VacancyFilterComponent implements OnInit { }); } + /** Подписка на параметры запроса */ queries$?: Subscription; + /** Состояние открытия фильтра (для мобильной версии) */ filterOpen = signal(false); + /** Форма для фильтрации по зарплате */ salaryForm: FormGroup; + /** Общее количество элементов */ totalItemsCount = signal(0); + // Сигналы для текущих значений фильтров + /** Текущий фильтр по опыту */ currentExperience = signal(undefined); + /** Текущий фильтр по формату работы */ currentWorkFormat = signal(undefined); + /** Текущий фильтр по графику работы */ currentWorkSchedule = signal(undefined); - + /** Текущая минимальная зарплата */ currentSalaryMin = signal(undefined); + /** Текущая максимальная зарплата */ currentSalaryMax = signal(undefined); - filterExperienceOptions = [ - { label: "без опыта", value: "no_experience" }, - { label: "до 1 года", value: "up_to_a_year" }, - { label: "от 1 года до 3 лет", value: "from_one_to_three_years" }, - { label: "от 3 лет и более", value: "from_three_years" }, - ]; - - filterWorkFormatOptions = [ - { label: "удаленная работа", value: "remote" }, - { label: "работа в офисе", value: "office" }, - { label: "смешанный формат", value: "hybrid" }, - ]; - - filterWorkScheduleOptions = [ - { label: "полный рабочий день", value: "full_time" }, - { label: "сменный график", value: "shift_work" }, - { label: "гибкий график", value: "flexible_schedule" }, - { label: "частичная занятость", value: "part_time" }, - { label: "стажировка", value: "internship" }, - ]; + /** Опции фильтра по опыту работы */ + readonly filterExperienceOptions = filterExperience; + + /** Опции фильтра по формату работы */ + readonly filterWorkFormatOptions = filterWorkFormat; + + /** Опции фильтра по графику работы */ + filterWorkScheduleOptions = filterWorkSchedule; + /** + * Установка фильтра по опыту работы + * @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() }, @@ -129,6 +170,11 @@ export class VacancyFilterComponent implements OnInit { .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); @@ -142,6 +188,11 @@ export class VacancyFilterComponent implements OnInit { .then(() => console.debug("Query change from ProjectsComponent")); } + /** + * Установка фильтра по графику работы + * @param event - событие клика + * @param scheduleId - идентификатор выбранного графика + */ setWorkScheduleFilter(event: Event, scheduleId: string): void { event.stopPropagation(); this.currentWorkSchedule.set( @@ -159,6 +210,10 @@ export class VacancyFilterComponent implements OnInit { .then(() => console.debug("Query change from ProjectsComponent")); } + /** + * Применение фильтров + * Обновляет URL с текущими значениями всех фильтров + */ applyFilter() { const salaryMin = this.salaryForm.get("salaryMin")?.value || ""; const salaryMax = this.salaryForm.get("salaryMax")?.value || ""; @@ -174,6 +229,10 @@ export class VacancyFilterComponent implements OnInit { }); } + /** + * Сброс всех фильтров + * Очищает все параметры фильтрации и обновляет URL + */ resetFilter(): void { this.currentExperience.set(undefined); this.currentWorkFormat.set(undefined); @@ -197,14 +256,29 @@ export class VacancyFilterComponent implements OnInit { .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( diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.ts b/projects/social_platform/src/app/office/vacancies/vacancies.component.ts index 81675da1a..d34172381 100644 --- a/projects/social_platform/src/app/office/vacancies/vacancies.component.ts +++ b/projects/social_platform/src/app/office/vacancies/vacancies.component.ts @@ -5,6 +5,21 @@ import { CommonModule } from "@angular/common"; import { BarComponent } from "@ui/components"; import { RouterOutlet } from "@angular/router"; +/** + * Основной компонент модуля вакансий + * + * Функциональность: + * - Отображает навигационную панель с двумя вкладками: "Вакансии" и "Мои отклики" + * - Содержит router-outlet для отображения дочерних компонентов + * - Служит контейнером для всех страниц модуля вакансий + * + * Используемые компоненты: + * - BarComponent - навигационная панель с кнопкой "Назад" и ссылками + * - RouterOutlet - для отображения дочерних маршрутов + * + * @selector app-vacancies + * @standalone true - автономный компонент + */ @Component({ selector: "app-vacancies", standalone: true, diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts b/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts index 99c311f23..4b7ffcefb 100644 --- a/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts +++ b/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts @@ -3,8 +3,16 @@ 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/office/vacancies/vacancies.routes.ts b/projects/social_platform/src/app/office/vacancies/vacancies.routes.ts index d247b0caf..daa763e54 100644 --- a/projects/social_platform/src/app/office/vacancies/vacancies.routes.ts +++ b/projects/social_platform/src/app/office/vacancies/vacancies.routes.ts @@ -3,35 +3,45 @@ import { Routes } from "@angular/router"; import { VacanciesComponent } from "./vacancies.component"; import { VacanciesResolver } from "./vacancies.resolver"; -import { VacanciesMyResolver } from "./list/my.resolver"; import { VacanciesListComponent } from "./list/list.component"; -import { VacanciesDetailResolver } from "./detail/vacancies-detail.resolver"; +/** + * Маршруты для модуля вакансий + * Определяет структуру навигации и загрузку данных для страниц вакансий + * + * Структура маршрутов: + * - /vacancies - корневой компонент с навигацией + * - /vacancies/all - список всех вакансий + * - /vacancies/my - список откликов пользователя + * - /vacancies/:vacancyId - детальная информация о вакансии + */ export const VACANCIES_ROUTES: Routes = [ { path: "", - component: VacanciesComponent, + component: VacanciesComponent, // Корневой компонент с навигационными вкладками children: [ { path: "", - redirectTo: "all", + redirectTo: "all", // Перенаправление на список всех вакансий по умолчанию pathMatch: "full", }, { path: "my", + // Ленивая загрузка маршрутов для откликов пользователя loadChildren: () => import("./list/list.routes").then(c => c.VACANCY_LIST_ROUTES), }, { path: "all", - component: VacanciesListComponent, + component: VacanciesListComponent, // Компонент списка всех вакансий resolve: { - data: VacanciesResolver, + data: VacanciesResolver, // Резолвер для предзагрузки данных вакансий }, }, ], }, { path: ":vacancyId", + // Ленивая загрузка маршрутов для детальной информации о вакансии loadChildren: () => import("./detail/vacancies-detail.routes").then(c => c.VACANCIES_DETAIL_ROUTES), }, diff --git a/projects/social_platform/src/app/office/vacancy/send/send.component.ts b/projects/social_platform/src/app/office/vacancy/send/send.component.ts index e4ae72f26..6ddb7778b 100644 --- a/projects/social_platform/src/app/office/vacancy/send/send.component.ts +++ b/projects/social_platform/src/app/office/vacancy/send/send.component.ts @@ -15,9 +15,13 @@ import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { BarComponent, ButtonComponent, IconComponent } from "@ui/components"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { AsyncPipe } from "@angular/common"; -import { BackComponent } from "@uilib"; import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; +import { noteList } from "projects/core/src/consts/note-list"; +/** + * Компонент для отправки отклика на вакансию + * Позволяет пользователю отправить мотивационное письмо и резюме на вакансию + */ @Component({ selector: "app-send", templateUrl: "./send.component.html", @@ -40,6 +44,15 @@ import { UploadFileComponent } from "@ui/components/upload-file/upload-file.comp ], }) export class VacancySendComponent implements OnInit { + /** + * Конструктор компонента + * @param authService - сервис аутентификации для получения данных текущего пользователя + * @param fb - FormBuilder для создания реактивных форм + * @param vacancyService - сервис для работы с вакансиями + * @param validationService - сервис валидации форм + * @param route - текущий маршрут для получения параметров + * @param navService - сервис навигации для установки заголовка страницы + */ constructor( public readonly authService: AuthService, private readonly fb: FormBuilder, @@ -48,72 +61,61 @@ export class VacancySendComponent implements OnInit { private readonly route: ActivatedRoute, private readonly navService: NavService ) { + // Создание формы отклика с валидацией this.sendForm = this.fb.group({ + // Мотивационное письмо: обязательное поле, минимум 20 символов, максимум 2000 whyMe: ["", [Validators.required, Validators.minLength(20), Validators.maxLength(2000)]], + // Прикрепленный файл резюме: обязательное поле accompanyingFile: ["", Validators.required], }); } + /** + * Инициализация компонента + * Устанавливает заголовок страницы + */ ngOnInit(): void { this.navService.setNavTitle("Отклик на вакансию"); } + /** Объект с сообщениями об ошибках */ errorMessage = ErrorMessage; + /** Форма отправки отклика */ sendForm: FormGroup; + /** Флаг состояния отправки формы */ sendFormIsSubmitting = false; - + /** Флаг отображения модального окна с результатом */ resultModal = false; + /** + * Обработчик отправки формы + * Валидирует форму и отправляет отклик на сервер + */ 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; }, error: () => { + // Ошибка отправки - снимаем флаг загрузки this.sendFormIsSubmitting = false; }, }); } - noteList = [ - { - text: "Проверьте описание вакансии.", - }, - { - text: "Адаптируйте резюме.", - }, - { - text: "Напишите сопроводительное письмо.", - }, - { - text: "Проверьте грамматику и орфографию.", - }, - { - text: "Убедитесь в правильности контактной информации.", - }, - { - text: "Подготовьте дополнительные документы.", - }, - { - text: "Проверьте форматирование.", - }, - { - text: "Убедитесь в соблюдении сроков.", - }, - { - text: "Сохраните копию.", - }, - { - text: "Будьте готовы к интервью.", - }, - ]; + /** Список советов для подготовки отклика */ + readonly noteList = noteList; } diff --git a/projects/social_platform/src/app/ui/README.md b/projects/social_platform/src/app/ui/README.md new file mode 100644 index 000000000..c4c8136e2 --- /dev/null +++ b/projects/social_platform/src/app/ui/README.md @@ -0,0 +1,189 @@ + + +# UI Модуль + +Библиотека переиспользуемых UI компонентов для всего приложения. + +## Компоненты + +### Формы и ввод + +#### 📝 Input + +Базовый компонент поля ввода + +- Поддержка различных типов +- Валидация и отображение ошибок +- Кастомизируемые стили + +#### 📄 Textarea + +Многострочное поле ввода + +- Автоматическое изменение размера +- Подсчет символов +- Валидация + +#### 🔽 Select + +Выпадающий список + +- Поиск по опциям +- Множественный выбор +- Кастомные шаблоны опций + +#### ✅ Checkbox + +Чекбокс с кастомным дизайном + +#### 🔄 Switch + +Переключатель вкл/выкл + +#### 🎯 Autocomplete + +Поле с автодополнением + +- Поиск по мере ввода +- Кастомные шаблоны результатов + +#### 📊 Range Input + +Ползунок для выбора диапазона значений + +#### 🔢 Num Slider + +Числовой слайдер + +### Отображение данных + +#### 🖼 Avatar + +Аватар пользователя + +- Поддержка изображений и инициалов +- Различные размеры +- Статусы онлайн/оффлайн + +#### 🏷 Tag + +Тег/метка + +- Различные цвета и размеры +- Возможность удаления + +#### 📊 Bar + +Прогресс-бар + +- Анимированный прогресс +- Различные стили + +#### ⭐ Icon + +Иконки SVG + +- Библиотека иконок +- Настраиваемые размеры и цвета + +### Интерактивные элементы + +#### 🔘 Button + +Кнопка с различными стилями + +- Primary, secondary, danger стили +- Состояния загрузки +- Иконки + +#### 🔍 Search + +Поле поиска + +- Автодополнение +- История поисков + +### Медиа и файлы + +#### 📁 Upload File + +Загрузка файлов + +- Drag & drop +- Предпросмотр +- Валидация типов и размеров + +#### 📎 File Item + +Отображение загруженного файла + +- Иконки по типам файлов +- Действия (скачать, удалить) + +#### 📋 File Upload Item + +Элемент в процессе загрузки + +- Прогресс загрузки +- Возможность отмены + +### Обратная связь + +#### ⚠️ Modal + +Модальные окна + +- Различные размеры +- Анимации появления/исчезновения + +#### 🔄 Loader + +Индикатор загрузки + +- Различные анимации +- Настраиваемые размеры + +#### 📢 Snackbar + +Уведомления + +- Различные типы (успех, ошибка, предупреждение) +- Автоматическое скрытие + +#### ❌ Delete Confirm + +Подтверждение удаления + +- Модальное окно подтверждения + +### Сообщения + +#### 💬 Chat Message + +Сообщение в чате + +- Различные типы сообщений +- Временные метки +- Статусы прочтения + +## Сервисы + +### AnimationService + +Сервис для управления анимациями + +### SnackbarService + +Сервис для показа уведомлений + +## Директивы + +### EditorSubmitButtonDirective + +Директива для кнопок отправки в редакторах + +## Пайпы + +### FileTypePipe + +Определение типа файла по расширению diff --git a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts b/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts index 256feaf6d..99729b6c9 100644 --- a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts +++ b/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts @@ -20,6 +20,32 @@ import { debounce, distinctUntilChanged, fromEvent, map, of, Subscription, timer import { animate, style, transition, trigger } from "@angular/animations"; import { LoaderComponent } from "@ui/components/loader/loader.component"; +/** + * Компонент автодополнения с поиском и выбором из списка предложений. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Поддерживает различные режимы отображения и настройки поведения. + * + * Входящие параметры: + * - suggestions: массив предложений для отображения + * - fieldToDisplayMode: режим отображения ("text" | "chip") + * - fieldToDisplay: поле объекта для отображения + * - valueField: поле для получения значения + * - forceSelect: принудительный выбор из списка + * - clearInputOnSelect: очистка поля после выбора + * - delay: задержка поиска в мс (по умолчанию 300) + * - placeholder: placeholder для поля ввода + * - searchIcon: иконка поиска + * - slimVersion: компактная версия + * - error: состояние ошибки + * + * События: + * - searchStart: начало поиска с текстом запроса + * - optionSelected: выбор опции из списка + * - inputCleared: очистка поля ввода + * + * Возвращает: + * - Выбранное значение через ControlValueAccessor + */ @Component({ selector: "app-autocomplete-input", standalone: true, @@ -45,6 +71,7 @@ import { LoaderComponent } from "@ui/components/loader/loader.component"; ], }) export class AutoCompleteInputComponent { + /** Массив предложений для отображения */ @Input({ required: true }) get suggestions(): T[] { return this._suggestions(); } @@ -54,52 +81,75 @@ export class AutoCompleteInputComponent { this.handleSuggestionsChange(val); } + /** Режим отображения выбранного поля */ @Input() fieldToDisplayMode: "text" | "chip" = "text"; + /** Поле объекта для отображения */ @Input() fieldToDisplay!: keyof T; + /** Поле для получения значения */ @Input() valueField!: string; + /** Принудительный выбор из списка */ @Input() forceSelect = false; + /** Очистка поля после выбора */ @Input() clearInputOnSelect = false; + /** Задержка поиска в мс */ @Input() delay = 300; + /** Placeholder для поля ввода */ @Input() placeholder = ""; + /** Иконка поиска */ @Input() searchIcon = "search"; + /** Компактная версия */ @Input() slimVersion = false; + /** Состояние ошибки */ @Input() error = false; + /** Событие начала поиска */ @Output() searchStart = new EventEmitter(); + /** Событие выбора опции */ @Output() optionSelected = new EventEmitter(); + /** Событие очистки поля */ @Output() inputCleared = new EventEmitter(); + /** Ссылка на элемент input */ @ViewChild("input") inputElem!: ElementRef; + /** Текущее выбранное значение */ value = signal(null); + /** Значение в поле ввода */ inputValue = signal(""); + /** Массив предложений */ _suggestions = signal([]); + /** Состояние открытия выпадающего списка */ isOpen = signal(false); + /** Состояние загрузки */ loading = signal(false); + /** Состояние отсутствия результатов */ noResults = signal(false); + /** Состояние блокировки */ disabled = signal(false); + /** Массив подписок */ subscriptions$ = signal([]); constructor(private readonly cdRef: ChangeDetectorRef) {} + /** Инициализация отслеживания ввода после загрузки представления */ ngAfterViewInit(): void { const input$ = fromEvent(this.inputElem.nativeElement, "input") .pipe( @@ -114,18 +164,20 @@ export class AutoCompleteInputComponent { ngOnInit(): void {} + /** Обработчик ввода текста */ onInput(event: Event): void { const value = (event.target as HTMLInputElement).value.trim(); this.inputValue.set(value); } + /** Обработчик потери фокуса */ onBlur(): void { this.onTouch(); } + // Методы ControlValueAccessor writeValue(value: any): void { this.value.set(value?.[this.valueField] ?? value); - this.handleProgrammaticInputValueChange(value); } @@ -145,10 +197,12 @@ export class AutoCompleteInputComponent { this.disabled.set(isDisabled); } + /** Обработчик нажатия Enter */ onEnter(event: Event): void { event.preventDefault(); } + /** Обработчик выбора значения из списка */ onUpdate(event: Event, value: any): void { event.stopPropagation(); @@ -163,6 +217,7 @@ export class AutoCompleteInputComponent { this.isOpen.set(false); } + /** Обработчик очистки значения */ onClearValue(event: Event): void { event.stopPropagation(); this.inputValue.set(""); @@ -170,6 +225,7 @@ export class AutoCompleteInputComponent { this.onChange(null); } + /** Обработчик клика вне компонента */ onClickOutside(): void { const value = this.findExistingSuggestion(this.suggestions); @@ -188,6 +244,7 @@ export class AutoCompleteInputComponent { this.isOpen.set(false); } + /** Обработчик поиска */ handleSearch(query: string): void { if (!query) { this.isOpen.set(false); @@ -197,10 +254,10 @@ export class AutoCompleteInputComponent { } this.loading.set(true); - this.searchStart.emit(query); } + /** Обработчик вставки текста */ handlePaste(event: ClipboardEvent): void { const query = event.clipboardData?.getData("text"); @@ -209,6 +266,7 @@ export class AutoCompleteInputComponent { } } + /** Обработчик изменения списка предложений */ handleSuggestionsChange(suggestions: any[]): void { if (!suggestions?.length && this.loading()) { this.noResults.set(true); @@ -223,6 +281,7 @@ export class AutoCompleteInputComponent { this.loading.set(false); } + /** Обработчик программного изменения значения поля ввода */ handleProgrammaticInputValueChange(appValue: any): void { if (this.fieldToDisplayMode === "chip" || this.clearInputOnSelect) { this.inputValue.set(""); @@ -231,6 +290,7 @@ export class AutoCompleteInputComponent { } } + /** Поиск существующего предложения по введенному тексту */ findExistingSuggestion(suggestions: typeof this.suggestions): any { if (!this.fieldToDisplay) { return suggestions.find(s => String(s).toLowerCase() === this.inputValue().toLowerCase()); @@ -240,6 +300,7 @@ export class AutoCompleteInputComponent { ); } + /** Очистка подписок при уничтожении компонента */ ngOnDestroy(): void { this.subscriptions$().forEach($ => $.unsubscribe()); } diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts index 756676f32..474ad3716 100644 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts +++ b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts @@ -9,6 +9,19 @@ import { IconComponent } from "@ui/components"; import { LoaderComponent } from "../loader/loader.component"; import { CommonModule } from "@angular/common"; +/** + * Компонент для управления аватаром пользователя. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Позволяет загружать, обновлять и удалять изображение аватара. + * + * Входящие параметры: + * - size: размер аватара в пикселях (по умолчанию 140) + * - error: состояние ошибки для отображения красной рамки + * - type: тип аватара ("avatar" | "project" | "profile", по умолчанию "avatar") + * + * Возвращает: + * - URL загруженного изображения через ControlValueAccessor + */ @Component({ selector: "app-avatar-control", templateUrl: "./avatar-control.component.html", @@ -26,16 +39,24 @@ import { CommonModule } from "@angular/common"; export class AvatarControlComponent implements OnInit, ControlValueAccessor { constructor(private fileService: FileService) {} + /** Размер авата��а в пикселях */ @Input() size = 140; + + /** Состояние ошибки */ @Input() error = false; + + /** Тип аватара */ @Input() type: "avatar" | "project" | "profile" = "avatar"; ngOnInit(): void {} + /** Уникальный ID для элемента input */ controlId = nanoid(3); + /** Текущее значение URL изображения */ value = ""; + /** Записывает значение URL изображения */ writeValue(address: string) { this.value = address; } @@ -52,8 +73,13 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { this.onChange = fn; } + /** Состояние загрузки файла */ loading = false; + /** + * Обработчик обновления изображения + * Загружает новый файл, при наличии старого - сначала удаляет его + */ onUpdate(event: Event) { const files = (event.currentTarget as HTMLInputElement).files; @@ -77,6 +103,10 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { source.subscribe(this.updateValue.bind(this)); } + /** + * Обновляет значение URL и уведомляет о изменении + * @param url - новый URL изображения + */ private updateValue(url: string): void { this.loading = false; diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.ts b/projects/social_platform/src/app/ui/components/avatar/avatar.component.ts index 1ac382e01..c36ed136a 100644 --- a/projects/social_platform/src/app/ui/components/avatar/avatar.component.ts +++ b/projects/social_platform/src/app/ui/components/avatar/avatar.component.ts @@ -1,7 +1,26 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input, type OnInit } from "@angular/core"; +/** + * Компонент для отображения аватара пользователя. + * Поддерживает различные размеры, индикатор онлайн статуса и прогресс-бар. + * + * Входящие параметры: + * - url: URL изображения аватара (обязательный) + * - size: размер аватара в пикселях (по умолчанию 50) + * - hasBorder: отображать рамку вокруг аватара + * - isOnline: показывать индикатор онлайн статуса + * - progress: значение прогресса для отображения кольца прогресса + * - onlineBadgeSize: размер индикатора онлайн статуса (по умолчанию 16) + * - onlineBadgeBorder: толщина рамки индикатора (по умолчанию 3) + * - onlineBadgeOffset: смещение индикатора от края (по умолчанию 0) + * + * Функциональность: + * - Автоматическая подстановка placeholder при отсутствии изображения + * - Индикатор онлайн статуса в правом нижнем углу + * - Кольцо прогресса вокруг аватара + */ @Component({ selector: "app-avatar", templateUrl: "./avatar.component.html", @@ -9,17 +28,31 @@ import { Component, Input, OnInit } from "@angular/core"; standalone: true, }) export class AvatarComponent implements OnInit { + /** URL изображения аватара */ @Input({ required: true }) url?: string; + + /** Размер аватара в пикселях */ @Input() size = 50; + + /** Отображать рамку */ @Input() hasBorder = false; + + /** Показывать индикатор онлайн статуса */ @Input() isOnline = false; + /** Значение прогресса (0-100) */ @Input() progress?: number; + /** Размер индикатора онлайн статуса */ @Input() onlineBadgeSize = 16; + + /** Толщина рамки индикатора */ @Input() onlineBadgeBorder = 3; + + /** Смещение индикатора от края */ @Input() onlineBadgeOffset = 0; + /** URL placeholder изображения по умолчанию */ placeholderUrl = "https://hwchamber.co.uk/wp-content/uploads/2022/04/avatar-placeholder.gif"; constructor() {} 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 index 7a5af9cb5..06fa6f1d4 100644 --- a/projects/social_platform/src/app/ui/components/bar/bar.component.ts +++ b/projects/social_platform/src/app/ui/components/bar/bar.component.ts @@ -5,6 +5,25 @@ 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, @@ -15,6 +34,7 @@ import { BackComponent } from "@uilib"; export class BarComponent { constructor() {} + /** Массив навигационных ссылок */ @Input() links!: { link: string; linkText: string; @@ -22,9 +42,12 @@ export class BarComponent { count?: number; }[]; + /** Маршрут для кнопки "Назад" */ @Input() backRoute?: string; + /** Показывать кнопку "Назад" */ @Input() backHave?: boolean; + /** Показывать индикатор в виде шарика */ @Input() ballHave?: boolean = false; } diff --git a/projects/social_platform/src/app/ui/components/button/button.component.ts b/projects/social_platform/src/app/ui/components/button/button.component.ts index 422e626d7..bfb1f6ddf 100644 --- a/projects/social_platform/src/app/ui/components/button/button.component.ts +++ b/projects/social_platform/src/app/ui/components/button/button.component.ts @@ -1,9 +1,27 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input, type OnInit } from "@angular/core"; import { LoaderComponent } from "../loader/loader.component"; import { CommonModule } from "@angular/common"; +/** + * Универсальный компонент кнопки с различными стилями и состояниями. + * Поддерживает различные цветовые схемы, индикатор загрузки и настройки внешнего вида. + * + * Входящие параметры: + * - color: цветовая схема кнопки ("primary" | "red" | "grey" | "green" | "gold" | "gradient" | "white") + * - loader: показывать индикатор загрузки + * - hasBorder: отображать рамку кнопки + * - type: тип кнопки для HTML ("submit" | "reset" | "button") + * - appearance: стиль отображения ("inline" | "outline") + * - backgroundColor: кастомный цвет фона + * - disabled: состояние блокировки кнопки + * - customTypographyClass: кастомный CSS класс для типографики + * + * Использование: + * - Вставлять контент кнопки через ng-content + * - Автоматически показывает лоадер при loader=true + */ @Component({ selector: "app-button", templateUrl: "./button.component.html", @@ -14,13 +32,28 @@ import { CommonModule } from "@angular/common"; export class ButtonComponent implements OnInit { constructor() {} + /** Цветовая схема кнопки */ @Input() color: "primary" | "red" | "grey" | "green" | "gold" | "gradient" | "white" = "primary"; + + /** Показывать индикатор загрузки */ @Input() loader = false; + + /** Отображать рамку */ @Input() hasBorder = true; + + /** Тип HTML кнопки */ @Input() type: "submit" | "reset" | "button" = "button"; + + /** Стиль отображения */ @Input() appearance: "inline" | "outline" = "inline"; + + /** Кастомный цвет фона */ @Input() backgroundColor?: string; + + /** Состояние блокировки */ @Input() disabled = false; + + /** Кастомный класс типографики */ @Input() customTypographyClass?: string; ngOnInit(): void {} diff --git a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts index da9f76384..9aac541a4 100644 --- a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts +++ b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts @@ -23,6 +23,29 @@ import { AsyncPipe } from "@angular/common"; import { AvatarComponent } from "../avatar/avatar.component"; import { ClickOutsideModule } from "ng-click-outside"; +/** + * Компонент сообщения в чате с контекстным меню и файловыми вложениями. + * Отображает сообщение пользователя с возможностью ответа, редактирования и удаления. + * + * Входящие параметры: + * - chatMessage: объект сообщения чата с текстом, автором, временем и вложениями + * + * События: + * - reply: ответ на сообщение (передает ID сообщения) + * - edit: редактирование сообщения (передает ID сообщения) + * - delete: удаление сообщения (передает ID сообщения) + * + * Функциональность: + * - Отображение аватара и информации об авторе + * - Контекстное меню по правому клику + * - Копирование текста сообщения в буфер обмена + * - Отображение файловых вложений + * - Форматирование времени отправки + * + * Использование: + * - В списках сообщений чата + * - Комментарии и обсуждения + */ @Component({ selector: "app-chat-message", templateUrl: "./chat-message.component.html", @@ -44,14 +67,21 @@ export class ChatMessageComponent implements OnInit, AfterViewInit, OnDestroy { public readonly authService: AuthService ) {} + /** Объект сообщения чата */ @Input({ required: true }) chatMessage!: ChatMessage; + /** Событие ответа на сообщение */ @Output() reply = new EventEmitter(); + + /** Событие редактирования сообщения */ @Output() edit = new EventEmitter(); + + /** Событие удаления сообщения */ @Output() delete = new EventEmitter(); ngOnInit(): void {} + /** Инициализация overlay для контекстного меню */ ngAfterViewInit(): void { this.overlayRef = this.overlay.create({ hasBackdrop: false, @@ -59,17 +89,24 @@ export class ChatMessageComponent implements OnInit, AfterViewInit, OnDestroy { this.portal = new DomPortal(this.contextMenu); } + /** Очистка ресурсов overlay */ ngOnDestroy(): void { this.overlayRef?.detach(); } + /** Ссылка на элемент контекстного меню */ @ViewChild("contextMenu") contextMenu!: ElementRef; + /** Ссылка на overlay */ private overlayRef?: OverlayRef; + + /** Portal для контекстного меню */ private portal?: DomPortal; + /** Состояние открытия контекстного меню */ isOpen = false; + /** Обработчик открытия контекстного меню по правому клику */ onOpenContextmenu(event: MouseEvent) { event.preventDefault(); @@ -95,11 +132,13 @@ export class ChatMessageComponent implements OnInit, AfterViewInit, OnDestroy { this.contextMenu.nativeElement.focus(); } + /** Закрытие контекстного меню */ onCloseContextmenu() { this.isOpen = false; this.overlayRef?.detach(); } + /** Копирование содержимого сообщения в буфер обмена */ onCopyContent(event: MouseEvent) { event.stopPropagation(); @@ -112,6 +151,7 @@ export class ChatMessageComponent implements OnInit, AfterViewInit, OnDestroy { }); } + /** Обработчик удаления сообщения */ onDelete(event: MouseEvent) { event.stopPropagation(); @@ -121,6 +161,7 @@ export class ChatMessageComponent implements OnInit, AfterViewInit, OnDestroy { this.overlayRef?.detach(); } + /** Обработчик ответа на сообщение */ onReply(event: MouseEvent) { event.stopPropagation(); @@ -130,6 +171,7 @@ export class ChatMessageComponent implements OnInit, AfterViewInit, OnDestroy { this.overlayRef?.detach(); } + /** Обработчик редактирования сообщения */ onEdit(event: MouseEvent) { event.stopPropagation(); diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts index d66f6d065..fcc8b59ab 100644 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts +++ b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts @@ -1,8 +1,21 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; import { IconComponent } from "@ui/components/icon/icon.component"; +/** + * Компонент чекбокса для выбора булевых значений. + * Отображает состояние отмечен/не отмечен с иконкой галочки. + * + * Входящие параметры: + * - checked: состояние чекбокса (отмечен/не отмечен) + * + * События: + * - checkedChange: изменение состояния чекбокса + * + * Возвращает: + * - boolean значение через событие checkedChange + */ @Component({ selector: "app-checkbox", templateUrl: "./checkbox.component.html", @@ -11,7 +24,10 @@ import { IconComponent } from "@ui/components/icon/icon.component"; imports: [IconComponent], }) export class CheckboxComponent implements OnInit { + /** Состояние чекбокса */ @Input({ required: true }) checked = false; + + /** Событие изменения состояния */ @Output() checkedChange = new EventEmitter(); ngOnInit(): void {} diff --git a/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.ts b/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.ts index 1a4aacb93..112ffa3ac 100644 --- a/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.ts +++ b/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.ts @@ -6,6 +6,18 @@ import { ButtonComponent, IconComponent } from "@ui/components"; import { AsyncPipe } from "@angular/common"; import { ModalComponent } from "../modal/modal.component"; +/** + * Компонент диалога подтверждения удаления. + * Отображает модальное окно с вопросом о подтверждении удаления элемента. + * Работает в связке с ModalService для управления состоянием диалога. + * + * Функциональность: + * - Отображение кастомного текста подтверждения + * - Кнопки подтверждения и отмены + * - Передача результата выбора через ModalService + * + * Не принимает входящих параметров - получает данные через ModalService + */ @Component({ selector: "app-delete-confirm", templateUrl: "./delete-confirm.component.html", @@ -18,6 +30,10 @@ export class DeleteConfirmComponent implements OnInit { ngOnInit(): void {} + /** + * Обработчик результата выбора пользователя + * @param result - true для подтверждения, false для отмены + */ onResult(result: boolean): void { this.modalService.confirmObserver?.next(result); this.modalService.confirmObserver?.complete(); @@ -25,5 +41,6 @@ export class DeleteConfirmComponent implements OnInit { this.modalService.confirmState = false; } + /** Observable с настройками текста диалога */ settings$ = this.modalService.confirmSettings.asObservable(); } diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts b/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts index c4f65f806..2941d1913 100644 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts +++ b/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts @@ -5,6 +5,21 @@ import { IconComponent } from "@ui/components"; import { UpperCasePipe } from "@angular/common"; import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; +/** + * Компонент для отображения информации о файле. + * Показывает тип файла, название, размер и предоставляет возможность скачивания. + * + * Входящие параметры: + * - type: MIME-тип файла (по умолчанию "file") + * - name: название файла + * - size: размер файла в байтах + * - link: ссылка для скачивания файла + * + * Функциональность: + * - Отображение иконки файла по типу + * - Форматированный вывод размера файла + * - Автоматическое скачивание файла по клику + */ @Component({ selector: "app-file-item", templateUrl: "./file-item.component.html", @@ -15,13 +30,21 @@ import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; export class FileItemComponent implements OnInit { constructor() {} + /** MIME-тип файла */ @Input() type = "file"; + + /** Название файла */ @Input() name = ""; + + /** Размер файла в байтах */ @Input() size = 0; + + /** Ссылка для скачивания */ @Input() link = ""; ngOnInit(): void {} + /** Функция скачивания файла через создание временной ссылки */ onDownloadFile(): void { const link = document.createElement("a"); diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts b/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts index 0a1b70e99..a07d56e9e 100644 --- a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts +++ b/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts @@ -7,6 +7,22 @@ import { IconComponent } from "@ui/components"; import { UpperCasePipe } from "@angular/common"; import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; +/** + * Компонент для отображения элемента загружаемого файла. + * Показывает информацию о файле, состояние загрузки и предоставляет действия для управления. + * + * Входящие параметры: + * - type: MIME-тип файла (по умолчанию "file") + * - name: имя файла + * - size: размер файла в байтах + * - link: ссылка на файл + * - loading: состояние загрузки файла + * - error: текст ошибки загрузки + * + * События: + * - delete: событие удаления файла + * - retry: событие повторной попытки загрузки + */ @Component({ selector: "app-file-upload-item", templateUrl: "./file-upload-item.component.html", @@ -17,14 +33,28 @@ import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; export class FileUploadItemComponent implements OnInit { constructor() {} + /** MIME-тип файла */ @Input() type = "file"; + + /** Имя файла */ @Input() name = ""; + + /** Размер файла в байтах */ @Input() size = 0; + + /** Ссылка на файл */ @Input() link = ""; + + /** Состояние загрузки */ @Input() loading = false; + + /** Текст ошибки */ @Input() error = ""; + /** Событие удаления файла */ @Output() delete = new EventEmitter(); + + /** Событие повторной попытки загрузки */ @Output() retry = new EventEmitter(); ngOnInit(): void {} diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.ts b/projects/social_platform/src/app/ui/components/icon/icon.component.ts index e08a7614c..aac947c28 100644 --- a/projects/social_platform/src/app/ui/components/icon/icon.component.ts +++ b/projects/social_platform/src/app/ui/components/icon/icon.component.ts @@ -2,6 +2,24 @@ import { Component, Input, OnInit } from "@angular/core"; +/** + * Компонент для отображения SVG иконок с настраиваемыми параметрами. + * Поддерживает автоматическое вычисление viewBox и размеров. + * + * Входящие параметры: + * - appSquare: размер квадратной иконки (автоматически устанавливает viewBox) + * - appViewBox: кастомный viewBox для SVG + * - appWidth: ширина иконки + * - appHeight: высота иконки + * - icon: имя иконки для отображения (обязательный) + * + * Функциональность: + * - Автоматическое вычисление viewBox для квадратных иконок + * - Поддержка кастомных размеров и пропорций + * - Динамическое обновление viewBox при изменении размеров + * + * Использование как атрибутивная директива: + */ @Component({ selector: "[appIcon]", templateUrl: "./icon.component.html", @@ -9,6 +27,7 @@ import { Component, Input, OnInit } from "@angular/core"; standalone: true, }) export class IconComponent implements OnInit { + /** Размер квадратной иконки */ @Input() set appSquare(square: string) { this.square = square; @@ -20,6 +39,7 @@ export class IconComponent implements OnInit { return this.square ?? ""; } + /** Кастомный viewBox */ @Input() set appViewBox(viewBox: string) { this.viewBox = viewBox; @@ -29,6 +49,7 @@ export class IconComponent implements OnInit { return this.viewBox ?? "0 0 0 0"; } + /** Ширина иконки */ @Input() set appWidth(width: string) { this.width = width; @@ -44,6 +65,7 @@ export class IconComponent implements OnInit { return this.width ?? ""; } + /** Высота иконки */ @Input() set appHeight(height: string) { this.height = height; @@ -59,16 +81,17 @@ export class IconComponent implements OnInit { return this.height ?? ""; } + /** Имя иконки для отображения */ @Input({ required: true }) icon!: string; square?: string; viewBox?: string; - width?: string; height?: string; ngOnInit(): void {} + /** Парсинг параметров viewBox */ viewBoxInfo(viewBox: string): string[] { return viewBox.split(" "); } diff --git a/projects/social_platform/src/app/ui/components/input/input.component.ts b/projects/social_platform/src/app/ui/components/input/input.component.ts index 8985c34c7..f42b3f330 100644 --- a/projects/social_platform/src/app/ui/components/input/input.component.ts +++ b/projects/social_platform/src/app/ui/components/input/input.component.ts @@ -1,10 +1,28 @@ /** @format */ -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { Component, EventEmitter, forwardRef, Input, type OnInit, Output } from "@angular/core"; +import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { IconComponent } from "@ui/components"; import { NgxMaskModule } from "ngx-mask"; +/** + * Компонент поля ввода с поддержкой масок и различных типов. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * + * Входящие параметры: + * - placeholder: текст подсказки в поле + * - type: тип поля ввода ("text" | "password" | "email" | "tel") + * - error: состояние ошибки для стилизации + * - mask: маска для форматирования ввода + * - appValue: значение поля (двустороннее связывание) + * + * События: + * - appValueChange: изменение значения поля + * - enter: нажатие клавиши Enter + * + * Возвращает: + * - Введенное значение через ControlValueAccessor + */ @Component({ selector: "app-input", templateUrl: "./input.component.html", @@ -22,11 +40,19 @@ import { NgxMaskModule } from "ngx-mask"; export class InputComponent implements OnInit, ControlValueAccessor { constructor() {} + /** Текст подсказки */ @Input() placeholder = ""; + + /** Тип поля ввода */ @Input() type: "text" | "password" | "email" | "tel" = "text"; + + /** Состояние ошибки */ @Input() error = false; + + /** Маска для форматирования */ @Input() mask = ""; + /** Двустороннее связывание значения */ @Input() set appValue(value: string) { this.value = value; @@ -36,23 +62,30 @@ export class InputComponent implements OnInit, ControlValueAccessor { return this.value; } + /** Событие изменения значения */ @Output() appValueChange = new EventEmitter(); + + /** Событие нажатия Enter */ @Output() enter = new EventEmitter(); ngOnInit(): void {} + /** Обработчик ввода текста */ onInput(event: Event): void { const value = (event.target as HTMLInputElement).value; this.onChange(value); this.appValueChange.emit(value); } + /** Обработчик потери фокуса */ onBlur(): void { this.onTouch(); } + /** Текущее значение поля */ value = ""; + // Методы ControlValueAccessor writeValue(value: string): void { setTimeout(() => { this.value = value; @@ -71,12 +104,14 @@ export class InputComponent implements OnInit, ControlValueAccessor { this.onTouch = fn; } + /** Состояние блокировки */ disabled = false; setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } + /** Обработчик нажатия Enter */ onEnter(event: Event) { event.preventDefault(); this.enter.emit(); diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.ts b/projects/social_platform/src/app/ui/components/loader/loader.component.ts index 744bddfeb..a99fecca0 100644 --- a/projects/social_platform/src/app/ui/components/loader/loader.component.ts +++ b/projects/social_platform/src/app/ui/components/loader/loader.component.ts @@ -2,6 +2,20 @@ import { Component, Input, OnInit } from "@angular/core"; +/** + * Компонент индикатора загрузки с настраиваемым внешним видом. + * Поддерживает различные типы анимации и настройки цвета, размера, скорости. + * + * Входящие параметры: + * - speed: скорость анимации (по умолчанию "1s") + * - size: размер индикатора (по умолчанию "47px") + * - color: цвет индикатора (по умолчанию "white") + * - type: тип анимации ("wave" | "circle", по умолчанию "wave") + * + * Использование: + * - Для показа состояния загрузки в кнопках, формах и других элементах + * - Настраиваемый размер и цвет под дизайн приложения + */ @Component({ selector: "app-loader", templateUrl: "./loader.component.html", @@ -11,9 +25,16 @@ import { Component, Input, OnInit } from "@angular/core"; export class LoaderComponent implements OnInit { constructor() {} + /** Скорость анимации */ @Input() speed = "1s"; + + /** Размер индикатора */ @Input() size = "47px"; + + /** Цвет индикатора */ @Input() color = "white"; + + /** Тип анимации */ @Input() type: "wave" | "circle" = "wave"; ngOnInit(): void {} diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.ts b/projects/social_platform/src/app/ui/components/modal/modal.component.ts index 46d1c0ad0..1057c68a3 100644 --- a/projects/social_platform/src/app/ui/components/modal/modal.component.ts +++ b/projects/social_platform/src/app/ui/components/modal/modal.component.ts @@ -16,6 +16,27 @@ import { Overlay, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; import { CommonModule } from "@angular/common"; +/** + * Универсальный компонент модального окна с overlay. + * Использует Angular CDK Overlay для создания модальных окон поверх основного контента. + * + * Входящие параметры: + * - color: цветовая схема модального окна ("primary" | "gradient") + * - open: состояние открытия/закрытия модального окна + * + * События: + * - openChange: изменение состояния открытия модального окна + * + * Функциональность: + * - Создание overlay поверх страницы + * - Автоматическое управление отображением при изменении open + * - Проекция контента через ng-content + * - Очистка ресурсов при уничтожении компонента + * + * Использование: + * - Обертка контента в ... + * - Модальные диалоги, формы, галереи изображений + */ @Component({ selector: "app-modal", templateUrl: "./modal.component.html", @@ -29,8 +50,10 @@ export class ModalComponent implements OnInit, AfterViewInit, OnDestroy { private readonly viewContainerRef: ViewContainerRef ) {} + /** Цветовая схема модального окна */ @Input() color?: "primary" | "gradient" = "primary"; + /** Состояние открытия модального окна */ @Input({ required: true }) set open(value: boolean) { setTimeout(() => { if (value) this.overlayRef?.attach(this.portal); @@ -42,10 +65,12 @@ export class ModalComponent implements OnInit, AfterViewInit, OnDestroy { return !!this.overlayRef?.hasAttached(); } + /** Событие изменения состояния открытия */ @Output() openChange = new EventEmitter(); ngOnInit(): void {} + /** Инициализация overlay после загрузки представления */ ngAfterViewInit(): void { if (this.modalTemplate) { this.overlayRef = this.overlay.create({}); @@ -53,12 +78,17 @@ export class ModalComponent implements OnInit, AfterViewInit, OnDestroy { } } + /** Очистка ресурсов при уничтожении */ ngOnDestroy(): void { this.overlayRef?.detach(); } + /** Ссылка на шаблон модального окна */ @ViewChild("modalTemplate") modalTemplate?: TemplateRef; + /** Portal для проекции контента */ portal?: TemplatePortal; + + /** Ссылка на overlay */ overlayRef?: OverlayRef; } 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 index d3985ad75..2cd12fbf9 100644 --- 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 @@ -14,6 +14,21 @@ import { 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", @@ -30,6 +45,7 @@ import { Subscription } from "rxjs"; export class NumSliderComponent implements OnInit, OnDestroy { constructor() {} + /** Массив доступных чисел */ @Input() set appNums(value: number[]) { this.nums = value; @@ -39,6 +55,7 @@ export class NumSliderComponent implements OnInit, OnDestroy { return this.nums; } + /** Текущее выбранное значение */ @Input() set appValue(value: number | null) { this.value = !value || isNaN(value) ? this.nums.sort()[0] : value; @@ -52,6 +69,7 @@ export class NumSliderComponent implements OnInit, OnDestroy { return this.value; } + /** Событие изменения значения */ @Output() appValueChange = new EventEmitter(); ngOnInit(): void {} @@ -60,21 +78,33 @@ export class NumSliderComponent implements OnInit, OnDestroy { 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 { @@ -93,6 +123,7 @@ export class NumSliderComponent implements OnInit, OnDestroy { this.disabled = isDisabled; } + /** Обработчик движения мыши/касания */ onMove(event: MouseEvent | TouchEvent) { if (!this.mousePressed) return; @@ -111,11 +142,12 @@ export class NumSliderComponent implements OnInit, OnDestroy { if (xChange < 0 && xChange > totalWidth) this.onStopInteraction(event); } + /** Получение индекса шага по координате X */ private getStepIdxFromX(totalWidth: number, x: number): number { - const intervalWidth = parseInt((totalWidth / (this.nums.length - 1)).toFixed()); + const intervalWidth = Number.parseInt((totalWidth / (this.nums.length - 1)).toFixed()); for (let i = 0; i < this.nums.length; i++) { - const halfInterval = parseInt((intervalWidth / 2).toFixed()); + 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; @@ -127,22 +159,25 @@ export class NumSliderComponent implements OnInit, OnDestroy { 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(); @@ -153,6 +188,7 @@ export class NumSliderComponent implements OnInit, OnDestroy { this.setElements(); } + /** Установка позиции элементов слайдера */ setElements() { if (!this.pointEl || !this.fillEl) return; @@ -160,6 +196,7 @@ export class NumSliderComponent implements OnInit, OnDestroy { this.fillEl.nativeElement.style.width = `${this.getButtonCoordinate()}%`; } + /** Завершение движения слайдера */ private stopMoving() { this.mousePressed = false; this.onChange(this.value ?? 0); 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 index 18761645b..cc5441c3b 100644 --- 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 @@ -5,6 +5,19 @@ 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, @@ -22,32 +35,36 @@ import { NgxMaskModule } from "ngx-mask"; 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] = parseInt(value); - this.onChange([parseInt(value), this.value[1]]); + 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] = parseInt(value); - this.onChange([this.value[0], parseInt(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(); }); } @@ -64,6 +81,7 @@ export class RangeInputComponent implements ControlValueAccessor { this.onTouch = fn; } + /** Состояние блокировки */ disabled = false; setDisabledState(isDisabled: boolean) { diff --git a/projects/social_platform/src/app/ui/components/search/search.component.ts b/projects/social_platform/src/app/ui/components/search/search.component.ts index c5a6bfd46..9603c397a 100644 --- a/projects/social_platform/src/app/ui/components/search/search.component.ts +++ b/projects/social_platform/src/app/ui/components/search/search.component.ts @@ -14,6 +14,26 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { IconComponent } from "@ui/components"; import { ClickOutsideModule } from "ng-click-outside"; +/** + * Компонент поля поиска с возможностью раскрытия/сворачивания. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Может работать в режиме всегда открытого поля или с анимацией раскрытия. + * + * Входящие параметры: + * - placeholder: текст подсказки в поле + * - type: тип поля ввода ("text" | "password" | "email") + * - error: состояние ошибки для стилизации + * - mask: маска для форматирования ввода + * - openable: возможность сворачивания ��оля (по умолчанию true) + * - appValue: значение поля (двустороннее связывание) + * + * События: + * - appValueChange: изменение значения поля + * - enter: нажатие клавиши Enter + * + * Возвращает: + * - Введенный текст поиска через ControlValueAccessor + */ @Component({ selector: "app-search", templateUrl: "./search.component.html", @@ -29,12 +49,22 @@ import { ClickOutsideModule } from "ng-click-outside"; imports: [ClickOutsideModule, IconComponent], }) export class SearchComponent implements OnInit, ControlValueAccessor { + /** Текст подсказки */ @Input() placeholder = ""; + + /** Тип поля ввода */ @Input() type: "text" | "password" | "email" = "text"; + + /** Состояние ошибки */ @Input() error = false; + + /** Маска для форматирования */ @Input() mask = ""; + + /** Возможность сворачивания поля */ @Input() openable = true; + /** Двустороннее связывание значения */ @Input() set appValue(value: string) { this.value = value; @@ -44,16 +74,23 @@ export class SearchComponent implements OnInit, ControlValueAccessor { return this.value; } + /** Событие изменения значения */ @Output() appValueChange = new EventEmitter(); + + /** Событие нажатия Enter */ @Output() enter = new EventEmitter(); ngOnInit(): void { this.open = !this.openable; } + /** Ссылка на поле ввода */ @ViewChild("inputEl") inputEl?: ElementRef; + + /** Состояние раскрытия поля */ open = false; + /** Переключение состояния раскрытия поля поиска */ onSwitchSearch(value: boolean): void { if (this.openable) this.open = value; @@ -64,18 +101,22 @@ export class SearchComponent implements OnInit, ControlValueAccessor { } } + /** Обработчик ввода текста */ onInput(event: Event): void { const value = (event.target as HTMLInputElement).value; this.onChange(value); this.appValueChange.emit(value); } + /** Обработчик потери фокуса */ onBlur(): void { this.onTouch(); } + /** Текущее значение поля */ value = ""; + // Методы ControlValueAccessor writeValue(value: string): void { setTimeout(() => { this.value = value; @@ -94,17 +135,20 @@ export class SearchComponent implements OnInit, ControlValueAccessor { this.onTouch = fn; } + /** Состояние блокировки */ disabled = false; setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } + /** Остановка всплытия события */ stopPropagation(event: Event): void { event.preventDefault(); event.stopPropagation(); } + /** Обработчик клика вне поля - сворачивает поиск */ onClickOutside(event: Event) { event.stopPropagation(); event.preventDefault(); 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 index 9c927f37d..5aea526b3 100644 --- a/projects/social_platform/src/app/ui/components/select/select.component.ts +++ b/projects/social_platform/src/app/ui/components/select/select.component.ts @@ -13,6 +13,25 @@ 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", @@ -28,22 +47,31 @@ import { ClickOutsideModule } from "ng-click-outside"; imports: [ClickOutsideModule, IconComponent], }) export class SelectComponent implements ControlValueAccessor { + /** Текст подсказки */ @Input() placeholder = ""; + + /** ID выбранной опции */ @Input() selectedId?: number; + + /** Массив доступных опций */ @Input({ required: true }) options: { value: string | number; label: string; id: number; }[] = []; + /** Состояние открытия выпадающего списка */ 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) { @@ -77,6 +105,7 @@ export class SelectComponent implements ControlValueAccessor { } } + /** Автоматический скролл к выделенному элементу */ trackHighlightScroll(): void { const ddElem = this.dropdown.nativeElement; @@ -94,8 +123,18 @@ export class SelectComponent implements ControlValueAccessor { } } - writeValue(id: number) { - this.selectedId = id; + // Методы 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; @@ -116,6 +155,7 @@ export class SelectComponent implements ControlValueAccessor { this.onTouched = fn; } + /** Обработчик выбора опции */ onUpdate(event: Event, id: number): void { event.stopPropagation(); if (this.disabled) { @@ -128,23 +168,28 @@ export class SelectComponent implements ControlValueAccessor { 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 { 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/snackbar/snackbar.component.ts b/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.ts index 8d9e0374c..0decd35d8 100644 --- a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.ts +++ b/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.ts @@ -7,6 +7,19 @@ import { Subscription } from "rxjs"; import { AnimationService } from "@ui/services/animation.service"; import { CommonModule } from "@angular/common"; +/** + * Компонент для отображения всплывающих уведомлений (snackbar). + * Подписывается на сервис уведомлений и отображает их с анимацией появления/исчезновения. + * Автоматически скрывает уведомления по истечении заданного времени. + * + * Функциональность: + * - Отображение списка активных уведомлений + * - Автоматическое скрытие по таймауту + * - Анимация появления и исчезновения + * - Возможность ручного закрытия уведомлений + * + * Не принимает входящих параметров - работает через сервис SnackbarService + */ @Component({ selector: "app-snackbar", templateUrl: "./snackbar.component.html", @@ -18,9 +31,13 @@ import { CommonModule } from "@angular/common"; export class SnackbarComponent implements OnInit, OnDestroy { constructor(private readonly snackbarService: SnackbarService) {} + /** Массив активных уведомлений */ snacks: Snack[] = []; + + /** Подписка на сервис уведомлений */ snackbar$?: Subscription; + /** Добавление нового уведомления */ private addNotification(snack: Snack): void { this.snacks.push(snack); @@ -29,14 +46,17 @@ export class SnackbarComponent implements OnInit, OnDestroy { } } + /** Подписка на уведомления при инициализации */ ngOnInit(): void { this.snackbar$ = this.snackbarService.snacks.subscribe(snack => this.addNotification(snack)); } + /** Отписка от уведомлений при уничтожении */ ngOnDestroy(): void { this.snackbar$?.unsubscribe(); } + /** Закрытие конкретного уведомления */ onClose(snack: Snack): void { this.snacks = this.snacks.filter(({ id }) => id !== snack.id); } diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.ts b/projects/social_platform/src/app/ui/components/switch/switch.component.ts index 566a790c0..e333af049 100644 --- a/projects/social_platform/src/app/ui/components/switch/switch.component.ts +++ b/projects/social_platform/src/app/ui/components/switch/switch.component.ts @@ -2,6 +2,23 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +/** + * Компонент переключателя (switch) для булевых значений. + * Альтернатива чекбоксу с современным дизайном в виде ползунка. + * + * Входящие параметры: + * - checked: состояние переключателя (включен/выключен) + * + * События: + * - checkedChange: изменение состояния переключателя + * + * Возвращает: + * - boolean значение через событие checkedChange + * + * Использование: + * - Для настроек вкл/выкл + * - Булевых переключателей в формах + */ @Component({ selector: "app-switch", templateUrl: "./switch.component.html", @@ -9,7 +26,10 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; standalone: true, }) export class SwitchComponent implements OnInit { + /** Состояние переключателя */ @Input({ required: true }) checked!: boolean; + + /** Событие изменения состояния */ @Output() checkedChange = new EventEmitter(); ngOnInit(): void {} 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 index 9df1d5a38..76a38c30c 100644 --- a/projects/social_platform/src/app/ui/components/tag/tag.component.ts +++ b/projects/social_platform/src/app/ui/components/tag/tag.component.ts @@ -2,6 +2,19 @@ import { Component, Input, OnInit } from "@angular/core"; +/** + * Компонент тега для отображения статусов, категорий или меток. + * Поддерживает различные цветовые схемы для визуального разделения типов. + * + * Входящие параметры: + * - color: цветовая схема тега ("primary" | "accent" | "complete") + * + * Использование: + * - Отображение статусов задач, заказов + * - Категоризация контента + * - Визуальные метки и индикаторы + * - Контент передается через ng-content + */ @Component({ selector: "app-tag", templateUrl: "./tag.component.html", @@ -11,6 +24,7 @@ import { Component, Input, OnInit } from "@angular/core"; export class TagComponent implements OnInit { constructor() {} + /** Цветовая схема тега */ @Input() color: "primary" | "accent" | "complete" = "primary"; ngOnInit(): void {} diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.ts b/projects/social_platform/src/app/ui/components/textarea/textarea.component.ts index 6ff6a9a69..daf85ef94 100644 --- a/projects/social_platform/src/app/ui/components/textarea/textarea.component.ts +++ b/projects/social_platform/src/app/ui/components/textarea/textarea.component.ts @@ -4,6 +4,24 @@ import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from "@ang import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { AutosizeModule } from "ngx-autosize"; +/** + * Компонент многострочного поля ввода с автоматическим изменением размера. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Автоматически подстраивает высоту под содержимое. + * + * Входящие параметры: + * - placeholder: текст подсказки в поле + * - type: тип поля (наследуется от input, но используется как textarea) + * - error: состояние ошибки для стилизации + * - mask: маска для форматирования (наследуется, но не используется) + * - text: значение текста (двустороннее связывание) + * + * События: + * - textChange: изменение текста в поле + * + * Возвращает: + * - Введенный многострочный текст через ControlValueAccessor + */ @Component({ selector: "app-textarea", templateUrl: "./textarea.component.html", @@ -19,10 +37,19 @@ import { AutosizeModule } from "ngx-autosize"; imports: [AutosizeModule], }) export class TextareaComponent implements OnInit, ControlValueAccessor { + /** Текст подсказки */ @Input() placeholder = ""; + + /** Тип поля (наследуется от базового компонента) */ @Input() type: "text" | "password" | "email" = "text"; + + /** Состояние ошибки */ @Input() error = false; + + /** Маска (наследуется, но не используется) */ @Input() mask = ""; + + /** Двустороннее связывание текста */ @Input() set text(value: string) { this.value = value; } @@ -31,24 +58,29 @@ export class TextareaComponent implements OnInit, ControlValueAccessor { return this.value; } + /** Событие изменения текста */ @Output() textChange = new EventEmitter(); constructor() {} ngOnInit(): void {} + /** Обработчик ввода текста */ onInput(event: Event): void { const value = (event.target as HTMLInputElement).value; this.onChange(value); this.textChange.emit(value); } + /** Обработчик потери фокуса */ onBlur(): void { this.onTouch(); } + /** Текущее значение поля */ value = ""; + // Методы ControlValueAccessor writeValue(value: string): void { this.value = value; } @@ -65,12 +97,14 @@ export class TextareaComponent implements OnInit, ControlValueAccessor { this.onTouch = fn; } + /** Состояние блокировки */ disabled = false; setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } + /** Предотвращение перехода на новую строку по Enter */ preventEnter(event: Event) { event.preventDefault(); } 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 index b9b8c7448..cc34f319f 100644 --- 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 @@ -7,6 +7,24 @@ import { nanoid } from "nanoid"; import { IconComponent } from "@ui/components"; import { SlicePipe } from "@angular/common"; +/** + * Компонент для загрузки файлов с предварительным просмотром. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Поддерживает ограничения по типу файлов и показывает состояние загрузки. + * + * Входящие параметры: + * - accept: ограничения по типу файлов (MIME-типы) + * - error: состояние ошибки для стилизации + * + * Возвращает: + * - URL загруженного файла через ControlValueAccessor + * + * Функциональность: + * - Drag & drop и выбор файлов через диалог + * - Предварительный просмотр выбранного файла + * - Индикатор загрузки + * - Возможность удаления загруженного файла + */ @Component({ selector: "app-upload-file", templateUrl: "./upload-file.component.html", @@ -24,15 +42,21 @@ import { SlicePipe } from "@angular/common"; export class UploadFileComponent implements OnInit, ControlValueAccessor { constructor(private fileService: FileService) {} + /** Ограничения по типу файлов */ @Input() accept = ""; + + /** Состояние ошибки */ @Input() error = false; ngOnInit(): void {} + /** Уникальный ID для элемента input */ controlId = nanoid(3); + /** URL загруженного файла */ value = ""; + // Методы ControlValueAccessor writeValue(url: string) { this.value = url; } @@ -49,8 +73,10 @@ export class UploadFileComponent implements OnInit, ControlValueAccessor { this.onChange = fn; } + /** Состояние загрузки */ loading = false; + /** Обработчик загрузки файла */ onUpdate(event: Event): void { const files = (event.currentTarget as HTMLInputElement).files; if (!files?.length) { @@ -67,6 +93,7 @@ export class UploadFileComponent implements OnInit, ControlValueAccessor { }); } + /** Обработчик удаления файла */ onRemove(): void { this.fileService.deleteFile(this.value).subscribe({ next: () => { diff --git a/projects/social_platform/src/app/ui/directives/editor-submit-button.directive.ts b/projects/social_platform/src/app/ui/directives/editor-submit-button.directive.ts index 1ccaa0c94..4585a23da 100644 --- a/projects/social_platform/src/app/ui/directives/editor-submit-button.directive.ts +++ b/projects/social_platform/src/app/ui/directives/editor-submit-button.directive.ts @@ -4,6 +4,15 @@ import { AfterViewInit, Directive, Input, OnDestroy, ViewContainerRef } from "@a import { fromEvent, Subscription } from "rxjs"; import { containerSm } from "@utils/responsive"; +/** + * Директива для управления позиционированием кнопки сохранения в редакторе + * при скролле страницы. Кнопка остается видимой, следуя за скроллом контейнера. + * + * Принимает: + * - containerSelector: селектор контейнера для отслеживания позиции (по умолчанию "profile") + * + * Использование: добавить атрибут [appEditorSubmitButton] к элементу кнопки + */ @Directive({ selector: "[appEditorSubmitButton]", standalone: true, @@ -11,8 +20,10 @@ import { containerSm } from "@utils/responsive"; export class EditorSubmitButtonDirective implements AfterViewInit, OnDestroy { constructor(private readonly viewRef: ViewContainerRef) {} + /** Селектор контейнера для отслеживания позиции */ @Input() containerSelector = "profile"; + /** Инициализация отслеживания скролла после загрузки представления */ ngAfterViewInit(): void { if (window.innerWidth > containerSm) setTimeout(() => { @@ -20,12 +31,14 @@ export class EditorSubmitButtonDirective implements AfterViewInit, OnDestroy { }, 0); } + /** Очистка подписки при уничтожении компонента */ ngOnDestroy(): void { this.scrollSubscription$?.unsubscribe(); } scrollSubscription$?: Subscription; + /** Инициализация отслеживания скролла и позиционирования кнопки */ initSaveButtonScroller(): void { const scroller = document.querySelector(".office__body"); const container = document.querySelector(this.containerSelector); diff --git a/projects/social_platform/src/app/ui/models/modal.service.ts b/projects/social_platform/src/app/ui/models/modal.service.ts index 211584349..bd168e2cc 100644 --- a/projects/social_platform/src/app/ui/models/modal.service.ts +++ b/projects/social_platform/src/app/ui/models/modal.service.ts @@ -1,22 +1,42 @@ /** @format */ import { Injectable } from "@angular/core"; -import { BehaviorSubject, Observable, Observer } from "rxjs"; +import { BehaviorSubject, Observable, type Observer } from "rxjs"; +/** + * Сервис для управления модальными окнами подтверждения удаления. + * Предоставляет методы для отображения диалогов подтверждения и управления их состоянием. + * + * Методы: + * - confirmDelete: показывает диалог подтверждения удаления + * + * Возвращает: + * - Observable с результатом выбора пользователя (true - подтвердить, false - отменить) + */ @Injectable({ providedIn: "root", }) export class ModalService { constructor() {} + /** Observer для обработки результата подтверждения */ confirmObserver?: Observer; + + /** Настройки текста для диалога подтверждения */ confirmSettings = new BehaviorSubject<{ mainText: string; subText: string }>({ mainText: "", subText: "", }); + /** Состояние отображения диалога подтверждения */ confirmState = false; + /** + * Показывает диалог подтверждения удаления + * @param mainText - основной текст сообщения + * @param subText - дополнительный текст сообщения + * @returns Observable - результат выбора пользователя + */ confirmDelete(mainText: string, subText: string): Observable { return new Observable(observer => { this.confirmState = true; diff --git a/projects/social_platform/src/app/ui/models/snack.model.ts b/projects/social_platform/src/app/ui/models/snack.model.ts index 1624f9f2e..c411c6715 100644 --- a/projects/social_platform/src/app/ui/models/snack.model.ts +++ b/projects/social_platform/src/app/ui/models/snack.model.ts @@ -1,8 +1,25 @@ /** @format */ +/** + * Модель для уведомлений snackbar. + * Определяет структуру данных для всплывающих уведомлений в приложении. + * + * Свойства: + * - id: уникальный идентификатор уведомления + * - text: текст уведомления для отображения + * - timeout: время отображения в миллисекундах + * - type: тип уведомления (ошибка, успех, информация) + */ export class Snack { + /** Уникальный идентификатор уведомления */ id!: string; + + /** Текст уведомления */ text!: string; + + /** Время отображения в миллисекундах */ timeout!: number; + + /** Тип уведомления */ type!: "error" | "success" | "info"; } diff --git a/projects/social_platform/src/app/ui/pipes/file-type.pipe.ts b/projects/social_platform/src/app/ui/pipes/file-type.pipe.ts index 2ce2a0c41..af9c879f4 100644 --- a/projects/social_platform/src/app/ui/pipes/file-type.pipe.ts +++ b/projects/social_platform/src/app/ui/pipes/file-type.pipe.ts @@ -2,32 +2,50 @@ import { Pipe, PipeTransform } from "@angular/core"; +/** + * Pipe для определения типа файла по MIME-типу. + * Преобразует MIME-тип в читаемое название типа файла. + * + * Принимает: + * - value: MIME-тип файла (string) + * + * Возвращает: + * - string: сокращенное название типа файла (jpeg, png, pdf, mp4 и т.д.) + * + * Использование: {{ mimeType | fileType }} + */ @Pipe({ name: "fileType", standalone: true, }) export class FileTypePipe implements PipeTransform { + /** Карта соответствия MIME-типов к читаемым названиям */ private readonly typeMap: Record = { - // images + // изображения "image/jpeg": "jpeg", "image/png": "png", "image/wepb": "webp", "image/svg+xml": "svg", "image/*": "image", - // media + // медиа "video/mp4": "mp4", "audio/mpeg": "mp3", - // docs, + // документы "application/pdf": "pdf", "application/doc": "doc", "text/plain": "txt", "text/csv": "csv", "application/vnd.ms-powerpoint": "ppt", - // arch + // архивы "application/zip": "arch", "*": "file", }; + /** + * Преобразует MIME-тип в читаемое название типа файла + * @param value - MIME-тип файла + * @returns сокращенное название типа файла + */ transform(value: string): string { if (value.includes("image/")) { return this.typeMap[value] ?? this.typeMap["image/*"]; diff --git a/projects/social_platform/src/app/ui/services/animation.service.ts b/projects/social_platform/src/app/ui/services/animation.service.ts index e68b8da44..fe41d9df9 100644 --- a/projects/social_platform/src/app/ui/services/animation.service.ts +++ b/projects/social_platform/src/app/ui/services/animation.service.ts @@ -3,12 +3,26 @@ import { Injectable } from "@angular/core"; import { animate, AnimationTriggerMetadata, style, transition, trigger } from "@angular/animations"; +/** + * Сервис для предоставления готовых анимаций Angular. + * Содержит статические методы для получения анимационных триггеров. + * + * Доступные анимации: + * - slideInOut: анимация появления/исчезновения с эффектом скольжения + * + * Возвращает: + * - AnimationTriggerMetadata: готовый анимационный триггер для использования в компонентах + */ @Injectable({ providedIn: "root", }) export class AnimationService { constructor() {} + /** + * Анимация появления и исчезновения элемента с эффектом скольжения справа + * @returns AnimationTriggerMetadata - анимационный триггер + */ public static get slideInOut(): AnimationTriggerMetadata { return trigger("slideInOut", [ transition(":enter", [ diff --git a/projects/social_platform/src/app/ui/services/snackbar.service.ts b/projects/social_platform/src/app/ui/services/snackbar.service.ts index 6149a38cf..e8be8bb55 100644 --- a/projects/social_platform/src/app/ui/services/snackbar.service.ts +++ b/projects/social_platform/src/app/ui/services/snackbar.service.ts @@ -5,23 +5,54 @@ import { distinctUntilChanged, Subject } from "rxjs"; import { Snack } from "@ui/models/snack.model"; import { nanoid } from "nanoid"; +/** + * Сервис для управления всплывающими уведомлениями (snackbar). + * Предоставляет методы для отображения различных типов уведомлений. + * + * Методы: + * - success: показывает успешное уведомление (зеленое) + * - error: показывает уведомление об ошибке (красное) + * - info: показывает информационное уведомление (синее) + * + * Все методы принимают: + * - text: текст уведомления + * - options: настройки (timeout - время отображения в мс, по умолчанию 5000) + */ @Injectable({ providedIn: "root", }) export class SnackbarService { constructor() {} + /** Subject для управления потоком уведомлений */ private readonly snacks$ = new Subject(); + + /** Observable поток уведомлений для подписки компонентами */ snacks = this.snacks$.asObservable().pipe(distinctUntilChanged()); + /** + * Показывает успешное уведомление + * @param text - текст уведомления + * @param options - настройки отображения + */ success(text: string, options: { timeout: number } = { timeout: 5000 }): void { this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "success" }); } + /** + * Показывает уведомление об ошибке + * @param text - текст уведомления + * @param options - настройки отображения + */ error(text: string, options: { timeout: number } = { timeout: 5000 }): void { this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "error" }); } + /** + * Показывает информационное уведомление + * @param text - текст уведомления + * @param options - настройки отображения + */ info(text: string, options: { timeout: number } = { timeout: 5000 }): void { this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "info" }); } diff --git a/projects/social_platform/src/app/utils/calculateProgress.ts b/projects/social_platform/src/app/utils/calculateProgress.ts index f04eeb461..5fd797997 100644 --- a/projects/social_platform/src/app/utils/calculateProgress.ts +++ b/projects/social_platform/src/app/utils/calculateProgress.ts @@ -3,6 +3,23 @@ import { User } from "@auth/models/user.model"; import { fieldsProfile } from "projects/core/src/consts/fieldsProfile"; +/** + * @fileoverview Функция для расчета прогресса заполнения профиля пользователя + * + * Этот модуль экспортирует функцию, которая вычисляет процент заполнения + * профиля пользователя на основе заполненных полей. + * + * @param {User} user - Объект пользователя, содержащий данные профиля + * @returns {number} - Процент заполнения профиля (от 0 до 100) + * + * Принцип работы: + * 1. Проходит по всем полям профиля из константы fieldsProfile + * 2. Проверяет заполнено ли каждое поле: + * - Для массивов: проверяет наличие хотя бы одного элемента + * - Для строк: проверяет, что строка не пустая + * 3. Вычисляет процент заполненных полей от общего количества полей + * 4. Возвращает округленное значение процента + */ export const calculateProfileProgress = (user: User) => { let filledCount = 0; diff --git a/projects/social_platform/src/app/utils/capitalize-string.ts b/projects/social_platform/src/app/utils/capitalize-string.ts index 6d0a694b3..6bd971fc1 100644 --- a/projects/social_platform/src/app/utils/capitalize-string.ts +++ b/projects/social_platform/src/app/utils/capitalize-string.ts @@ -1,4 +1,23 @@ -/** @format */ +/** + * Эта функция преобразует строку так, что первая буква каждого слова становится + * заглавной, а остальные - строчными. + * + * + * Принцип работы: + * 1. Разбивает строку на слова по пробелам + * 2. Если строка состоит из одного слова, делает первую букву заглавной, а остальные - строчными + * 3. Если строка состоит из нескольких слов, для каждого слова делает первую букву заглавной, + * а остальные - строчными, затем соединяет слова обратно с пробелами + * + * Пример использования: + * capitalizeString("иван иванов") // "Иван Иванов" + * capitalizeString("МОСКВА") // "Москва" + * + * @format + * @fileoverview Функция для преобразования строки к формату с заглавной буквы + * @param {string} string - Исходная строка для преобразования + * @returns {string} - Преобразованная строка с заглавными первыми буквами каждого слова + */ export function capitalizeString(string: string): string { const stringArray = string.split(" "); diff --git a/projects/social_platform/src/app/utils/expand-element.ts b/projects/social_platform/src/app/utils/expand-element.ts index e305b2a00..ce97cf03d 100644 --- a/projects/social_platform/src/app/utils/expand-element.ts +++ b/projects/social_platform/src/app/utils/expand-element.ts @@ -1,4 +1,22 @@ -/** @format */ +/** + * Эта функция реализует анимированное разворачивание или сворачивание HTML элемента + * с использованием CSS-переходов. + * + * + * Принцип работы: + * 1. Запоминает начальную высоту элемента + * 2. Добавляет или удаляет класс расширения в зависимости от текущего состояния + * 3. Измеряет новую высоту после изменения класса + * 4. Устанавливает начальную высоту и запускает анимацию перехода к новой высоте + * 5. После завершения анимации удаляет явно заданную высоту, чтобы элемент мог + * дальше изменяться естественным образом + * + * @format + * @fileoverview Функция для плавного разворачивания/сворачивания HTML элемента + * @param {HTMLElement} elem - HTML элемент, который нужно развернуть/свернуть + * @param {string} expandedClass - CSS класс, который добавляется/удаляется для изменения состояния + * @param {boolean} isExpanded - Текущее состояние элемента (true - развернут, false - свернут) + */ export function expandElement(elem: HTMLElement, expandedClass: string, isExpanded: boolean) { const startHeight = window.getComputedStyle(elem).height; diff --git a/projects/social_platform/src/app/utils/responsive.ts b/projects/social_platform/src/app/utils/responsive.ts index e2e8e5313..576cbefc9 100644 --- a/projects/social_platform/src/app/utils/responsive.ts +++ b/projects/social_platform/src/app/utils/responsive.ts @@ -1,4 +1,12 @@ -/** @format */ +/** + * Этот файл содержит константы, используемые для определения + * контрольных точек (breakpoints) в адаптивном дизайне. + * + * @format + * @fileoverview Константы для адаптивного дизайна + * @constant containerSm - Малый размер контейнера (680 пикселей) + * @constant containerMd - Средний размер контейнера (1280 пикселей) + */ export const containerSm = 680; export const containerMd = 1280; diff --git a/projects/social_platform/src/app/utils/stripNull.ts b/projects/social_platform/src/app/utils/stripNull.ts index 4c161452f..7be962e70 100644 --- a/projects/social_platform/src/app/utils/stripNull.ts +++ b/projects/social_platform/src/app/utils/stripNull.ts @@ -1,4 +1,22 @@ -/** @format */ +/** + * Эта функция создает новый объект, исключая из исходного объекта все свойства, + * значения которых равны null, undefined или пустой строке. + * + * + * Принцип работы: + * 1. Преобразует объект в массив пар [ключ, значение] + * 2. Фильтрует пары, оставляя только те, где значение не null, не undefined и не пустая строка + * 3. Преобразует отфильтрованный массив обратно в объект + * + * Пример использования: + * const user = { name: "Иван", email: "", age: null, city: "Москва" }; + * const cleanUser = stripNullish(user); // { name: "Иван", city: "Москва" } + * + * @format + * @fileoverview Функция для удаления пустых и null значений из объекта + * @param {T} obj - Исходный объект, из которого нужно удалить пустые значения + * @returns {Partial} - Новый объект без пустых значений + */ export const stripNullish = (obj: T): Partial => (Object.entries(obj) as [keyof T, any][]) diff --git a/projects/social_platform/src/app/utils/transformYear.ts b/projects/social_platform/src/app/utils/transformYear.ts index abbd71a70..9d81c6cdc 100644 --- a/projects/social_platform/src/app/utils/transformYear.ts +++ b/projects/social_platform/src/app/utils/transformYear.ts @@ -1,4 +1,21 @@ -/** @format */ +/** + * Эта функция принимает строку, содержащую год, и преобразует её в числовое значение, + * ограничивая длину до 5 символов. + * + * + * Принцип работы: + * 1. Преобразует входное значение в строку (если оно уже не строка) + * 2. Берет первые 5 символов строки (ограничение длины) + * 3. Преобразует полученную строку в число и возвращает результат + * + * Примечание: Функция не выполняет проверку корректности года или дополнительную валидацию. + * Она просто преобразует строковое представление в число. + * + * @format + * @fileoverview Функция для преобразования строкового представления года в число + * @param {string} value - Строка, содержащая год + * @returns {number} - Числовое представление года + */ export const transformYearStringToNumber = (value: string) => { const yearString = value.toString().slice(0, 5); diff --git a/projects/social_platform/src/app/utils/yearRangeValidators.ts b/projects/social_platform/src/app/utils/yearRangeValidators.ts index 17b75d18b..3307f67a0 100644 --- a/projects/social_platform/src/app/utils/yearRangeValidators.ts +++ b/projects/social_platform/src/app/utils/yearRangeValidators.ts @@ -2,6 +2,28 @@ import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; +/** + * @fileoverview Валидатор для проверки диапазона годов в Angular формах + * + * Этот модуль предоставляет функцию-валидатор для Angular форм, которая проверяет, + * что год начала меньше или равен году окончания. + * + * @param {string} entryYearValue - Имя контрола формы, содержащего год начала + * @param {string} completionYearValue - Имя контрола формы, содержащего год окончания + * @returns {ValidatorFn} - Функция-валидатор для Angular формы + * + * Принцип работы: + * 1. Получает значения контролов года начала и года окончания из формы + * 2. Проверяет, что оба значения существуют и не null + * 3. Сравнивает значения и возвращает ошибку, если год начала больше года окончания + * 4. Возвращает null, если валидация прошла успешно + * + * Пример использования: + * this.form = this.fb.group({ + * entryYear: [null], + * completionYear: [null] + * }, { validators: yearRangeValidators('entryYear', 'completionYear') }); + */ export const yearRangeValidators = ( entryYearValue: string, completionYearValue: string diff --git a/projects/social_platform/src/assets/icons/svg/accent-error.svg b/projects/social_platform/src/assets/icons/svg/accent-error.svg new file mode 100644 index 000000000..74a3eb41c --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/accent-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg b/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg index c2f0bd641..daca6e2e3 100644 --- a/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg +++ b/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/projects/social_platform/src/assets/images/projects/detail/bid.svg b/projects/social_platform/src/assets/images/projects/detail/bid.svg new file mode 100644 index 000000000..7f2ca7884 --- /dev/null +++ b/projects/social_platform/src/assets/images/projects/detail/bid.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/social_platform/src/assets/images/projects/edit/additional.svg b/projects/social_platform/src/assets/images/projects/edit/additional.svg new file mode 100644 index 000000000..4568aaa06 --- /dev/null +++ b/projects/social_platform/src/assets/images/projects/edit/additional.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/projects/social_platform/src/assets/images/projects/shared/edit_disable.svg b/projects/social_platform/src/assets/images/projects/shared/edit_disable.svg new file mode 100644 index 000000000..1d923904f --- /dev/null +++ b/projects/social_platform/src/assets/images/projects/shared/edit_disable.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/ui/README.md b/projects/ui/README.md index 4e0ece8c0..5eccadf8a 100644 --- a/projects/ui/README.md +++ b/projects/ui/README.md @@ -1,27 +1,342 @@ -# Ui +# UI Library - Библиотека компонентов пользовательского интерфейса -This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.0.0. +Эта библиотека содержит переиспользуемые Angular компоненты для построения пользовательского интерфейса приложения. Библиотека разделена на две основные категории: компоненты макета (layout) и примитивные компоненты (primitives). -## Code scaffolding +## Структура проекта -Run `ng generate component component-name --project ui` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ui`. +\`\`\` +src/ +├── lib/ +│ ├── models/ # Модели данных +│ │ └── user.model.ts # Модель пользователя +│ └── components/ # Компоненты UI +│ ├── layout/ # Компоненты макета +│ │ ├── sidebar/ # Боковая панель навигации +│ │ ├── profile-control-panel/ # Панель управления профилем +│ │ ├── profile-info/ # Информация о профиле +│ │ ├── invite-manage-card/ # Карточка управления приглашениями +│ │ └── subscription-plans/ # Планы подписки +│ └── primitives/ # Базовые компоненты +│ ├── avatar/ # Аватар пользователя +│ ├── back/ # Кнопка "Назад" +│ └── icon/ # Иконки +└── public-api.ts # Публичный API библиотеки +\`\`\` -> Note: Don't forget to add `--project ui` or else it will be added to the default project in your `angular.json` file. +## Установка и использование -## Build +### Импорт компонентов -Run `ng build ui` to build the project. The build artifacts will be stored in the `dist/` directory. +\`\`\`typescript +import { +SidebarComponent, +ProfileControlPanelComponent, +AvatarComponent, +IconComponent +} from '@uilib'; +\`\`\` -## Publishing +### Использование в шаблонах -After building your library with `ng build ui`, go to the dist folder `cd dist/ui` and run `npm publish`. +\`\`\`html -## Running unit tests + -Run `ng test ui` to execute the unit tests via [Karma](https://karma-runner.github.io). + + -## Further help + -To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. + + +\`\`\` + +## Компоненты макета (Layout Components) + +### SidebarComponent + +Боковая панель навигации с логотипом и списком навигационных элементов. + +**Входные параметры:** + +- `navItems: NavItem[]` - массив элементов навигации +- `logoSrc: string` - путь к логотипу (обязательный) + +**Интерфейс NavItem:** +\`\`\`typescript +interface NavItem { +link: string; // Ссылка для роутинга +icon: string; // Название иконки +name: string; // Отображаемое имя +} +\`\`\` + +### ProfileControlPanelComponent + +Панель управления профилем с уведомлениями, чатами и кнопкой выхода. + +**Входные параметры:** + +- `user: User | null` - данные пользователя (обязательный) +- `invites: Invite[]` - массив приглашений (обязательный) +- `hasNotifications: boolean` - наличие уведомлений (обязательный) +- `hasUnreads: boolean` - наличие непрочитанных сообщений (обязательный) + +**Выходные события:** + +- `acceptInvite: EventEmitter` - принятие приглашения +- `rejectInvite: EventEmitter` - отклонение приглашения +- `logout: EventEmitter` - выход из системы + +### ProfileInfoComponent + +Отображение информации о профиле пользователя. + +**Входные параметры:** + +- `user: User` - данные пользователя (обязательный) + +**Выходные события:** + +- `logout: EventEmitter` - выход из системы + +### InviteManageCardComponent + +Карточка для управления приглашениями в проекты. + +**Входные параметры:** + +- `invite: Invite` - данные приглашения (обязательный) + +**Выходные события:** + +- `accept: EventEmitter` - принятие приглашения +- `reject: EventEmitter` - отклонение приглашения + +### SubscriptionPlansComponent + +Модальное окно с планами подписки. + +**Входные параметры:** + +- `open: boolean` - состояние открытия модального окна +- `subscriptionPlans: SubscriptionPlan[]` - массив планов подписки (обязательный) + +**Выходные события:** + +- `openChange: EventEmitter` - изменение состояния модального окна + +## Примитивные компоненты (Primitive Components) + +### AvatarComponent + +Компонент для отображения аватара пользователя с поддержкой индикатора онлайн-статуса. + +**Входные параметры:** + +- `url?: string` - URL изображения аватара +- `size: number = 50` - размер аватара в пикселях +- `hasBorder: boolean = false` - наличие рамки +- `isOnline: boolean = false` - онлайн статус +- `onlineBadgeSize: number = 16` - размер индикатора онлайн +- `onlineBadgeBorder: number = 3` - толщина рамки индикатора +- `onlineBadgeOffset: number = 0` - смещение индикатора + +### BackComponent + +Кнопка "Назад" для навигации. + +**Входные параметры:** + +- `path?: string` - путь для перехода (опциональный, по умолчанию используется history.back()) + +### IconComponent + +Компонент для отображения SVG иконок из спрайта. + +**Входные параметры:** + +- `icon: string` - название иконки (обязательный) +- `appSquare?: string` - размер квадратной иконки +- `appWidth?: string` - ширина иконки +- `appHeight?: string` - высота иконки +- `appViewBox?: string` - viewBox для SVG + +## Модели данных + +### User + +Модель пользователя системы. + +\`\`\`typescript +interface User { +id: number; // Уникальный идентификатор +email: string; // Email адрес +firstName: string; // Имя +lastName: string; // Фамилия +avatar: string; // URL аватара +isOnline: boolean; // Онлайн статус +isActive: boolean; // Активность аккаунта +onboardingStage: number | null; // Этап онбординга +speciality: string; // Специальность +userType: number; // Тип пользователя +timeCreated: string; // Время создания +timeUpdated: string; // Время обновления +verificationDate: string; // Дата верификации +} +\`\`\` + +## Стилизация + +Библиотека использует CSS переменные для темизации: + +\`\`\`css +:root { +--white: #ffffff; +--black: #000000; +--accent: #your-accent-color; +--accent-dark: #your-accent-dark-color; +--grey-for-text: #your-grey-color; +--light-gray: #your-light-gray; +--red: #your-red-color; +--gold-dark: #your-gold-color; +/_ и другие переменные _/ +} +\`\`\` + +## Зависимости + +Библиотека требует следующие зависимости: + +- `@angular/core` +- `@angular/common` +- `@angular/router` +- `ng-click-outside` - для обработки кликов вне элемента +- Различные внутренние модули проекта (`@auth/services`, `@office/models`, etc.) + +## Тестирование + +Каждый компонент имеет соответствующий `.spec.ts` файл с unit тестами. Тесты используют Angular Testing Utilities и Jasmine. + +Для запуска тестов: +\`\`\`bash +ng test ui +\`\`\` + +## Сборка + +Для сборки библиотеки: +\`\`\`bash +ng build ui +\`\`\` + +Результат сборки будет в папке `dist/ui/`. + +## Примеры использования + +### Базовая боковая панель + +\`\`\`typescript +// component.ts +export class AppComponent { +logoSrc = 'assets/logo.png'; +navItems: NavItem[] = [ +{ link: '/dashboard', icon: 'dashboard', name: 'Панель управления' }, +{ link: '/projects', icon: 'projects', name: 'Проекты' }, +{ link: '/profile', icon: 'user', name: 'Профиль' } +]; +} +\`\`\` + +\`\`\`html + + + + + + + + + + + + +\`\`\` + +### Использование аватара с онлайн статусом + +\`\`\`html + + +\`\`\` + +/\*\* + +- Модель пользователя системы +- Содержит всю необходимую информацию о пользователе для отображения в UI компонентах + _/ + export interface User { + /\*\* Уникальный идентификатор пользователя _/ + id: number; + +/\*_ Email адрес пользователя _/ +email: string; + +/\*_ Имя пользователя _/ +firstName: string; + +/\*_ Фамилия пользователя _/ +lastName: string; + +/\*_ URL изображения аватара пользователя _/ +avatar: string; + +/\*_ Статус онлайн (true - пользователь в сети, false - оффлайн) _/ +isOnline: boolean; + +/\*_ Статус активности аккаунта (true - активен, false - заблокирован/неактивен) _/ +isActive: boolean; + +/\*_ Текущий этап процесса онбординга (null если онбординг завершен) _/ +onboardingStage: number | null; + +/\*_ Специальность/профессия пользователя _/ +speciality: string; + +/\*_ Тип пользователя (числовой код, определяющий роль в системе) _/ +userType: number; + +/\*_ Дата и время создания аккаунта в ISO формате _/ +timeCreated: string; + +/\*_ Дата и время последнего обновления профиля в ISO формате _/ +timeUpdated: string; + +/\*_ Дата верификации аккаунта в ISO формате _/ +verificationDate: string; +} diff --git a/projects/ui/src/lib/components/index.ts b/projects/ui/src/lib/components/index.ts index 69bd17089..fbdd23120 100644 --- a/projects/ui/src/lib/components/index.ts +++ b/projects/ui/src/lib/components/index.ts @@ -1,4 +1,12 @@ -/** @format */ +/** + * Индексный файл для всех UI компонентов + * + * Реэкспортирует компоненты из подкатегорий: + * - layout: компоненты макета (sidebar, profile panels, etc.) + * - primitives: базовые компоненты (avatar, icons, buttons, etc.) + * + * @format + */ export * from "./layout"; export * from "./primitives"; diff --git a/projects/ui/src/lib/components/layout/index.ts b/projects/ui/src/lib/components/layout/index.ts index b8780b16c..aa99379e9 100644 --- a/projects/ui/src/lib/components/layout/index.ts +++ b/projects/ui/src/lib/components/layout/index.ts @@ -1,4 +1,14 @@ -/** @format */ +/** + * Индексный файл для компонентов макета + * + * Экспортирует все компоненты, связанные с макетом приложения: + * - Боковая панель навигации + * - Панели управления профилем + * - Карточки приглашений + * - Модальные окна подписок + * + * @format + */ export * from "./invite-manage-card/invite-manage-card.component"; export * from "./profile-control-panel/profile-control-panel.component"; diff --git a/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.ts b/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.ts index 709e43c4a..e24689e84 100644 --- a/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.ts +++ b/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.ts @@ -1,12 +1,30 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { Invite } from "@models/invite.model"; +import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; +import type { Invite } from "@models/invite.model"; import { DayjsPipe } from "projects/core"; import { ButtonComponent } from "@ui/components"; import { RouterLink } from "@angular/router"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +/** + * Компонент карточки управления приглашением + * + * Отображает информацию о приглашении в проект: + * - Аватары пользователя и проекта + * - Текст приглашения с ссылками на профиль и проект + * - Время создания приглашения + * - Кнопки принятия и отклонения + * + * @example + * \`\`\`html + * + * + * \`\`\` + */ @Component({ selector: "app-invite-manage-card", templateUrl: "./invite-manage-card.component.html", @@ -17,8 +35,13 @@ import { AvatarComponent } from "@ui/components/avatar/avatar.component"; export class InviteManageCardComponent implements OnInit { constructor() {} + /** Данные приглашения для отображения */ @Input({ required: true }) invite!: Invite; + + /** Событие принятия приглашения (передает ID приглашения) */ @Output() accept = new EventEmitter(); + + /** Событие отклонения приглашения (передает ID приглашения) */ @Output() reject = new EventEmitter(); ngOnInit(): void {} diff --git a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts index 589f360fe..86cdcd27c 100644 --- a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts +++ b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts @@ -4,10 +4,30 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter } from "@angular/core"; import { InviteManageCardComponent, ProfileInfoComponent, IconComponent } from "@uilib"; import { ClickOutsideModule } from "ng-click-outside"; -import { Invite } from "@office/models/invite.model"; +import type { Invite } from "@office/models/invite.model"; import { RouterLink } from "@angular/router"; -import { User } from "../../../models/user.model"; +import type { User } from "../../../models/user.model"; +/** + * Компонент панели управления профилем + * + * Отображает кнопки для уведомлений, чатов и выхода из системы. + * Включает выпадающую панель с уведомлениями и приглашениями. + * Показывает информацию о текущем пользователе. + * + * @example + * \`\`\`html + * + * + * \`\`\` + */ @Component({ selector: "app-profile-control-panel", standalone: true, @@ -24,26 +44,42 @@ import { User } from "../../../models/user.model"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProfileControlPanelComponent { + /** Данные текущего пользователя */ @Input({ required: true }) user!: User | null; + /** Массив приглашений пользователя */ @Input({ required: true }) invites!: Invite[]; + /** Флаг наличия уведомлений */ @Input({ required: true }) hasNotifications = false; + /** Флаг наличия непрочитанных сообщений */ @Input({ required: true }) hasUnreads = false; + /** Событие принятия приглашения (передает ID приглашения) */ @Output() acceptInvite = new EventEmitter(); + /** Событие отклонения приглашения (передает ID приглашения) */ @Output() rejectInvite = new EventEmitter(); + /** Событие выхода из системы */ @Output() logout = new EventEmitter(); + /** + * Проверяет наличие неотвеченных приглашений + * @returns true если есть приглашения без ответа + */ get hasInvites(): boolean { return !!this.invites.filter(invite => invite.isAccepted === null).length; } + /** Флаг отображения панели уведомлений */ showNotifications = false; + /** + * Обработчик клика вне панели уведомлений + * Скрывает панель уведомлений + */ onClickOutside() { this.showNotifications = false; } diff --git a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts index b202cf957..50ad1fc23 100644 --- a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts +++ b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts @@ -1,11 +1,25 @@ /** @format */ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; import { Router, RouterLink } from "@angular/router"; import { DayjsPipe } from "projects/core"; import { AvatarComponent, IconComponent } from "@uilib"; -import { User } from "../../../models/user.model"; +import type { User } from "../../../models/user.model"; +/** + * Компонент отображения информации о профиле пользователя + * + * Показывает аватар, имя пользователя, статус верификации и кнопку выхода. + * Поддерживает переход к профилю пользователя по клику. + * + * @example + * \`\`\`html + * + * + * \`\`\` + */ @Component({ selector: "app-profile-info", templateUrl: "./profile-info.component.html", @@ -18,6 +32,9 @@ export class ProfileInfoComponent implements OnInit { ngOnInit(): void {} + /** Данные пользователя для отображения */ @Input({ required: true }) user!: User; + + /** Событие выхода из системы */ @Output() logout = new EventEmitter(); } diff --git a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts index 51210a856..d1bc569df 100644 --- a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts +++ b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input, type OnInit } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; import { IconComponent, @@ -11,12 +11,33 @@ import { import { AsyncPipe } from "@angular/common"; import { ClickOutsideModule } from "ng-click-outside"; +/** + * Интерфейс для элемента навигации в боковой панели + */ export interface NavItem { + /** Ссылка для роутинга Angular */ link: string; + /** Название иконки из спрайта */ icon: string; + /** Отображаемое название пункта меню */ name: string; } +/** + * Компонент боковой панели навигации + * + * Отображает логотип, список навигационных элементов и дополнительный контент. + * Поддерживает анимированную полосу при наведении на элементы навигации. + * + * @example + * \`\`\`html + * + * + * + * \`\`\` + */ @Component({ selector: "ui-sidebar", templateUrl: "./sidebar.component.html", @@ -34,14 +55,24 @@ export interface NavItem { ], }) export class SidebarComponent implements OnInit { + /** Массив элементов навигации */ @Input() navItems: NavItem[] = []; + + /** Путь к изображению логотипа (обязательный параметр) */ @Input({ required: true }) logoSrc!: string; ngOnInit(): void {} + /** Позиция анимированной полосы (индекс элемента навигации) */ barPosition = 0; + + /** Флаг отображения анимированной полосы */ showBar = true; + /** + * Перенаправляет пользователя на главную страницу приложения + * Используется при клике на логотип + */ redirectToHome(): void { window.location.href = "https://app.procollab.ru/office/feed"; } diff --git a/projects/ui/src/lib/components/layout/subscription-plans/subscription-plans.component.ts b/projects/ui/src/lib/components/layout/subscription-plans/subscription-plans.component.ts index 767c1f609..2c0521e3d 100644 --- a/projects/ui/src/lib/components/layout/subscription-plans/subscription-plans.component.ts +++ b/projects/ui/src/lib/components/layout/subscription-plans/subscription-plans.component.ts @@ -1,12 +1,28 @@ /** @format */ -import { Component, EventEmitter, inject, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, inject, Input, Output } from "@angular/core"; import { CommonModule } from "@angular/common"; import { IconComponent } from "@uilib"; import { ButtonComponent, CheckboxComponent } from "@ui/components"; import { Router, RouterLink } from "@angular/router"; -import { SubscriptionPlan, SubscriptionPlansService } from "@corelib"; +import { type SubscriptionPlan, SubscriptionPlansService } from "@corelib"; +/** + * Компонент модального окна с планами подписки + * + * Отображает доступные тарифные планы с их особенностями и ценами. + * Позволяет пользователю выбрать и купить подписку. + * Включает чекбокс согласия с офертой. + * + * @example + * \`\`\`html + * + * + * \`\`\` + */ @Component({ selector: "app-subscription-plans", standalone: true, @@ -15,18 +31,33 @@ import { SubscriptionPlan, SubscriptionPlansService } from "@corelib"; styleUrl: "./subscription-plans.component.scss", }) export class SubscriptionPlansComponent { + /** Сервис роутинга Angular */ router = inject(Router); + + /** Сервис для работы с подписками */ subscriptionService = inject(SubscriptionPlansService); + /** Флаг согласия с офертой */ offertAgreement = false; + /** Флаг открытия модального окна */ @Input() open = false; + + /** Массив доступных планов подписки */ @Input({ required: true }) subscriptionPlans!: SubscriptionPlan[]; + /** Событие изменения состояния модального окна */ @Output() openChange = new EventEmitter(); + /** + * Обработчик покупки подписки + * Инициирует процесс покупки выбранного плана + * + * @param planId - ID выбранного плана подписки + */ onBuyClick(planId: SubscriptionPlan["id"]) { this.subscriptionService.buySubscription(planId).subscribe(status => { + // Перенаправляем пользователя на страницу оплаты location.href = status.confirmation.confirmationUrl; }); } diff --git a/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts b/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts index 576228571..df4aa4174 100644 --- a/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts +++ b/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts @@ -1,7 +1,30 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input, type OnInit } from "@angular/core"; +/** + * Компонент аватара пользователя + * + * Отображает круглое изображение профиля с возможностью: + * - Настройки размера + * - Добавления рамки + * - Показа индикатора онлайн статуса + * - Использования placeholder изображения при отсутствии URL + * + * @example + * \`\`\`html + * + * + * + * + * + * + * \`\`\` + */ @Component({ selector: "app-avatar", templateUrl: "./avatar.component.html", @@ -9,15 +32,28 @@ import { Component, Input, OnInit } from "@angular/core"; standalone: true, }) export class AvatarComponent implements OnInit { + /** URL изображения аватара (опциональный, при отсутствии используется placeholder) */ @Input({ required: true }) url?: string; + + /** Размер аватара в пикселях (по умолчанию 50px) */ @Input() size = 50; + + /** Флаг отображения рамки вокруг аватара */ @Input() hasBorder = false; + + /** Флаг отображения индикатора онлайн статуса */ @Input() isOnline = false; + /** Размер индикатора онлайн статуса в пикселях */ @Input() onlineBadgeSize = 16; + + /** Толщина рамки индикатора онлайн статуса в пикселях */ @Input() onlineBadgeBorder = 3; + + /** Смещение индикатора онлайн статуса от края аватара */ @Input() onlineBadgeOffset = 0; + /** URL placeholder изображения, используемого при отсутствии аватара */ placeholderUrl = "https://uch-ibadan.org.ng/wp-content/uploads/2021/10/Profile_avatar_placeholder_large.png"; diff --git a/projects/ui/src/lib/components/primitives/back/back.component.ts b/projects/ui/src/lib/components/primitives/back/back.component.ts index 092723a6b..b365ccb24 100644 --- a/projects/ui/src/lib/components/primitives/back/back.component.ts +++ b/projects/ui/src/lib/components/primitives/back/back.component.ts @@ -1,10 +1,26 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input, type OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { Location } from "@angular/common"; import { IconComponent } from "@ui/components"; +/** + * Компонент кнопки "Назад" + * + * Предоставляет функциональность навигации назад с двумя режимами: + * 1. Переход по указанному пути (если передан параметр path) + * 2. Возврат к предыдущей странице в истории браузера (по умолчанию) + * + * @example + * \`\`\`html + * + * + * + * + * + * \`\`\` + */ @Component({ selector: "app-back", templateUrl: "./back.component.html", @@ -15,9 +31,17 @@ import { IconComponent } from "@ui/components"; export class BackComponent implements OnInit { constructor(private readonly router: Router, private readonly location: Location) {} + /** Опциональный путь для перехода (если не указан, используется history.back()) */ @Input() path?: string; + ngOnInit(): void {} + /** + * Обработчик клика по кнопке "Назад" + * + * Если указан path - выполняет навигацию по этому пути, + * иначе возвращается к предыдущей странице в истории браузера + */ onClick(): void { if (this.path) { this.router diff --git a/projects/ui/src/lib/components/primitives/icon/icon.component.ts b/projects/ui/src/lib/components/primitives/icon/icon.component.ts index e08a7614c..15ae2c07e 100644 --- a/projects/ui/src/lib/components/primitives/icon/icon.component.ts +++ b/projects/ui/src/lib/components/primitives/icon/icon.component.ts @@ -1,7 +1,29 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; - +import { Component, Input, type OnInit } from "@angular/core"; + +/** + * Компонент для отображения SVG иконок из спрайта + * + * Поддерживает различные способы задания размеров: + * - appSquare: для квадратных иконок (устанавливает одинаковую ширину и высоту) + * - appWidth/appHeight: для задания конкретных размеров + * - appViewBox: для настройки области просмотра SVG + * + * Иконки загружаются из файла спрайта: assets/icons/symbol/svg/sprite.css.svg + * + * @example + * \`\`\`html + * + * + * + * + * + * + * + * + * \`\`\` + */ @Component({ selector: "[appIcon]", templateUrl: "./icon.component.html", @@ -9,10 +31,14 @@ import { Component, Input, OnInit } from "@angular/core"; standalone: true, }) export class IconComponent implements OnInit { + /** + * Устанавливает размер квадратной иконки + * Автоматически создает viewBox если он не задан + */ @Input() set appSquare(square: string) { this.square = square; - + // Автоматически создаем viewBox для квадратной иконки !this.viewBox && (this.viewBox = `0 0 ${square} ${square}`); } @@ -20,6 +46,9 @@ export class IconComponent implements OnInit { return this.square ?? ""; } + /** + * Устанавливает область просмотра SVG (viewBox) + */ @Input() set appViewBox(viewBox: string) { this.viewBox = viewBox; @@ -29,6 +58,10 @@ export class IconComponent implements OnInit { return this.viewBox ?? "0 0 0 0"; } + /** + * Устанавливает ширину иконки + * Обновляет viewBox если он существует + */ @Input() set appWidth(width: string) { this.width = width; @@ -44,6 +77,10 @@ export class IconComponent implements OnInit { return this.width ?? ""; } + /** + * Устанавливает высоту иконки + * Обновляет viewBox если он существует + */ @Input() set appHeight(height: string) { this.height = height; @@ -59,16 +96,28 @@ export class IconComponent implements OnInit { return this.height ?? ""; } + /** Название иконки из спрайта (обязательный параметр) */ @Input({ required: true }) icon!: string; + /** Внутреннее хранение размера квадратной иконки */ square?: string; + + /** Внутреннее хранение области просмотра */ viewBox?: string; + /** Внутреннее хранение ширины */ width?: string; + + /** Внутреннее хранение высоты */ height?: string; ngOnInit(): void {} + /** + * Парсит строку viewBox и возвращает массив значений + * @param viewBox - строка viewBox в формате "x y width height" + * @returns массив строк с координатами и размерами + */ viewBoxInfo(viewBox: string): string[] { return viewBox.split(" "); } diff --git a/projects/ui/src/lib/components/primitives/index.ts b/projects/ui/src/lib/components/primitives/index.ts index 304da97fa..6c767ebbb 100644 --- a/projects/ui/src/lib/components/primitives/index.ts +++ b/projects/ui/src/lib/components/primitives/index.ts @@ -1,4 +1,16 @@ -/** @format */ +/** + * Индексный файл для примитивных компонентов + * + * Экспортирует базовые переиспользуемые компоненты: + * - Аватар пользователя + * - Кнопка "Назад" + * - Компонент иконок + * + * Эти компоненты могут использоваться как строительные блоки + * для создания более сложных UI элементов + * + * @format + */ export * from "./avatar/avatar.component"; export * from "./back/back.component"; diff --git a/projects/ui/src/lib/models/user.model.ts b/projects/ui/src/lib/models/user.model.ts index a5bb5e4a4..2a2dc931c 100644 --- a/projects/ui/src/lib/models/user.model.ts +++ b/projects/ui/src/lib/models/user.model.ts @@ -1,17 +1,46 @@ /** @format */ +/** + * Модель пользователя системы + * Содержит всю необходимую информацию о пользователе для отображения в UI компонентах + */ export interface User { + /** Уникальный идентификатор пользователя */ id: number; + + /** Email адрес пользователя */ email: string; + + /** Имя пользователя */ firstName: string; + + /** Фамилия пользователя */ lastName: string; + + /** URL изображения аватара пользователя */ avatar: string; + + /** Статус онлайн (true - пользователь в сети, false - оффлайн) */ isOnline: boolean; + + /** Статус активности аккаунта (true - активен, false - заблокирован/неактивен) */ isActive: boolean; + + /** Текущий этап процесса онбординга (null если онбординг завершен) */ onboardingStage: number | null; + + /** Специальность/профессия пользователя */ speciality: string; + + /** Тип пользователя (числовой код, определяющий роль в системе) */ userType: number; + + /** Дата и время создания аккаунта в ISO формате */ timeCreated: string; + + /** Дата и время последнего обновления профиля в ISO формате */ timeUpdated: string; + + /** Дата верификации аккаунта в ISO формате */ verificationDate: string; } diff --git a/projects/ui/src/public-api.ts b/projects/ui/src/public-api.ts index 575ec0eff..c9d818175 100644 --- a/projects/ui/src/public-api.ts +++ b/projects/ui/src/public-api.ts @@ -1,8 +1,20 @@ /** - * /* - * Public API Surface of ui + * Публичный API библиотеки UI компонентов + * + * Этот файл определяет, какие компоненты, сервисы и типы + * будут доступны для импорта из библиотеки @uilib + * + * @example + * \`\`\`typescript + * import { + * SidebarComponent, + * AvatarComponent, + * User + * } from '@uilib'; + * \`\`\` * * @format */ +// Экспортируем все компоненты из папки components export * from "./lib/components";