diff --git a/package-lock.json b/package-lock.json index 821115081..5844bd070 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "nanoid": "^4.0.0", "ng-click-outside": "^9.0.1", "ngx-autosize": "^2.0.4", + "ngx-image-cropper": "^9.1.5", "ngx-mask": "^13.0.3", "rxjs": "~7.5.0", "snakecase-keys": "^5.4.2", @@ -14735,6 +14736,19 @@ "@angular/core": ">12.0.0" } }, + "node_modules/ngx-image-cropper": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/ngx-image-cropper/-/ngx-image-cropper-9.1.5.tgz", + "integrity": "sha512-I8MAjMREtXDALgYyeMIvcqE+WpXA4IA9Z59JsujDuqOnjFY/Mpb+8JUyVVuOa0Irp20ukDPQJZmECR4v96hTrg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=17.3.0", + "@angular/core": ">=17.3.0" + } + }, "node_modules/ngx-mask": { "version": "13.2.2", "resolved": "https://registry.npmjs.org/ngx-mask/-/ngx-mask-13.2.2.tgz", @@ -30921,6 +30935,14 @@ "tslib": ">2.0.0" } }, + "ngx-image-cropper": { + "version": "9.1.5", + "resolved": "https://registry.npmjs.org/ngx-image-cropper/-/ngx-image-cropper-9.1.5.tgz", + "integrity": "sha512-I8MAjMREtXDALgYyeMIvcqE+WpXA4IA9Z59JsujDuqOnjFY/Mpb+8JUyVVuOa0Irp20ukDPQJZmECR4v96hTrg==", + "requires": { + "tslib": "^2.3.0" + } + }, "ngx-mask": { "version": "13.2.2", "resolved": "https://registry.npmjs.org/ngx-mask/-/ngx-mask-13.2.2.tgz", diff --git a/package.json b/package.json index f668fb1c5..4dae92da2 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "nanoid": "^4.0.0", "ng-click-outside": "^9.0.1", "ngx-autosize": "^2.0.4", + "ngx-image-cropper": "^9.1.5", "ngx-mask": "^13.0.3", "rxjs": "~7.5.0", "snakecase-keys": "^5.4.2", diff --git a/projects/core/src/consts/list-years.ts b/projects/core/src/consts/list-years.ts deleted file mode 100644 index 6024d80ed..000000000 --- a/projects/core/src/consts/list-years.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** @format */ - -export const yearList = [ - { - value: "2000 год", - id: 0, - label: "2000 год", - }, - { - value: "2001 год", - id: 1, - label: "2001 год", - }, - { - value: "2002 год", - id: 2, - label: "2002 год", - }, - { - value: "2003 год", - id: 3, - label: "2003 год", - }, - { - value: "2004 год", - id: 4, - label: "2004 год", - }, - { - value: "2005 год", - id: 5, - label: "2005 год", - }, - { - value: "2006 год", - id: 6, - label: "2006 год", - }, - { - value: "2007 год", - id: 7, - label: "2007 год", - }, - { - value: "2008 год", - id: 8, - label: "2008 год", - }, - { - value: "2009 год", - id: 9, - label: "2009 год", - }, - { - value: "2010 год", - id: 10, - label: "2010 год", - }, - { - value: "2011 год", - id: 11, - label: "2011 год", - }, - { - value: "2012 год", - id: 12, - label: "2012 год", - }, - { - value: "2013 год", - id: 13, - label: "2013 год", - }, - { - value: "2014 год", - id: 14, - label: "2014 год", - }, - { - value: "2015 год", - id: 15, - label: "2015 год", - }, - { - value: "2016 год", - id: 16, - label: "2016 год", - }, - { - value: "2017 год", - id: 17, - label: "2017 год", - }, - { - value: "2018 год", - id: 18, - label: "2018 год", - }, - { - value: "2019 год", - id: 19, - label: "2019 год", - }, - { - value: "2020 год", - id: 20, - label: "2020 год", - }, - { - value: "2021 год", - id: 21, - label: "2021 год", - }, - { - value: "2022 год", - id: 22, - label: "2022 год", - }, - { - value: "2023 год", - id: 23, - label: "2023 год", - }, - { - value: "2024 год", - id: 24, - label: "2024 год", - }, - { - value: "2025 год", - id: 25, - label: "2025 год", - }, - { - value: "2026 год", - id: 26, - label: "2026 год", - }, - { - value: "2027 год", - id: 27, - label: "2027 год", - }, - { - value: "2028 год", - id: 28, - label: "2028 год", - }, - { - value: "2029 год", - id: 29, - label: "2029 год", - }, - { - value: "2030 год", - id: 30, - label: "2030 год", - }, - { - value: "2031 год", - id: 31, - label: "2031 год", - }, - { - value: 2025, - id: 25, - label: "настоящее время", - }, -]; diff --git a/projects/core/src/lib/services/validation.service.ts b/projects/core/src/lib/services/validation.service.ts index bd358a0dc..ceff06c13 100644 --- a/projects/core/src/lib/services/validation.service.ts +++ b/projects/core/src/lib/services/validation.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@angular/core"; import { AbstractControl, FormGroup, ValidationErrors, ValidatorFn } from "@angular/forms"; +import { PasswordValidationErrors } from "@auth/models/password-errors.model"; import * as dayjs from "dayjs"; import * as cpf from "dayjs/plugin/customParseFormat"; import * as relativeTime from "dayjs/plugin/relativeTime"; @@ -58,7 +59,6 @@ export class ValidationService { return group => { const controls = [group.get(left), group.get(right)]; - // Проверяем существование контролов if (!controls.every(Boolean)) { throw new Error(`No control with name ${left} or ${right}`); } @@ -66,16 +66,13 @@ export class ValidationService { 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); } @@ -86,6 +83,123 @@ export class ValidationService { }; } + /** + * Валидатор для проверки силы пароля + * + * Требования к паролю: + * - Минимум 8 символов + * - Минимум одна заглавная буква (A-Z) + * - Минимум одна строчная буква (a-z) + * - Минимум одна цифра (0-9) + * - Минимум один специальный символ (!@#$%^&*()_+-=[]{}|;:,.<>?) + * - Не должен содержать пробелы + * - Не должен содержать последовательности типа "123456" или "abcdef" + * - Не должен содержать повторяющиеся символы более 2 раз подряд + */ + usePasswordValidator(minLength = 6): ValidatorFn { + return (control: AbstractControl): PasswordValidationErrors | null => { + const value: string = control.value; + + if (!value) { + return null; + } + + const errors: PasswordValidationErrors = {}; + + if (value.length < minLength) { + errors.passwordTooShort = { + requiredLength: minLength, + actualLength: value.length, + }; + } + + if (!/[A-Z]/.test(value)) { + errors.passwordNoUppercase = { + message: "Пароль должен содержать минимум одну заглавную букву", + }; + } + + if (!/[a-z]/.test(value)) { + errors.passwordNoLowercase = { + message: "Пароль должен содержать минимум одну строчную букву", + }; + } + + if (!/[0-9]/.test(value)) { + errors.passwordNoNumber = { + message: "Пароль должен содержать минимум одну цифру", + }; + } + + if (!/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.test(value)) { + errors.passwordNoSpecialChar = { + message: "Пароль должен содержать минимум один специальный символ (!@#$%^&* и т.д.)", + }; + } + + if (/\s/.test(value)) { + errors.passwordHasSpaces = { + message: "Пароль не должен содержать пробелы", + }; + } + + if (this.hasSequence(value)) { + errors.passwordHasSequence = { + message: "Пароль не должен содержать последовательности символов", + }; + } + + if (this.hasRepeatingChars(value)) { + errors.passwordHasRepeating = { + message: "Пароль не должен содержать более 2 одинаковых символов подряд", + }; + } + + return Object.keys(errors).length > 0 ? errors : null; + }; + } + + /** + * Проверяет наличие последовательностей в пароле + */ + private hasSequence(password: string): boolean { + const sequences = [ + "01234567890", + "09876543210", + "abcdefghijklmnopqrstuvwxyz", + "zyxwvutsrqponmlkjihgfedcba", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "ZYXWVUTSRQPONMLKJIHGFEDCBA", + "qwertyuiopasdfghjklzxcvbnm", + "йцукенгшщзхъфывапролджэячсмитьбю", + ]; + + const lowerPassword = password.toLowerCase(); + + for (const sequence of sequences) { + for (let i = 0; i <= sequence.length - 4; i++) { + const subSequence = sequence.substring(i, i + 4); + if (lowerPassword.includes(subSequence)) { + return true; + } + } + } + + return false; + } + + /** + * Проверяет наличие более 2 повторяющихся символов подряд + */ + private hasRepeatingChars(password: string): boolean { + for (let i = 0; i < password.length - 2; i++) { + if (password[i] === password[i + 1] && password[i + 1] === password[i + 2]) { + return true; + } + } + return false; + } + /** * Валидатор для проверки формата даты DD.MM.YYYY * @param control - Контрол формы для валидации @@ -101,10 +215,8 @@ export class ValidationService { */ 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 }; } @@ -140,13 +252,49 @@ export class ValidationService { useAgeValidator(age = 14): ValidatorFn { return control => { const value = dayjs(control.value, "DD.MM.YYYY", true); + const difference = dayjs().diff(value, "year"); - if (value.isValid()) { - const difference = dayjs().diff(value, "year"); - return difference >= age ? null : { tooYoung: { requiredAge: age } }; - } + const isInvalidDate = !value.isValid() || value.year() < 1900; + const isTooYoung = difference < age; + const isTooOld = difference > 100; - return null; + return isInvalidDate + ? { invalidDateFormat: { requiredAge: 100 } } + : isTooYoung + ? { tooYoung: { requiredAge: age } } + : isTooOld + ? { tooOld: { requiredAge: 100 } } + : null; + }; + } + + /** + * Создает валидатор для проверки валидности полного email + * @returns ValidatorFn для проверки возраста + * + * Применение: + * - Проверка валидности + * - Валидация полного email + * + * Логика: + * 1. Создаем регулрку для email + * 2. Тестируем подходит ли она нам + * + * Пример использования: + * email: ['', [ + * Validators.required, + * this.validationService.useEmailValidator() + * ]] + */ + useEmailValidator(): ValidatorFn { + return control => { + const value = control.value; + const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (regex.test(value)) { + return null; + } else { + return { invalidEmail: {} }; + } }; } diff --git a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html index 37c8266a6..37c6c2922 100644 --- a/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html +++ b/projects/skills/src/app/shared/sidebar-profile/sidebar-profile.component.html @@ -20,13 +20,13 @@
{{ user()?.firstName }} {{ user()?.lastName }}
- @if (user()?.verificationDate; as verificationDate) { +
-
- Применить -
@@ -36,9 +31,6 @@

Фильтр

} - Применить Написать новость 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 55e891d2a..684a1a981 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 @@ -23,11 +23,12 @@ import { Subscription } from "rxjs"; * * Предоставляет интерфейс для фильтрации элементов ленты по типам контента. * Позволяет пользователю выбирать, какие типы элементов отображать в ленте. + * Обновления URL происходят мгновенно при каждом изменении фильтра. * * ОСНОВНЫЕ ФУНКЦИИ: * - Отображение выпадающего меню с опциями фильтрации * - Управление состоянием активных фильтров - * - Синхронизация фильтров с URL параметрами + * - Мгновенная синхронизация фильтров с URL параметрами * - Применение и сброс фильтров * * ДОСТУПНЫЕ ФИЛЬТРЫ: @@ -82,12 +83,15 @@ export class FeedFilterComponent implements OnInit, OnDestroy { }); // Читаем активные фильтры из URL - this.route.queryParams.subscribe(params => { - params["includes"] && - this.includedFilters.set(params["includes"].split(this.feedService.FILTER_SPLIT_SYMBOL)); + const routeSubscription = this.route.queryParams.subscribe(queries => { + if (queries["includes"]) { + this.includedFilters.set(queries["includes"].split(this.feedService.FILTER_SPLIT_SYMBOL)); + } else { + this.includedFilters.set([]); + } }); - this.subscriptions.push(profileSubscription); + this.subscriptions.push(profileSubscription, routeSubscription); } ngOnDestroy(): void { @@ -114,18 +118,21 @@ export class FeedFilterComponent implements OnInit, OnDestroy { includedFilters = signal([]); /** - * ПРИМЕНЕНИЕ ФИЛЬТРОВ + * ОБНОВЛЕНИЕ URL С ТЕКУЩИМИ ФИЛЬТРАМИ * - * ЧТО ДЕЛАЕТ: - * - Обновляет URL параметры с выбранными фильтрами - * - Инициирует перезагрузку ленты с новыми фильтрами - * - Сохраняет другие параметры запроса + * Приватный метод для обновления URL параметров. + * Вызывается автоматически при любом изменении фильтров. */ - applyFilter(): void { + private updateUrl(): void { + const includesParam = + this.includedFilters().length > 0 + ? this.includedFilters().join(this.feedService.FILTER_SPLIT_SYMBOL) + : null; + this.router .navigate([], { queryParams: { - includes: this.includedFilters().join(this.feedService.FILTER_SPLIT_SYMBOL), + includes: includesParam, }, relativeTo: this.route, queryParamsHandling: "merge", @@ -134,7 +141,7 @@ export class FeedFilterComponent implements OnInit, OnDestroy { } /** - * ПЕРЕКЛЮЧЕНИЕ ФИЛЬТРА + * ПЕРЕКЛЮЧЕНИЕ ФИЛЬТРА С МГНОВЕННЫМ ОБНОВЛЕНИЕМ URL * * ЧТО ПРИНИМАЕТ: * @param keyword - значение фильтра для переключения @@ -142,21 +149,26 @@ export class FeedFilterComponent implements OnInit, OnDestroy { * ЧТО ДЕЛАЕТ: * - Добавляет фильтр, если он не активен * - Удаляет фильтр, если он уже активен - * - Обновляет состояние активных фильтров + * - Мгновенно обновляет URL параметры */ setFilter(keyword: string): void { this.includedFilters.update(included => { - if (included.indexOf(keyword) !== -1) { + const newIncluded = [...included]; + + if (newIncluded.indexOf(keyword) !== -1) { // Удаляем фильтр, если он уже активен - const idx = included.indexOf(keyword); - included.splice(idx, 1); + const idx = newIncluded.indexOf(keyword); + newIncluded.splice(idx, 1); } else { // Добавляем новый фильтр - included.push(keyword); + newIncluded.push(keyword); } - return included; + return newIncluded; }); + + // Мгновенно обновляем URL + this.updateUrl(); } /** @@ -164,12 +176,12 @@ export class FeedFilterComponent implements OnInit, OnDestroy { * * ЧТО ДЕЛАЕТ: * - Очищает все активные фильтры - * - Применяет пустой набор фильтров + * - Мгновенно обновляет URL * - Возвращает ленту к состоянию по умолчанию */ resetFilter(): void { this.includedFilters.set([]); - this.applyFilter(); + this.updateUrl(); } /** diff --git a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.html b/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.html index 7a49b3293..25faa2e92 100644 --- a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.html +++ b/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.html @@ -6,7 +6,7 @@
{{ feedItem.project.name }}
- {{ feedItem.datetimeCreated | dayjs: "format":"DD MMMM, HH:mm" }} + {{ feedItem.datetimeCreated | dayjs: "format":"DD MMMM YYYY, HH:mm" }}
@@ -43,8 +43,8 @@

} } + @if (feedItem.description) {
- @if (feedItem.description) {

}
- } + }
{{ feedItem.role }} Привет, {{ profile.firstName }} {{ profile.lastNam @@ -166,7 +166,7 @@

Привет, {{ profile.firstName }} {{ profile.lastNam } @if (stageForm.get("educationStatus"); as educationStatus) {
- + Привет, {{ profile.firstName }} {{ profile.lastNam class="edit" appIcon icon="edit-pen" - appSquare="20" + appSquare="24" (click)="editEducation($index)" >

@@ -525,43 +525,48 @@

Привет, {{ profile.firstName }} {{ profile.lastNam >{{ tooltipLanguageText }} -
- @if (stageForm.get("language"); as language) { -
- - - - +
+
+ @if (stageForm.get("language"); as language) { +
+ + + + - @if (language | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (stageForm.get("languageLevel"); as languageLevel) { -
- - - + @if (language | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } @if (stageForm.get("languageLevel"); as languageLevel) { +
+ + + - @if (languageLevel | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
+ @if (languageLevel | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
} -
- } +
+ Количество добавляемых языков не более 4-х Привет, {{ profile.firstName }} {{ profile.lastNam
-

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

+

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

{{ isModalErrorYearText() }}.

diff --git a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.scss b/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.scss index ced455749..574e956cc 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.scss +++ b/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.scss @@ -47,11 +47,17 @@ display: flex; gap: 20px; align-items: center; + margin-top: 10px; + margin-bottom: 10px; .years__left, .years__right { width: 50%; } + + &--attention { + color: var(--dark-grey); + } } } @@ -82,7 +88,7 @@ position: absolute; top: 65%; left: 38%; - z-index: 100; + z-index: 10; display: none; width: 250px; padding: 12px; @@ -125,7 +131,7 @@ gap: 20px; align-items: center; justify-content: space-between; - width: 90%; + width: 100%; padding: 12px; overflow: hidden; border: 1px solid var(--medium-grey-for-outline); @@ -133,6 +139,7 @@ } &__text { + width: 90%; color: var(--dark-grey); } @@ -163,7 +170,6 @@ } .edit { - width: 10%; color: var(--dark-grey); cursor: pointer; } @@ -172,6 +178,10 @@ color: var(--red); } +i { + width: 24px; +} + .cancel { display: flex; flex-direction: column; 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 d88fff928..425e8485c 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 @@ -4,7 +4,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit, signal } from "@angula import { AuthService } from "@auth/services"; import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { ErrorMessage } from "@error/models/error-message"; -import { ControlErrorPipe, ValidationService } from "projects/core"; +import { ControlErrorPipe, ValidationService, YearsFromBirthdayPipe } from "projects/core"; import { concatMap, Subscription } from "rxjs"; import { Router } from "@angular/router"; import { User } from "@auth/models/user.model"; @@ -12,13 +12,13 @@ import { OnboardingService } from "../services/onboarding.service"; import { ButtonComponent, InputComponent, SelectComponent } from "@ui/components"; import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; import { CommonModule } from "@angular/common"; -import { yearList } from "projects/core/src/consts/list-years"; import { educationUserLevel, educationUserType } from "projects/core/src/consts/list-education"; import { languageLevelsList, languageNamesList } from "projects/core/src/consts/list-language"; import { IconComponent } from "@uilib"; import { transformYearStringToNumber } from "@utils/transformYear"; import { yearRangeValidators } from "@utils/yearRangeValidators"; import { ModalComponent } from "@ui/components/modal/modal.component"; +import { generateYearList } from "@utils/generate-year-list"; /** * КОМПОНЕНТ НУЛЕВОГО ЭТАПА ОНБОРДИНГА @@ -243,7 +243,7 @@ export class OnboardingStageZeroComponent implements OnInit, OnDestroy { isHintAchievementsVisible = false; isHintLanguageVisible = false; - readonly yearListEducation = yearList; + readonly yearListEducation = generateYearList(55); readonly educationStatusList = educationUserType; @@ -652,42 +652,60 @@ export class OnboardingStageZeroComponent implements OnInit, OnDestroy { } addLanguage() { - ["language", "languageLevel"].forEach(name => this.stageForm.get(name)?.clearValidators()); - ["language", "languageLevel"].forEach(name => - this.stageForm.get(name)?.setValidators([Validators.required]) - ); - ["language", "languageLevel"].forEach(name => - this.stageForm.get(name)?.updateValueAndValidity() - ); - ["language", "languageLevel"].forEach(name => this.stageForm.get(name)?.markAsTouched()); + const languageValue = this.stageForm.get("language")?.value; + const languageLevelValue = this.stageForm.get("languageLevel")?.value; - const languageItem = this.fb.group({ - language: this.stageForm.get("language")?.value, - languageLevel: this.stageForm.get("languageLevel")?.value, + ["language", "languageLevel"].forEach(name => { + this.stageForm.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.stageForm.get(name)?.setValidators([Validators.required]); }); - this.editIndex.set(null); - } else { - this.languageItems.update(items => [...items, languageItem.value]); - this.userLanguages.push(languageItem); } + ["language", "languageLevel"].forEach(name => { - this.stageForm.get(name)?.reset(); - this.stageForm.get(name)?.setValue(""); - this.stageForm.get(name)?.clearValidators(); - this.stageForm.get(name)?.markAsPristine(); this.stageForm.get(name)?.updateValueAndValidity(); + this.stageForm.get(name)?.markAsTouched(); + }); + + const isLanguageValid = this.stageForm.get("language")?.valid; + const isLanguageLevelValid = this.stageForm.get("languageLevel")?.valid; + + if (!isLanguageValid || !isLanguageLevelValid) { + return; + } + + const languageItem = this.fb.group({ + language: languageValue, + languageLevel: languageLevelValue, }); - this.editLanguageClick = false; + if (languageValue && languageLevelValue) { + if (this.editIndex() !== null) { + this.languageItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = languageItem.value; + + this.userLanguages.at(this.editIndex()!).patchValue(languageItem.value); + return updatedItems; + }); + this.editIndex.set(null); + } else { + this.languageItems.update(items => [...items, languageItem.value]); + this.userLanguages.push(languageItem); + } + ["language", "languageLevel"].forEach(name => { + this.stageForm.get(name)?.reset(); + this.stageForm.get(name)?.setValue(null); + this.stageForm.get(name)?.clearValidators(); + this.stageForm.get(name)?.markAsPristine(); + this.stageForm.get(name)?.updateValueAndValidity(); + }); + + this.editLanguageClick = false; + } } editLanguage(index: number) { @@ -763,7 +781,13 @@ export class OnboardingStageZeroComponent implements OnInit, OnDestroy { .pipe(concatMap(() => this.authService.setOnboardingStage(1))) .subscribe({ next: () => this.completeRegistration(1), - error: () => this.stageSubmitting.set(false), + error: error => { + this.stageSubmitting.set(false); + this.isModalErrorYear.set(true); + if (error.error.language) { + this.isModalErrorYearText.set(error.error.language); + } + }, }); } diff --git a/projects/social_platform/src/app/office/profile/detail/main/main.component.html b/projects/social_platform/src/app/office/profile/detail/main/main.component.html index 4c13ea660..f5a9dd5ff 100644 --- a/projects/social_platform/src/app/office/profile/detail/main/main.component.html +++ b/projects/social_platform/src/app/office/profile/detail/main/main.component.html @@ -26,7 +26,7 @@

Обо мне

    @for (p of user.programs.slice(0, 3); track p.id) { -
  • +
  • } @@ -257,9 +257,9 @@

    Образование

    - +

    {{ education.organizationName }} - +

    {{ education.description }}

    @@ -315,9 +315,9 @@

    Работа

    - +

    {{ workExperience.organizationName }} - +

    {{ workExperience.description }}

    @@ -340,7 +340,7 @@

    Языки

      - @for (p of user.userLanguages.slice(0, 3); track $index) { + @for (p of user.userLanguages.slice(0, 2); track $index) {
    • Языки

} -
- @if (user.userLanguages) { +
+ @if (user.userLanguages.length > 2) {
    - @for (userLanguagesItem of user.userLanguages.slice(3); track $index) { + @for (userLanguagesItem of user.userLanguages.slice(2); track $index) {
  • Языки {{ userLanguages.language }} {{ userLanguages.languageLevel }}

    - @if (userLanguagesLength > 3) { + @if (userLanguagesLength > 2) {
    {{ readAllLanguages ? "Скрыть" : "Читать полностью" }}
    @@ -451,4 +451,16 @@

    Контакты

    }
+ + + + } diff --git a/projects/social_platform/src/app/office/profile/detail/main/main.component.scss b/projects/social_platform/src/app/office/profile/detail/main/main.component.scss index a7b00c771..6d73578e4 100644 --- a/projects/social_platform/src/app/office/profile/detail/main/main.component.scss +++ b/projects/social_platform/src/app/office/profile/detail/main/main.component.scss @@ -30,6 +30,28 @@ $section-padding: 24px; } } +.languages { + &__title { + margin-bottom: 12px; + color: var(--black); + } + + ul { + overflow: hidden; + + span { + overflow: hidden; + text-overflow: ellipsis; + } + } + + li:not(:last-child) { + margin-bottom: 12px; + } + + @include expandable-list; +} + .main { display: flex; flex-direction: column; @@ -518,6 +540,7 @@ $section-padding: 24px; gap: 80px; align-items: center; justify-content: space-between; + width: 100%; } &__left { 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 757cea152..6a794bc42 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 @@ -29,6 +29,7 @@ import { ProfileService } from "@auth/services/profile.service"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { AvatarComponent } from "../../../../ui/components/avatar/avatar.component"; import { Skill } from "@office/models/skill"; +import { HttpErrorResponse } from "@angular/common/http"; /** * Главный компонент страницы профиля пользователя @@ -148,6 +149,8 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { readAllWorkExperience = false; readAllModal = false; + approveOwnSkillModal = false; + @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; @ViewChild(NewsCardComponent) newsCardComponent?: NewsCardComponent; @@ -236,13 +239,17 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { * @param event - событие клика для предотвращения всплытия * @param skill - объект навыка для обновления */ - onToggleApprove(skillId: number, event: Event, skill: Skill) { + onToggleApprove(skillId: number, event: Event, skill: Skill, profileId: number) { event.stopPropagation(); const userId = this.route.snapshot.params["id"]; - if (skill.approves.length > 0) { + const isApprovedByCurrentUser = skill.approves.some(approve => { + return approve.confirmedBy.id === profileId; + }); + + if (isApprovedByCurrentUser) { this.profileApproveSkillService.unApproveSkill(userId, skillId).subscribe(() => { - skill.approves = skill.approves.slice(0, -1); + skill.approves = skill.approves.filter(approve => approve.confirmedBy.id !== profileId); this.cdRef.markForCheck(); }); } else { @@ -260,13 +267,27 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { ) ) ) - .subscribe(updatedApprove => { - skill.approves = [...skill.approves, updatedApprove]; - this.cdRef.markForCheck(); + .subscribe({ + next: updatedApprove => { + skill.approves = [...skill.approves, updatedApprove]; + this.cdRef.markForCheck(); + }, + error: err => { + if (err instanceof HttpErrorResponse) { + if (err.status === 400) { + this.approveOwnSkillModal = true; + this.cdRef.markForCheck(); + } + } + }, }); } } + isUserApproveSkill(skill: Skill, profileId: number): boolean { + return skill.approves.some(approve => approve.confirmedBy.id === profileId); + } + openSkills: any = {}; /** 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 7ded85773..df1108b76 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 @@ -133,12 +133,12 @@

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

} }
-
+ diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.scss b/projects/social_platform/src/app/office/profile/detail/profile-detail.component.scss index 7c78d34b0..bcd773f6a 100644 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.scss +++ b/projects/social_platform/src/app/office/profile/detail/profile-detail.component.scss @@ -31,7 +31,7 @@ $detail-bar-mb: 12px; display: flex; flex-direction: column; flex-grow: 1; - gap: 20px; + gap: 10px; max-height: calc(100% - #{$detail-bar-height} - #{$detail-bar-mb}); padding-bottom: 12px; } @@ -180,7 +180,7 @@ $detail-bar-mb: 12px; transform: translateX(-50%); @include responsive.apply-desktop { - top: 50%; + top: 10%; bottom: auto; left: 21px; transform: translate(0, -50%); diff --git a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.html b/projects/social_platform/src/app/office/profile/detail/projects/projects.component.html index 5de149c7d..baf44d358 100644 --- a/projects/social_platform/src/app/office/profile/detail/projects/projects.component.html +++ b/projects/social_platform/src/app/office/profile/detail/projects/projects.component.html @@ -1,9 +1,9 @@ - @if (user | async; as user) {
@if (loggedUserId | async; as loggedUserId) {
+ @if (user.projects.length) {

{{ @@ -16,12 +16,13 @@

@for (p of user.projects; track p.id) {
  • - +
  • }

    + } @if (subs | async; as subs) { @if (subs.length) {

    {{ @@ -30,18 +31,25 @@

    : "Проекты, на которые подписан " + user.firstName }}

    - @if (subs | async; as subs) {
      @for (p of subs; track p.id) {
    • - +
    • }
    - }
    + } } @if (!user.projects.length) { @if (subs | async; as subs) { @if (!subs.length) { +

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

    + } } @else { +

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

    + } }
    }
    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 01611cc13..7496f7ce4 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 @@ -4,7 +4,7 @@

    Редактировать профиль

    - + Назад
    @@ -287,7 +287,7 @@ [selectedId]="selectedComplitionYearEducationId()" formControlName="completionYear" placeholder="2023 год" - [options]="yearListEducation.slice(5)" + [options]="yearListEducation" > @@ -347,7 +347,7 @@ } @if (profileForm.get("educationStatus"); as educationStatus) {
    - + !open); } - - onBack() { - this.location.back(); - } } 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 69b15aed7..6d542b082 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 @@ -55,11 +55,16 @@

    {{ program.name }}

    - @if (!program.isUserMember && !registerDateExpired) { + @if (!program.isUserMember && !registerDateExpired) { @if + (program.name.includes("Кейс-чемпионат MIR")) { + + Зарегистрироваться + + } @else { Зарегистрироваться - } + } } @@ -163,4 +168,23 @@
    + + +
    +
    + +

    Ошибка в доступе к программе!

    +
    + + @if (showProgramModalErrorMessage()) { +

    + {{ showProgramModalErrorMessage() }} +

    + } + + Хорошо +
    +
    } 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 bc9c3c020..be57ed88c 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 @@ -397,3 +397,50 @@ margin-top: 20px; } } + +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 80%; + max-height: calc(100vh - 40px); + padding: 40px 0 80px; + overflow-y: auto; + + @include responsive.apply-desktop { + width: 50%; + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__top { + display: flex; + flex-direction: column; + margin-bottom: 10px; + } + + &__title { + text-align: center; + } + + &__text { + text-align: center; + } + + &__button { + 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 d20b835ed..55bd31691 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 @@ -1,5 +1,4 @@ /** @format */ - import { ChangeDetectorRef, Component, @@ -11,7 +10,17 @@ import { } from "@angular/core"; import { ProgramService } from "@office/program/services/program.service"; import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { concatMap, fromEvent, map, noop, of, Subscription, tap, throttleTime } from "rxjs"; +import { + concatMap, + fromEvent, + map, + noop, + Observable, + of, + Subscription, + tap, + throttleTime, +} from "rxjs"; import { Program } from "@office/program/models/program.model"; import { ProgramNewsService } from "@office/program/services/program-news.service"; import { FeedNews } from "@office/projects/models/project-news.model"; @@ -24,52 +33,12 @@ 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"; +import { ModalComponent } from "@ui/components/modal/modal.component"; import { ProjectService } from "@office/services/project.service"; +import { MatProgressBarModule } from "@angular/material/progress-bar"; +import { AsyncPipe } from "@angular/common"; +import { LoadingService } from "@office/services/loading.service"; -/** - * Главный компонент детальной страницы программы - * - * Отображает основную информацию о программе и новостную ленту: - * - Детальное описание программы с возможностью развернуть/свернуть - * - Информацию о датах и регистрации - * - Новостную ленту для участников программы - * - Форму добавления новостей - * - Взаимодействие с новостями (лайки, просмотры) - * - * Принимает: - * @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", @@ -83,9 +52,12 @@ import { ProjectService } from "@office/services/project.service"; ProgramNewsCardComponent, TagComponent, UserLinksPipe, + AsyncPipe, ParseBreaksPipe, ParseLinksPipe, NewsFormComponent, + ModalComponent, + MatProgressBarModule, ], }) export class ProgramDetailMainComponent implements OnInit, OnDestroy { @@ -95,7 +67,8 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { private readonly projectService: ProjectService, private readonly router: Router, private readonly route: ActivatedRoute, - private readonly cdRef: ChangeDetectorRef + private readonly cdRef: ChangeDetectorRef, + private readonly loadingService: LoadingService ) {} news = signal([]); @@ -103,6 +76,9 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { fetchLimit = signal(10); fetchPage = signal(0); + showProgramModal = signal(false); + showProgramModalErrorMessage = signal(null); + programId?: number; subscriptions$ = signal([]); @@ -118,6 +94,22 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { ) .subscribe(); + const routeModalSub$ = this.route.queryParams.subscribe(param => { + if (param["access"] === "accessDenied") { + this.loadingService.hide(); + + this.showProgramModal.set(true); + this.showProgramModalErrorMessage.set("У вас не доступа к этой вкладке!"); + + this.router.navigate([], { + relativeTo: this.route, + queryParams: { access: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + }); + const program$ = this.route.data .pipe( map(r => r["data"]), @@ -133,23 +125,34 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { } }) ) - .subscribe(news => { - if (news.results?.length) { - this.news.set(news.results); - this.totalNewsCount.set(news.count); - } + .subscribe({ + next: news => { + if (news.results?.length) { + this.news.set(news.results); + this.totalNewsCount.set(news.count); + } + + this.loadingService.hide(); + }, + error: () => { + this.loadingService.hide(); + + this.showProgramModal.set(true); + this.showProgramModalErrorMessage.set("Произошла ошибка при загрузке программы"); + }, }); + this.loadEvent = fromEvent(window, "load"); + this.subscriptions$().push(program$); this.subscriptions$().push(programIdSubscription$); + this.subscriptions$().push(routeModalSub$); } ngAfterViewInit() { const descElement = this.descEl?.nativeElement; this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - this.cdRef.detectChanges(); - const target = document.querySelector(".office__body"); if (target) { const scrollEvents$ = fromEvent(target, "scroll") @@ -158,7 +161,6 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { throttleTime(2000) ) .subscribe(); - this.subscriptions$().push(scrollEvents$); } } @@ -183,19 +185,14 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { const target = document.querySelector(".office__body"); if (!target) return of({}); - const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - if (scrollBottom > 0) return of({}); - this.fetchPage.update(p => p + 1); - return this.fetchNews(this.fetchPage() * this.fetchLimit(), this.fetchLimit()); } fetchNews(offset: number, limit: number) { const programId = this.route.snapshot.params["programId"]; - return this.programNewsService.fetchNews(limit, offset, programId).pipe( tap(({ count, results }) => { this.totalNewsCount.set(count); @@ -213,7 +210,6 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { // @ts-ignore return e.target.dataset.id; }); - this.programNewsService.readNews(this.route.snapshot.params["programId"], ids).subscribe(noop); } @@ -229,7 +225,6 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { 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); @@ -241,7 +236,6 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { onLike(newsId: number) { const item = this.news().find((n: any) => n.id === newsId); if (!item) return; - this.programNewsService .toggleLike(this.route.snapshot.params["programId"], newsId, !item.isUserLiked) .subscribe(() => { @@ -255,19 +249,35 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { this.readFullDescription = !isExpanded; } - addProject(): void { - this.projectService.create().subscribe(project => { - this.projectService.projectsCount.next({ - ...this.projectService.projectsCount.getValue(), - my: this.projectService.projectsCount.getValue().my + 1, - }); + closeModal(): void { + this.showProgramModal.set(false); + this.loadingService.hide(); + } - this.router - .navigateByUrl(`/office/projects/${project.id}/edit?editingStep=main`) - .then(() => console.debug("Route change from ProjectsComponent")); + addProject(): void { + this.loadingService.show(); + + this.projectService.create().subscribe({ + next: project => { + this.projectService.projectsCount.next({ + ...this.projectService.projectsCount.getValue(), + my: this.projectService.projectsCount.getValue().my + 1, + }); + this.router + .navigateByUrl(`/office/projects/${project.id}/edit?editingStep=main`) + .then(() => { + console.debug("Route change from ProjectsComponent"); + }); + }, + error: error => { + this.loadingService.hide(); + console.error("Project creation error:", error); + }, }); } + private loadEvent?: Observable; + program?: Program; registerDateExpired!: boolean; descriptionExpandable!: boolean; 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 index ee61df964..8a9de925c 100644 --- 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 @@ -1,5 +1,5 @@ - +@if (filters()?.length) {
    @@ -54,3 +54,4 @@

    {{ field.label }}

    } } }
    +} 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 fef6abb23..97ad6ef38 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 @@ -37,6 +37,7 @@ grid-template-columns: repeat(2, 1fr); gap: 20px 40px; width: 70%; + height: 100%; } } 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 98a04bedd..03a812d96 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 @@ -132,9 +132,11 @@ export class ProgramProjectsComponent implements OnInit, AfterViewInit, OnDestro tap(r => (this.projectsTotalCount = r["count"])), map(r => r["results"]) ) - .subscribe(projects => { - this.projects = projects; - this.searchedProjects = projects; + .subscribe({ + next: projects => { + this.projects = projects; + this.searchedProjects = projects; + }, }); const searchFormSearch$ = this.searchForm.get("search")?.valueChanges.subscribe(search => { @@ -171,21 +173,21 @@ export class ProgramProjectsComponent implements OnInit, AfterViewInit, OnDestro const hasFilters = reqQuery && reqQuery["filters"] && Object.keys(reqQuery["filters"]).length > 0; - const params = new HttpParams({ fromObject: { partner_program: programId } }); + const params = new HttpParams({ fromObject: { offset: 0, limit: 21 } }); 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(programId, params); }) ); } - return this.programService.getAllProjects(params).pipe( + return this.programService.getAllProjects(programId, params).pipe( catchError(err => { console.error("getAllProjects failed", err); - return this.programService.getAllProjects(params); + return this.programService.getAllProjects(programId, params); }) ); } @@ -298,7 +300,7 @@ export class ProgramProjectsComponent implements OnInit, AfterViewInit, OnDestro const limit = this.perPage; return this.programService - .getAllProjects(new HttpParams({ fromObject: { partner_program: programId, offset, limit } })) + .getAllProjects(programId, new HttpParams({ fromObject: { offset, limit } })) .pipe( tap(projects => { this.projectsTotalCount = projects.count; 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 8724b79bb..c474a63ce 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 @@ -1,11 +1,12 @@ /** @format */ import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { ActivatedRoute, ActivatedRouteSnapshot, ResolveFn, Router } from "@angular/router"; import { ProgramService } from "@office/program/services/program.service"; import { Project } from "@models/project.model"; import { ApiPagination } from "@models/api-pagination.model"; import { HttpParams } from "@angular/common/http"; +import { catchError, EMPTY } from "rxjs"; /** * Резолвер для предзагрузки проектов программы @@ -33,9 +34,27 @@ export const ProgramProjectsResolver: ResolveFn> = ( route: ActivatedRouteSnapshot ) => { const programService = inject(ProgramService); - return programService.getAllProjects( - new HttpParams({ - fromObject: { partner_program: route.parent?.params["programId"], offset: 0, limit: 21 }, - }) - ); + const programId = route.parent?.params["programId"]; + const router = inject(Router); + + return programService + .getAllProjects( + programId, + new HttpParams({ + fromObject: { offset: 0, limit: 21 }, + }) + ) + .pipe( + catchError(error => { + if (error.status === 403) { + router.navigate([], { + queryParams: { access: "accessDenied" }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + + return EMPTY; + }) + ); }; diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.html b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.html index 461b473d0..d7b88b035 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 @@ -11,7 +11,7 @@ }
    - {{ newsItem.datetimeCreated | dayjs: "format":"DD MMMM, HH:mm" }} + {{ newsItem.datetimeCreated | dayjs: "format":"DD MMMM YYYY, HH:mm" }}
    @@ -97,14 +97,14 @@ {{ newsItem.likesCount }} - 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 f372baa8f..397fcd89b 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 @@ -79,8 +79,8 @@ export class ProgramService { return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/register/`, additionalData); } - getAllProjects(params?: HttpParams): Observable> { - return this.apiService.get(`${this.PROJECTS_URL}/`, params); + getAllProjects(programId: number, params?: HttpParams): Observable> { + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects`, params); } getAllMembers(programId: number, skip: number, take: number): Observable> { 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 9ad6b85bc..c800f2630 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 @@ -286,7 +286,7 @@

    Вакансии

    } @if(profileId === project.leader){ Добавить вакансию 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 168ea8126..5fc8932f4 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 @@ -22,8 +22,8 @@ [programTagsOptions]="programTagsOptions" [leaderId]="leaderId" [projSubmitInitiated]="projSubmitInitiated" + [isProjectBoundToProgram]="isProjectBoundToProgram" (assignToProgram)="assignProjectToProgram()" - (saveProject)="onSaveProject($event)" > } @else if (editingStep === "contacts") { @@ -53,6 +53,9 @@ }
    + + Удалить проект + void; - /** - * Выполнение сохранения проекта - * Из дочернего компонента project-main-step через emit - * - * @param event тип проекта для публикации или для черновика - */ - onSaveProject(event: { type: "draft" | "published" }): void { - if (event.type === "draft") { - this.saveProjectAsDraft(); - } else { - this.saveProjectAsPublished(); - } - } - /** * Очистка всех ошибок валидации */ @@ -321,6 +310,21 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { this.projectAchievementsService.clearAllAchievementsErrors(this.achievements); } + /** + * Удаление проекта с проверкой удаления у пользователя + */ + deleteProject(): void { + if (!confirm("Вы точно хотите удалить проект?")) { + return; + } + + this.projectService.remove(Number(this.route.snapshot.paramMap.get("projectId"))).subscribe({ + next: () => { + this.router.navigateByUrl(`/office/projects/my`); + }, + }); + } + /** * Сохранение проекта как опубликованного с проверкой доп. полей */ @@ -376,26 +380,30 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { }); const payload = this.projectFormService.getFormValue(); + const projectId = Number(this.route.snapshot.paramMap.get("projectId")); + + if (this.vacancyForm.dirty) { + this.projectVacancyService.submitVacancy(projectId); + } if ( !this.validationService.getFormValidation(this.projectForm) || - !this.validationService.getFormValidation(this.additionalForm) + !this.validationService.getFormValidation(this.additionalForm) || + !this.validationService.getFormValidation(this.vacancyForm) ) { return; } this.setProjFormIsSubmitting(true); - this.projectService - .updateProject(Number(this.route.snapshot.paramMap.get("projectId")), payload) - .subscribe({ - next: () => { - this.setProjFormIsSubmitting(false); - this.router.navigateByUrl(`/office/projects/my`); - }, - error: () => { - this.setProjFormIsSubmitting(false); - }, - }); + this.projectService.updateProject(projectId, payload).subscribe({ + next: () => { + this.setProjFormIsSubmitting(false); + this.router.navigateByUrl(`/office/projects/my`); + }, + error: () => { + this.setProjFormIsSubmitting(false); + }, + }); } // Методы для работы с модальными окнами @@ -565,10 +573,12 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { // Используем сервис для инициализации данных проекта this.projectFormService.initializeProjectData(project); this.projectTeamService.setInvites(invites); + this.projectTeamService.setCollaborators(project.collaborators); // Инициализируем дополнительные поля через сервис if (project.partnerProgram) { this.isCompetitive = project.partnerProgram.canSubmit; + this.isProjectBoundToProgram = !!project.partnerProgram.programId; this.projectAdditionalService.initializeAdditionalForm( project.partnerProgram?.programFields, 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 index 2f776ea91..875a7b496 100644 --- 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 @@ -109,7 +109,7 @@ export class ProjectFormService { problem: project.problem ?? "", presentationAddress: project.presentationAddress, coverImageAddress: project.coverImageAddress, - partnerProgramId: project.partnerProgramId ?? null, + partnerProgramId: project.partnerProgram?.programId ?? null, }); if (project.partnerProgram) { 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 index f3578d20a..becc9e491 100644 --- 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 @@ -3,6 +3,7 @@ import { computed, inject, Injectable, signal } from "@angular/core"; import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { ValidationService } from "@corelib"; +import { Collaborator } from "@office/models/collaborator.model"; import { Invite } from "@office/models/invite.model"; import { InviteService } from "@services/invite.service"; @@ -19,6 +20,7 @@ export class ProjectTeamService { private readonly validationService = inject(ValidationService); public readonly invites = signal([]); + public readonly collaborators = signal([]); public readonly isInviteModalOpen = signal(false); public readonly inviteNotExistingError = signal(null); @@ -63,6 +65,22 @@ export class ProjectTeamService { this.invites.set(invites); } + /** + * Устанавливает список команды + * @param collaborators массив Collaborator + */ + public setCollaborators(collaborators: Collaborator[]): void { + this.collaborators.set(collaborators); + } + + /** + * Возвращает текущий список команды. + * @returns Collaborator[] массив команды + */ + public getCollaborators(): Collaborator[] { + return this.collaborators(); + } + /** * Возвращает текущий список приглашений. * @returns Invite[] массив приглашений @@ -84,14 +102,6 @@ export class ProjectTeamService { 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) - ); - /** * Открывает модальное окно для отправки приглашения. */ 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 index 882e391d9..b2b07b095 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html @@ -182,8 +182,8 @@
    - @if (authService.profile | async; as profile) { @if (profile.id == leaderId) { @if - (programTagsOptions.length) { + @if (!isProjectBoundToProgram) { @if (authService.profile | async; as profile) { @if (profile.id + == leaderId) { @if (programTagsOptions.length) {
    Привязать проект к программе - } } } @if (presentationAddress; as presentationAddress) { + } } } } @if (presentationAddress; as presentationAddress) {
    (); - @Output() saveProject = new EventEmitter<{ type: "draft" | "published" }>(); private subscription = new Subscription(); 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 index 6ee82d631..a61b685be 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html @@ -5,8 +5,27 @@
      + @for (collaborator of collaborators; track collaborator.userId) { +
    • + +
    • + } +
    + + +
      @for (user of invites; track user.id) { @if (user.isAccepted === null) {
    • 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 index 82c44d127..9e0ccbe42 100644 --- 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 @@ -12,6 +12,7 @@ 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"; +import { CollaboratorCardComponent } from "@office/shared/collaborator-card/collaborator-card.component"; @Component({ selector: "app-project-team-step", @@ -28,6 +29,7 @@ import { IconComponent } from "@uilib"; ControlErrorPipe, InviteCardComponent, ModalComponent, + CollaboratorCardComponent, ], }) export class ProjectTeamStepComponent implements OnInit { @@ -41,6 +43,7 @@ export class ProjectTeamStepComponent implements OnInit { ngOnInit(): void { this.projectTeamService.setInvites(this.invites); + this.projectTeamService.setCollaborators(this.collaborators); // Настраиваем динамическую валидацию this.projectTeamService.setupDynamicValidation(); @@ -68,8 +71,12 @@ export class ProjectTeamStepComponent implements OnInit { return this.projectTeamService.getInvites(); } - get invitesFill() { - return this.projectTeamService.invitesFill; + get collaborators() { + return this.projectTeamService.getCollaborators(); + } + + get invitesFill(): boolean { + return this.invites.some(inv => inv.isAccepted === null); } get isInviteModalOpen() { diff --git a/projects/social_platform/src/app/office/projects/list/list.component.html b/projects/social_platform/src/app/office/projects/list/list.component.html index b58e06bef..ee0f61b1e 100644 --- a/projects/social_platform/src/app/office/projects/list/list.component.html +++ b/projects/social_platform/src/app/office/projects/list/list.component.html @@ -8,7 +8,7 @@
    }
    -
    -
    - Заработная плата -
    - -
    - -
    +
    + Заработная плата +
    + + + +
    + + +
    - +
    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 5d8c54b95..d02211cbe 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 @@ -16,21 +16,15 @@ import { ActivatedRoute, Router } from "@angular/router"; import { ButtonComponent, CheckboxComponent, IconComponent, InputComponent } from "@ui/components"; import { ClickOutsideModule } from "ng-click-outside"; 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 { debounceTime, map, Subject, 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", @@ -42,7 +36,6 @@ import { filterWorkSchedule } from "projects/core/src/consts/filter-work-schedul ClickOutsideModule, IconComponent, InputComponent, - ReactiveFormsModule, ], templateUrl: "./vacancy-filter.component.html", styleUrl: "./vacancy-filter.component.scss", @@ -67,17 +60,7 @@ export class VacancyFilterComponent implements OnInit { /** Сервис для работы с вакансиями */ vacancyService = inject(VacancyService); - /** - * Конструктор компонента - * @param fb - FormBuilder для создания формы зарплатного диапазона - */ - constructor(private readonly fb: FormBuilder) { - // Создание формы для фильтрации по зарплате - this.salaryForm = this.fb.group({ - salaryMin: [""], // Минимальная зарплата - salaryMax: [""], // Максимальная зарплата - }); - } + constructor() {} /** Приватное поле для хранения значения поиска */ private _searchValue: string | undefined; @@ -101,29 +84,12 @@ export class VacancyFilterComponent implements OnInit { /** Событие изменения значения поиска */ @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"]); - this.currentSalaryMin.set(queries["salary_min"]); - this.currentSalaryMax.set(queries["salary_max"]); - this.searchValue = queries["role_contains"]; - }); - } - /** Подписка на параметры запроса */ queries$?: Subscription; + /** Состояние открытия фильтра (для мобильной версии) */ filterOpen = signal(false); - /** Форма для фильтрации по зарплате */ - salaryForm: FormGroup; + /** Общее количество элементов */ totalItemsCount = signal(0); @@ -139,6 +105,15 @@ export class VacancyFilterComponent implements OnInit { /** Текущая максимальная зарплата */ currentSalaryMax = signal(undefined); + // Сигналы для значений полей зарплаты + /** Значение поля минимальной зарплаты */ + salaryMinValue = signal(""); + /** Значение поля максимальной зарплаты */ + salaryMaxValue = signal(""); + + // Subject для debounce изменений зарплаты + private salaryChanges$ = new Subject<{ min: string; max: string }>(); + /** Опции фильтра по опыту работы */ readonly filterExperienceOptions = filterExperience; @@ -148,6 +123,61 @@ export class VacancyFilterComponent implements OnInit { /** Опции фильтра по графику работы */ filterWorkScheduleOptions = filterWorkSchedule; + /** + * Инициализация компонента + */ + ngOnInit() { + // Подписка на изменения зарплаты с debounce + this.salaryChanges$.pipe(debounceTime(300)).subscribe(({ min, max }) => { + this.router.navigate([], { + queryParams: { + role_contains: this.searchValue || null, + salary_min: min || null, + salary_max: max || null, + }, + queryParamsHandling: "merge", + relativeTo: this.route, + }); + }); + + // Подписка на изменения параметров запроса + this.queries$ = this.route.queryParams.subscribe(queries => { + // Синхронизация текущих значений фильтров с URL + this.currentExperience.set(queries["required_experience"]); + this.currentWorkFormat.set(queries["work_format"]); + this.currentWorkSchedule.set(queries["work_schedule"]); + this.currentSalaryMin.set(queries["salary_min"]); + this.currentSalaryMax.set(queries["salary_max"]); + this.searchValue = queries["role_contains"]; + + // Синхронизация полей зарплаты + this.salaryMinValue.set(queries["salary_min"] || ""); + this.salaryMaxValue.set(queries["salary_max"] || ""); + }); + } + + /** + * Обработчик изменения минимальной зарплаты + */ + onSalaryMinChange(value: string): void { + this.salaryMinValue.set(value); + this.salaryChanges$.next({ + min: value, + max: this.salaryMaxValue(), + }); + } + + /** + * Обработчик изменения максимальной зарплаты + */ + onSalaryMaxChange(value: string): void { + this.salaryMaxValue.set(value); + this.salaryChanges$.next({ + min: this.salaryMinValue(), + max: value, + }); + } + /** * Установка фильтра по опыту работы * @param event - событие клика @@ -210,25 +240,6 @@ 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 || ""; - - this.router.navigate([], { - queryParams: { - role_contains: this.searchValue || null, - salary_min: salaryMin, - salary_max: salaryMax, - }, - queryParamsHandling: "merge", - relativeTo: this.route, - }); - } - /** * Сброс всех фильтров * Очищает все параметры фильтрации и обновляет URL @@ -237,8 +248,14 @@ export class VacancyFilterComponent implements OnInit { this.currentExperience.set(undefined); this.currentWorkFormat.set(undefined); this.currentWorkSchedule.set(undefined); + this.currentSalaryMax.set(undefined); + this.currentSalaryMin.set(undefined); + + // Сбрасываем значения полей + this.salaryMinValue.set(""); + this.salaryMaxValue.set(""); + this.onSearchValueChanged(""); - this.salaryForm.reset(); this.router .navigate([], { @@ -299,4 +316,9 @@ export class VacancyFilterComponent implements OnInit { map(res => res) ); } + + ngOnDestroy() { + this.queries$?.unsubscribe(); + this.salaryChanges$.complete(); + } } diff --git a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.scss b/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.scss index 470b7f64a..3d533c241 100644 --- a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.scss +++ b/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.scss @@ -134,7 +134,7 @@ position: absolute; top: 50%; right: 10px; - z-index: 4; + z-index: 2; display: flex; align-items: center; transform: translateY(-50%); diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html index a81782361..14bd2607e 100644 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html +++ b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html @@ -14,7 +14,7 @@ type="file" class="control__input" accept="image/*" - (change)="onUpdate($event)" + (change)="onFileSelected($event)" /> + + +
    +
    + +

    Редактирование изображения перед отправкой!

    +
    + + @if (showCropperModalErrorMessage) { +

    + {{ showCropperModalErrorMessage }} +

    + } + +
    + +
    + +
    + Отменить + Сохранить +
    +
    +
    diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.scss b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.scss index 00cb2eb6f..a14eee03b 100644 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.scss +++ b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.scss @@ -1,3 +1,5 @@ +@use "styles/responsive"; + .control { border-radius: 50%; @@ -69,3 +71,102 @@ .placeholder { background-color: var(--gray); } + +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 80%; + max-height: calc(100vh - 40px); + padding: 40px 0 80px; + overflow-y: auto; + + @include responsive.apply-desktop { + width: 50%; + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__cropper-container { + width: 100%; + max-width: 500px; + margin: 20px 0; + overflow: hidden; + background: var(--background-secondary, #f8f9fa); + border-radius: 8px; + box-shadow: 0 4px 12px rgb(0 0 0 / 10%); + + ::ng-deep { + .ngx-ic-main { + background: white; + border-radius: 8px; + } + + .ngx-ic-overlay { + border-radius: 8px; + } + + .ngx-ic-crop { + border-radius: 50%; + box-shadow: 0 0 0 9999px rgb(0 0 0 / 50%); + } + + .ngx-ic-move, + .ngx-ic-resize { + border: 2px solid white; + box-shadow: 0 2px 4px rgb(0 0 0 / 20%); + } + + .ngx-ic-crop::before { + border-color: rgb(255 255 255 / 50%); + } + } + } + + &__top { + display: flex; + flex-direction: column; + margin-bottom: 10px; + } + + &__title { + text-align: center; + } + + &__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/ui/components/avatar-control/avatar-control.component.ts b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts index 474ad3716..b2816f185 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 @@ -5,14 +5,17 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { nanoid } from "nanoid"; import { FileService } from "@core/services/file.service"; import { catchError, concatMap, map, of } from "rxjs"; -import { IconComponent } from "@ui/components"; +import { IconComponent, ButtonComponent } from "@ui/components"; import { LoaderComponent } from "../loader/loader.component"; import { CommonModule } from "@angular/common"; +import { ImageCroppedEvent, ImageCropperComponent } from "ngx-image-cropper"; +import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; +import { ModalComponent } from "../modal/modal.component"; /** * Компонент для управления аватаром пользователя. * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Позволяет загружать, обновлять и удалять изображение аватара. + * Позволяет загружать, обрезать, обновлять и удалять изображение аватара. * * Входящие параметры: * - size: размер аватара в пикселях (по умолчанию 140) @@ -34,12 +37,19 @@ import { CommonModule } from "@angular/common"; }, ], standalone: true, - imports: [LoaderComponent, IconComponent, CommonModule], + imports: [ + LoaderComponent, + IconComponent, + CommonModule, + ImageCropperComponent, + ModalComponent, + ButtonComponent, + ], }) export class AvatarControlComponent implements OnInit, ControlValueAccessor { - constructor(private fileService: FileService) {} + constructor(private fileService: FileService, private sanitizer: DomSanitizer) {} - /** Размер авата��а в пикселях */ + /** Размер аватара в пикселях */ @Input() size = 140; /** Состояние ошибки */ @@ -56,6 +66,21 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { /** Текущее значение URL изображения */ value = ""; + /** Показывать ли модальное окно кроппера */ + showCropperModal = false; + + /** Текст ошибки при обрезки фотографии */ + showCropperModalErrorMessage = ""; + + /** Исходное изображение для обрезки */ + imageChangedEvent: Event | null = null; + + /** Обрезанное изображение */ + croppedImage: SafeUrl = ""; + + /** Blob обрезанного изображения для загрузки */ + croppedBlob: Blob | null = null; + /** Записывает значение URL изображения */ writeValue(address: string) { this.value = address; @@ -77,17 +102,63 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { loading = false; /** - * Обработчик обновления изображения - * Загружает новый файл, при наличии старого - сначала удаляет его + * Обработчик выбора файла - открывает кроппер */ - onUpdate(event: Event) { + onFileSelected(event: Event) { const files = (event.currentTarget as HTMLInputElement).files; if (!files?.length) { return; } + this.imageChangedEvent = event; + this.showCropperModal = true; + } + + /** + * Обработчик обрезки изображения + */ + imageCropped(event: ImageCroppedEvent) { + if (event.objectUrl) { + this.croppedImage = this.sanitizer.bypassSecurityTrustUrl(event.objectUrl); + } + this.croppedBlob = event.blob || null; + } + + /** + * Обработчик загружено фото или нет + */ + imageLoaded() {} + + /** + * Обработчик готовности обрезки фотографии + */ + cropperReady() {} + + /** + * Обработчик ошибки загрузки + */ + loadImageFailed() { + console.error("Не удалось загрузить изображение"); + this.showCropperModalErrorMessage = "Не удалось загрузить изображение. Попробуйте ещё раз!"; + } + + /** + * Сохранить обрезанное изображение + */ + saveCroppedImage() { + if (!this.croppedBlob) { + return; + } + this.loading = true; + this.showCropperModal = false; + + // Создаем файл из blob + const file = new File([this.croppedBlob], "cropped-avatar.jpg", { + type: "image/jpeg", + lastModified: Date.now(), + }); const source = this.value ? this.fileService.deleteFile(this.value).pipe( @@ -95,14 +166,30 @@ export class AvatarControlComponent implements OnInit, ControlValueAccessor { console.error(err); return of({}); }), - concatMap(() => this.fileService.uploadFile(files[0])), + concatMap(() => this.fileService.uploadFile(file)), map(r => r["url"]) ) - : this.fileService.uploadFile(files[0]).pipe(map(r => r.url)); + : this.fileService.uploadFile(file).pipe(map(r => r.url)); source.subscribe(this.updateValue.bind(this)); } + /** + * Закрыть кроппер без сохранения + */ + closeCropper() { + this.showCropperModal = false; + this.imageChangedEvent = null; + this.croppedImage = ""; + this.croppedBlob = null; + + // Сбрасываем значение input + const input = document.getElementById(this.controlId) as HTMLInputElement; + if (input) { + input.value = ""; + } + } + /** * Обновляет значение URL и уведомляет о изменении * @param url - новый URL изображения diff --git a/projects/social_platform/src/app/ui/components/input/input.component.html b/projects/social_platform/src/app/ui/components/input/input.component.html index de4e0f9ae..5d5d4e083 100644 --- a/projects/social_platform/src/app/ui/components/input/input.component.html +++ b/projects/social_platform/src/app/ui/components/input/input.component.html @@ -19,7 +19,12 @@ @if (error) { } -
    +
    diff --git a/projects/social_platform/src/app/ui/components/input/input.component.scss b/projects/social_platform/src/app/ui/components/input/input.component.scss index 08215c8bb..f44fa86b3 100644 --- a/projects/social_platform/src/app/ui/components/input/input.component.scss +++ b/projects/social_platform/src/app/ui/components/input/input.component.scss @@ -48,8 +48,7 @@ &__right-icon { position: absolute; - top: 20%; - right: 24px; + top: 27%; display: flex; align-items: center; justify-content: center; 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 f42b3f330..80734f233 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,7 +1,8 @@ /** @format */ -import { Component, EventEmitter, forwardRef, Input, type OnInit, Output } from "@angular/core"; -import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { IconComponent } from "@ui/components"; import { NgxMaskModule } from "ngx-mask"; @@ -35,7 +36,7 @@ import { NgxMaskModule } from "ngx-mask"; }, ], standalone: true, - imports: [NgxMaskModule, IconComponent], + imports: [CommonModule, NgxMaskModule, IconComponent], }) export class InputComponent implements OnInit, ControlValueAccessor { constructor() {} diff --git a/projects/social_platform/src/app/ui/components/search/search.component.scss b/projects/social_platform/src/app/ui/components/search/search.component.scss index 5aea863ae..bc4f8d116 100644 --- a/projects/social_platform/src/app/ui/components/search/search.component.scss +++ b/projects/social_platform/src/app/ui/components/search/search.component.scss @@ -16,7 +16,7 @@ } &__hidden { - height: 22px; + height: 18px; } &__input { diff --git a/projects/social_platform/src/app/ui/components/select/select.component.scss b/projects/social_platform/src/app/ui/components/select/select.component.scss index c6819a050..2a0471e56 100644 --- a/projects/social_platform/src/app/ui/components/select/select.component.scss +++ b/projects/social_platform/src/app/ui/components/select/select.component.scss @@ -43,7 +43,7 @@ right: 0; bottom: -6px; left: 0; - z-index: 3; + z-index: 11; max-height: 200px; padding: 10px; overflow-y: auto; diff --git a/projects/social_platform/src/app/utils/generate-year-list.ts b/projects/social_platform/src/app/utils/generate-year-list.ts new file mode 100644 index 000000000..597bbb1ca --- /dev/null +++ b/projects/social_platform/src/app/utils/generate-year-list.ts @@ -0,0 +1,48 @@ +/** @format */ + +interface yearListElement { + id: number; // порядковый номер в массиве + value: string; // строка, которую будем показывать в UI + label: string; // то же самое, что и value (можно использовать обе подписи) +} + +/** + * Генерирует массив годов. + * + * @param amount – сколько лет нужно вывести. + * Считаем, что список должен начинаться с «текущий‑year‑amount+1» и + * заканчиваться текущим календарным годом. + * @returns массив объектов вида { id, value, label } + * + * Пример: generateYearList(3) (при текущем 2025‑м году) → + * [ + * { id: 0, value: '2023 год', label: '2023 год' }, + * { id: 1, value: '2024 год', label: '2024 год' }, + * { id: 2, value: '2025 год', label: '2025 год' } + * ] + */ +export const generateYearList = (amount: number): yearListElement[] => { + if (amount <= 0) return []; + + const now = new Date().getFullYear(); + const firstYear = now - amount + 1; + const list: yearListElement[] = []; + + for (let i = 0; i < amount; i++) { + const year = firstYear + i; + list.push({ + id: i, + value: `${year} год`, + label: `${year} год`, + }); + } + + const currentId = amount - 1; + list.push({ + id: currentId, + value: `${now} год`, + label: "по наст. вр.", + }); + + return list; +}; diff --git a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.scss b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.scss index 0b3c901db..cd316af9c 100644 --- a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.scss +++ b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.scss @@ -57,6 +57,7 @@ } app-profile-info { + width: 100%; padding: 26px 10px 10px; background-color: var(--light-gray); border-radius: var(--rounded-lg); diff --git a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.html b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.html index 7b09eb0c1..509f3be5f 100644 --- a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.html +++ b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.html @@ -7,13 +7,13 @@
    {{ user.firstName }} {{ user.lastName }}
    - @if (user.verificationDate; as verificationDate) { +